demobite 1.14.1 → 1.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobite",
3
- "version": "1.14.1",
3
+ "version": "1.16.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"
@@ -0,0 +1,28 @@
1
+ // THE CAMERA'S CLOCK (2026-10-15). What a storyboard step really costs on the camera, so the
2
+ // planner's total is the take's total. Numbers from record.mjs: estimateSpeech = words / 2.6 + 0.4;
3
+ // the hold is speech + 0.5 s breath measured from arrival; a click or hover spends ~1 s on cursor
4
+ // flight and dwell before arrival; the first narrated beat carries STARTUP_LEAD_SLACK_S = 2.5;
5
+ // the take closes on CLOSING_BEAT_MS = 2200. Keep the two in step.
6
+ export const CLOSING_S = 2.2;
7
+ const ms = (v, fallback) => (v == null ? fallback : v < 60 ? v * 1000 : v);
8
+ const speech = (text) => { const n = String(text ?? "").trim().split(/\s+/).filter(Boolean).length; return n ? n / 2.6 + 0.4 + 0.5 : 0; };
9
+ export const secondsOf = (s, firstNarrated) => {
10
+ const text = typeof s.narration === "string" ? s.narration : s.narration?.text ?? "";
11
+ const say = speech(text);
12
+ const slack = say && firstNarrated ? 2.5 : 0;
13
+ switch (s.action) {
14
+ case "goto": return 0.1;
15
+ case "settle": return Math.max(ms(s.ms, 1200) / 1000, say) + slack;
16
+ case "scroll": return Math.max(ms(s.ms, 1900) / 1000, say) + slack;
17
+ case "click":
18
+ case "hover": return 1.0 + Math.max(ms(s.after, 2000) / 1000, say) + slack;
19
+ case "type": return 1.0 + String(s.text ?? "").length * 0.05 + Math.max(ms(s.after, 1500) / 1000, say) + slack;
20
+ case "expect": return 0.3;
21
+ default: return 1 + say;
22
+ }
23
+ };
24
+ export const estimateTake = (steps) => {
25
+ let first = true, total = CLOSING_S;
26
+ for (const s of steps ?? []) { total += secondsOf(s, first); if (s.narration) first = false; }
27
+ return total;
28
+ };
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ // EXPLORE (the built-in agent, founder 2026-10-15: "the agent we're building does
3
+ // a very short exploration if the brief isn't enough… the customer needs
4
+ // nothing but identity"). Without the app's code, the planner knows only the
5
+ // words on the starting page. This walks the live app for it: from the start
6
+ // address it follows the nav links, tabs and menus whose words match what the
7
+ // planner is looking for (the brief's nouns), two hops deep, and brings back
8
+ // every screen it reached — address, title, headings, tabs, controls — with a
9
+ // screenshot of the best matches. Headless, on the camera's profile (signed
10
+ // in), no video, no window. Reversible clicks only: links, tabs, menu
11
+ // openers; never a button that submits, creates, deletes or pays.
12
+ //
13
+ // node scripts/explore.mjs <url> --for "integrations,settings,github" [--depth 2] [--out <dir>]
14
+ // prints one "· phrase" per screen (the app shows them live), then RESULT {json}
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ const argv = process.argv.slice(2);
19
+ const url = argv.find((a) => /^https?:\/\//.test(a));
20
+ const forArg = argv[argv.indexOf("--for") + 1] || "";
21
+ const depth = Math.min(3, Math.max(1, Number(argv[argv.indexOf("--depth") + 1]) || 2));
22
+ const outDir = argv.includes("--out") ? argv[argv.indexOf("--out") + 1] : process.cwd();
23
+ if (!url || !forArg) { console.error('Usage: node scripts/explore.mjs <url> --for "word,word" [--depth 2] [--out <dir>]'); process.exit(2); }
24
+ const STOP = new Set(["the", "a", "an", "and", "or", "of", "to", "in", "on", "at", "for", "with", "page", "section", "tab", "go", "open", "show", "explain", "demo", "record", "then", "available", "there", "it", "its", "this", "that", "your", "our", "my", "how", "what", "all", "about", "from", "into"]);
25
+ const stem = (w) => w.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/(ings|ing|ies|es|s|ed)$/, "");
26
+ const wants = [...new Set(forArg.split(/[,\s]+/).map(stem).filter((w) => w.length > 2 && !STOP.has(w)))];
27
+ const say = (t) => console.log(`· ${t}`);
28
+ const DANGER = /\b(delete|remove|pay|purchase|buy|subscribe|upgrade|publish|send|submit|save|create|add|invite|export|sign out|log out|logout|cancel|disconnect|revoke|reset|confirm)\b/i;
29
+
30
+ const { chromium } = await import("playwright");
31
+ const profileDir = process.env.RECORDER_PROFILE || path.resolve(process.cwd(), ".recorder", "profile");
32
+ const launchOpts = { headless: true, viewport: { width: 1440, height: 900 }, args: ["--force-color-profile=srgb", "--disable-blink-features=AutomationControlled"], ignoreDefaultArgs: ["--enable-automation"] };
33
+ let ctx;
34
+ try { ctx = await chromium.launchPersistentContext(profileDir, { ...launchOpts, channel: "chrome" }); }
35
+ catch { ctx = await chromium.launchPersistentContext(profileDir, launchOpts); }
36
+ const page = ctx.pages()[0] ?? (await ctx.newPage());
37
+ const origin = new URL(url).origin;
38
+
39
+ // What is on this screen, and which of its controls could lead somewhere.
40
+ const harvest = () => page.evaluate(() => {
41
+ const vis = (el) => { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return r.width > 0 && r.height > 0 && cs.visibility !== "hidden" && cs.display !== "none"; };
42
+ const clean = (t) => (t || "").replace(/\s+/g, " ").trim();
43
+ const label = (el) => clean(el.innerText || el.getAttribute("aria-label") || el.getAttribute("title") || "");
44
+ const uniq = (xs) => { const seen = new Set(); return xs.filter((x) => { const k = JSON.stringify(x); if (!x || seen.has(k)) return false; seen.add(k); return true; }); };
45
+ const headings = uniq([...document.querySelectorAll("h1, h2, h3")].filter(vis).map((el) => clean(el.innerText)).filter((t) => t && t.length < 90));
46
+ const links = uniq([...document.querySelectorAll("a[href]")].filter(vis).map((el) => ({ kind: "link", text: label(el).slice(0, 60), href: el.href, tip: clean(el.closest("[title],[aria-label]")?.getAttribute("title") || el.closest("[aria-label]")?.getAttribute("aria-label") || "").slice(0, 60) })).filter((l) => (l.text || l.tip) && l.href.startsWith(location.origin) && !/^(mailto|tel):/.test(l.href)));
47
+ const tabs = uniq([...document.querySelectorAll("[role='tab'], [role='tablist'] button, [data-state] button[data-value], nav button, header button, aside button, button[aria-haspopup], button[aria-expanded]")].filter(vis).map((el) => ({ kind: el.getAttribute("role") === "tab" ? "tab" : "menu", text: label(el).slice(0, 60), id: el.id || el.getAttribute("data-value") || "" })).filter((t) => t.text));
48
+ const buttons = uniq([...document.querySelectorAll("button, [role='button']")].filter(vis).map((el) => label(el).slice(0, 60)).filter(Boolean)).slice(0, 40);
49
+ const fields = uniq([...document.querySelectorAll("input, textarea, select")].filter(vis).map((el) => clean(el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.closest("label")?.innerText || "")).filter(Boolean)).slice(0, 20);
50
+ const dialog = clean(document.querySelector("[role='dialog'] h1, [role='dialog'] h2, [role='dialog'] h3")?.innerText || "");
51
+ return { title: document.title, url: location.href, headings, links, tabs, buttons, fields, dialog };
52
+ });
53
+ const score = (text) => { const s = stem(text); let n = 0; for (const w of wants) if (s.includes(w) || w.includes(s) && s.length > 3) n++; return n; };
54
+
55
+ const screens = [];
56
+ const seen = new Set();
57
+ let shots = 0;
58
+ const started = Date.now();
59
+ const budgetMs = 75000;
60
+ const settle = async () => { await page.waitForLoadState("load").catch(() => {}); await page.waitForTimeout(1800); };
61
+
62
+ const record = async (how) => {
63
+ const h = await harvest();
64
+ const key = h.url.replace(/[#?].*$/, "") + (h.dialog ? `#${h.dialog}` : "") + (h.url.includes("?") ? `?${new URL(h.url).search.slice(1)}` : "");
65
+ if (seen.has(key)) return null;
66
+ seen.add(key);
67
+ const sc = score([h.title, ...h.headings, h.dialog].join(" ")) * 2 + h.tabs.reduce((n, t) => n + score(t.text), 0) + h.buttons.reduce((n, b) => n + score(b), 0);
68
+ const entry = { how, url: h.url, title: h.title, headings: h.headings.slice(0, 12), tabs: h.tabs.map((t) => t.text).slice(0, 20), buttons: h.buttons, fields: h.fields, dialog: h.dialog || undefined, nav: h.links.filter((l) => l.tip || l.text).slice(0, 40).map((l) => `${l.text || l.tip} → ${l.href.replace(origin, "")}`), score: sc, shot: null };
69
+ if (shots < 6) { const f = path.join(outDir, `explore-${++shots}.jpg`); await page.screenshot({ path: f, type: "jpeg", quality: 50 }).catch(() => {}); entry.shot = f; }
70
+ screens.push(entry);
71
+ say(`${how}: "${(h.dialog || h.headings[0] || h.title || h.url).slice(0, 70)}"${h.tabs.length ? ` · tabs: ${h.tabs.map((t) => t.text).slice(0, 6).join(", ")}` : ""}`);
72
+ return { h, entry };
73
+ };
74
+
75
+ const candidates = (h) => {
76
+ const out = [];
77
+ for (const l of h.links) { const s = score(`${l.text} ${l.tip} ${l.href.replace(origin, "")}`); if (s) out.push({ s, kind: "link", text: l.text || l.tip, href: l.href }); }
78
+ for (const t of h.tabs) { const s = score(t.text); if (s && !DANGER.test(t.text)) out.push({ s, kind: t.kind, text: t.text }); }
79
+ return out.sort((a, b) => b.s - a.s).slice(0, 4);
80
+ };
81
+
82
+ const visit = async (c, fromUrl) => {
83
+ if (c.kind === "link") {
84
+ await page.goto(c.href, { waitUntil: "load", timeout: 30000 }).catch(() => null);
85
+ await settle();
86
+ return record(`${c.text}`);
87
+ }
88
+ // A tab or a menu opener on the current page: click it by its text, stay on the page.
89
+ if (page.url() !== fromUrl) { await page.goto(fromUrl, { waitUntil: "load", timeout: 30000 }).catch(() => null); await settle(); }
90
+ const loc = page.locator(c.kind === "tab" ? `[role='tab']:has-text("${c.text.replace(/"/g, "")}")` : `button:has-text("${c.text.replace(/"/g, "")}")`).first();
91
+ const ok = await loc.click({ timeout: 5000 }).then(() => true).catch(() => false);
92
+ if (!ok) return null;
93
+ await page.waitForTimeout(1500);
94
+ const r = await record(`${c.text}`);
95
+ // A menu that opened: its links are candidates too, then close it.
96
+ if (c.kind === "menu" && r) { r.menu = true; }
97
+ return r;
98
+ };
99
+
100
+ try {
101
+ say(`Looking around ${new URL(url).host} for ${wants.join(", ")}…`);
102
+ await page.goto(url, { waitUntil: "load", timeout: 45000 });
103
+ await settle();
104
+ let frontier = [];
105
+ const root = await record("Start");
106
+ if (root) frontier.push({ ...root, d: 0 });
107
+ while (frontier.length && Date.now() - started < budgetMs && screens.length < 10) {
108
+ const cur = frontier.shift();
109
+ if (cur.d >= depth) continue;
110
+ for (const c of candidates(cur.h)) {
111
+ if (Date.now() - started > budgetMs || screens.length >= 10) break;
112
+ const r = await visit(c, cur.entry.url);
113
+ if (r) frontier.push({ ...r, d: cur.d + 1 });
114
+ if (r?.menu) await page.keyboard.press("Escape").catch(() => {});
115
+ }
116
+ }
117
+ say(screens.length > 1 ? `Found ${screens.length - 1} screen${screens.length > 2 ? "s" : ""} beyond the start.` : "Nothing beyond the start page matched.");
118
+ screens.sort((a, b) => b.score - a.score);
119
+ console.log("RESULT " + JSON.stringify({ wants, screens }));
120
+ } catch (e) {
121
+ console.error(String(e.message).split("\n")[0].slice(0, 160));
122
+ console.log("RESULT " + JSON.stringify({ wants, screens, error: String(e.message).slice(0, 120) }));
123
+ }
124
+ await ctx.close();
@@ -347,6 +347,10 @@ const wire = {
347
347
  frame: { width: FW, height: FH },
348
348
  // BROWSER HEADER (2026-09-16): true by default; DemoBites adds the dark macOS bar on top at ingest. A workspace rule "No browser header" sets it false.
349
349
  ...(typeof man.browserHeader === "boolean" ? { browserHeader: man.browserHeader } : {}),
350
+ // APPROVED STORY (founder 2026-10-15, bite 1228: "the storyteller is blabbing"): the lines came from the
351
+ // director and the person said Film it to THESE lines. DemoBites keeps them verbatim and never writes
352
+ // a filler into a silence or a longer line into a longer beat.
353
+ narration_approved: true,
350
354
  duration,
351
355
  steps,
352
356
  camera,
@@ -18,6 +18,7 @@
18
18
  // reads is logged on the take (<takeDir>/code-shared.log) so the person can
19
19
  // see exactly what left the machine.
20
20
  import fs from "node:fs";
21
+ import { estimateTake } from "./clock.mjs";
21
22
  import path from "node:path";
22
23
  import { spawnSync } from "node:child_process";
23
24
  import { fileURLToPath } from "node:url";
@@ -108,6 +109,26 @@ const screenshot = (url) => {
108
109
  { type: "image", source: { type: "base64", media_type: "image/jpeg", data: fs.readFileSync(file).toString("base64") } },
109
110
  ];
110
111
  };
112
+ let explored = 0;
113
+ const explore = (from, lookingFor, depth) => {
114
+ if (!/^https?:\/\//.test(String(from))) return [{ type: "text", text: "from (a url) required" }];
115
+ const words = Array.isArray(lookingFor) ? lookingFor.join(",") : String(lookingFor || "");
116
+ if (!words.trim()) return [{ type: "text", text: "looking_for required" }];
117
+ if (++explored > 3) return [{ type: "text", text: "No more exploring for this storyboard; write it from what you saw." }];
118
+ const exDir = path.join(dir, `explore-${explored}`);
119
+ fs.mkdirSync(exDir, { recursive: true });
120
+ const child = spawnSync("node", [script("explore.mjs"), from, "--for", words, "--depth", String(Math.min(3, Math.max(1, Number(depth) || 2))), "--out", exDir], { cwd: process.cwd(), encoding: "utf8", env: process.env, maxBuffer: 64 * 1024 * 1024 });
121
+ const lines = (child.stdout || "").split("\n");
122
+ for (const l of lines) if (l.startsWith("· ")) say(l.slice(2));
123
+ const last = lines.filter((l) => l.startsWith("RESULT ")).pop();
124
+ let res = null; try { res = JSON.parse(last.slice(7)); } catch { /* none */ }
125
+ if (!res) return [{ type: "text", text: `Exploring failed: ${(child.stderr || "").trim().split("\n").pop() || "no result"}` }];
126
+ const content = [];
127
+ const text = res.screens.map((sc, i) => `## Screen ${i + 1}: ${sc.title || sc.url}\n${sc.url}\nreached by: ${sc.how}${sc.dialog ? `\ndialog: ${sc.dialog}` : ""}\nheadings: ${sc.headings.join(" · ") || "(none)"}\ntabs: ${sc.tabs.join(" · ") || "(none)"}\nbuttons: ${sc.buttons.join(" · ") || "(none)"}${sc.fields.length ? `\nfields: ${sc.fields.join(" · ")}` : ""}\nnav: ${sc.nav.slice(0, 25).join(" · ") || "(none)"}`).join("\n\n");
128
+ content.push({ type: "text", text: `${text || "(no screens)"}${res.error ? `\n\n(stopped early: ${res.error})` : ""}\n\nScreenshots follow for the best matches, in that order.` });
129
+ for (const sc of res.screens.slice(0, 3)) if (sc.shot && fs.existsSync(sc.shot)) content.push({ type: "image", source: { type: "base64", media_type: "image/jpeg", data: fs.readFileSync(sc.shot).toString("base64") } });
130
+ return content;
131
+ };
111
132
  const probe = (sb) => {
112
133
  if (!sb || !Array.isArray(sb.steps)) return "storyboard with steps[] required";
113
134
  const file = path.join(dir, "probe.json");
@@ -130,6 +151,10 @@ const validate = (sb) => {
130
151
  if (s.frame !== undefined && s.frame !== "close" && s.frame !== "wide") return `${at}: frame must be close or wide`;
131
152
  }
132
153
  if (sb.steps.length > 14) return "more than 14 steps; 6 to 10 is the law";
154
+ // THE CAMERA'S CLOCK (founder 2026-10-15): the take may not run past 45 s. The estimate is the
155
+ // camera's own (clock.mjs); the model gets the number and the cure.
156
+ const est = estimateTake(sb.steps);
157
+ if (est > 45) return `the camera would run this take about ${Math.round(est)} s; the law is 30 to 45. Cut words (6 to 11 a line) or steps (5 to 8 narrated); never pad, never add.`;
133
158
  return null;
134
159
  };
135
160
 
@@ -158,7 +183,7 @@ const prune = (messages) => {
158
183
  if (b.type !== "tool_result" || !Array.isArray(b.content)) continue;
159
184
  for (let k = 0; k < b.content.length; k++) {
160
185
  if (b.content[k].type !== "image") continue;
161
- if (++seen > 2) b.content[k] = { type: "text", text: "(a screenshot shown earlier)" };
186
+ if (++seen > 4) b.content[k] = { type: "text", text: "(a screenshot shown earlier)" };
162
187
  }
163
188
  }
164
189
  }
@@ -172,7 +197,7 @@ const firstMessage = () => {
172
197
  `## The app's words on the starting screen\n${vocab}`,
173
198
  `## Workspace rules (version ${rules.version || 0})\n${rules.lines?.length ? rules.lines.map((l, i) => `${i + 1}. ${l}`).join("\n") : "(none)"}`,
174
199
  `## 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.`,
200
+ codeOn ? `Write the storyboard, probe it, fix it, probe again, then submit_storyboard.` : `There is no code: explore first (explore from the starting address, looking_for = the brief's nouns), then write the storyboard, probe it, fix it, probe again, then submit_storyboard.`,
176
201
  ];
177
202
  return parts.join("\n\n");
178
203
  };
@@ -206,6 +231,7 @@ const plan = async () => {
206
231
  if (u.name === "list_files") out = listFiles();
207
232
  else if (u.name === "read_file") out = readFile(input.path);
208
233
  else if (u.name === "harvest_words") { console.log(` harvesting ${(input.urls || []).join(", ")}`); out = harvest(input.urls); }
234
+ else if (u.name === "explore") out = explore(input.from, input.looking_for, input.depth);
209
235
  else if (u.name === "screenshot") { console.log(` looking at ${input.url}`); out = screenshot(input.url); }
210
236
  else if (u.name === "probe") { console.log(" dry run…"); out = probe(input.storyboard); }
211
237
  else if (u.name === "submit_storyboard") {
@@ -23,11 +23,13 @@ if (!mode || !file || !["print", "words", "await"].includes(mode)) {
23
23
  }
24
24
  const sb = JSON.parse(fs.readFileSync(file, "utf8"));
25
25
  const steps = Array.isArray(sb.steps) ? sb.steps : [];
26
- const FLOOR = { goto: 1.5, settle: 2, click: 2.5, hover: 2, type: 3, scroll: 2, expect: 1 };
27
- const secondsOf = (s) => {
28
- const words = String(s.narration ?? "").trim().split(/\s+/).filter(Boolean).length;
29
- return Math.max(typeof s.dwell === "number" ? s.dwell : 0, words / 2.5, FLOOR[s.action] ?? 2);
30
- };
26
+ // THE CAMERA'S CLOCK (founder 2026-10-15, bite 1228 said 0:36 and ran 0:51): a beat is held until its
27
+ // line would have finished (words / 2.6 s + 0.4 s, record.mjs estimateSpeech) plus half a second of
28
+ // breath; a click or hover adds about a second of cursor flight and dwell; the first narrated settle
29
+ // carries 2.5 s of startup slack; the take closes on a 2.2 s beat. Same numbers as record.mjs.
30
+ import { secondsOf as cameraSeconds, CLOSING_S } from "./clock.mjs";
31
+ let firstNarrated = true;
32
+ const secondsOf = (s) => { const r = cameraSeconds(s, firstNarrated); if (s.narration) firstNarrated = false; return r; };
31
33
  const mmss = (sec) => `${Math.floor(sec / 60)}:${String(Math.round(sec % 60)).padStart(2, "0")}`;
32
34
  // A row per step that says or shows something; silent plumbing (a bare goto,
33
35
  // an expect) rides inside the previous row's time.
@@ -39,7 +41,7 @@ for (const s of steps) {
39
41
  if (text && !(s.action === "expect" && !s.narration)) rows.push({ at: clock, text: text.replace(/\s+/g, " "), pointed: s.pointed === true, step: s });
40
42
  clock += dur;
41
43
  }
42
- const total = clock;
44
+ const total = clock + CLOSING_S;
43
45
  const title = (sb.title ?? sb.app ?? "Demo").toString().trim();
44
46
 
45
47
  if (mode === "words") {