demobite 1.14.0 → 1.15.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.
@@ -333,9 +333,12 @@ if (arg === "listen") {
333
333
  const takeDir = path.join(process.cwd(), ".recorder", "takes", job.id);
334
334
  fs.mkdirSync(takeDir, { recursive: true });
335
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
- const r = spawnSync("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" } });
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));
337
340
  const st = await api({ action: "poll", job_id: job.id });
338
- if (st && !["ready", "failed", "cancelled"].includes(st.status)) await event(job.id, "failed", { message: r.status === 0 ? "The demo did not come through — try again" : `The take stopped (exit ${r.status})` });
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})` });
339
342
  clearInterval(heartbeat);
340
343
  console.log(` ■ Job ${job.id} ${st?.status ?? "ended"}.\n`);
341
344
  waitOn();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobite",
3
- "version": "1.14.0",
3
+ "version": "1.15.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,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();
@@ -108,6 +108,26 @@ const screenshot = (url) => {
108
108
  { type: "image", source: { type: "base64", media_type: "image/jpeg", data: fs.readFileSync(file).toString("base64") } },
109
109
  ];
110
110
  };
111
+ let explored = 0;
112
+ const explore = (from, lookingFor, depth) => {
113
+ if (!/^https?:\/\//.test(String(from))) return [{ type: "text", text: "from (a url) required" }];
114
+ const words = Array.isArray(lookingFor) ? lookingFor.join(",") : String(lookingFor || "");
115
+ if (!words.trim()) return [{ type: "text", text: "looking_for required" }];
116
+ if (++explored > 3) return [{ type: "text", text: "No more exploring for this storyboard; write it from what you saw." }];
117
+ const exDir = path.join(dir, `explore-${explored}`);
118
+ fs.mkdirSync(exDir, { recursive: true });
119
+ 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 });
120
+ const lines = (child.stdout || "").split("\n");
121
+ for (const l of lines) if (l.startsWith("· ")) say(l.slice(2));
122
+ const last = lines.filter((l) => l.startsWith("RESULT ")).pop();
123
+ let res = null; try { res = JSON.parse(last.slice(7)); } catch { /* none */ }
124
+ if (!res) return [{ type: "text", text: `Exploring failed: ${(child.stderr || "").trim().split("\n").pop() || "no result"}` }];
125
+ const content = [];
126
+ 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");
127
+ 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.` });
128
+ 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") } });
129
+ return content;
130
+ };
111
131
  const probe = (sb) => {
112
132
  if (!sb || !Array.isArray(sb.steps)) return "storyboard with steps[] required";
113
133
  const file = path.join(dir, "probe.json");
@@ -158,7 +178,7 @@ const prune = (messages) => {
158
178
  if (b.type !== "tool_result" || !Array.isArray(b.content)) continue;
159
179
  for (let k = 0; k < b.content.length; k++) {
160
180
  if (b.content[k].type !== "image") continue;
161
- if (++seen > 2) b.content[k] = { type: "text", text: "(a screenshot shown earlier)" };
181
+ if (++seen > 4) b.content[k] = { type: "text", text: "(a screenshot shown earlier)" };
162
182
  }
163
183
  }
164
184
  }
@@ -172,7 +192,7 @@ const firstMessage = () => {
172
192
  `## The app's words on the starting screen\n${vocab}`,
173
193
  `## Workspace rules (version ${rules.version || 0})\n${rules.lines?.length ? rules.lines.map((l, i) => `${i + 1}. ${l}`).join("\n") : "(none)"}`,
174
194
  `## 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.`,
195
+ 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
196
  ];
177
197
  return parts.join("\n\n");
178
198
  };
@@ -206,6 +226,7 @@ const plan = async () => {
206
226
  if (u.name === "list_files") out = listFiles();
207
227
  else if (u.name === "read_file") out = readFile(input.path);
208
228
  else if (u.name === "harvest_words") { console.log(` harvesting ${(input.urls || []).join(", ")}`); out = harvest(input.urls); }
229
+ else if (u.name === "explore") out = explore(input.from, input.looking_for, input.depth);
209
230
  else if (u.name === "screenshot") { console.log(` looking at ${input.url}`); out = screenshot(input.url); }
210
231
  else if (u.name === "probe") { console.log(" dry run…"); out = probe(input.storyboard); }
211
232
  else if (u.name === "submit_storyboard") {