demobites 1.21.1 → 1.23.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.
@@ -36,7 +36,8 @@ const warn = (m) => console.log(` ! ${m}`);
36
36
 
37
37
  // The skill's home and the project's key. The key lives in <cwd>/.recorder/config.json
38
38
  // (login.mjs writes it there), so everything here is per-project.
39
- const skillsDir = path.join(os.homedir(), ".claude", "skills");
39
+ // A test home (DEMOBITE_HOME) keeps its own skill copy too: two recorders on one Mac must never refresh each other's camera.
40
+ const skillsDir = process.env.DEMOBITE_HOME ? path.join(process.env.DEMOBITE_HOME, "skills") : path.join(os.homedir(), ".claude", "skills");
40
41
  const dest = path.join(skillsDir, "agentic-recorder");
41
42
  const readCfg = () => {
42
43
  try { return JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "config.json"), "utf8")); }
@@ -121,6 +122,41 @@ const removeService = () => {
121
122
  else if (process.platform === "linux") spawnSync("systemctl", ["--user", "disable", "--now", SERVICE_UNIT], { stdio: "ignore" });
122
123
  else spawnSync("reg", ["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", RUN_NAME, "/f"], { stdio: "ignore" });
123
124
  };
125
+ // ── demobite repo <site> [--forget] (1.22.0, founder 2026-10-16) ──────────────────────────────
126
+ // Run in the folder where the app's repository is: that folder becomes the code of that SITE on this
127
+ // machine. The planner reads from it when a demo records there (up to 10 files a take, each logged).
128
+ // The binding lives with the machine (~/.demobite/home/.recorder/repos.json, or the Terminal listener's
129
+ // own .recorder), and the app shows it on the bar as the Code mark.
130
+ const SLD = new Set(["co", "com", "net", "org", "gov", "edu", "ac"]);
131
+ const siteOf = (hostname) => { const h = String(hostname).toLowerCase().replace(/:\d+$/, ""); if (/^[\d.]+$/.test(h) || h === "localhost" || !h.includes(".")) return h; const l = h.split("."); return l.length >= 3 && SLD.has(l[l.length - 2]) && l[l.length - 1].length === 2 ? l.slice(-3).join(".") : l.slice(-2).join("."); };
132
+ const reposFile = (cwd) => path.join(cwd, ".recorder", "repos.json");
133
+ if (arg === "repo") {
134
+ const raw = process.argv.slice(3).find((a) => !a.startsWith("--")) || "";
135
+ const forget = process.argv.includes("--forget");
136
+ let site = "";
137
+ try { site = siteOf(new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`).hostname); } catch { /* below */ }
138
+ if (!site) { console.error(" Usage: demobite repo <site> [--forget] (run it in the folder where the app's repository is)\n e.g. demobite repo app.acme.com"); process.exit(2); }
139
+ const lock = readJson(LOCK);
140
+ const home = lock?.cwd && fs.existsSync(path.join(lock.cwd, ".recorder")) ? lock.cwd : SERVICE_CWD;
141
+ const file = reposFile(home);
142
+ const repos = readJson(file) ?? {};
143
+ if (forget) {
144
+ if (!repos[site]) { console.log(` Nothing bound to ${site} on this machine.`); process.exit(0); }
145
+ delete repos[site]; writeJson(file, repos);
146
+ console.log(` ○ ${site} no longer has code on this machine. The planner works from the live app there.`);
147
+ process.exit(0);
148
+ }
149
+ const folder = process.cwd();
150
+ if (spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { encoding: "utf8", cwd: folder }).status !== 0) { console.error(` This folder is not a git repository: ${folder}\n Open a Terminal in the folder where the app's repository is, then run it again.`); process.exit(1); }
151
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", cwd: folder }).stdout.trim() || folder;
152
+ const remote = spawnSync("git", ["config", "--get", "remote.origin.url"], { encoding: "utf8", cwd: top }).stdout.trim();
153
+ const m = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
154
+ const repo = m ? m[1] : path.basename(top);
155
+ repos[site] = { folder: top, repo, at: new Date().toISOString() };
156
+ writeJson(file, repos);
157
+ console.log(` ✓ The code of ${site} on this machine: ${top.replace(os.homedir(), "~")} (${repo}).\n The planner reads it when a demo records there: up to 10 files a take, each one listed in the take.\n Lines it needs pass through DemoBites to the model and are not stored. Undo: demobite repo ${site} --forget`);
158
+ process.exit(0);
159
+ }
124
160
  if (arg === "off") {
125
161
  removeService();
126
162
  const pid = killListener();
@@ -476,8 +512,11 @@ if (arg === "listen") {
476
512
  const readiness = () => {
477
513
  let signins = [];
478
514
  try { signins = Object.keys(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "signins.json"), "utf8")).origins || {}); } catch { /* none */ }
479
- return { ...agentInfo, chrome: haveChrome, recorder: pkg.version, signins, cwd: process.cwd(), platform: process.platform, repo };
515
+ const repos = Object.entries(readJson(reposFile(process.cwd())) ?? {}).filter(([, v]) => v && typeof v.folder === "string" && fs.existsSync(v.folder)).map(([site, v]) => ({ site, folder: v.folder, repo: v.repo ?? null }));
516
+ return { ...agentInfo, chrome: haveChrome, recorder: pkg.version, signins, cwd: process.cwd(), platform: process.platform, repo, repos };
480
517
  };
518
+ // The code bound to a job's site (1.22.0): the planner reads there.
519
+ const codeFor = (url) => { try { const site = siteOf(new URL(url).hostname); const b = (readJson(reposFile(process.cwd())) ?? {})[site]; return b && fs.existsSync(b.folder) ? { site, ...b } : null; } catch { return null; } };
481
520
  const api = async (body) => {
482
521
  try {
483
522
  const res = await fetch(`${base}/api/recorder/listen`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.api_key}` }, body: JSON.stringify(body) });
@@ -649,7 +688,9 @@ if (arg === "listen") {
649
688
  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, ...(job.retake_of ? { retake_of: job.retake_of, retake_note: job.retake_note ?? null, storyboard: job.storyboard ?? null } : {}) }, null, 2) + "\n");
650
689
  // Spawned async (1.14.1): a spawnSync here blocked the event loop, so the 5 s heartbeat never fired
651
690
  // during a take and the rail called the machine offline mid-recording (founder 2026-10-15 17:39).
652
- const child = (await import("node:child_process")).spawn(process.execPath, [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" }, detached: process.platform !== "win32" });
691
+ const bound = job.url ? codeFor(job.url) : null;
692
+ if (bound) console.log(` Code: ${bound.repo ?? bound.site} (${bound.folder.replace(os.homedir(), "~")})`);
693
+ const child = (await import("node:child_process")).spawn(process.execPath, [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", ...(bound ? { DEMOBITES_CODE_DIR: bound.folder } : {}) }, detached: process.platform !== "win32" });
653
694
  track(child);
654
695
  const code = await new Promise((resolve) => child.on("close", resolve));
655
696
  const st = await api({ action: "poll", job_id: job.id });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobites",
3
- "version": "1.21.1",
3
+ "version": "1.23.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
  "demobites": "launcher/index.mjs"
@@ -367,6 +367,33 @@ const pushMouse = (type, x, y, button = null, extra = null) => {
367
367
  // has nothing to sit on between moves.
368
368
  const sampler = setInterval(() => pushMouse("move", cx, cy), CURSOR_SAMPLE_MS);
369
369
 
370
+ // THE LIVE FRAME (founder 2026-10-16, "a thumbnail of what we're recording"): a small still of what the
371
+ // camera sees, posted at every step and every few seconds, so the Recordings row in the app shows the
372
+ // take as it happens. Never on the take's path: a frame that fails is dropped, never waited on. One CDP
373
+ // capture at ~480 px wide, JPEG, well under 40 KB; the server keeps only the latest and drops it when
374
+ // the take ends.
375
+ const FRAME_EVERY_MS = 5000;
376
+ let frameCdp = null;
377
+ let framing = false;
378
+ let frameStep = { n: 0, N: 0 };
379
+ let frameWarned = false;
380
+ const postFrame = async () => {
381
+ if (framing || !frameStep.N) return;
382
+ framing = true;
383
+ // A capture can stall while the page navigates (the first take of 2026-10-16 posted nothing: one hung
384
+ // capture held the flag for the whole take). Four seconds, then the frame is dropped and the next one tries.
385
+ const within = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`timeout ${ms} ms`)), ms))]);
386
+ try {
387
+ frameCdp ??= await within(ctx.newCDPSession(page), 4000);
388
+ const scale = Math.min(1, 480 / VIEW.width);
389
+ const { data } = await within(frameCdp.send("Page.captureScreenshot", { format: "jpeg", quality: 55, clip: { x: 0, y: 0, width: VIEW.width, height: VIEW.height, scale } }), 4000);
390
+ await within(postEvent("frame", { ...frameStep, jpeg: data }), 8000);
391
+ } catch (e) {
392
+ if (!frameWarned) { frameWarned = true; console.error(`frame: dropped (${String(e?.message ?? e).split("\n")[0]})`); }
393
+ } finally { framing = false; }
394
+ };
395
+ const framer = setInterval(() => void postFrame(), FRAME_EVERY_MS);
396
+
370
397
  /** Ask the element what cursor it shows. Takes the locator visibleBox already
371
398
  * resolved — re-scanning cost ~1s of dead video per step, which inflated a
372
399
  * 29s take to 36s purely from instrumentation. Playwright locators pierce
@@ -779,7 +806,7 @@ try {
779
806
  process.stdout.write(`step ${rec.n} ${step.action} ${step.label ?? ""}\n`);
780
807
  // The customer's line (founder 2026-10-14): one bar, step n of N. The
781
808
  // agent relays this, never the step/action lines above.
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 }); }
809
+ { 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 }); frameStep = { n: rec.n, N }; void postFrame(); }
783
810
  if (step.action === "goto") {
784
811
  const navIssuedAt = t();
785
812
  // domcontentloaded, not load: a heavy site behind a proxy (the cloud
@@ -1210,6 +1237,7 @@ try {
1210
1237
  }
1211
1238
 
1212
1239
  clearInterval(sampler);
1240
+ clearInterval(framer);
1213
1241
  manifest.duration = t();
1214
1242
  void postEvent("finishing", { text: "Camera off. Saving the take…" });
1215
1243
  const video = page.video();
@@ -60,12 +60,15 @@ say("Reading the workspace rules…");
60
60
  run("rules.mjs", [dir]);
61
61
  const rules = (() => { try { return JSON.parse(fs.readFileSync(path.resolve(".recorder", "rules.json"), "utf8")); } catch { return { version: 0, lines: [] }; } })();
62
62
 
63
- const codeOn = !process.env.DEMOBITES_NO_CODE && spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { encoding: "utf8" }).status === 0;
63
+ // THE CODE FOLDER (1.22.0, founder 2026-10-16): the folder bound to this site with `demobite repo <site>`
64
+ // (the listener passes it as DEMOBITES_CODE_DIR), else the folder the listener runs in.
65
+ const CODE_DIR = process.env.DEMOBITES_CODE_DIR ? path.resolve(process.env.DEMOBITES_CODE_DIR) : process.cwd();
66
+ const codeOn = !process.env.DEMOBITES_NO_CODE && spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { encoding: "utf8", cwd: CODE_DIR }).status === 0;
64
67
  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;
65
68
  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;
66
69
  const listFiles = () => {
67
70
  if (!codeOn) return "(no repository here, or code sharing is off)";
68
- const r = spawnSync("git", ["ls-files"], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
71
+ const r = spawnSync("git", ["ls-files"], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, cwd: CODE_DIR });
69
72
  if (r.status !== 0) return "(no repository here)";
70
73
  const all = r.stdout.split("\n").filter(Boolean).filter((f) => !SECRET_RE.test(f) && /\.(tsx?|jsx?|vue|svelte|html|mdx?)$/i.test(f));
71
74
  const ui = all.filter((f) => UI_RE.test(f));
@@ -77,9 +80,9 @@ const shared = [];
77
80
  const readFile = (rel) => {
78
81
  if (!codeOn) return "Code sharing is off on this machine.";
79
82
  if (typeof rel !== "string" || !rel.trim()) return "path required";
80
- const abs = path.resolve(process.cwd(), rel);
81
- if (!abs.startsWith(process.cwd() + path.sep)) return "Outside the project.";
82
- const r = path.relative(process.cwd(), abs);
83
+ const abs = path.resolve(CODE_DIR, rel);
84
+ if (!abs.startsWith(CODE_DIR + path.sep)) return "Outside the project.";
85
+ const r = path.relative(CODE_DIR, abs);
83
86
  if (SECRET_RE.test(r)) return "Not that file.";
84
87
  if (reads >= 10) return "No more reads for this storyboard.";
85
88
  let text;