demobites 1.36.0 → 1.38.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.36.0",
3
+ "version": "1.38.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"
@@ -136,7 +136,9 @@ const explore = (from, lookingFor, depth) => {
136
136
  fs.mkdirSync(exDir, { recursive: true });
137
137
  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 });
138
138
  const lines = (child.stdout || "").split("\n");
139
- for (const l of lines) if (l.startsWith("· ")) say(l.slice(2));
139
+ // THE PERSON HEARS PHASES, NOT FINDINGS (founder 2026-10-18: "Nothing beyond the start page matched… you need to
140
+ // control what you're showing to the user"): the exploration's lines stay in the log.
141
+ for (const l of lines) if (l.startsWith("· ")) console.log(l);
140
142
  const last = lines.filter((l) => l.startsWith("RESULT ")).pop();
141
143
  let res = null; try { res = JSON.parse(last.slice(7)); } catch { /* none */ }
142
144
  if (!res) return [{ type: "text", text: `Exploring failed: ${(child.stderr || "").trim().split("\n").pop() || "no result"}` }];
@@ -241,13 +243,17 @@ let probes = 0;
241
243
  let firstProbeAt = 0;
242
244
  let asked = 0;
243
245
  const capped = () => probes >= CAP_PROBES || (firstProbeAt && Date.now() - firstProbeAt > CAP_MS);
244
- const CAP_TEXT = () => asked < 2
246
+ const CAP_TEXT = () => asked < 1
245
247
  ? `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
248
  : `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
249
  /** The question goes to the chat; the planner waits here for the person's word (up to 30 minutes). */
