demobite 1.8.2 → 1.9.0

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.
@@ -141,6 +141,19 @@ if (needed.length) {
141
141
  if (r.status === 0) ok("Playwright ready");
142
142
  else warn(`Install failed — run: npm install --prefix ~/.claude/skills/agentic-recorder ${needed.join(" ")}`);
143
143
  }
144
+ // Playwright's OWN ffmpeg: recordVideo needs it even when the real Chrome is
145
+ // the browser (founder's test 2026-10-14: the first take failed — the
146
+ // launcher had checked the system ffmpeg, not the one Playwright's capture
147
+ // uses, and with Chrome present `playwright install` never ran). Always.
148
+ {
149
+ const pw = path.join(dest, "node_modules", "playwright", "cli.js");
150
+ const marker = path.join(dest, ".pw-ffmpeg-ready");
151
+ if (!fs.existsSync(marker) && fs.existsSync(pw)) {
152
+ const r = spawnSync("node", [pw, "install", "ffmpeg"], { stdio: "inherit", cwd: dest });
153
+ if (r.status === 0) { fs.writeFileSync(marker, new Date().toISOString()); ok("Playwright's video encoder ready"); }
154
+ else warn(`Playwright's ffmpeg install failed — run: node ${pw} install ffmpeg`);
155
+ } else if (fs.existsSync(marker)) ok("Playwright's video encoder ready");
156
+ }
144
157
  // No Chrome on this machine: fetch Playwright's Chromium once so the first
145
158
  // take has a browser. Linux needs the shared libraries too (--with-deps).
