demobites 1.32.0 → 1.34.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.32.0",
3
+ "version": "1.34.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"
@@ -90,7 +90,7 @@ function bboxCrossing(step) {
90
90
  return cross;
91
91
  }
92
92
 
93
- function roiChanges(bb) {
93
+ function roiChanges(bb, around) {
94
94
  // ONSETS of visible change inside the padded bbox crop, in video time.
95
95
  // Not threshold crossings: hover styles animate (LinkedIn fades its row
96
96
  // background over ~150ms), and a threshold detector fires mid-fade —
@@ -101,14 +101,19 @@ function roiChanges(bb) {
101
101
  const pad = 6;
102
102
  const x0 = Math.max(0, bb.x - pad), y0 = Math.max(0, bb.y - pad);
103
103
  const w = Math.min(FRAME.w - x0, bb.w + 2 * pad), h = Math.min(FRAME.h - y0, bb.h + 2 * pad);
104
+ // ONLY THE WINDOW THAT MATTERS (2026-10-18: six anchors decoded the whole take six times, 48 s on the founder's
105
+ // Air). The caller keeps changes within [pred - 3.2, pred + 1.2]; the decode starts 3.6 s before the prediction and
106
+ // runs 5.2 s. Seeking the mp4 before the input is frame-accurate (decode from the keyframe, drop until the mark),
107
+ // and the reported pts restart at 0, so the window's start is added back.
108
+ const from = around == null ? 0 : Math.max(0, around - 3.6);
104
109
  const res = spawnSync(FFMPEG(), [
105
- "-loglevel", "info", "-i", clean,
110
+ "-loglevel", "info", ...(around == null ? [] : ["-ss", String(from), "-t", "5.2"]), "-i", clean,
106
111
  "-vf", `crop=${w}:${h}:${x0}:${y0},select='gte(scene,0)',metadata=print`, "-f", "null", "-",
107
112
  ], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
108
113
  const err = `${res.stderr ?? ""}`;
109
114
  const rows = [];
110
115
  const re = /pts_time:([0-9.]+)[\s\S]*?lavfi\.scene_score=([0-9.]+)/g;
111
- for (let m; (m = re.exec(err)); ) rows.push({ t: parseFloat(m[1]), s: parseFloat(m[2]) });
116
+ for (let m; (m = re.exec(err)); ) rows.push({ t: parseFloat(m[1]) + from, s: parseFloat(m[2]) });
112
117
  const onsets = [];
113
118
  for (let i = 1; i < rows.length; i++) {
114
119
  if (rows[i].s < 0.004) continue; // not a peak-worthy change
@@ -126,7 +131,7 @@ for (const s of man.steps ?? []) {
126
131
  const wallCross = bboxCrossing(s);
127
132
  if (wallCross == null) continue;
128
133
  const pred = tb.a * wallCross + tb.b;
129
- const changes = roiChanges(s.target.bbox).filter((c) => c >= pred - 3.2 && c <= pred + 1.2);
134
+ const changes = roiChanges(s.target.bbox, pred).filter((c) => c >= pred - 3.2 && c <= pred + 1.2);
130
135
  if (changes.length === 0) continue;
131
136
  anchors.push({ label: s.label ?? s.action, pred, changes, weight: 1 / Math.sqrt(changes.length) });
132
137
  }
package/scripts/trim.mjs CHANGED
@@ -40,7 +40,7 @@ const clean = path.join(dir, "clean.mp4");
40
40
  // tail into a fake 1.027x, and a scene-change fit anchored on a modal that
41
41
  // took 0.49s to load tilted 0.897x. Both produced ramps of error that read as
42
42
  // a broken cursor.
43
- const r0 = man.record_from ?? 0;
43
+ let r0 = man.record_from ?? 0;
44
44
 
45
45
  // Head-beacon phase 0: the identity law above fixes the RATE; the beacon
46
46
  // fixes the ANCHOR. record.mjs flashed the blank page full-frame at stamped
@@ -105,7 +105,36 @@ if ((man.beacon?.flips?.length ?? 0) >= 3) {
105
105
  }
106
106
  }
107
107
 
108
- const cutAt = Math.max(0, r0 - beaconDelta);
108
+ let cutAt = Math.max(0, r0 - beaconDelta);
109
+
110
+ // THE FIRST FRAME IS A LOADED PAGE (founder 2026-10-18, bite 1236: "you started without the In-app Pulse loaded…
111
+ // after 3 seconds everything is loaded… use the trim at the start"). The camera's still-picture gate waits for the
112
+ // outer page; an inner panel can keep loading after it. So the head of the clean take is scanned for visible change
113
+ // in its first seconds, and when the picture kept changing, the cut moves to the last change plus a breath, and
114
+ // record_from moves with it (the timebase stays video = wall - record_from). Bounded at 3.5 s; a still head is
115
+ // untouched.
116
+ const HEAD_SCAN_S = 4.0;
117
+ const HEAD_MAX_SHIFT_S = 3.5;
118
+ try {
119
+ const res = spawnSync(FFMPEG(), ["-hide_banner", "-loglevel", "info", "-i", raw, "-vf", `trim=start=${cutAt}:end=${cutAt + HEAD_SCAN_S},setpts=PTS-STARTPTS,scale=480:-2,select='gt(scene,0.02)',showinfo`, "-f", "null", "-"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
120
+ const changes = [];
121
+ for (const m of `${res.stderr ?? ""}`.matchAll(/pts_time:([0-9.]+)/g)) changes.push(parseFloat(m[1]));
122
+ const late = changes.filter((t) => t > 0.3 && t < HEAD_SCAN_S - 0.3);
123
+ if (late.length) {
124
+ const shift = Math.min(HEAD_MAX_SHIFT_S, Math.round((late[late.length - 1] + 0.25) * 100) / 100);
125
+ console.log(`first frame: the picture kept changing for ${late[late.length - 1].toFixed(2)}s after the cut (${late.length} change${late.length === 1 ? "" : "s"}) — cutting ${shift.toFixed(2)}s later, to a loaded page`);
126
+ void postEvent("finishing", { text: "Cutting to the first loaded frame…" });
127
+ cutAt += shift;
128
+ r0 += shift;
129
+ man.record_from = r0;
130
+ man.first_frame_shift_s = shift;
131
+ // Nothing in the story happens before its first frame.
132
+ for (const st of man.steps ?? []) { if (Number(st.t_start) < r0) st.t_start = r0; if (Number(st.t_end) < r0) st.t_end = r0; }
133
+ for (const sh of man.shots ?? []) { if (Number(sh.t_start) < r0) sh.t_start = r0; }
134
+ } else console.log(`first frame: still (${changes.length} change${changes.length === 1 ? "" : "s"} in the first ${HEAD_SCAN_S}s, none after 0.3s)`);
135
+ } catch (e) {
136
+ console.log(`first frame: scan skipped (${String(e?.message ?? e).split("\n")[0]})`);
137
+ }
109
138
 
110
139
  // TAIL law (vanilla-Linux smoke 2026-09-09): raw.webm keeps receiving frames
111
140
  // while the browser context tears down — on a headless shell that was a
@@ -166,12 +195,23 @@ man.timebase = {
166
195
  fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
167
196
  console.log(`timebase: video = (wall - ${r0.toFixed(3)})${speed === 1 ? "" : ` / ${speed}`}${beaconMethod ? ` (cut at raw ${cutAt.toFixed(3)}s)` : ""}`);
168
197
 
198
+ // THE MACHINE'S OWN ENCODER (founder 2026-10-18: "saving the take… this should be 5 seconds"; the trim took 39.6 s on
199
+ // his Air, most of it libx264 at preset medium). Apple's VideoToolbox encodes the same h264 several times faster;
200
+ // elsewhere libx264 at a faster preset. The ingest wants h264 at 30 fps in an mp4, which both give.
201
+ const ENCODER = (() => {
202
+ try {
203
+ const enc = execFileSync(FFMPEG(), ["-hide_banner", "-encoders"], { stdio: ["ignore", "pipe", "ignore"] }).toString();
204
+ if (process.platform === "darwin" && /\bh264_videotoolbox\b/.test(enc)) return ["-c:v", "h264_videotoolbox", "-b:v", "3M", "-maxrate", "4M", "-bufsize", "8M", "-profile:v", "high", "-allow_sw", "1", "-g", "60"];
205
+ } catch { /* libx264 */ }
206
+ return ["-c:v", "libx264", "-preset", "veryfast", "-crf", "19", "-g", "60"];
207
+ })();
208
+ console.log(`encoder: ${ENCODER[1]}`);
169
209
  execFileSync(FFMPEG(), [
170
210
  "-y", "-loglevel", "error",
171
211
  "-i", raw,
172
212
  "-filter_complex", `[0:v]trim=${tailCut ? `start=${cutAt}:end=${tailCut}` : `start=${cutAt}`},setpts=(PTS-STARTPTS)/${speed},fps=30,format=yuv420p[out]`,
173
213
  "-map", "[out]",
174
- "-c:v", "libx264", "-preset", "medium", "-crf", "19",
214
+ ...ENCODER,
175
215
  "-movflags", "+faststart",
176
216
  clean,
177
217
  ], { stdio: "inherit" });
@@ -184,7 +224,7 @@ execFileSync(FFMPEG(), [
184
224
  try {
185
225
  const filters = execFileSync(FFMPEG(), ["-hide_banner", "-filters"], { stdio: ["ignore", "pipe", "ignore"] }).toString();
186
226
  if (/\bfreezeframes\b/.test(filters) && /\bsignalstats\b/.test(filters)) {
187
- const stats = spawnSync(FFMPEG(), ["-hide_banner", "-loglevel", "error", "-i", clean, "-vf", "crop=iw/2:ih/2:iw/2:ih/2,signalstats,metadata=print:file=-", "-f", "null", "-"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
227
+ const stats = spawnSync(FFMPEG(), ["-hide_banner", "-loglevel", "error", "-i", clean, "-vf", "crop=iw/2:ih/2:iw/2:ih/2,scale=240:-2,signalstats,metadata=print:file=-", "-f", "null", "-"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
188
228
  const bad = [];
189
229
  let frame = -1, yavg = null, ymin = null, ymax = null;
190
230
  const flush = () => { if (frame >= 0 && yavg !== null && yavg >= 118 && yavg <= 138 && ymin !== null && ymax !== null && ymax - ymin <= 24) bad.push(frame); };
@@ -208,7 +248,7 @@ try {
208
248
  graph += `[a${chain.length}]format=yuv420p[out]`;
209
249
  const fixed = path.join(dir, "clean-fixed.mp4");
210
250
  void postEvent("finishing", { text: `Replacing ${bad.length} glitch frame${bad.length === 1 ? "" : "s"}…` });
211
- execFileSync(FFMPEG(), ["-y", "-loglevel", "error", "-i", clean, "-filter_complex", graph, "-map", "[out]", "-c:v", "libx264", "-preset", "medium", "-crf", "19", "-movflags", "+faststart", fixed], { stdio: "inherit" });
251
+ execFileSync(FFMPEG(), ["-y", "-loglevel", "error", "-i", clean, "-filter_complex", graph, "-map", "[out]", ...ENCODER, "-movflags", "+faststart", fixed], { stdio: "inherit" });
212
252
  fs.renameSync(fixed, clean);
213
253
  man.glitch = { frames: bad.length, windows: chain.map((r) => ({ first: r.first, last: r.last })) };
214
254
  fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
@@ -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 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 at most 4 dry runs and 3 minutes from the first one; after that, hand in what passed and name in 'unsure' what you could not find.` : `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 at most 4 dry runs and 3 minutes from the first one; after that, hand in what passed and name in 'unsure' what you could not find.`,
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
  };
@@ -230,12 +230,40 @@ const messages = [{ role: "user", content: [{ type: "text", text: await firstMes
230
230
  // three minutes from the first one, whichever comes first. After that no probe runs; the next move is the storyboard
231
231
  // as it stands, with the steps it could not find named in `unsure`, so the person answers the question instead of
232
232
  // waiting while it rewrites and retries (that job: 18 dry runs, 13 model turns).
233
- const CAP_PROBES = 4;
234
- const CAP_MS = 3 * 60 * 1000;
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;
235
240
  let probes = 0;
236
241
  let firstProbeAt = 0;
242
+ let asked = 0;
237
243
  const capped = () => probes >= CAP_PROBES || (firstProbeAt && Date.now() - firstProbeAt > CAP_MS);
238
- const CAP_TEXT = () => `PLANNING CAP REACHED (${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. 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; write ONE short sentence in \`unsure\` naming what you could not find on the page (the step and the control), so the person can tell you where it is. The person is waiting.`;
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
+ };
239
267
  const plan = async () => {
240
268
  say("Writing the storyboard…");
241
269
  storyboard = null;
@@ -265,6 +293,7 @@ const plan = async () => {
265
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); }
266
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); }
267
295
  else if (u.name === "screenshot") { say(`Looking at ${where(input.url)}…`); out = screenshot(input.url); }
296
+ else if (u.name === "ask_person") { out = await askPerson(input.question); }
268
297
  else if (u.name === "probe") {
269
298
  if (capped()) out = CAP_TEXT();
270
299
  else {