248
250
  const askPerson = async (text) => {
249
251
  const q = String(text || "").trim().slice(0, 600);
250
252
  if (!q) return "ask_person needs a question.";
253
+ // ONE QUESTION, EVER (founder 2026-10-18: "at most one clarification… then go"; a second question three seconds
254
+ // after one dry run left him stuck): once answered, the planner works with what it has and hands in the
255
+ // storyboard, naming what it could not find in `unsure`. The person fixes it from the storyboard, not from a loop.
256
+ if (asked >= 1) return "You asked once and they answered. No more questions: hand in the storyboard NOW with submit_storyboard — keep every step that passed, turn a step you could not place into a hover or a settle on the nearest element the page has, and name what you could not find in `unsure`.";
251
257
  say("Asking you in the chat…");
252
258
  if (!listening) return "(no chat here: decide yourself and hand in the storyboard)";
253
259
  await postEvent("question", { text: q });
@@ -273,7 +279,7 @@ const plan = async () => {
273
279
  const res = await relay(messages);
274
280
  const content = Array.isArray(res.content) ? res.content : [];
275
281
  messages.push({ role: "assistant", content });
276
- for (const b of content) if (b.type === "text" && b.text?.trim()) say(b.text.trim().split("\n")[0].replace(/^[#*\-\s]+/, "").slice(0, 140));
282
+ for (const b of content) if (b.type === "text" && b.text?.trim()) console.log(`· ${b.text.trim().split("\n")[0].replace(/^[#*\-\s]+/, "").slice(0, 140)}`);
277
283
  const uses = content.filter((b) => b.type === "tool_use");
278
284
  if (!uses.length) {
279
285
  if (storyboard) return;
@@ -22,6 +22,7 @@
22
22
  // this take; a no-op everywhere else (the terminal flow, the cloud sandbox
23
23
  // copy that may not ship listen-event.mjs).
24
24
  const postEvent = await import("./listen-event.mjs").then((m) => m.postEvent).catch(() => async () => null);
25
+ const listening = await import("./listen-event.mjs").then((m) => m.listening).catch(() => false);
25
26
  /** Where the person watches their takes: the Demos grid. `dashboardUrl` from
26
27
  * the server wins (relative or absolute), else <base>/demos. */
27
28
  export function gridUrl(base, dashboardUrl) {
@@ -151,6 +152,16 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
151
152
  if (last.zooms === 0) console.error("WARNING: no camera shots landed.");
152
153
  }
153
154
  if (finalStudioUrl) console.log(`Studio: ${finalStudioUrl}`);
155
+ // THE STILLS, DRAWN HERE BEFORE "READY" (2026-10-18): the demo's intro and backdrop, by the studio's own code in
156
+ // this recorder's Chrome, remembered by the app so the export draws nothing. Bounded; a miss costs nothing.
157
+ if (listening) {
158
+ try {
159
+ const { spawnSync } = await import("node:child_process");
160
+ const here = (await import("node:path")).dirname((await import("node:url")).fileURLToPath(import.meta.url));
161
+ const r = spawnSync(process.execPath, [(await import("node:path")).join(here, "stills.mjs"), String(approvedBiteId)], { cwd: process.cwd(), encoding: "utf8", env: process.env, timeout: 60000 });
162
+ for (const l of (r.stdout || "").trim().split("\n").filter(Boolean)) console.log(` ${l}`);
163
+ } catch (e) { console.log(` stills: skipped (${String(e?.message ?? e).split("\n")[0]})`); }
164
+ }
154
165
  await postEvent("ready", { biteId: approvedBiteId, studioUrl: finalStudioUrl ?? null });
155
166
  return { exitCode: 0, status: "completed", biteId: approvedBiteId, studioUrl: finalStudioUrl };
156
167
  }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // THE STILLS ARE DRAWN HERE (founder 2026-10-18: "at that point you have a browser… load fabric… create the two
3
+ // images"). After the demo is built and before this recorder says "ready", its own Chrome opens the app's internal
4
+ // still page — the studio's own render code, the design's fonts from the web — for the intro and the backdrop,
5
+ // and hands the PNGs to the app, which remembers them for the export. Seconds here; a 30-second sandbox saved there.
6
+ // Never fatal: on any miss the export draws them itself as before.
7
+ // usage: node stills.mjs <biteId> (base and key from <cwd>/.recorder/config.json)
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ const biteId = Number(process.argv[2] || 0);
11
+ if (!biteId) { console.error("usage: stills.mjs <biteId>"); process.exit(2); }
12
+ const cfg = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), ".recorder", "config.json"), "utf8"));
13
+ const base = (process.env.RECORDER_BASE || cfg.base || "https://app.demobites.com").replace(/\/+$/, "");
14
+ const headers = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
15
+ const t0 = Date.now();
16
+ const plan = await fetch(`${base}/api/recorder/stills?bite_id=${biteId}`, { headers, signal: AbortSignal.timeout(15000) }).then((r) => (r.ok ? r.json() : null)).catch(() => null);
17
+ if (!plan?.stills?.length) { console.log("stills: nothing to draw here"); process.exit(0); }
18
+ const { chromium } = await import("playwright");
19
+ const browser = await chromium.launch({ channel: "chrome", headless: true }).catch(async () => chromium.launch({ headless: true }));
20
+ const out = {};
21
+ try {
22
+ const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
23
+ for (const still of plan.stills) {
24
+ const page = await ctx.newPage();
25
+ try {
26
+ const res = await page.goto(still.url, { waitUntil: "domcontentloaded", timeout: 20000 });
27
+ if (!res || res.status() !== 200) throw new Error(`the still page answered ${res ? res.status() : "nothing"}`);
28
+ await page.waitForFunction("window.__FABRIC_STILL__ !== undefined", undefined, { timeout: 25000 });
29
+ const r = await page.evaluate("window.__FABRIC_STILL__");
30
+ if (!r?.ok || typeof r.dataUrl !== "string" || !r.dataUrl.startsWith("data:image/png;base64,")) throw new Error(r?.error || "no PNG from the still page");
31
+ out[still.kind] = r.dataUrl.slice("data:image/png;base64,".length);
32
+ console.log(`stills: ${still.kind} drawn (${Math.round(out[still.kind].length * 0.75 / 1024)} KB)`);
33
+ } catch (e) { console.log(`stills: ${still.kind} skipped (${String(e?.message ?? e).split("\n")[0]})`); }
34
+ finally { await page.close().catch(() => {}); }
35
+ }
36
+ } finally { await browser.close().catch(() => {}); }
37
+ if (!Object.keys(out).length) process.exit(0);
38
+ const put = await fetch(`${base}/api/recorder/stills`, { method: "PUT", headers, body: JSON.stringify({ bite_id: biteId, hash: plan.hash, stills: out }), signal: AbortSignal.timeout(30000) }).then((r) => r.json().catch(() => ({ ok: r.ok }))).catch((e) => ({ error: String(e?.message ?? e) }));
39
+ console.log(`stills: ${put?.ok ? `remembered for the export (${Object.keys(out).join(", ")})` : `not kept (${put?.error ?? "?"})`} in ${((Date.now() - t0) / 1000).toFixed(1)} s`);