demobite 1.13.1 → 1.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/launcher/index.mjs +35 -8
- package/package.json +1 -1
- package/skill/scripts/peek.mjs +33 -0
- package/skill/scripts/plan.mjs +270 -0
- package/skill/scripts/probe.mjs +130 -0
package/launcher/index.mjs
CHANGED
|
@@ -116,8 +116,7 @@ const chromePaths = [
|
|
|
116
116
|
const haveChrome = chromePaths.some((p) => fs.existsSync(p)) || hasBin("google-chrome") || hasBin("google-chrome-stable");
|
|
117
117
|
if (haveChrome) ok("Google Chrome (films with the real browser)");
|
|
118
118
|
else console.log(" · Google Chrome not found — installing Playwright's Chromium for the recorder");
|
|
119
|
-
if (hasBin("claude")) ok("Claude Code (
|
|
120
|
-
else warn("Claude Code not found — using Cursor or Codex? They drive the recorder too; MCP setup prints below");
|
|
119
|
+
if (hasBin("claude")) ok("Claude Code found (optional: `--engine=claude` plans demos with it, on your own plan)");
|
|
121
120
|
|
|
122
121
|
// ── 2. Install / update the skill ──────────────────────────────────────────
|
|
123
122
|
fs.mkdirSync(dest, { recursive: true });
|
|
@@ -215,13 +214,24 @@ if (arg === "listen") {
|
|
|
215
214
|
// speak to it. The model that FILMS is ours (JOB_MODEL), passed per job: the
|
|
216
215
|
// output quality is DemoBites' to defend, never a hidden knob on the machine.
|
|
217
216
|
const JOB_MODEL = process.env.DEMOBITES_JOB_MODEL || "sonnet";
|
|
217
|
+
// THE BUILT-IN AGENT (1.14.0, founder 2026-10-15): demos are planned by DemoBites' own planner
|
|
218
|
+
// (scripts/plan.mjs ↔ PUT /api/recorder/agent) and filmed by the camera scripts; no coding agent
|
|
219
|
+
// needed on the machine. Claude Code stays an option: `--engine=claude` (or DEMOBITES_ENGINE=claude)
|
|
220
|
+
// plans with it instead, on the person's own plan, and their code never passes through DemoBites.
|
|
221
|
+
const engine = (argvRest.find((a) => a.startsWith("--engine=")) || "").slice(9) || process.env.DEMOBITES_ENGINE || "builtin";
|
|
222
|
+
const claudeVersion = (() => {
|
|
223
|
+
if (!agentBin) return null;
|
|
224
|
+
const v = spawnSync(agentBin, ["--version"], { encoding: "utf8", timeout: 8000 });
|
|
225
|
+
return v.status === 0 ? (v.stdout.trim().match(/\d+\.\d+(?:\.\d+)?/) || [null])[0] : null;
|
|
226
|
+
})();
|
|
218
227
|
const agentInfo = (() => {
|
|
219
|
-
if (
|
|
228
|
+
if (engine !== "claude") return { agent: "DemoBites agent", agent_version: pkg.version, engine: "builtin", claude_version: claudeVersion, code_sharing: !process.env.DEMOBITES_NO_CODE, model: null, effort: null, job_model: JOB_MODEL };
|
|
229
|
+
if (!agentBin) return { agent: null, agent_version: null, engine: "claude", model: null, effort: null, job_model: null };
|
|
220
230
|
const v = spawnSync(agentBin, ["--version"], { encoding: "utf8", timeout: 8000 });
|
|
221
231
|
const version = v.status === 0 ? (v.stdout.trim().match(/\d+\.\d+(?:\.\d+)?/) || [null])[0] : null;
|
|
222
232
|
let settings = {};
|
|
223
233
|
try { settings = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".claude", "settings.json"), "utf8")); } catch { /* defaults */ }
|
|
224
|
-
return { agent: "Claude Code", agent_version: version, model: typeof settings.model === "string" ? settings.model : null, effort: typeof settings.effortLevel === "string" ? settings.effortLevel : (typeof settings.effort === "string" ? settings.effort : null), job_model: JOB_MODEL };
|
|
234
|
+
return { agent: "Claude Code", agent_version: version, engine: "claude", model: typeof settings.model === "string" ? settings.model : null, effort: typeof settings.effortLevel === "string" ? settings.effortLevel : (typeof settings.effort === "string" ? settings.effort : null), job_model: JOB_MODEL };
|
|
225
235
|
})();
|
|
226
236
|
// The repo this listener was started in (the Code signal on the rail): the
|
|
227
237
|
// git remote's owner/name, or null when the folder is not a repo.
|
|
@@ -247,7 +257,7 @@ if (arg === "listen") {
|
|
|
247
257
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
248
258
|
|
|
249
259
|
console.log(`\n Listening as "${name}" for ${cfg.workspace?.name ?? "your workspace"} on ${base.replace(/^https?:\/\//, "")}.`);
|
|
250
|
-
console.log(` Ask for a demo in the app (Create → Agentic Capture). ${agentBin ? `Claude Code${agentInfo.agent_version ? " " + agentInfo.agent_version : ""}
|
|
260
|
+
console.log(` Ask for a demo in the app (Create → Agentic Capture). ${engine === "claude" ? (agentBin ? `Claude Code${agentInfo.agent_version ? " " + agentInfo.agent_version : ""} plans the demos, on your own plan; they film on ${JOB_MODEL} unless you pick another model in the app.` : "! --engine=claude, but Claude Code was not found on this machine.") : `Demos are planned by the DemoBites agent on ${JOB_MODEL} (pick another model in the app)${process.env.DEMOBITES_NO_CODE ? "; code sharing is off" : repo ? "; the files it reads from this project are logged in each take" : ""}.`}`);
|
|
251
261
|
// The one thing the person must know, boxed (founder 2026-10-15): this
|
|
252
262
|
// window is the machine's side of every recording.
|
|
253
263
|
console.log(`
|
|
@@ -256,7 +266,7 @@ if (arg === "listen") {
|
|
|
256
266
|
│ LEAVE THIS WINDOW OPEN │
|
|
257
267
|
│ │
|
|
258
268
|
│ It is your machine's side of every recording: │
|
|
259
|
-
│ the camera, the sign-ins
|
|
269
|
+
│ the camera, the sign-ins and the planning run here. │
|
|
260
270
|
│ Closing it, or Ctrl-C, stops the listening. │
|
|
261
271
|
│ │
|
|
262
272
|
└──────────────────────────────────────────────────────────┘
|
|
@@ -316,8 +326,25 @@ if (arg === "listen") {
|
|
|
316
326
|
// Alive while filming (1.13.1): a heartbeat every 5 s so the rail never shows this machine
|
|
317
327
|
// offline mid-take (the gap after the last step used to do exactly that).
|
|
318
328
|
const heartbeat = setInterval(() => { void api({ action: "checkin", machine_id: listen.machine_id, name, readiness: readiness(), busy: true }); }, 5000);
|
|
319
|
-
console.log(`\n ▶ Job ${job.id}: ${job.brief.slice(0, 100)}${job.brief.length > 100 ? "…" : ""} (
|
|
320
|
-
if (
|
|
329
|
+
console.log(`\n ▶ Job ${job.id}: ${job.brief.slice(0, 100)}${job.brief.length > 100 ? "…" : ""} (${engine === "claude" ? "Claude Code" : "DemoBites agent"} on ${model})`);
|
|
330
|
+
if (engine !== "claude") {
|
|
331
|
+
// The built-in agent: plan.mjs plans with the server's planner, films with the camera scripts.
|
|
332
|
+
await event(job.id, "started", { machine: name });
|
|
333
|
+
const takeDir = path.join(process.cwd(), ".recorder", "takes", job.id);
|
|
334
|
+
fs.mkdirSync(takeDir, { recursive: true });
|
|
335
|
+
fs.writeFileSync(path.join(takeDir, "job.json"), JSON.stringify({ id: job.id, brief: job.brief, url: job.url ?? null, model, review_storyboard: job.review_storyboard !== false }, null, 2) + "\n");
|
|
336
|
+
// Spawned async (1.14.1): a spawnSync here blocked the event loop, so the 5 s heartbeat never fired
|
|
337
|
+
// during a take and the rail called the machine offline mid-recording (founder 2026-10-15 17:39).
|
|
338
|
+
const child = (await import("node:child_process")).spawn("node", [path.join(dest, "scripts", "plan.mjs"), takeDir], { cwd: process.cwd(), stdio: "inherit", env: { ...process.env, RECORDER_JOB: job.id, RECORDER_BASE: base, RECORDER_LISTEN: "1" } });
|
|
339
|
+
const code = await new Promise((resolve) => child.on("close", resolve));
|
|
340
|
+
const st = await api({ action: "poll", job_id: job.id });
|
|
341
|
+
if (st && !["ready", "failed", "cancelled"].includes(st.status)) await event(job.id, "failed", { message: code === 0 ? "The demo did not come through — try again" : `The take stopped (exit ${code})` });
|
|
342
|
+
clearInterval(heartbeat);
|
|
343
|
+
console.log(` ■ Job ${job.id} ${st?.status ?? "ended"}.\n`);
|
|
344
|
+
waitOn();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (!agentBin) { await event(job.id, "failed", { message: "Claude Code is not installed on this machine" }); clearInterval(heartbeat); waitOn(); return; }
|
|
321
348
|
await event(job.id, "started", { machine: name });
|
|
322
349
|
const prompt = [
|
|
323
350
|
"Film a demo with the agentic-recorder skill. Read ~/.claude/skills/agentic-recorder/SKILL.md first and follow it exactly, in LISTENING MODE (its section \"Listening mode\"): the person is watching in the DemoBites app, not here — never ask anything in this conversation; the scripts carry every question to the app and bring the answer back.",
|
package/package.json
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PEEK (the built-in agent, 2026-10-15): one screenshot of an address in the
|
|
3
|
+
// camera's own browser (the recorder profile, so a saved sign-in counts), no
|
|
4
|
+
// video, no window. The planner looks at it when words alone are not enough.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/peek.mjs <url> <out.jpg>
|
|
7
|
+
// RESULT { title, url }
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
const [url, out] = process.argv.slice(2);
|
|
11
|
+
if (!url || !out) { console.error("Usage: node scripts/peek.mjs <url> <out.jpg>"); process.exit(2); }
|
|
12
|
+
const { chromium } = await import("playwright");
|
|
13
|
+
const profileDir = process.env.RECORDER_PROFILE || path.resolve(process.cwd(), ".recorder", "profile");
|
|
14
|
+
const launchOpts = {
|
|
15
|
+
headless: true,
|
|
16
|
+
viewport: { width: 1280, height: 800 },
|
|
17
|
+
args: ["--force-color-profile=srgb", "--disable-blink-features=AutomationControlled"],
|
|
18
|
+
ignoreDefaultArgs: ["--enable-automation"],
|
|
19
|
+
};
|
|
20
|
+
let ctx;
|
|
21
|
+
try { ctx = await chromium.launchPersistentContext(profileDir, { ...launchOpts, channel: "chrome" }); }
|
|
22
|
+
catch { ctx = await chromium.launchPersistentContext(profileDir, launchOpts); }
|
|
23
|
+
const page = ctx.pages()[0] ?? (await ctx.newPage());
|
|
24
|
+
try {
|
|
25
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: 45000 }).catch(() => page.goto(url, { waitUntil: "load", timeout: 45000 }));
|
|
26
|
+
await page.waitForTimeout(2500);
|
|
27
|
+
await page.screenshot({ path: out, type: "jpeg", quality: 55 });
|
|
28
|
+
console.log("RESULT " + JSON.stringify({ title: (await page.title().catch(() => "")).slice(0, 120), url: page.url() }));
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.error(String(e.message).split("\n")[0].slice(0, 160));
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
}
|
|
33
|
+
await ctx.close();
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// THE BUILT-IN AGENT (founder 2026-10-15: "I don't want to say to the customer,
|
|
3
|
+
// you need to have Claude Code on your machine"). A demo asked for in the app
|
|
4
|
+
// is planned by DemoBites' own planner (PUT <base>/api/recorder/agent, on
|
|
5
|
+
// DemoBites' bill) and filmed by the camera scripts beside this file. No
|
|
6
|
+
// coding agent on the machine. This script is the loop between the two:
|
|
7
|
+
//
|
|
8
|
+
// node scripts/plan.mjs <takeDir> (RECORDER_JOB, RECORDER_BASE set by `npx demobite listen`;
|
|
9
|
+
// <takeDir>/job.json = { id, brief, url, model, review_storyboard })
|
|
10
|
+
//
|
|
11
|
+
// What runs here, on the machine: the sign-in window (signin.mjs), the words
|
|
12
|
+
// of each screen (vocab.mjs), screenshots and the dry run (peek.mjs,
|
|
13
|
+
// probe.mjs), reading the project's files when this folder is the app's
|
|
14
|
+
// repository and sharing is on (DEMOBITES_NO_CODE=1 turns it off), the
|
|
15
|
+
// storyboard box (storyboard.mjs await), and the whole filming pipeline
|
|
16
|
+
// (cleanup --prep, record, cleanup, trim, calibrate, manifest, upload). What
|
|
17
|
+
// runs on the server: the model, and nothing else. Every file the planner
|
|
18
|
+
// reads is logged on the take (<takeDir>/code-shared.log) so the person can
|
|
19
|
+
// see exactly what left the machine.
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { listening, postEvent } from "./listen-event.mjs";
|
|
25
|
+
|
|
26
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const dir = process.argv[2];
|
|
28
|
+
if (!dir) { console.error("Usage: node scripts/plan.mjs <takeDir>"); process.exit(2); }
|
|
29
|
+
if (!listening) { console.error("plan.mjs runs under `npx demobite listen` (RECORDER_JOB and RECORDER_BASE)."); process.exit(2); }
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
31
|
+
const job = JSON.parse(fs.readFileSync(path.join(dir, "job.json"), "utf8"));
|
|
32
|
+
const base = process.env.RECORDER_BASE.replace(/\/+$/, "");
|
|
33
|
+
const cfg = (() => { try { return JSON.parse(fs.readFileSync(path.resolve(".recorder", "config.json"), "utf8")); } catch { return {}; } })();
|
|
34
|
+
if (!cfg.api_key) { console.error("Not connected: .recorder/config.json has no key."); process.exit(1); }
|
|
35
|
+
const say = (t) => { console.log(`· ${t}`); void postEvent("agent", { text: t }); };
|
|
36
|
+
const script = (name) => path.join(here, name);
|
|
37
|
+
const run = (name, args, opts = {}) => spawnSync("node", [script(name), ...args], { cwd: process.cwd(), encoding: "utf8", env: process.env, maxBuffer: 64 * 1024 * 1024, ...opts });
|
|
38
|
+
|
|
39
|
+
// ── The address and the sign-in ──────────────────────────────────────────
|
|
40
|
+
const ensureSignin = async (url) => {
|
|
41
|
+
if (!url) return;
|
|
42
|
+
const check = run("signin.mjs", ["--check", url]);
|
|
43
|
+
if (check.status === 0) return;
|
|
44
|
+
// Not in the ledger yet: look first, headless. An open page, or one the profile is already signed
|
|
45
|
+
// into, needs no window (the window is for a wall, and only for a wall).
|
|
46
|
+
const look = run("look.mjs", [url]);
|
|
47
|
+
const last = (look.stdout || "").trim().split("\n").filter((l) => l.startsWith("RESULT ")).pop();
|
|
48
|
+
let verdict = null; try { verdict = JSON.parse(last.slice(7)); } catch { /* none */ }
|
|
49
|
+
if (verdict && (verdict.access === "open" || verdict.access === "signed")) return;
|
|
50
|
+
console.log(` Sign-in needed at ${new URL(url).origin}; opening the Chrome window.`);
|
|
51
|
+
const r = spawnSync("node", [script("signin.mjs"), url], { cwd: process.cwd(), stdio: "inherit", env: process.env });
|
|
52
|
+
if (r.status !== 0) { await postEvent("failed", { message: "Nobody signed in, so the demo did not start" }); process.exit(1); }
|
|
53
|
+
};
|
|
54
|
+
const startUrl = job.url || cfg.url || null;
|
|
55
|
+
if (startUrl) await ensureSignin(startUrl);
|
|
56
|
+
|
|
57
|
+
// ── What the planner works from ──────────────────────────────────────────
|
|
58
|
+
say("Reading the workspace rules…");
|
|
59
|
+
run("rules.mjs", [dir]);
|
|
60
|
+
const rules = (() => { try { return JSON.parse(fs.readFileSync(path.resolve(".recorder", "rules.json"), "utf8")); } catch { return { version: 0, lines: [] }; } })();
|
|
61
|
+
|
|
62
|
+
const codeOn = !process.env.DEMOBITES_NO_CODE && spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { encoding: "utf8" }).status === 0;
|
|
63
|
+
const SECRET_RE = /(^|\/)(\.env(\..*)?|.*\.(pem|key|p12|pfx|crt)|.*secret.*|.*credential.*|\.recorder\/.*|node_modules\/.*|\.git\/.*|.*\.lock|package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/i;
|
|
64
|
+
const UI_RE = /(^|\/)(app|pages|routes|src|components|views|screens|navigation|nav|layouts?|lib\/(nav|routes|menu))(\/|$)|(^|\/)(route|router|routes|nav|navigation|sidebar|menu|layout|page)\.[jt]sx?$|\/page\.[jt]sx?$|\/layout\.[jt]sx?$|\/route\.[jt]s$/i;
|
|
65
|
+
const listFiles = () => {
|
|
66
|
+
if (!codeOn) return "(no repository here, or code sharing is off)";
|
|
67
|
+
const r = spawnSync("git", ["ls-files"], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
|
68
|
+
if (r.status !== 0) return "(no repository here)";
|
|
69
|
+
const all = r.stdout.split("\n").filter(Boolean).filter((f) => !SECRET_RE.test(f) && /\.(tsx?|jsx?|vue|svelte|html|mdx?)$/i.test(f));
|
|
70
|
+
const ui = all.filter((f) => UI_RE.test(f));
|
|
71
|
+
const picked = (ui.length ? ui : all).slice(0, 400);
|
|
72
|
+
return `${picked.join("\n")}${all.length > picked.length ? `\n… ${all.length - picked.length} more files not listed` : ""}`;
|
|
73
|
+
};
|
|
74
|
+
let reads = 0;
|
|
75
|
+
const shared = [];
|
|
76
|
+
const readFile = (rel) => {
|
|
77
|
+
if (!codeOn) return "Code sharing is off on this machine.";
|
|
78
|
+
if (typeof rel !== "string" || !rel.trim()) return "path required";
|
|
79
|
+
const abs = path.resolve(process.cwd(), rel);
|
|
80
|
+
if (!abs.startsWith(process.cwd() + path.sep)) return "Outside the project.";
|
|
81
|
+
const r = path.relative(process.cwd(), abs);
|
|
82
|
+
if (SECRET_RE.test(r)) return "Not that file.";
|
|
83
|
+
if (reads >= 10) return "No more reads for this storyboard.";
|
|
84
|
+
let text;
|
|
85
|
+
try { text = fs.readFileSync(abs, "utf8"); } catch { return "No such file."; }
|
|
86
|
+
reads++;
|
|
87
|
+
shared.push(r);
|
|
88
|
+
fs.appendFileSync(path.join(dir, "code-shared.log"), `${new Date().toISOString()} ${r} (${Math.min(text.length, 12000)} chars)\n`);
|
|
89
|
+
return text.length > 12000 ? text.slice(0, 12000) + "\n… (cut at 12 KB)" : text;
|
|
90
|
+
};
|
|
91
|
+
const harvest = (urls) => {
|
|
92
|
+
const ok = (Array.isArray(urls) ? urls : []).filter((u) => /^https?:\/\//.test(String(u))).slice(0, 4);
|
|
93
|
+
if (!ok.length) return "urls required";
|
|
94
|
+
const r = run("vocab.mjs", [dir, ...ok]);
|
|
95
|
+
const out = `${r.stdout || ""}${r.stderr ? `\n${r.stderr}` : ""}`.trim();
|
|
96
|
+
return out.length > 12000 ? out.slice(0, 12000) + "\n… (cut)" : out || "(nothing harvested)";
|
|
97
|
+
};
|
|
98
|
+
let peeks = 0;
|
|
99
|
+
const screenshot = (url) => {
|
|
100
|
+
if (!/^https?:\/\//.test(String(url))) return [{ type: "text", text: "url required" }];
|
|
101
|
+
const file = path.join(dir, `peek-${++peeks}.jpg`);
|
|
102
|
+
const r = run("peek.mjs", [url, file]);
|
|
103
|
+
const last = (r.stdout || "").trim().split("\n").filter((l) => l.startsWith("RESULT ")).pop();
|
|
104
|
+
let meta = null; try { meta = JSON.parse(last.slice(7)); } catch { /* none */ }
|
|
105
|
+
if (!meta || !fs.existsSync(file)) return [{ type: "text", text: `Could not open ${url}: ${(r.stderr || r.stdout || "").trim().split("\n").pop() || "no answer"}` }];
|
|
106
|
+
return [
|
|
107
|
+
{ type: "text", text: `${meta.url} — "${meta.title}"` },
|
|
108
|
+
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: fs.readFileSync(file).toString("base64") } },
|
|
109
|
+
];
|
|
110
|
+
};
|
|
111
|
+
const probe = (sb) => {
|
|
112
|
+
if (!sb || !Array.isArray(sb.steps)) return "storyboard with steps[] required";
|
|
113
|
+
const file = path.join(dir, "probe.json");
|
|
114
|
+
fs.writeFileSync(file, JSON.stringify(sb, null, 2) + "\n");
|
|
115
|
+
const r = run("probe.mjs", [file]);
|
|
116
|
+
const out = `${r.stdout || ""}${r.stderr ? `\n${r.stderr}` : ""}`.trim();
|
|
117
|
+
return out.length > 12000 ? out.slice(0, 12000) + "\n… (cut)" : out || "(no report)";
|
|
118
|
+
};
|
|
119
|
+
const ACTIONS = new Set(["goto", "settle", "scroll", "click", "hover", "type", "expect"]);
|
|
120
|
+
const validate = (sb) => {
|
|
121
|
+
if (!sb || typeof sb !== "object") return "storyboard required";
|
|
122
|
+
if (!Array.isArray(sb.steps) || !sb.steps.length) return "steps[] is empty";
|
|
123
|
+
if (sb.steps[0].action !== "goto" || !sb.steps[0].url) return "the first step must be a goto with a url";
|
|
124
|
+
for (const [i, s] of sb.steps.entries()) {
|
|
125
|
+
const at = `step ${i + 1}`;
|
|
126
|
+
if (!ACTIONS.has(s.action)) return `${at}: unknown action ${JSON.stringify(s.action)}`;
|
|
127
|
+
if ((s.action === "click" || s.action === "hover" || s.action === "expect") && !s.selector) return `${at}: ${s.action} needs selector`;
|
|
128
|
+
if (s.action === "type" && (!s.selector || typeof s.text !== "string")) return `${at}: type needs selector and text`;
|
|
129
|
+
if (s.action === "scroll" && typeof s.dy !== "number") return `${at}: scroll needs numeric dy`;
|
|
130
|
+
if (s.frame !== undefined && s.frame !== "close" && s.frame !== "wide") return `${at}: frame must be close or wide`;
|
|
131
|
+
}
|
|
132
|
+
if (sb.steps.length > 14) return "more than 14 steps; 6 to 10 is the law";
|
|
133
|
+
return null;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// ── The loop with the planner ────────────────────────────────────────────
|
|
137
|
+
const relay = async (messages) => {
|
|
138
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
139
|
+
let res;
|
|
140
|
+
try {
|
|
141
|
+
res = await fetch(`${base}/api/recorder/agent`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.api_key}` }, body: JSON.stringify({ job_id: job.id, messages }) });
|
|
142
|
+
} catch (e) { if (attempt === 2) throw e; await new Promise((r) => setTimeout(r, 3000)); continue; }
|
|
143
|
+
if (res.ok) return res.json();
|
|
144
|
+
const body = await res.json().catch(() => ({}));
|
|
145
|
+
if (res.status === 429 || res.status === 409 || res.status === 404 || res.status === 401) throw new Error(body.error || `planner ${res.status}`);
|
|
146
|
+
if (attempt === 2) throw new Error(body.error || `planner ${res.status}`);
|
|
147
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
148
|
+
}
|
|
149
|
+
throw new Error("planner unreachable");
|
|
150
|
+
};
|
|
151
|
+
// Only the newest screenshots stay in the conversation; older ones become a note (the request body has a ceiling).
|
|
152
|
+
const prune = (messages) => {
|
|
153
|
+
let seen = 0;
|
|
154
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
155
|
+
const c = messages[i].content;
|
|
156
|
+
if (!Array.isArray(c)) continue;
|
|
157
|
+
for (const b of c) {
|
|
158
|
+
if (b.type !== "tool_result" || !Array.isArray(b.content)) continue;
|
|
159
|
+
for (let k = 0; k < b.content.length; k++) {
|
|
160
|
+
if (b.content[k].type !== "image") continue;
|
|
161
|
+
if (++seen > 2) b.content[k] = { type: "text", text: "(a screenshot shown earlier)" };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const firstMessage = () => {
|
|
168
|
+
const vocab = startUrl ? harvest([startUrl]) : "(no starting address was given: find it from the brief, the code or by looking)";
|
|
169
|
+
const parts = [
|
|
170
|
+
`## The brief\n${job.brief}`,
|
|
171
|
+
`## Starting address\n${startUrl || "(none given)"}`,
|
|
172
|
+
`## The app's words on the starting screen\n${vocab}`,
|
|
173
|
+
`## Workspace rules (version ${rules.version || 0})\n${rules.lines?.length ? rules.lines.map((l, i) => `${i + 1}. ${l}`).join("\n") : "(none)"}`,
|
|
174
|
+
`## The project's files${codeOn ? " (this machine sits in the app's repository; read_file is on)" : " (no code here)"}\n${listFiles()}`,
|
|
175
|
+
`Write the storyboard, probe it, fix it, probe again, then submit_storyboard.`,
|
|
176
|
+
];
|
|
177
|
+
return parts.join("\n\n");
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
let storyboard = null;
|
|
181
|
+
let creates = "";
|
|
182
|
+
const messages = [{ role: "user", content: [{ type: "text", text: firstMessage() }] }];
|
|
183
|
+
const plan = async () => {
|
|
184
|
+
say("Writing the storyboard…");
|
|
185
|
+
storyboard = null;
|
|
186
|
+
let nudged = false;
|
|
187
|
+
for (let turn = 0; turn < 40; turn++) {
|
|
188
|
+
prune(messages);
|
|
189
|
+
const res = await relay(messages);
|
|
190
|
+
const content = Array.isArray(res.content) ? res.content : [];
|
|
191
|
+
messages.push({ role: "assistant", content });
|
|
192
|
+
for (const b of content) if (b.type === "text" && b.text?.trim()) console.log(` ${b.text.trim().split("\n")[0].slice(0, 140)}`);
|
|
193
|
+
const uses = content.filter((b) => b.type === "tool_use");
|
|
194
|
+
if (!uses.length) {
|
|
195
|
+
if (storyboard) return;
|
|
196
|
+
if (nudged) throw new Error("The planner stopped without a storyboard");
|
|
197
|
+
nudged = true;
|
|
198
|
+
messages.push({ role: "user", content: [{ type: "text", text: "Hand in the storyboard with submit_storyboard (after a probe that passed)." }] });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const results = [];
|
|
202
|
+
for (const u of uses) {
|
|
203
|
+
const input = u.input || {};
|
|
204
|
+
let out;
|
|
205
|
+
try {
|
|
206
|
+
if (u.name === "list_files") out = listFiles();
|
|
207
|
+
else if (u.name === "read_file") out = readFile(input.path);
|
|
208
|
+
else if (u.name === "harvest_words") { console.log(` harvesting ${(input.urls || []).join(", ")}`); out = harvest(input.urls); }
|
|
209
|
+
else if (u.name === "screenshot") { console.log(` looking at ${input.url}`); out = screenshot(input.url); }
|
|
210
|
+
else if (u.name === "probe") { console.log(" dry run…"); out = probe(input.storyboard); }
|
|
211
|
+
else if (u.name === "submit_storyboard") {
|
|
212
|
+
const bad = validate(input.storyboard);
|
|
213
|
+
if (bad) out = `Not accepted: ${bad}`;
|
|
214
|
+
else { storyboard = input.storyboard; creates = typeof input.creates === "string" ? input.creates : ""; out = "Accepted."; }
|
|
215
|
+
} else out = `Unknown tool ${u.name}`;
|
|
216
|
+
} catch (e) { out = `Tool failed: ${e.message}`; }
|
|
217
|
+
results.push({ type: "tool_result", tool_use_id: u.id, content: typeof out === "string" ? [{ type: "text", text: out }] : out });
|
|
218
|
+
}
|
|
219
|
+
messages.push({ role: "user", content: results });
|
|
220
|
+
if (storyboard) return;
|
|
221
|
+
}
|
|
222
|
+
throw new Error("The planner ran out of turns");
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
for (let revision = 0; revision < 4; revision++) {
|
|
227
|
+
await plan();
|
|
228
|
+
if (!storyboard.title) storyboard.title = job.brief.slice(0, 60);
|
|
229
|
+
if (!storyboard.app) storyboard.app = cfg.app || (startUrl ? new URL(startUrl).hostname : "Demo");
|
|
230
|
+
if (rules.version) storyboard.rulesVersion = rules.version;
|
|
231
|
+
const sbFile = path.join(dir, "storyboard.json");
|
|
232
|
+
fs.writeFileSync(sbFile, JSON.stringify(storyboard, null, 2) + "\n");
|
|
233
|
+
if (shared.length) console.log(` Files the planner read: ${shared.join(", ")}`);
|
|
234
|
+
// The sign-in for the storyboard's own address, when it differs from the start.
|
|
235
|
+
const sbUrl = storyboard.steps?.[0]?.url || storyboard.url;
|
|
236
|
+
if (sbUrl && (!startUrl || new URL(sbUrl).origin !== new URL(startUrl).origin)) await ensureSignin(sbUrl);
|
|
237
|
+
// The box goes to the app; the person's word comes back.
|
|
238
|
+
const w = run("storyboard.mjs", ["await", sbFile]);
|
|
239
|
+
const answer = (w.stdout || "").trim().split("\n").pop() || "";
|
|
240
|
+
if (answer === "cancelled" || w.status === 2) { console.log(" The person cancelled."); process.exit(0); }
|
|
241
|
+
if (answer.startsWith("change:")) {
|
|
242
|
+
const note = answer.slice(7).trim();
|
|
243
|
+
say("Changing the storyboard…");
|
|
244
|
+
messages.push({ role: "user", content: [{ type: "text", text: `The person asked for a change: "${note}". Revise the storyboard, probe it, and submit_storyboard again.` }] });
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
} catch (e) {
|
|
250
|
+
console.error(` ! ${e.message}`);
|
|
251
|
+
await postEvent("failed", { message: `The storyboard could not be written: ${String(e.message).slice(0, 160)}` });
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── The take ─────────────────────────────────────────────────────────────
|
|
256
|
+
const sbFile = path.join(dir, "storyboard.json");
|
|
257
|
+
const step = async (name, args, what) => {
|
|
258
|
+
const r = spawnSync("node", [script(name), ...args], { cwd: process.cwd(), stdio: "inherit", env: process.env });
|
|
259
|
+
if (r.status !== 0) { console.error(` ! ${name} failed (exit ${r.status})`); await postEvent("failed", { message: `${what} did not finish` }); process.exit(1); }
|
|
260
|
+
};
|
|
261
|
+
const sb = JSON.parse(fs.readFileSync(sbFile, "utf8"));
|
|
262
|
+
if (Array.isArray(sb.prep) && sb.prep.length) await step("cleanup.mjs", [dir, "--prep"], "Preparing the data");
|
|
263
|
+
await step("record.mjs", [dir, sbFile], "Filming");
|
|
264
|
+
if (Array.isArray(sb.cleanup) && sb.cleanup.length) await step("cleanup.mjs", [dir], "Putting the data back");
|
|
265
|
+
await step("trim.mjs", [dir], "Trimming the take");
|
|
266
|
+
await step("calibrate.mjs", [dir], "Measuring the take");
|
|
267
|
+
await step("manifest.mjs", [dir], "Packing the take");
|
|
268
|
+
await step("upload.mjs", [dir, "--no-open"], "Delivering the take");
|
|
269
|
+
if (creates) console.log(` ${creates}`);
|
|
270
|
+
process.exit(0);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PROBE (the built-in agent, 2026-10-15): the headless dry run of a
|
|
3
|
+
// storyboard, the way the skill's Phase 4 asks for it. Every camera step is
|
|
4
|
+
// performed in order on the recorder profile without video; a step marked
|
|
5
|
+
// pointed is only hovered (an irreversible action is never pressed). Each
|
|
6
|
+
// selector is reported resolved or not, by the visible-instance rule of
|
|
7
|
+
// record.mjs (first visible match whose top clears minY). When one fails, the
|
|
8
|
+
// report lists the visible controls on that page so the planner can pick the
|
|
9
|
+
// real one, and the run stops there.
|
|
10
|
+
//
|
|
11
|
+
// node scripts/probe.mjs <storyboard.json>
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
const file = process.argv[2];
|
|
16
|
+
if (!file) { console.error("Usage: node scripts/probe.mjs <storyboard.json>"); process.exit(2); }
|
|
17
|
+
const sb = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
18
|
+
const steps = Array.isArray(sb.steps) ? sb.steps : [];
|
|
19
|
+
const { chromium } = await import("playwright");
|
|
20
|
+
const profileDir = process.env.RECORDER_PROFILE || path.resolve(process.cwd(), ".recorder", "profile");
|
|
21
|
+
const launchOpts = {
|
|
22
|
+
headless: true,
|
|
23
|
+
viewport: { width: 1920, height: 1080 },
|
|
24
|
+
args: ["--force-color-profile=srgb", "--disable-blink-features=AutomationControlled"],
|
|
25
|
+
ignoreDefaultArgs: ["--enable-automation"],
|
|
26
|
+
};
|
|
27
|
+
let ctx;
|
|
28
|
+
try { ctx = await chromium.launchPersistentContext(profileDir, { ...launchOpts, channel: "chrome" }); }
|
|
29
|
+
catch { ctx = await chromium.launchPersistentContext(profileDir, launchOpts); }
|
|
30
|
+
const page = ctx.pages()[0] ?? (await ctx.newPage());
|
|
31
|
+
if (sb.hideCss) await page.addInitScript((css) => { document.addEventListener("DOMContentLoaded", () => { const s = document.createElement("style"); s.textContent = css; document.head.appendChild(s); }); }, sb.hideCss);
|
|
32
|
+
const ms = (v, d) => (typeof v !== "number" ? d : v < 60 ? v * 1000 : v);
|
|
33
|
+
const lines = [];
|
|
34
|
+
const out = (t) => { lines.push(t); console.log(t); };
|
|
35
|
+
let failed = false;
|
|
36
|
+
|
|
37
|
+
const visible = async (selector, minY) => {
|
|
38
|
+
const loc = page.locator(selector);
|
|
39
|
+
const deadline = Date.now() + 8000;
|
|
40
|
+
while (Date.now() < deadline) {
|
|
41
|
+
const n = await loc.count().catch(() => 0);
|
|
42
|
+
for (let i = 0; i < Math.min(n, 12); i++) {
|
|
43
|
+
const el = loc.nth(i);
|
|
44
|
+
const box = await el.boundingBox().catch(() => null);
|
|
45
|
+
if (box && box.width > 0 && box.height > 0 && box.y >= (minY || 0)) return el;
|
|
46
|
+
}
|
|
47
|
+
await page.waitForTimeout(250);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
const controls = async () => page.evaluate(() => {
|
|
52
|
+
const vis = (el) => { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return r.width > 0 && r.height > 0 && cs.visibility !== "hidden" && cs.display !== "none"; };
|
|
53
|
+
const clean = (t) => (t || "").replace(/\s+/g, " ").trim();
|
|
54
|
+
const label = (el) => clean(el.innerText || el.getAttribute("aria-label") || el.getAttribute("title") || el.getAttribute("placeholder") || "");
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const items = [];
|
|
57
|
+
for (const el of document.querySelectorAll("a[href], button, [role='button'], [role='tab'], [role='menuitem'], input, textarea, select, [role='link']")) {
|
|
58
|
+
if (!vis(el)) continue;
|
|
59
|
+
const l = label(el);
|
|
60
|
+
if (!l || l.length > 60) continue;
|
|
61
|
+
const tag = el.tagName.toLowerCase();
|
|
62
|
+
const key = `${tag}:${l}`;
|
|
63
|
+
if (seen.has(key)) continue;
|
|
64
|
+
seen.add(key);
|
|
65
|
+
const extra = el.getAttribute("data-test") || el.getAttribute("data-testid") || el.getAttribute("aria-label") || "";
|
|
66
|
+
items.push(`${tag}${el.getAttribute("role") ? `[role=${el.getAttribute("role")}]` : ""} "${l}"${extra && extra !== l ? ` (${extra})` : ""}${tag === "a" ? ` → ${el.getAttribute("href")}` : ""}`);
|
|
67
|
+
if (items.length >= 60) break;
|
|
68
|
+
}
|
|
69
|
+
return items;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const started = Date.now();
|
|
73
|
+
for (const [i, s] of steps.entries()) {
|
|
74
|
+
if (Date.now() - started > 150000) { out(`step ${i + 1}: not reached (the probe ran out of time)`); failed = true; break; }
|
|
75
|
+
const n = i + 1;
|
|
76
|
+
try {
|
|
77
|
+
if (s.action === "goto") {
|
|
78
|
+
await page.goto(s.url, { waitUntil: "networkidle", timeout: 45000 }).catch(() => page.goto(s.url, { waitUntil: "load", timeout: 45000 }));
|
|
79
|
+
await page.waitForTimeout(1500);
|
|
80
|
+
out(`step ${n} goto ${s.url}: ok — "${(await page.title().catch(() => "")).slice(0, 80)}" at ${page.url()}`);
|
|
81
|
+
} else if (s.action === "settle") {
|
|
82
|
+
await page.waitForTimeout(Math.min(ms(s.ms, 1200), 4000));
|
|
83
|
+
out(`step ${n} settle: ok`);
|
|
84
|
+
} else if (s.action === "scroll") {
|
|
85
|
+
await page.mouse.wheel(0, s.dy || 0);
|
|
86
|
+
await page.waitForTimeout(600);
|
|
87
|
+
out(`step ${n} scroll ${s.dy}: ok`);
|
|
88
|
+
} else if (s.action === "click" || s.action === "hover" || s.action === "expect" || s.action === "type") {
|
|
89
|
+
const el = await visible(s.selector, s.minY);
|
|
90
|
+
if (!el) {
|
|
91
|
+
out(`step ${n} ${s.action} ${JSON.stringify(s.selector)}: NOT FOUND on ${page.url()}`);
|
|
92
|
+
out(` visible controls here:\n ${(await controls()).join("\n ")}`);
|
|
93
|
+
failed = true;
|
|
94
|
+
if (s.required === false) continue;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (s.action === "click" && s.pointed !== true) {
|
|
98
|
+
await el.hover().catch(() => {});
|
|
99
|
+
await el.click({ timeout: 8000 });
|
|
100
|
+
await page.waitForTimeout(Math.min(ms(s.after, 1500), 4000));
|
|
101
|
+
if (s.waitLoad) await page.waitForLoadState("load").catch(() => {});
|
|
102
|
+
let revealed = "";
|
|
103
|
+
if (typeof s.reveals === "string" && s.reveals) revealed = (await visible(s.reveals, 0)) ? `, reveals ${JSON.stringify(s.reveals)} seen` : `, reveals ${JSON.stringify(s.reveals)} NOT SEEN`;
|
|
104
|
+
out(`step ${n} click ${JSON.stringify(s.selector)}: ok${revealed} — now at ${page.url()}`);
|
|
105
|
+
} else if (s.action === "type") {
|
|
106
|
+
if (s.clear) await el.fill("");
|
|
107
|
+
await el.click({ timeout: 8000 }).catch(() => {});
|
|
108
|
+
await el.type(String(s.text), { delay: 10 });
|
|
109
|
+
if (s.enter) { await page.keyboard.press("Enter"); await page.waitForTimeout(Math.min(ms(s.after, 1500), 4000)); }
|
|
110
|
+
out(`step ${n} type into ${JSON.stringify(s.selector)}: ok — now at ${page.url()}`);
|
|
111
|
+
} else {
|
|
112
|
+
await el.hover().catch(() => {});
|
|
113
|
+
await page.waitForTimeout(400);
|
|
114
|
+
out(`step ${n} ${s.action === "click" ? "pointed at" : s.action} ${JSON.stringify(s.selector)}: ok`);
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
out(`step ${n}: unknown action ${JSON.stringify(s.action)}`);
|
|
118
|
+
failed = true;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
out(`step ${n} ${s.action} ${JSON.stringify(s.selector ?? s.url ?? "")}: FAILED — ${String(e.message).split("\n")[0].slice(0, 160)}`);
|
|
123
|
+
try { out(` visible controls here:\n ${(await controls()).join("\n ")}`); } catch { /* page gone */ }
|
|
124
|
+
failed = true;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
out(failed ? "PROBE FAILED: fix the steps above and probe again." : `PROBE PASSED: ${steps.length} steps resolved.`);
|
|
129
|
+
await ctx.close();
|
|
130
|
+
process.exit(failed ? 1 : 0);
|