demobite 1.0.7 → 1.0.9

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.0.7",
3
+ "version": "1.0.9",
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"
@@ -131,10 +131,20 @@ console.log(`calibrate: map video = ${tb.a} * wall + ${tb.b.toFixed(3)} (${tb.me
131
131
  console.log(`calibrate: ${anchors.length} hover anchor${anchors.length === 1 ? "" : "s"} usable`);
132
132
 
133
133
  let anchored = false;
134
+ // BEACON OUTRANKS ANCHORS (2026-09-02, second prod bite-96 take): the head
135
+ // beacon had measured the cut to +-14 ms, yet ONE hover anchor locked onto a
136
+ // stray pixel change 2.66 s away and dragged the clock with it. When the
137
+ // beacon has spoken (trim stamped beaconDelta from >=3 matched flashes),
138
+ // anchors may only REFINE the clock inside a third of a second; they never
139
+ // re-decide it. Without a beacon the wide sweep stays (the pre-beacon world).
140
+ const beaconAnchored = typeof tb?.beaconDelta === "number";
141
+ const SWEEP_LO = beaconAnchored ? -0.35 : -3.0;
142
+ const SWEEP_HI = beaconAnchored ? 0.35 : 1.0;
143
+ if (beaconAnchored) console.log(`calibrate: beacon-anchored clock — hover anchors may refine by at most ${SWEEP_HI.toFixed(2)}s`);
134
144
  if (anchors.length >= 2) {
135
145
  const TOL = 0.067; // two frames
136
146
  let best = { delta: 0, score: -1, resid: Infinity };
137
- for (let d = -3.0; d <= 1.0001; d += 1 / 60) {
147
+ for (let d = SWEEP_LO; d <= SWEEP_HI + 0.0001; d += 1 / 60) {
138
148
  let score = 0, resid = 0, hits = 0;
139
149
  for (const a of anchors) {
140
150
  const target = a.pred + d;
@@ -171,21 +181,27 @@ if (anchors.length >= 2) {
171
181
  console.log(` anchor ${a.label}: no flip within tolerance at this offset (skipped)`);
172
182
  }
173
183
  }
174
- const rms = Math.sqrt(residuals.reduce((s, r) => s + r * r, 0) / residuals.length);
175
- if (rms > 0.08) {
184
+ if (residuals.length < 2) {
185
+ // One surviving anchor is an opinion, not a measurement.
186
+ console.log(`calibrate: only ${residuals.length} anchor survived the lock — clock left ${beaconAnchored ? "on the beacon" : "as stamped"}.`);
187
+ anchored = beaconAnchored;
188
+ best.delta = 0;
189
+ }
190
+ const rms = residuals.length >= 2 ? Math.sqrt(residuals.reduce((s, r) => s + r * r, 0) / residuals.length) : 0;
191
+ if (residuals.length >= 2 && rms > 0.08) {
176
192
  console.error(`CALIBRATE FAILED: hover-anchor rms ${(rms * 1000).toFixed(0)}ms exceeds 80ms — footage disagrees with itself. Do not upload.`);
177
193
  process.exit(1);
178
194
  }
179
- if (Math.abs(best.delta) > 0.005) {
195
+ if (residuals.length >= 2 && Math.abs(best.delta) > 0.005) {
180
196
  tb.b += best.delta;
181
197
  tb.videoRecordFrom = -tb.b;
182
198
  tb.method += ` + hover-anchor ${best.delta.toFixed(3)}s (${best.hits} anchors, rms ${(rms * 1000).toFixed(0)}ms)`;
183
199
  fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
184
200
  console.log(`calibrate: hover anchors moved the clock ${best.delta.toFixed(3)}s — b is now ${tb.b.toFixed(3)} (rms ${(rms * 1000).toFixed(0)}ms)`);
185
- } else {
201
+ } else if (residuals.length >= 2) {
186
202
  console.log(`calibrate: hover anchors confirm the stamp as-is (rms ${(rms * 1000).toFixed(0)}ms)`);
187
203
  }
188
- anchored = true;
204
+ if (residuals.length >= 2) anchored = true;
189
205
  }
190
206
  }
191
207
 
@@ -221,6 +237,8 @@ if (scenes.length === 0) {
221
237
  process.exit(0);
222
238
  }
223
239
 
240
+ const SWEEP_LO_FB = beaconAnchored ? -0.35 : -4;
241
+ const SWEEP_HI_FB = beaconAnchored ? 0.35 : 1.0;
224
242
  if (!anchored) {
225
243
  // Old plausible-latency search. Kept ONLY as the no-anchor fallback; its
226
244
  // known failure mode (plausible != true, 0.237s off on a real take) is why
@@ -237,7 +255,7 @@ if (!anchored) {
237
255
  return total;
238
256
  };
239
257
  let bestDelta = 0, bestScore = score(0);
240
- for (let d = -4; d <= 1.0001; d += 1 / 30) {
258
+ for (let d = SWEEP_LO_FB; d <= SWEEP_HI_FB + 0.0001; d += 1 / 30) {
241
259
  const sc = score(d);
242
260
  if (sc < bestScore - 1e-9) { bestScore = sc; bestDelta = d; }
243
261
  }
@@ -272,6 +290,15 @@ if (!anchored && (median < -0.15 || median > 0.9)) {
272
290
  );
273
291
  process.exit(1);
274
292
  }
293
+ if (!beaconAnchored && anchored && (median < -0.6 || median > 2.5)) {
294
+ // INCIDENT 2026-09-02: +4.6 s "latency" on an anchored clock was the head
295
+ // miscut showing through (the anchors had locked onto the wrong beat).
296
+ console.error(`VERIFY FAILED: median click latency ${median.toFixed(3)}s on an anchor-measured clock is not an app delay, it is a broken timebase (the cut point or the anchor lock is wrong). Do not upload — film again.`);
297
+ process.exit(1);
298
+ }
299
+ if (beaconAnchored && (median < -0.6 || median > 2.5)) {
300
+ console.log(`WARNING: median click latency ${median.toFixed(3)}s on a beacon-measured clock — the clock is trusted, so the app genuinely answered late. The footage shows the wait; judge whether the beat reads as slow.`);
301
+ }
275
302
  if (anchored && (median < -0.15 || median > 0.9)) {
276
303
  console.log(`note: median click latency ${median.toFixed(3)}s is unusual, but the clock is anchor-measured — likely a genuinely slow app response. Judge the take on duration and feel.`);
277
304
  }
@@ -40,7 +40,7 @@ const DIR = path.resolve(outArg);
40
40
  // RE-TAKE (founder 2026-09-02): the storyboard IS the bite's DNA. Keep a verbatim
41
41
  // copy in the take dir so upload.mjs can stage it as the recording recipe —
42
42
  // selectors, urls, typed text, hideCss — the wire manifest alone cannot re-film.
43
- const ENGINE_VERSION = "1.0.7";
43
+ const ENGINE_VERSION = "1.0.9";
44
44
  fs.mkdirSync(DIR, { recursive: true });
45
45
  // The copy is the RECIPE, not the transport: cdpWsUrl / storageStatePath are
46
46
  // per-take plumbing stamped by the cloud runner (a CDP url carries a session
@@ -615,6 +615,7 @@ async function smoothScroll(dy, ms = 1400, within = null) {
615
615
  }), [dy, ms, within]);
616
616
  }
617
617
 
618
+ const BEACON_GAPS_MS = [300, 450, 600, 750, 900, 1050];
618
619
  // ── Head beacon ────────────────────────────────────────────────────────────
619
620
  // The identity law (video = wall - record_from) assumes raw.webm's t=0 lines
620
621
  // up with T0. That anchoring is otherwise UNMEASURED: on a cold remote-CDP
@@ -634,22 +635,26 @@ try {
634
635
  // stamp error at half a CDP round trip — inside one frame co-located.
635
636
  return { color, wall: (before + after) / 2 };
636
637
  };
637
- // Wait for the screencast to actually be ROLLING (first frames on disk):
638
- // over a remote CDP link the recorder starts late, and a flash before the
639
- // first frame is invisible (mode A smoke, 2026-09-02).
640
- const rollDeadline = Date.now() + 5000;
641
- while (Date.now() < rollDeadline) {
642
- const rolling = fs.readdirSync(DIR).some((f) => f.endsWith(".webm") && fs.statSync(path.join(DIR, f)).size > 4096);
643
- if (rolling) break;
644
- await page.waitForTimeout(100);
645
- }
638
+ // GAP-CODED PATTERN (2026-09-02, after the prod bite-96 miscut): nothing
639
+ // inside this process can see whether the screencast is rolling yet (the
640
+ // video file does not grow while recording), and under load the first
641
+ // frame can land seconds after the page exists. So the beacon no longer
642
+ // relies on being seen from its first flash: seven flashes with strictly
643
+ // increasing gaps form a signature trim.mjs recognises from ANY three
644
+ // consecutive flashes it finds. A screencast that started late still
645
+ // yields the anchor; one that missed every flash stops the take in trim.
646
646
  await page.evaluate(() => { document.documentElement.style.background = "#ffffff"; });
647
- await page.waitForTimeout(600); // let the white state reach the recording
647
+ await page.waitForTimeout(1500); // let the page exist and the white state settle
648
648
  const flips = [];
649
- flips.push(await flip("#ff00ff")); // onset 1: white -> magenta
650
- await page.waitForTimeout(400);
651
- flips.push(await flip("#ffffff")); // onset 2: magenta -> white
652
- await page.waitForTimeout(400);
649
+ let color = "#ff00ff";
650
+ for (const gapMs of BEACON_GAPS_MS) {
651
+ flips.push(await flip(color));
652
+ color = color === "#ff00ff" ? "#ffffff" : "#ff00ff";
653
+ await page.waitForTimeout(gapMs);
654
+ }
655
+ flips.push(await flip(color)); // final onset closes the last gap
656
+ if (color === "#ff00ff") { await page.waitForTimeout(250); flips.push(await flip("#ffffff")); }
657
+ await page.waitForTimeout(300);
653
658
  manifest.beacon = { flips };
654
659
  process.stdout.write(`beacon: flips at ${flips.map((f) => f.wall.toFixed(3)).join("s, ")}s (wall)\n`);
655
660
  } catch (e) {
package/scripts/trim.mjs CHANGED
@@ -56,32 +56,57 @@ const r0 = man.record_from ?? 0;
56
56
  // Shift the cut by it so clean.mp4 truly begins at the record_from moment.
57
57
  let beaconDelta = 0;
58
58
  let beaconMethod = "";
59
- if ((man.beacon?.flips?.length ?? 0) >= 2) {
59
+ if ((man.beacon?.flips?.length ?? 0) >= 3) {
60
60
  const flips = man.beacon.flips;
61
61
  // The flashes are the first big whole-frame changes in the head. Search a
62
62
  // window generous enough for seconds of anchor error in either direction.
63
- const searchEnd = Math.min(Math.max(r0, flips[flips.length - 1].wall) + 8, 40);
63
+ const searchEnd = Math.min(Math.max(r0, flips[flips.length - 1].wall) + 10, 45);
64
64
  const res = spawnSync("ffmpeg", [
65
65
  "-loglevel", "info", "-t", String(searchEnd), "-i", raw,
66
66
  "-vf", "select='gt(scene,0.3)',showinfo", "-f", "null", "-",
67
67
  ], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
68
68
  const onsets = [...`${res.stderr ?? ""}`.matchAll(/pts_time:([0-9.]+)/g)]
69
69
  .map((m) => parseFloat(m[1]));
70
- if (onsets.length >= 2) {
71
- // Pair the first two onsets to the two flips, in order. Anything the page
72
- // paints later (the goto) lands after onset 2 and never enters the pair.
73
- const d1 = flips[0].wall - onsets[0];
74
- const d2 = flips[1].wall - onsets[1];
75
- const spread = Math.abs(d1 - d2);
76
- if (spread <= 0.15) {
77
- beaconDelta = (d1 + d2) / 2;
78
- beaconMethod = ` + head-beacon anchor ${beaconDelta >= 0 ? "+" : ""}${beaconDelta.toFixed(3)}s (2 flips, spread ${(spread * 1000).toFixed(0)}ms)`;
79
- console.log(`beacon: anchor error ${beaconDelta >= 0 ? "+" : ""}${beaconDelta.toFixed(3)}s measured (flip spread ${(spread * 1000).toFixed(0)}ms) — cut corrected`);
80
- } else {
81
- console.log(`beacon: flip deltas disagree (${(spread * 1000).toFixed(0)}ms spread) — ignoring beacon, cut stays as stamped`);
70
+ // Align the KNOWN flash gaps against the DETECTED onset gaps: slide every
71
+ // flip index against every onset index and count consecutive gap matches
72
+ // (tolerance 70 ms). Three flashes in a row (two matching gaps) identify
73
+ // the pattern unambiguously because the gaps strictly increase; the
74
+ // alignment with the most matches wins. Missing leading flashes (a
75
+ // screencast that started late) cost nothing but the pairs they would
76
+ // have added.
77
+ const TOL = 0.07;
78
+ let best = null;
79
+ for (let i = 0; i < flips.length - 2; i++) {
80
+ for (let j = 0; j < onsets.length - 2; j++) {
81
+ let k = 0;
82
+ while (i + k + 1 < flips.length && j + k + 1 < onsets.length) {
83
+ const gf = flips[i + k + 1].wall - flips[i + k].wall;
84
+ const go = onsets[j + k + 1] - onsets[j + k];
85
+ if (Math.abs(gf - go) > TOL) break;
86
+ k++;
87
+ }
88
+ if (k >= 2 && (!best || k > best.k)) best = { i, j, k };
89
+ }
90
+ }
91
+ if (best) {
92
+ const deltas = [];
93
+ for (let m = 0; m <= best.k; m++) deltas.push(flips[best.i + m].wall - onsets[best.j + m]);
94
+ const spread = Math.max(...deltas) - Math.min(...deltas);
95
+ beaconDelta = deltas.reduce((a, b) => a + b, 0) / deltas.length;
96
+ const missed = best.i;
97
+ beaconMethod = ` + head-beacon anchor ${beaconDelta >= 0 ? "+" : ""}${beaconDelta.toFixed(3)}s (${deltas.length} flashes matched, ${missed} missed, spread ${(spread * 1000).toFixed(0)}ms)`;
98
+ console.log(`beacon: anchor error ${beaconDelta >= 0 ? "+" : ""}${beaconDelta.toFixed(3)}s measured from ${deltas.length} flashes (${missed} before the first frame, spread ${(spread * 1000).toFixed(0)}ms) — cut corrected`);
99
+ if (spread > 0.15) {
100
+ console.error(`BEACON FAILED: matched flashes disagree by ${(spread * 1000).toFixed(0)}ms. The cut point cannot be trusted — do not upload, film again.`);
101
+ process.exit(3);
82
102
  }
83
103
  } else {
84
- console.log("beacon: flashes not found in the raw head cut stays as stamped");
104
+ // INCIDENT 2026-09-02: a missed beacon staged a take whose head was cut
105
+ // seconds late (screencast started after the flashes). The stamped cut
106
+ // is only right when the screencast began at wall 0, which nothing
107
+ // guarantees — so a recorded-but-unfound beacon is a hard stop.
108
+ console.error(`BEACON FAILED: no run of the flash pattern found in the raw head (${onsets.length} onsets seen). The startup lead is unmeasured and the cut point cannot be trusted. Do not upload — film again (a busy machine delays the screencast; let builds finish first).`);
109
+ process.exit(3);
85
110
  }
86
111
  }
87
112
 
@@ -72,5 +72,5 @@ r = run("trim.mjs", [takeDir]); if (r.status !== 0) process.exit(r.status ?? 1);
72
72
  r = run("calibrate.mjs", [takeDir]);
73
73
  if (r.status !== 0) { console.error("Calibration failed — do not stage this take. Investigate record_from / anchors and film again."); process.exit(3); }
74
74
  r = run("manifest.mjs", [takeDir]); if (r.status !== 0) process.exit(r.status ?? 1);
75
- r = run("upload.mjs", [takeDir, "--retake-of", String(biteId)]);
75
+ r = run("upload.mjs", [takeDir, "--retake-of", String(biteId), ...(note ? ["--note", note] : [])]);
76
76
  process.exit(r.status ?? 0);
@@ -35,6 +35,11 @@ if (!dir) {
35
35
  const retakeIdx = process.argv.indexOf("--retake-of");
36
36
  const retakeOfBiteId = retakeIdx >= 0 ? Number(process.argv[retakeIdx + 1]) : null;
37
37
  if (retakeIdx >= 0 && !(Number.isInteger(retakeOfBiteId) && retakeOfBiteId > 0)) { console.error("--retake-of needs a bite id"); process.exit(2); }
38
+ // `--note "<what changed>"` (device lane, founder 2026-09-02): the human's note
39
+ // rides in recipe.config.retake_note so the in-app preview and the recording
40
+ // history show why this version was filmed. Never a secret, never required.
41
+ const noteIdx = process.argv.indexOf("--note");
42
+ const retakeNote = noteIdx >= 0 ? String(process.argv[noteIdx + 1] ?? "").trim().slice(0, 600) : "";
38
43
  const cfgPath = path.resolve(".recorder", "config.json");
39
44
  let cfg = {};
40
45
  try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
@@ -57,6 +62,7 @@ try {
57
62
  const rc = fs.existsSync(rcPath) ? JSON.parse(fs.readFileSync(rcPath, "utf8")) : {};
58
63
  recipe = { version: 1, lane: rc.lane ?? "skill", engine: rc.engine ?? null, storyboard: JSON.parse(fs.readFileSync(sbPath, "utf8")), config: rc.config ?? {} };
59
64
  if (recipe.config && "api_key" in recipe.config) delete recipe.config.api_key;
65
+ if (retakeNote) recipe.config = { ...(recipe.config ?? {}), retake_note: retakeNote };
60
66
  }
61
67
  } catch (e) { console.error("recipe skipped:", e.message); }
62
68
  if (!fs.existsSync(cleanPath)) { console.error(`${cleanPath} not found. Run: node scripts/trim.mjs ${dir}`); process.exit(1); }