146
159
  if (!haveChrome) {
@@ -165,6 +178,103 @@ if (!haveChrome) {
165
178
  if (r.status !== 0) warn("Recording will fail until ffmpeg and ffprobe both work. Re-run this command, or install them on your system.");
166
179
  }
167
180
 
181
+
182
+ // ── LISTENING (founder 2026-10-14 night, "YES!!!!"): `npx demobite listen`.
183
+ // The machine checks in to the app with the person's recorder key, pulls
184
+ // the jobs the Capture tab created for this owner, runs the person's own
185
+ // coding agent headless with the recorder skill, and posts every moment
186
+ // back. One verb — record — for the owner only; never "remote control".
187
+ // Closing the terminal ends it. `--base <url>` points at dev or a preview.
188
+ if (arg === "listen") {
189
+ const argvRest = process.argv.slice(3);
190
+ const baseFlag = argvRest[argvRest.indexOf("--base") + 1];
191
+ let cfg = readCfg();
192
+ const base = (argvRest.includes("--base") && baseFlag ? baseFlag : cfg?.base || "https://app.demobites.com").replace(/\/+$/, "");
193
+ if (!cfg?.api_key || (argvRest.includes("--base") && cfg?.base && cfg.base.replace(/\/+$/, "") !== base)) {
194
+ console.log("\n Connecting this project to your DemoBites account first…\n");
195
+ const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs"), base], { stdio: "inherit", cwd: process.cwd() });
196
+ if (r.status !== 0) process.exit(r.status ?? 1);
197
+ cfg = readCfg();
198
+ }
199
+ if (!cfg?.api_key) { warn("Not connected — run: npx demobite login"); process.exit(1); }
200
+ const listenFile = path.join(process.cwd(), ".recorder", "listen.json");
201
+ let listen = {};
202
+ try { listen = JSON.parse(fs.readFileSync(listenFile, "utf8")); } catch { /* first time */ }
203
+ if (!listen.machine_id) {
204
+ listen.machine_id = (await import("node:crypto")).randomBytes(12).toString("hex");
205
+ fs.mkdirSync(path.dirname(listenFile), { recursive: true });
206
+ fs.writeFileSync(listenFile, JSON.stringify(listen, null, 2) + "\n");
207
+ }
208
+ const name = (argvRest[argvRest.indexOf("--name") + 1] && argvRest.includes("--name")) ? argvRest[argvRest.indexOf("--name") + 1] : os.hostname().replace(/\.local$/, "");
209
+ const agentBin = (() => { const r = spawnSync(process.platform === "win32" ? "where" : "which", ["claude"], { encoding: "utf8" }); return r.status === 0 ? r.stdout.trim().split("\n")[0] : null; })();
210
+ const readiness = () => {
211
+ let signins = [];
212
+ try { signins = Object.keys(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "signins.json"), "utf8")).origins || {}); } catch { /* none */ }
213
+ return { agent: agentBin ? "Claude Code" : null, chrome: haveChrome, recorder: pkg.version, signins, cwd: process.cwd(), platform: process.platform };
214
+ };
215
+ const api = async (body) => {
216
+ try {
217
+ const res = await fetch(`${base}/api/recorder/listen`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.api_key}` }, body: JSON.stringify(body) });
218
+ if (res.status === 401) { console.error("\n The recorder key was refused. Run: npx demobite login"); process.exit(1); }
219
+ return res.ok ? await res.json() : null;
220
+ } catch { return null; }
221
+ };
222
+ const event = (job_id, kind, payload) => api({ action: "event", job_id, kind, payload });
223
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
224
+
225
+ console.log(`\n Listening as "${name}" for ${cfg.workspace?.name ?? "your workspace"} on ${base.replace(/^https?:\/\//, "")}.`);
226
+ console.log(` Ask for a demo in the app (Create → Agentic Capture). ${agentBin ? "Claude Code found." : "! Claude Code was not found on this machine — install it and sign in, it does the thinking."}`);
227
+ console.log(" Leave this terminal open; closing it stops the listening.\n");
228
+
229
+ const runJob = async (job) => {
230
+ console.log(`\n ▶ Job ${job.id}: ${job.brief.slice(0, 100)}${job.brief.length > 100 ? "…" : ""}`);
231
+ if (!agentBin) { await event(job.id, "failed", { message: "Claude Code is not installed on this machine" }); return; }
232
+ await event(job.id, "started", { machine: name });
233
+ const prompt = [
234
+ "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.",
235
+ `Brief: ${job.brief}`,
236
+ job.url ? `Address to start from: ${job.url}` : "Address: work it out from this project (the saved config, the codebase).",
237
+ `Show the storyboard before filming: ${job.review_storyboard ? "yes — run storyboard.mjs await and obey its answer" : "no — storyboard.mjs await returns film at once"}.`,
238
+ ].join("\n");
239
+ const child = (await import("node:child_process")).spawn(agentBin, ["-p", prompt, "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions"], {
240
+ cwd: process.cwd(),
241
+ env: { ...process.env, RECORDER_JOB: job.id, RECORDER_BASE: base, RECORDER_LISTEN: "1" },
242
+ stdio: ["ignore", "pipe", "pipe"],
243
+ });
244
+ let buf = "";
245
+ child.stdout.on("data", (chunk) => {
246
+ buf += chunk.toString();
247
+ let nl;
248
+ while ((nl = buf.indexOf("\n")) >= 0) {
249
+ const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
250
+ if (!line.trim()) continue;
251
+ try {
252
+ const m = JSON.parse(line);
253
+ if (m.type === "assistant") {
254
+ for (const c of m.message?.content ?? []) {
255
+ if (c.type === "text" && c.text?.trim()) { console.log(` · ${c.text.trim().split("\n")[0].slice(0, 140)}`); void event(job.id, "agent", { text: c.text.trim().slice(0, 1200) }); }
256
+ }
257
+ }
258
+ } catch { /* not a JSON line */ }
259
+ }
260
+ });
261
+ child.stderr.on("data", (chunk) => { const t = chunk.toString().trim(); if (t) console.error(` ! ${t.split("\n")[0].slice(0, 160)}`); });
262
+ const code = await new Promise((resolve) => child.on("close", resolve));
263
+ const st = await api({ action: "poll", job_id: job.id });
264
+ if (st && !["ready", "failed", "cancelled"].includes(st.status)) {
265
+ if (code === 0) await event(job.id, "note", { text: "The agent finished without a ready signal." });
266
+ await event(job.id, "failed", { message: code === 0 ? "The demo did not come through — try again" : `The agent stopped (exit ${code})` });
267
+ }
268
+ console.log(` ■ Job ${job.id} ${st?.status ?? "ended"}.`);
269
+ };
270
+
271
+ for (;;) {
272
+ const r = await api({ action: "checkin", machine_id: listen.machine_id, name, readiness: readiness() });
273
+ if (r?.job) await runJob(r.job);
274
+ else await sleep(5000);
275
+ }
276
+ }
277
+
168
278
  // ── 3. Subcommands ─────────────────────────────────────────────────────────
169
279
  if (arg === "login" || arg === "logout") {
170
280
  // `login <base>` forwards the base (dev, a preview) to login.mjs, which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobite",
3
- "version": "1.8.2",
3
+ "version": "1.9.0",
4
4
  "description": "The DemoBites agentic recorder — you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
5
5
  "bin": {
6
6
  "demobite": "launcher/index.mjs"
@@ -12,6 +12,10 @@
12
12
  import { chromium } from "playwright";
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
+ // LISTENING (2026-10-14): the app's mailbox, when `npx demobite listen` runs
16
+ // this take; a no-op everywhere else (the terminal flow, the cloud sandbox
17
+ // copy that may not ship listen-event.mjs).
18
+ const postEvent = await import("./listen-event.mjs").then((m) => m.postEvent).catch(() => async () => null);
15
19
 
16
20
  const [, , outArg, storyArg] = process.argv;
17
21
  if (!outArg || !storyArg) {
@@ -775,7 +779,7 @@ try {
775
779
  process.stdout.write(`step ${rec.n} ${step.action} ${step.label ?? ""}\n`);
776
780
  // The customer's line (founder 2026-10-14): one bar, step n of N. The
777
781
  // agent relays this, never the step/action lines above.
778
- { const N = STORYBOARD.steps.length, filled = Math.round((rec.n / N) * 20); process.stdout.write(`Filming ${"█".repeat(filled)}${"░".repeat(20 - filled)} step ${rec.n} of ${N}\n`); }
782
+ { const N = STORYBOARD.steps.length, filled = Math.round((rec.n / N) * 20); process.stdout.write(`Filming ${"█".repeat(filled)}${"░".repeat(20 - filled)} step ${rec.n} of ${N}\n`); void postEvent("filming", { n: rec.n, N }); }
779
783
  if (step.action === "goto") {
780
784
  const navIssuedAt = t();
781
785
  // domcontentloaded, not load: a heavy site behind a proxy (the cloud
package/skill/SKILL.md CHANGED
@@ -24,6 +24,17 @@ The plugin is a product. Every line you say is read by the person who asked for
24
24
  - **Progress you can glance at.** While filming, relay record.mjs's `Filming ████░░ step n of N` line. While writing the storyboard, one dim line of what you are reading.
25
25
  - **Plain words only.** Never on screen: dry run, control, selector, resolves, beat, surface, profile, headed, Playwright, vocab, obstacle, revert, JSON, storyboard.json, manifest, take. Use: step, demo, page, window, sign in, film, deliver, Demos grid.
26
26
  - **Demo, never recording.** The thing the person gets is a demo; the act is filming.
27
+ - **Findings go to us, never to the person.** Anything you learned about the recorder, the installer, the camera or DemoBites itself (a title that got cut, a merge rule, a missing encoder) is written to `.recorder/notes.md` in the project — dated, one paragraph each — and never said in the chat. The person asked for a demo, not a report on our product. (Founder 2026-10-14: "all this nonsense and all this explanation is not relevant.")
28
+
29
+ ## Listening mode — the person is in the app, not here
30
+
31
+ When `RECORDER_JOB` is set (the take was asked for in the DemoBites app and `npx demobite listen` is running you), the person watches the Capture tab, not this conversation. Everything you would have said to them, the scripts carry to the app; everything you would have asked, the app answers:
32
+
33
+ - **Never ask anything here.** No sign-in question, no "1 / 2 / 3", no "say yes". Nobody reads this conversation.
34
+ - **The sign-in:** when `signin.mjs --check <url>` says "none", run `signin.mjs <url>` at once — it tells the app "sign in on your machine" and the window opens there.
35
+ - **The storyboard:** after writing `storyboard.json`, run `node scripts/storyboard.mjs await <takeDir>/storyboard.json` instead of printing the box. It sends the box to the app and prints the person's answer: `film` → Phase 5; `change: <note>` → rewrite the storyboard, run `await` again; `cancelled` → stop, say nothing.
36
+ - **Filming, delivery, ready:** `record.mjs`, `upload.mjs` and `status.mjs` post their moments by themselves. Run them exactly as in the phases; the app shows the bar and the editor link.
37
+ - **Findings** still go to `.recorder/notes.md`.
27
38
 
28
39
  ## Phase 0: Auth gate, ALWAYS FIRST — announced, never silent
29
40
 
@@ -366,7 +377,7 @@ On the person's "1": say "Filming. About <m:ss>; I'll tell you when it lands." a
366
377
 
367
378
  ## Phase 6: Deliver into DemoBites
368
379
 
369
- When `upload.mjs` prints its last line, say: "✓ Delivered. \"<title>\" (<m:ss>) is landing in your Demos grid now: <base>/demos — watch it come in there. You can change any sentence, the voice and the zooms in the editor." When minutes ran out, relay upload.mjs's "Kept…" line as it is.
380
+ When `upload.mjs` prints its last line, say ONE line: "✓ Delivered. \"<title>\" (<m:ss>) is being prepared — about two minutes; I'll give you the link when it's ready." Then run `node scripts/status.mjs <takeDir>` (it waits until the demo is completed and prints `Studio: <url>`). When it does, say exactly, and only: "✓ Ready — open it: <that url>". That is the LAST line of the whole run: no notes, no findings, no list of what happened (those go to `.recorder/notes.md`, How you talk). When minutes ran out, relay upload.mjs's "Kept…" line as it is, and stop.
370
381
 
371
382
 
372
383
  Send the TRIMMED CLEAN take into DemoBites. The studio owns the look: NO backdrop, NO rounded corners, NO shadow on the uploaded file. Everything (voice, zooms, look, intro, outro) becomes editable there.
@@ -0,0 +1,27 @@
1
+ // AGENTIC CAPTURE — LISTENING (founder 2026-10-14): the recorder's moments,
2
+ // posted to the app's mailbox when a job is running (`npx demobite listen`
3
+ // sets RECORDER_JOB and RECORDER_BASE). Outside listening mode every call is
4
+ // a no-op, so the terminal flow is untouched. Never throws: a lost event
5
+ // never stops a take.
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+
9
+ const job = process.env.RECORDER_JOB || "";
10
+ const base = (process.env.RECORDER_BASE || "").replace(/\/+$/, "");
11
+ export const listening = Boolean(job && base);
12
+
13
+ const apiKey = () => {
14
+ try { return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), ".recorder", "config.json"), "utf8")).api_key || ""; } catch { return ""; }
15
+ };
16
+ const call = async (body) => {
17
+ const key = apiKey();
18
+ if (!listening || !key) return null;
19
+ try {
20
+ const res = await fetch(`${base}/api/recorder/listen`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` }, body: JSON.stringify({ job_id: job, ...body }) });
21
+ return res.ok ? await res.json() : null;
22
+ } catch { return null; }
23
+ };
24
+ /** One moment of the job: started | signin_wait | signed_in | storyboard | filming | delivered | ready | failed | note | agent */
25
+ export const postEvent = (kind, payload) => call({ action: "event", kind, payload });
26
+ /** The job's status and the person's answer at a stopping point. */
27
+ export const poll = () => call({ action: "poll" });
@@ -26,6 +26,7 @@
26
26
  // recorder's own health check, no human needed.
27
27
  import fs from "node:fs";
28
28
  import path from "node:path";
29
+ import { postEvent } from "./listen-event.mjs";
29
30
 
30
31
  const argv = process.argv.slice(2);
31
32
  const ledgerPath = path.resolve(process.cwd(), ".recorder", "signins.json");
@@ -86,6 +87,7 @@ if (probe) {
86
87
  }
87
88
 
88
89
  console.log("⏳ Waiting for you — sign in in the Chrome window that just opened. Your hands only; nothing you type is seen.");
90
+ await postEvent("signin_wait", { url });
89
91
  const started = Date.now();
90
92
  let lastCheck = "none";
91
93
  while (Date.now() - started < timeoutMs) {
@@ -94,6 +96,7 @@ while (Date.now() - started < timeoutMs) {
94
96
  try { here = new URL(page.url()); } catch { continue; }
95
97
  if (here.pathname !== startPath && !/sign-?in|log-?in|auth|sso|oauth|callback/i.test(here.pathname)) {
96
98
  console.log(`✓ Signed in. The session is saved for ${here.origin}; next time there is no question and no window.`);
99
+ await postEvent("signed_in", { origin: here.origin });
97
100
  try { const l = readLedger(); l.origins = { ...(l.origins ?? {}), [here.origin]: new Date().toISOString() }; fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); fs.writeFileSync(ledgerPath, JSON.stringify(l, null, 2) + "\n"); } catch { /* the session still lives in the profile */ }
98
101
  await ctx.close();
99
102
  process.exit(0);
@@ -18,6 +18,10 @@
18
18
  // waiting: "minutes", resetsAt, dashboardUrl, stagingId }) and waits on the
19
19
  // same grid; the stage status reads "waiting" until minutes are back.
20
20
 
21
+ // LISTENING (2026-10-14): the app's mailbox, when `npx demobite listen` runs
22
+ // this take; a no-op everywhere else (the terminal flow, the cloud sandbox
23
+ // copy that may not ship listen-event.mjs).
24
+ const postEvent = await import("./listen-event.mjs").then((m) => m.postEvent).catch(() => async () => null);
21
25
  /** Where the person watches their takes: the Demos grid. `dashboardUrl` from
22
26
  * the server wins (relative or absolute), else <base>/demos. */
23
27
  export function gridUrl(base, dashboardUrl) {
@@ -106,6 +110,7 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
106
110
  if (!announced) {
107
111
  process.stdout.write("\n");
108
112
  console.log(st.status === "delivered" ? `delivered: bite ${st.biteId} is being created` : `Approved — bite ${st.biteId} is being created`);
113
+ void postEvent("delivered", { biteId: st.biteId });
109
114
  announced = true;
110
115
  approvedBiteId = st.biteId;
111
116
  finalStudioUrl = st.studioUrl ? new URL(st.studioUrl, base).toString() : null;
@@ -146,6 +151,7 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
146
151
  if (last.zooms === 0) console.error("WARNING: no camera shots landed.");
147
152
  }
148
153
  if (finalStudioUrl) console.log(`Studio: ${finalStudioUrl}`);
154
+ await postEvent("ready", { biteId: approvedBiteId, studioUrl: finalStudioUrl ?? null });
149
155
  return { exitCode: 0, status: "completed", biteId: approvedBiteId, studioUrl: finalStudioUrl };
150
156
  }
151
157
 
@@ -6,15 +6,19 @@
6
6
  //
7
7
  // node scripts/storyboard.mjs print <storyboard.json> the box: number, time, one line per step, the total
8
8
  // node scripts/storyboard.mjs words <storyboard.json> every step's narration, word for word (option 3)
9
+ // node scripts/storyboard.mjs await <storyboard.json> LISTENING MODE: posts the box to the app and waits for the
10
+ // person's answer there — prints "film" or "change: <note>"
11
+ // (or "cancelled"); never asks anything in the chat
9
12
  //
10
13
  // Times are estimates from the steps themselves: a step lasts its dwell, or
11
14
  // as long as its narration takes to say (about 2.5 words a second), with a
12
15
  // floor per action. The real cut is fitted by DemoBites afterwards.
13
16
  import fs from "node:fs";
17
+ import { listening, postEvent, poll } from "./listen-event.mjs";
14
18
 
15
19
  const [mode, file] = process.argv.slice(2);
16
- if (!mode || !file || !["print", "words"].includes(mode)) {
17
- console.error("Usage: node scripts/storyboard.mjs print|words <storyboard.json>");
20
+ if (!mode || !file || !["print", "words", "await"].includes(mode)) {
21
+ console.error("Usage: node scripts/storyboard.mjs print|words|await <storyboard.json>");
18
22
  process.exit(2);
19
23
  }
20
24
  const sb = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -45,6 +49,33 @@ if (mode === "words") {
45
49
  process.exit(0);
46
50
  }
47
51
 
52
+ if (mode === "await") {
53
+ // The box goes to the app; the answer comes back from the app. With the
54
+ // person's "show me the storyboard" box off, the app moves the job on at
55
+ // once and this prints "film" without waiting.
56
+ const boxOut = [];
57
+ const W2 = 76;
58
+ const cap2 = (t, n) => (t.length > n ? t.slice(0, n - 1) + "…" : t);
59
+ const head2 = `─ STORYBOARD · ${cap2(title, 40)} `; const tail2 = ` ${mmss(total)} ─`;
60
+ boxOut.push(`┌${head2}${"─".repeat(Math.max(1, W2 - head2.length - tail2.length))}${tail2}┐`); boxOut.push(`│ ${"".padEnd(W2 - 2)} │`);
61
+ rows.forEach((r, i) => boxOut.push(`│ ${`${String(i + 1).padStart(2)} ${mmss(r.at)} ${cap2(r.text + (r.pointed ? " — pointed at, not pressed" : ""), W2 - 14)}`.padEnd(W2 - 2)} │`));
62
+ boxOut.push(`│ ${"".padEnd(W2 - 2)} │`); boxOut.push(`└${"─".repeat(W2)}┘`);
63
+ if (!listening) { console.log(boxOut.join("\n")); console.log("film"); process.exit(0); }
64
+ await postEvent("storyboard", { box: boxOut.join("\n"), total: mmss(total), steps: rows.map((r, i) => ({ n: i + 1, at: mmss(r.at), text: r.text, pointed: r.pointed })) });
65
+ const started = Date.now();
66
+ while (Date.now() - started < 30 * 60 * 1000) {
67
+ const st = await poll();
68
+ if (st?.status === "cancelled" || st?.status === "failed") { console.log("cancelled"); process.exit(2); }
69
+ if (st && st.status !== "waiting_storyboard") {
70
+ const a = st.answer && typeof st.answer === "object" ? st.answer : null;
71
+ if (a?.action === "change") { console.log(`change: ${a.note ?? ""}`); process.exit(0); }
72
+ console.log("film"); process.exit(0);
73
+ }
74
+ await new Promise((r) => setTimeout(r, 3000));
75
+ }
76
+ console.log("cancelled"); process.exit(2);
77
+ }
78
+
48
79
  const W = 76;
49
80
  const cap = (t, n) => (t.length > n ? t.slice(0, n - 1) + "…" : t);
50
81
  const head = `─ STORYBOARD · ${cap(title, 40)} `;