demobites 1.31.0 → 1.33.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": "demobites",
3
- "version": "1.31.0",
3
+ "version": "1.33.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"
@@ -394,7 +394,10 @@ const postFrame = async () => {
394
394
  // the JPEG OUTSIDE the page with ffmpeg. No ffmpeg → no thumbnail, never a glitch.
395
395
  const { data } = await within(frameCdp.send("Page.captureScreenshot", { format: "jpeg", quality: 60 }), 4000);
396
396
  const small = await within(shrinkJpeg(Buffer.from(data, "base64"), 480), 4000);
397
- if (small) await within(postEvent("frame", { ...frameStep, jpeg: small.toString("base64") }), 8000);
397
+ // A BLANK STILL IS NO STILL (founder 2026-10-18: a white box with the red pill while the first page loaded). A
398
+ // 480 px JPEG of a white page is under 3 KB; a page with anything on it is well over. The app keeps the last real
399
+ // still, or says the page is loading.
400
+ if (small && small.length > 3000) await within(postEvent("frame", { ...frameStep, jpeg: small.toString("base64") }), 8000);
398
401
  } catch (e) {
399
402
  if (!frameWarned) { frameWarned = true; console.error(`frame: dropped (${String(e?.message ?? e).split("\n")[0]})`); }
400
403
  } finally { framing = false; }
@@ -1319,11 +1322,28 @@ if (connectedBrowser) { try { await connectedBrowser.close(); } catch {} }
1319
1322
  if (ownedBrowser) { try { await ownedBrowser.close(); } catch {} }
1320
1323
  fs.writeFileSync(path.join(DIR, "manifest.json"), JSON.stringify(manifest, null, 2));
1321
1324
  if (failure) {
1322
- console.error(`TAKE FAILED at step ${manifest.steps.length + 1}: ${failure.message}`);
1325
+ const n = manifest.steps.length + 1;
1326
+ const N = STORYBOARD.steps.length;
1327
+ const st = STORYBOARD.steps[n - 1] || {};
1328
+ console.error(`TAKE FAILED at step ${n}: ${failure.message}`);
1323
1329
  console.error("Partial raw.webm + manifest.json saved in", DIR);
1324
- process.exit(1);
1330
+ // THE REASON REACHES THE PERSON (founder 2026-10-18, "Recording did not finish" with nothing else: "please
1331
+ // analyze"): the step, its words, and what the camera could not do, in plain words. Exit 3 tells plan.mjs the
1332
+ // failure is already posted.
1333
+ const m = /no visible target for (.+)$/.exec(failure.message);
1334
+ const what = m ? `the camera could not find ${describeTarget(m[1])} on the page` : failure.message.replace(/\s+/g, " ").slice(0, 160);
1335
+ const label = st.name || st.label || (st.selector ? describeTarget(st.selector) : st.action) || "";
1336
+ await postEvent("failed", { message: `The recording stopped at step ${n} of ${N}${label ? ` (${label})` : ""}: ${what}.`, step: n, N, reason: failure.message.slice(0, 300) });
1337
+ process.exit(3);
1325
1338
  }
1326
1339
  const cursorKinds = [...new Set(mouseEvents.map((e) => e.cursor))].join(", ");
