Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions packages/core/lib/v3/chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import * as readline from "node:readline/promises";
import { stdin, stdout } from "node:process";
import type { ActResult, Action } from "./types/public/methods.js";
import type { Page } from "./understudy/page.js";

export interface ChatContext {
act: (instruction: string) => Promise<ActResult>;
extract: (instruction: string) => Promise<{ extraction: string }>;
observe: (instruction: string) => Promise<Action[]>;
page: Page;
}

const tty = stdout.isTTY ?? false;
const dim = (s: string) => (tty ? `\x1b[2m${s}\x1b[0m` : s);
const grn = (s: string) => (tty ? `\x1b[32m${s}\x1b[0m` : s);
const cyn = (s: string) => (tty ? `\x1b[36m${s}\x1b[0m` : s);
const red = (s: string) => (tty ? `\x1b[31m${s}\x1b[0m` : s);

// Extract the arg from parens: foo("bar") → "bar", foo() → undefined
function arg(input: string): string | undefined {
const m = input.match(/\(\s*["'`](.+?)["'`]\s*\)/);
return m?.[1];
}

const commands: Record<
string,
(ctx: ChatContext, raw: string) => Promise<string>
> = {
// page.*
"page.url": async (ctx) => cyn(` → ${ctx.page.url()}`),
"page.title": async (ctx) => cyn(` → ${await ctx.page.title()}`),
"page.goto": async (ctx, raw) => {
const url = arg(raw);
if (!url) return dim(' page.goto("https://...")');
await ctx.page.goto(url);
return grn(` ✓ ${ctx.page.url()}`);
},
"page.reload": async (ctx) => {
await ctx.page.reload();
return grn(" ✓ reloaded");
},

// stagehand.*
act: async (ctx, raw) => {
const instruction = arg(raw);
if (!instruction) return dim(' stagehand.act("...")');
const r = await ctx.act(instruction);
return r.success ? grn(` ✓ ${r.message}`) : red(` ✗ ${r.message}`);
},
extract: async (ctx, raw) => {
const instruction = arg(raw);
if (!instruction) return dim(' stagehand.extract("...")');
const r = await ctx.extract(instruction);
return cyn(` → "${r.extraction}"`);
},
observe: async (ctx, raw) => {
const instruction = arg(raw);
if (!instruction) return dim(' stagehand.observe("...")');
const actions = await ctx.observe(instruction);
if (actions.length === 0) return dim(" (no actions found)");
return actions
.map(
(a) =>
cyn(` → ${a.description}`) + dim(a.method ? ` (${a.method})` : ""),
)
.join("\n");
},
};

// Match "stagehand.act(...)" or "act(...)" or "page.goto(...)"
function resolve(input: string): string | null {
const lower = input.toLowerCase();
for (const key of Object.keys(commands)) {
if (
lower.startsWith(key + "(") ||
lower.startsWith("stagehand." + key + "(")
) {
return key;
}
// bare: page.url()
if (lower === key + "()" || lower === "stagehand." + key + "()") {
return key;
}
}
return null;
}

export async function chat(ctx: ChatContext): Promise<void> {
if (!stdin.isTTY) {
console.log(
dim("[stagehand] chat() requires an interactive terminal, skipping."),
);
return;
}

console.log();
console.log(dim(` Stagehand paused on ${ctx.page.url()}`));
console.log(dim(" Type help for commands. Press Enter to continue.\n"));

const rl = readline.createInterface({ input: stdin, output: stdout });

// eslint-disable-next-line no-constant-condition
while (true) {
const line = await rl.question("🤘 ");

if (line.trim() === "") {
rl.close();
return;
}

if (line.trim().toLowerCase() === "help") {
console.log();
console.log(
dim(' stagehand.act("...") Perform an action on the page'),
);
console.log(
dim(' stagehand.extract("...") Extract data from the page'),
);
console.log(dim(' stagehand.observe("...") Find candidate actions'));
console.log();
console.log(dim(" page.url() Current page URL"));
console.log(dim(" page.title() Current page title"));
console.log(dim(' page.goto("...") Navigate to a URL'));
console.log(dim(" page.reload() Reload the page"));
console.log();
console.log(dim(" Enter Continue script execution"));
console.log();
continue;
}

const key = resolve(line.trim());
if (!key) {
console.log(dim(" Unknown command. Type help for a list of commands."));
continue;
}

try {
console.log(await commands[key](ctx, line.trim()));
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Exception and error message sanitization

Do not print raw exception/result messages to the REPL. Map errors to sanitized, typed safe messages before showing them to users.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/lib/v3/chat.ts, line 128:

<comment>Do not print raw exception/result messages to the REPL. Map errors to sanitized, typed safe messages before showing them to users.</comment>

<file context>
@@ -0,0 +1,132 @@
+    try {
+      console.log(await commands[key](ctx, line.trim()));
+    } catch (err: unknown) {
+      const msg = err instanceof Error ? err.message : String(err);
+      console.log(red(`  ✗ ${msg}`));
+    }
</file context>
Fix with Cubic

console.log(red(` ✗ ${msg}`));
}
}
}
24 changes: 24 additions & 0 deletions packages/core/lib/v3/v3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1421,6 +1421,30 @@ export class V3 {
return this.ctx;
}

/**
* Open an interactive chat REPL in the terminal.
* Pauses script execution and lets you run stagehand and page
* commands interactively. Press Enter to resume.
*/
async chat(): Promise<void> {
if (this.state.kind === "UNINITIALIZED") {
throw new StagehandNotInitializedError("chat()");
}
const { chat } = await import("./chat.js");
const page = await this.resolvePage();
await chat({
act: (instruction) => this.act(instruction),
extract: (instruction) => this.extract(instruction),
observe: (instruction) => this.observe(instruction),
page,
});
}

/** Alias for `chat()`. Pauses script execution and opens an interactive REPL. */
async pause(): Promise<void> {
return this.chat();
}

/** Best-effort cleanup of context and launched resources. */
async close(opts?: { force?: boolean }): Promise<void> {
// If we're already closing and this isn't a forced close, no-op.
Expand Down
Loading