1340
+ /** A selector in the person's words: has-text('X') → "X"; aria-label → its words; else the selector itself. */
1341
+ function describeTarget(sel) {
1342
+ const t = /has-text\(['"](.+?)['"]\)/.exec(sel)?.[1] || /aria-label=['"](.+?)['"]/.exec(sel)?.[1];
1343
+ if (!t) return `"${sel}"`;
1344
+ const kind = /menuitem/.test(sel) ? "in the menu" : /role=tab/.test(sel) ? "among the tabs" : /button/.test(sel) ? "as a button" : "";
1345
+ return `"${t}"${kind ? ` ${kind}` : ""}`;
1346
+ }
1327
1347
  console.log(
1328
1348
  `DONE raw.webm + manifest.json in ${DIR} — ${manifest.duration.toFixed(1)}s, ` +
1329
1349
  `${manifest.steps.length} steps, ${manifest.shots.length} camera shots, ` +
@@ -22,7 +22,7 @@ import { estimateTake } from "./clock.mjs";
22
22
  import path from "node:path";
23
23
  import { spawnSync } from "node:child_process";
24
24
  import { fileURLToPath } from "node:url";
25
- import { listening, postEvent } from "./listen-event.mjs";
25
+ import { listening, postEvent, poll } from "./listen-event.mjs";
26
26
 
27
27
  const here = path.dirname(fileURLToPath(import.meta.url));
28
28
  const dir = process.argv[2];
@@ -218,7 +218,7 @@ const firstMessage = async () => {
218
218
  // storyboard is the base; the note says what to keep an eye on; the probe finds what moved.
219
219
  job.retake_of && job.storyboard
220
220
  ? `## This is a retake\nThe person wants the same demo filmed again, the same length and the same shots, because the app changed since. Their note: "${(job.retake_note || "no note").replace(/"/g, "'")}". ${job.storyboard.storyboard ? "Start from the previous storyboard below: keep its steps and its lines; probe it; fix only what no longer resolves or what the note names; then submit_storyboard. Never explore when the probe passes." : "Only the previous storyboard's lines survive (below): rebuild the same steps with the same lines, find the controls again, probe, then submit_storyboard."}\n\n## The previous storyboard\n${JSON.stringify(job.storyboard.storyboard ?? job.storyboard.steps ?? job.storyboard)}`
221
- : 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.`,
221
+ : codeOn ? `Write the storyboard from the page's own words, probe it, fix it, probe again, then submit_storyboard. Read a file only when the page's words do not name a control you need; never as a first move. You have three dry runs and two minutes from the first one; when a control of the brief is not where you looked, ask_person at once: the person is there and knows the app.` : `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. You have three dry runs and two minutes from the first one; when a control of the brief is not where you looked, ask_person at once: the person is there and knows the app.`,
222
222
  ];
223
223
  return parts.join("\n\n");
224
224
  };
@@ -226,6 +226,44 @@ const firstMessage = async () => {
226
226
  let storyboard = null;
227
227
  let creates = "";
228
228
  const messages = [{ role: "user", content: [{ type: "text", text: await firstMessage() }] }];
229
+ // THE PLANNING CAP (founder 2026-10-18, a 13-minute storyboard: "inconceivable"): the planner gets FOUR dry runs, or
230
+ // three minutes from the first one, whichever comes first. After that no probe runs; the next move is the storyboard
231
+ // as it stands, with the steps it could not find named in `unsure`, so the person answers the question instead of
232
+ // waiting while it rewrites and retries (that job: 18 dry runs, 13 model turns).
233
+ // THE PERSON LEADS (founder 2026-10-18: "if the planner gets stuck, he should put the customer into discussion…
234
+ // I wouldn't go more than 2 minutes"): three dry runs, or two minutes from the first, and the planner ASKS — one
235
+ // short question through the chat (ask_person); the answer comes back as the tool's result and the counters start
236
+ // again. The person is there and knows their app; a storyboard guessed in minute six is worth less than a question
237
+ // in minute two.
238
+ const CAP_PROBES = 3;
239
+ const CAP_MS = 2 * 60 * 1000;
240
+ let probes = 0;
241
+ let firstProbeAt = 0;
242
+ let asked = 0;
243
+ const capped = () => probes >= CAP_PROBES || (firstProbeAt && Date.now() - firstProbeAt > CAP_MS);
244
+ const CAP_TEXT = () => asked < 2
245
+ ? `STOP (${probes} dry run${probes === 1 ? "" : "s"}, ${Math.round((Date.now() - (firstProbeAt || Date.now())) / 1000)} s since the first). No more dry runs, no more reading. Call ask_person NOW with ONE short question in plain words: what you were looking for, where you looked, and how they want to play it (where it is, or what to show instead). The person is there and knows the app. Their answer comes back as the result; then fix the storyboard from their words, probe once, and hand it in.`
246
+ : `STOP. Hand in the storyboard NOW with submit_storyboard: keep every step that passed; a step that failed becomes a hover or a settle on the nearest element the page does have, or goes; name in \`unsure\` what you could not find.`;
247
+ /** The question goes to the chat; the planner waits here for the person's word (up to 30 minutes). */
248
+ const askPerson = async (text) => {
249
+ const q = String(text || "").trim().slice(0, 600);
250
+ if (!q) return "ask_person needs a question.";
251
+ say("Asking you in the chat…");
252
+ if (!listening) return "(no chat here: decide yourself and hand in the storyboard)";
253
+ await postEvent("question", { text: q });
254
+ const started = Date.now();
255
+ while (Date.now() - started < 30 * 60 * 1000) {
256
+ const st = await poll();
257
+ if (st?.status === "cancelled" || st?.status === "failed") { console.log(" The person cancelled."); process.exit(0); }
258
+ if (st && st.status !== "waiting_answer") {
259
+ const a = st.answer && typeof st.answer === "object" ? st.answer : null;
260
+ asked += 1; probes = 0; firstProbeAt = 0;
261
+ return a?.note ? `The person says: "${String(a.note).slice(0, 2000)}"` : "The person said to go on as you see fit.";
262
+ }
263
+ await new Promise((r) => setTimeout(r, 3000));
264
+ }
265
+ return "No answer came in 30 minutes: hand in the storyboard as it stands, with what you could not find in unsure.";
266
+ };
229
267
  const plan = async () => {
230
268
  say("Writing the storyboard…");
231
269
  storyboard = null;
@@ -255,12 +293,21 @@ const plan = async () => {
255
293
  else if (u.name === "harvest_words") { say(`Reading the app's own words on ${(input.urls || []).length} page${(input.urls || []).length === 1 ? "" : "s"}…`); out = harvest(input.urls); }
256
294
  else if (u.name === "explore") { say(`Walking the app from ${where(input.from)}, looking for ${String(input.looking_for || "the brief's words").slice(0, 60)}…`); out = explore(input.from, input.looking_for, input.depth); }
257
295
  else if (u.name === "screenshot") { say(`Looking at ${where(input.url)}…`); out = screenshot(input.url); }
258
- else if (u.name === "probe") { say(`Dry run of the storyboard, ${(input.storyboard?.steps || []).length} steps…`); out = probe(input.storyboard); }
296
+ else if (u.name === "ask_person") { out = await askPerson(input.question); }
297
+ else if (u.name === "probe") {
298
+ if (capped()) out = CAP_TEXT();
299
+ else {
300
+ probes += 1; if (!firstProbeAt) firstProbeAt = Date.now();
301
+ say(`Dry run of the storyboard, ${(input.storyboard?.steps || []).length} steps… (${probes} of ${CAP_PROBES})`);
302
+ out = probe(input.storyboard);
303
+ if (capped()) out += `\n\n${CAP_TEXT()}`;
304
+ }
305
+ }
259
306
  else if (u.name === "submit_storyboard") {
260
307
  say("Handing in the storyboard…");
261
308
  const bad = validate(input.storyboard);
262
309
  if (bad) out = `Not accepted: ${bad}`;
263
- else { storyboard = input.storyboard; creates = typeof input.creates === "string" ? input.creates : ""; out = "Accepted."; }
310
+ else { storyboard = input.storyboard; creates = typeof input.creates === "string" ? input.creates : ""; if (typeof input.unsure === "string" && input.unsure.trim()) storyboard.unsure = input.unsure.trim().slice(0, 240); out = "Accepted."; }
264
311
  } else out = `Unknown tool ${u.name}`;
265
312
  } catch (e) { out = `Tool failed: ${e.message}`; }
266
313
  results.push({ type: "tool_result", tool_use_id: u.id, content: typeof out === "string" ? [{ type: "text", text: out }] : out });
@@ -311,7 +358,7 @@ const step = async (name, args, what) => {
311
358
  console.log(` ${new Date(t0).toISOString()} ${what}…`);
312
359
  const r = spawnSync("node", [script(name), ...args], { cwd: process.cwd(), stdio: "inherit", env: process.env });
313
360
  console.log(` ${new Date().toISOString()} ${what}: ${((Date.now() - t0) / 1000).toFixed(1)}s`);
314
- if (r.status !== 0) { console.error(` ! ${name} failed (exit ${r.status})`); await postEvent("failed", { message: `${what} did not finish` }); process.exit(1); }
361
+ if (r.status !== 0) { console.error(` ! ${name} failed (exit ${r.status})`); if (r.status !== 3) await postEvent("failed", { message: `${what} did not finish` }); process.exit(1); }
315
362
  };
316
363
  const sb = JSON.parse(fs.readFileSync(sbFile, "utf8"));
317
364
  if (Array.isArray(sb.prep) && sb.prep.length) await step("cleanup.mjs", [dir, "--prep"], "Preparing the data");