retake-demos 0.2.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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Describe mode: URL + one sentence → a draft manifest in the editor.
3
+ *
4
+ * The model never records. Retake scouts the page with Playwright (what is on
5
+ * it, what can be clicked, real selectors), hands that plus the user's sentence
6
+ * to a model, and asks for YAML that fits our schema. The draft is validated;
7
+ * one retry with the validation errors; then it is the user's to edit and run.
8
+ *
9
+ * Providers:
10
+ * RETAKE_MODEL=claude-code the `claude` CLI already signed in on this machine (claude -p) — no key
11
+ * RETAKE_MODEL=codex the `codex` CLI already signed in on this machine (codex exec) — no key
12
+ * RETAKE_MODEL=groq GROQ_API_KEY RETAKE_GROQ_MODEL (default openai/gpt-oss-120b)
13
+ * RETAKE_MODEL=mistral MISTRAL_API_KEY RETAKE_MISTRAL_MODEL (default mistral-small-latest)
14
+ * RETAKE_MODEL=local RETAKE_LOCAL_URL RETAKE_LOCAL_MODEL (http://localhost:11434/v1 + llama3.1 — Ollama/LM Studio)
15
+ * The hosted ones speak OpenAI-compatible chat/completions via plain fetch; the CLIs are spawned.
16
+ * If RETAKE_MODEL is unset, the first available in the order above is used.
17
+ * A `.env` in the Retake root is loaded at startup (KEY=value lines).
18
+ */
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { execFileSync, spawnSync } from "node:child_process";
23
+ import { chromium } from "playwright";
24
+ import YAML from "yaml";
25
+ import { Manifest } from "./manifest.js";
26
+ import { digest } from "./digest.js";
27
+ export function loadDotenv(root, files = [".env"]) {
28
+ for (const name of files) {
29
+ const f = path.join(root, name);
30
+ if (!fs.existsSync(f))
31
+ continue;
32
+ for (const line of fs.readFileSync(f, "utf8").split("\n")) {
33
+ const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
34
+ if (m && process.env[m[1]] === undefined)
35
+ process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
36
+ }
37
+ }
38
+ }
39
+ function cliPath(bin) {
40
+ const home = os.homedir();
41
+ for (const p of [`/opt/homebrew/bin/${bin}`, `/usr/local/bin/${bin}`, `${home}/.local/bin/${bin}`, `${home}/.npm-global/bin/${bin}`])
42
+ if (fs.existsSync(p))
43
+ return p;
44
+ try {
45
+ return execFileSync("which", [bin], { encoding: "utf8" }).trim() || null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ export function pickProvider() {
52
+ const want = process.env.RETAKE_MODEL;
53
+ const claude = cliPath("claude"), codex = cliPath("codex");
54
+ const all = [
55
+ claude ? { name: "claude-code", baseUrl: claude, key: "", model: process.env.RETAKE_CLAUDE_MODEL ?? "default" } : null,
56
+ codex ? { name: "codex", baseUrl: codex, key: "", model: process.env.RETAKE_CODEX_MODEL ?? "default" } : null,
57
+ process.env.GROQ_API_KEY
58
+ ? { name: "groq", baseUrl: "https://api.groq.com/openai/v1", key: process.env.GROQ_API_KEY, model: process.env.RETAKE_GROQ_MODEL ?? "openai/gpt-oss-120b" }
59
+ : null,
60
+ process.env.MISTRAL_API_KEY
61
+ ? { name: "mistral", baseUrl: "https://api.mistral.ai/v1", key: process.env.MISTRAL_API_KEY, model: process.env.RETAKE_MISTRAL_MODEL ?? "mistral-small-latest" }
62
+ : null,
63
+ process.env.RETAKE_LOCAL_URL
64
+ ? { name: "local", baseUrl: process.env.RETAKE_LOCAL_URL.replace(/\/$/, ""), key: process.env.RETAKE_LOCAL_KEY ?? "local", model: process.env.RETAKE_LOCAL_MODEL ?? "llama3.1" }
65
+ : null,
66
+ ];
67
+ if (want)
68
+ return all.find((p) => p?.name === want) ?? null;
69
+ return all.find(Boolean) ?? null;
70
+ }
71
+ export function providerStatus() {
72
+ const p = pickProvider();
73
+ const available = ["claude-code", "codex", "groq", "mistral", "local"].filter((n) => (n === "claude-code" && cliPath("claude")) || (n === "codex" && cliPath("codex")) || (n === "groq" && process.env.GROQ_API_KEY) || (n === "mistral" && process.env.MISTRAL_API_KEY) || (n === "local" && process.env.RETAKE_LOCAL_URL));
74
+ return { active: p ? `${p.name} · ${p.model}` : null, available };
75
+ }
76
+ async function chat(p, system, user, projectDir) {
77
+ if (p.name === "claude-code" || p.name === "codex")
78
+ return chatCli(p, system, user, projectDir);
79
+ const r = await fetch(`${p.baseUrl}/chat/completions`, {
80
+ method: "POST",
81
+ headers: { "content-type": "application/json", authorization: `Bearer ${p.key}` },
82
+ body: JSON.stringify({ model: p.model, temperature: 0.2, messages: [{ role: "system", content: system }, { role: "user", content: user }] }),
83
+ });
84
+ if (!r.ok)
85
+ throw new Error(`${p.name} ${r.status}: ${(await r.text()).slice(0, 300)}`);
86
+ const j = (await r.json());
87
+ const text = j.choices?.[0]?.message?.content ?? "";
88
+ if (!text)
89
+ throw new Error(`${p.name} returned no text`);
90
+ return text;
91
+ }
92
+ /** The signed-in CLIs: one prompt in, text out. No tools, no keys, uses the user's own subscription. */
93
+ function chatCli(p, system, user, projectDir) {
94
+ const prompt = `${system}\n\n${user}`;
95
+ const modelArgs = p.model && p.model !== "default" ? ["--model", p.model] : [];
96
+ // A clean env: when Retake itself runs inside a Claude Code session, the
97
+ // inherited CLAUDE*/ANTHROPIC* vars would point the child at the wrong auth.
98
+ const env = {};
99
+ for (const [k, v] of Object.entries(process.env))
100
+ if (v !== undefined && !/^(CLAUDE|ANTHROPIC)/i.test(k))
101
+ env[k] = v;
102
+ let r;
103
+ if (p.name === "claude-code") {
104
+ // With a project folder, let it read the repo itself — that is where the
105
+ // routes, the login and the real selectors live. Read-only tools only.
106
+ const args = projectDir
107
+ ? ["-p", "--output-format", "text", "--add-dir", projectDir, "--allowedTools", "Read,Grep,Glob", ...modelArgs]
108
+ : ["-p", "--output-format", "text", "--disallowedTools", "*", ...modelArgs];
109
+ r = spawnSync(p.baseUrl, args, { input: prompt, encoding: "utf8", timeout: 180_000, maxBuffer: 8e6, env });
110
+ if (r.status !== 0 && /401|revoked|authenticate/i.test(r.stdout + r.stderr)) {
111
+ // Nested inside another Claude Code session the keychain entry can be a
112
+ // revoked session token; a minimal env falls back to the saved login.
113
+ r = spawnSync(p.baseUrl, args, { input: prompt, encoding: "utf8", timeout: 180_000, maxBuffer: 8e6, env: { HOME: env.HOME ?? os.homedir(), PATH: env.PATH ?? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" } });
114
+ }
115
+ }
116
+ else {
117
+ const out = path.join(os.tmpdir(), `retake-codex-${process.pid}.txt`);
118
+ const catalogPath = path.join(os.tmpdir(), `retake-codex-models-${process.pid}.json`);
119
+ try {
120
+ const bundled = spawnSync(p.baseUrl, ["debug", "models", "--bundled"], { encoding: "utf8", timeout: 15_000, maxBuffer: 4e6, env });
121
+ if (bundled.status !== 0 || !bundled.stdout)
122
+ throw new Error(`could not read Codex's bundled model catalog: ${bundled.stderr || "no output"}`);
123
+ JSON.parse(bundled.stdout);
124
+ fs.writeFileSync(catalogPath, bundled.stdout);
125
+ r = spawnSync(p.baseUrl, ["exec", "--skip-git-repo-check", "--ephemeral", "-s", "read-only", "--color", "never", "-c", `model_catalog_json=${JSON.stringify(catalogPath)}`, ...(process.env.RETAKE_CODEX_REASONING ? ["-c", `model_reasoning_effort=${JSON.stringify(process.env.RETAKE_CODEX_REASONING)}`] : []), ...(projectDir ? ["-C", projectDir] : []), "-o", out, ...(modelArgs.length ? ["-m", p.model] : []), "-"], { input: prompt, encoding: "utf8", timeout: 180_000, maxBuffer: 8e6, env });
126
+ if (fs.existsSync(out)) {
127
+ const t = fs.readFileSync(out, "utf8");
128
+ fs.rmSync(out, { force: true });
129
+ if (t.trim())
130
+ return t;
131
+ }
132
+ }
133
+ finally {
134
+ fs.rmSync(catalogPath, { force: true });
135
+ }
136
+ }
137
+ if (r.error)
138
+ throw new Error(`${p.name}: ${r.error.message}`);
139
+ if (r.status !== 0)
140
+ throw new Error(`${p.name} exited ${r.status}: ${(r.stderr || r.stdout || "").slice(0, 300)}`);
141
+ const text = (r.stdout || "").trim();
142
+ if (!text)
143
+ throw new Error(`${p.name} returned no text`);
144
+ return text;
145
+ }
146
+ // --- scouting ---------------------------------------------------------------
147
+ const SCOUT_SCRIPT = String.raw `(() => {
148
+ const vis = (el) => { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return r.width > 0 && r.height > 0 && cs.visibility !== "hidden" && cs.display !== "none"; };
149
+ const cssEsc = (s) => (window.CSS && CSS.escape ? CSS.escape(s) : s.replace(/[^\w-]/g, "\\$&"));
150
+ const uniq = (sel) => { try { return document.querySelectorAll(sel).length === 1; } catch { return false; } };
151
+ const selectorFor = (el) => {
152
+ const tag = el.tagName.toLowerCase();
153
+ const id = el.getAttribute("id");
154
+ if (id && uniq("#" + cssEsc(id))) return "#" + cssEsc(id);
155
+ const tid = el.getAttribute("data-testid");
156
+ if (tid) return '[data-testid="' + tid + '"]';
157
+ const aria = el.getAttribute("aria-label");
158
+ if (aria && uniq(tag + '[aria-label="' + aria + '"]')) return tag + '[aria-label="' + aria + '"]';
159
+ const ph = el.getAttribute("placeholder");
160
+ if (ph && uniq(tag + '[placeholder="' + ph + '"]')) return tag + '[placeholder="' + ph + '"]';
161
+ const cls = Array.from(el.classList).filter((c) => !/^\d|^(on|active|selected|focus|hover)$/.test(c)).slice(0, 2);
162
+ if (cls.length && uniq(tag + "." + cls.map(cssEsc).join("."))) return tag + "." + cls.map(cssEsc).join(".");
163
+ const txt = (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40);
164
+ if (txt && (tag === "button" || tag === "a")) {
165
+ // Playwright's nth counts hidden matches too, so index over all of them.
166
+ const same = Array.from(document.querySelectorAll(tag)).filter((o) => (o.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40) === txt);
167
+ const base = tag + ':has-text("' + txt.replace(/"/g, '\\"') + '")';
168
+ return same.length > 1 ? base + " >> nth=" + same.indexOf(el) : base;
169
+ }
170
+ if (cls.length) return tag + "." + cls.map(cssEsc).join(".");
171
+ return tag;
172
+ };
173
+ const els = Array.from(document.querySelectorAll("button, a[href], input, textarea, select, [role=button], [role=tab], [contenteditable=true]"))
174
+ .filter(vis).slice(0, 80)
175
+ .map((el) => ({
176
+ tag: el.tagName.toLowerCase(),
177
+ selector: selectorFor(el),
178
+ text: (el.innerText || el.getAttribute("aria-label") || el.getAttribute("value") || "").trim().replace(/\s+/g, " ").slice(0, 60),
179
+ role: el.getAttribute("role") || undefined,
180
+ placeholder: el.getAttribute("placeholder") || undefined,
181
+ href: el.getAttribute("href") || undefined,
182
+ }));
183
+ const headings = Array.from(document.querySelectorAll("h1,h2,h3")).filter(vis).map((h) => (h.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80)).filter(Boolean).slice(0, 20);
184
+ const text = (document.body.innerText || "").replace(/\s+/g, " ").slice(0, 1800);
185
+ return { title: document.title, headings, text, elements: els };
186
+ })()`;
187
+ export async function scout(url, opts = {}) {
188
+ const browser = await chromium.launch({ headless: true });
189
+ try {
190
+ const page = await browser.newPage({ viewport: opts.viewport ?? { width: 1000, height: 700 } });
191
+ await page.goto(url, { waitUntil: "networkidle", timeout: opts.timeoutMs ?? 20_000 });
192
+ await page.waitForTimeout(600);
193
+ // A string, not a function: tsx/esbuild's keepNames would inject a `__name`
194
+ // helper that does not exist inside the page.
195
+ const scouted = (await page.evaluate(SCOUT_SCRIPT));
196
+ return { url, ...scouted };
197
+ }
198
+ finally {
199
+ await browser.close();
200
+ }
201
+ }
202
+ /** Look at an app (its page, and its source when given) and propose demos worth
203
+ recording. Output is a markdown file you can keep, plus a parsed list the UI
204
+ turns into one-click starting points. */
205
+ export async function suggestIdeas(input) {
206
+ const { url, scout: sc, provider } = input;
207
+ const dg = input.project ? digest(input.project) : undefined;
208
+ const system = [
209
+ "You propose short silent product demos worth recording — the kind that make someone understand an app in 30 seconds.",
210
+ "Answer in markdown, nothing else. For each idea use exactly this shape:",
211
+ "",
212
+ "## <short title>",
213
+ "**Story:** one sentence a person could act out in the app, naming the concrete screens/actions.",
214
+ "**Length:** ~25s",
215
+ "**Why it lands:** one sentence on what a viewer understands afterwards.",
216
+ "",
217
+ "Rules: 5 to 7 ideas, best first. Every idea must be recordable in ONE continuous browser run with no new tabs. Prefer flows that end in a visible result, not tours of menus. Use the app's real vocabulary. No marketing adjectives.",
218
+ ].join("\n");
219
+ const user = [
220
+ `App: ${url}`,
221
+ `Page title: ${sc.title}`,
222
+ `Headings: ${sc.headings.join(" | ")}`,
223
+ `Visible controls: ${sc.elements.map((e) => e.text || e.placeholder || e.selector).filter(Boolean).slice(0, 40).join(" · ")}`,
224
+ `Page text: ${sc.text.slice(0, 1200)}`,
225
+ ...(dg ? ["", "The project's source says:", dg.text] : []),
226
+ "",
227
+ "Propose the demos.",
228
+ ].join("\n");
229
+ const markdown = (await chat(provider, system, user, input.project)).replace(/^```[a-z]*\n?/im, "").replace(/```\s*$/m, "").trim();
230
+ const ideas = [];
231
+ for (const block of markdown.split(/^##\s+/m).slice(1)) {
232
+ const lines = block.split("\n");
233
+ const title = lines[0].trim();
234
+ const field = (k) => (new RegExp(`\\*\\*${k}:?\\*\\*\\s*(.+)`, "i").exec(block)?.[1] ?? "").trim();
235
+ if (title)
236
+ ideas.push({ title, story: field("Story"), length: field("Length"), why: field("Why it lands") });
237
+ }
238
+ return { markdown, ideas };
239
+ }
240
+ // --- fixing ------------------------------------------------------------------
241
+ /** A small, fixed vocabulary of edits. The model picks from these; Retake
242
+ applies them to the YAML document (comments kept) and already knows which
243
+ ones need the browser again. Deliberately narrow — reliability first. */
244
+ export const EDIT_VERBS = [
245
+ "set_caption", // {scene, caption}
246
+ "set_camera", // {scene, camera: "auto"|"static"|number}
247
+ "set_hold", // {scene, holdMs}
248
+ "set_trim", // {head?, tail?} seconds off each end
249
+ "set_format", // {preset}
250
+ "set_layout", // {layout}
251
+ "add_wait", // {after: <step index>, ms}
252
+ "wait_for", // {step: <step index>, selector} turn a fixed pause into waiting for something to appear (or insert after that step)
253
+ "set_wait", // {step: <step index>, ms}
254
+ "set_text", // {step: <step index>, text} for type/fill steps
255
+ "replace_selector", // {step: <step index>, selector}
256
+ "delete_step", // {step: <step index>}
257
+ "insert_step", // {after: <step index, -1 for the start>, step: {action: ..., ...}} add one step, validated
258
+ "set_step", // {step: <step index>, value: {action: ..., ...}} replace one step in place, validated
259
+ "set_cursor", // {cursor: "default"|"touch"|"none"}
260
+ "rerecord", // {} throw the recording away and record again
261
+ ];
262
+ export async function proposeEdits(input) {
263
+ const system = [
264
+ "You edit a Retake demo manifest in response to plain-English feedback about the video it produced.",
265
+ "Respond with JSON only: {\"edits\": [...], \"note\": \"one short sentence saying what you changed\"}.",
266
+ "Each edit is one of:",
267
+ ' {"op":"set_caption","scene":"<label>","caption":"..."}',
268
+ ' {"op":"set_camera","scene":"<label>","camera":"auto"|"static"|<zoom number 1.1-1.6>}',
269
+ ' {"op":"set_hold","scene":"<label>","holdMs":<ms>}',
270
+ ' {"op":"set_trim","head":<sec>,"tail":<sec>}',
271
+ ' {"op":"set_format","preset":"post-landscape"|"post-square"|"post-vertical"|"docs-gif"}',
272
+ ' {"op":"set_layout","layout":"band"|"card"|"overlay-bottom"|"overlay-top"|"none"}',
273
+ ' {"op":"add_wait","after":<step index>,"ms":<ms>} — slow down after a step',
274
+ ' {"op":"wait_for","step":<step index>,"selector":"..."} — replace a fixed wait with waiting for an element to appear (if that step is not a wait, the waitFor is inserted after it)',
275
+ ' {"op":"set_wait","step":<step index>,"ms":<ms>} — change an existing wait',
276
+ ' {"op":"set_text","step":<step index>,"text":"..."} — what gets typed',
277
+ ' {"op":"replace_selector","step":<step index>,"selector":"..."}',
278
+ ' {"op":"delete_step","step":<step index>}',
279
+ ' {"op":"insert_step","after":<step index or -1>,"step":{"action":"click","selector":"..."}} — add one step after that index (any step shape: click, type, waitFor, scroll, scene…)',
280
+ ' {"op":"set_step","step":<step index>,"value":{"action":"waitFor","selector":"..."}} — replace one step in place',
281
+ ' {"op":"set_cursor","cursor":"default"|"touch"|"none"}',
282
+ ' {"op":"rerecord"}',
283
+ "Rules: make the SMALLEST change that does what was asked; never rewrite things that were not mentioned; use scene labels and step indexes exactly as given; if the request is impossible or unclear, return an empty edits list and say why in note.",
284
+ ].join("\n");
285
+ const user = `Feedback: ${input.instruction}\n\nReceipts from the last take (what actually happened, with step indexes and scene labels):\n${input.receipts}\n\nCurrent manifest:\n${input.yaml}`;
286
+ const raw = await chat(input.provider, system, user);
287
+ const text = raw.replace(/^```[a-z]*\n?/im, "").replace(/```\s*$/m, "").trim();
288
+ const start = text.indexOf("{");
289
+ const parsed = JSON.parse(text.slice(start));
290
+ const edits = (parsed.edits ?? []).filter((e) => EDIT_VERBS.includes(e.op));
291
+ return { edits, note: parsed.note ?? "" };
292
+ }
293
+ // --- drafting ---------------------------------------------------------------
294
+ const SCHEMA_DOC = `
295
+ Manifest YAML fields:
296
+ name (kebab-case), title, url, preset: post-landscape, waitForSelector (selector present when page is ready),
297
+ camera: auto, setup: [steps run before recording], steps: [...], outputs: {mp4: true, gif: true, thumbnail: {scene: <label>}}
298
+ Do NOT set viewport, cursor, chrome, background, fps, crf or captions — the preset handles quality.
299
+ Steps (each may have pauseAfter ms, waitFor selector, timeout ms):
300
+ {action: scene, label, caption, camera?: static | {focus: selector, zoom: 1.25}} a named beat; caption is burned in until the next scene (under 60 chars). Set camera: static on every scene unless the thing being shown is genuinely too small to read at full frame — a calm, still demo reads as more real than a zooming one. If the person asked for zooms (or stillness), that wins.
301
+ {action: wait, ms}
302
+ {action: click, selector, zoom: 1.3} zoom is optional
303
+ {action: type, selector, text, delay: 30}
304
+ {action: fill, selector, text}
305
+ {action: hover, selector}
306
+ {action: scroll, y}
307
+ {action: keyboard, key}
308
+ {action: waitFor, selector, timeout: 30000} wait for something to appear (use after actions that trigger async work)
309
+ {action: evaluate, script}
310
+ Rules: never invent field names or add "?" to keys; 15–35 seconds total; 3–6 scenes; open with a scene; every click uses a selector from the scouted list verbatim (selectors ending in \">> nth=N\" are already disambiguated — keep them exactly); after any action that loads or computes something, waitFor an element that only appears as a result (never one that is already on the page) — if you cannot know it from the scout, use {action: wait, ms: 3000} instead; end with a wait of ~2000ms on the payoff; do not use zoom steps — scenes carry the camera.
311
+ `;
312
+ /** The person's standing taste — demos/style.md, written once, read by every
313
+ draft. "No zooms, cursor visible, captions plain" should not need repeating. */
314
+ export function styleNote(demosDir) {
315
+ try {
316
+ const t = fs.readFileSync(path.join(demosDir, "style.md"), "utf8").trim();
317
+ return t || null;
318
+ }
319
+ catch {
320
+ return null;
321
+ }
322
+ }
323
+ export async function draftManifest(input) {
324
+ const { name, url, describe, scout: sc, provider } = input;
325
+ const dg = input.project ? digest(input.project) : undefined;
326
+ const style = input.demosDir ? styleNote(input.demosDir) : null;
327
+ const system = `You write Retake demo manifests: YAML that drives a Playwright walkthrough of a web app to make a short silent product demo video. Output ONLY the YAML document, no prose, no code fences.\n${SCHEMA_DOC}`;
328
+ const user = [
329
+ `name: ${name}`,
330
+ `url: ${url}`,
331
+ `What to record: ${describe}`,
332
+ ...(style ? ["", "The person's standing style preferences — these always win over defaults:", style] : []),
333
+ ``,
334
+ ...(dg ? ["The project's own source code says:", dg.text, "", "Use the routes and selectors above when they fit the story — they are more reliable than anything guessed. Add `reducedMotion: true` if infinite animations were flagged. If a sign-in is needed, put it in `setup` with ${ENV} placeholders and `secret: true`, never literal passwords.", ""] : []),
335
+ `Scouted page — title: ${sc.title}`,
336
+ `Headings: ${sc.headings.join(" | ")}`,
337
+ `Visible interactive elements (tag · selector · text/placeholder):`,
338
+ ...sc.elements.map((e) => `- ${e.tag} · ${e.selector} · ${e.text || e.placeholder || e.href || ""}`),
339
+ ``,
340
+ `Page text excerpt: ${sc.text}`,
341
+ ``,
342
+ `Write the manifest.`,
343
+ ].join("\n");
344
+ const clean = (t) => t.replace(/^```[a-z]*\n?/im, "").replace(/```\s*$/m, "").trim();
345
+ let text = clean(await chat(provider, system, user, input.project));
346
+ let retried = false;
347
+ const check = (t) => {
348
+ const parsed = Manifest.safeParse(YAML.parse(t));
349
+ return parsed.success ? null : parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
350
+ };
351
+ let err;
352
+ try {
353
+ err = check(text);
354
+ }
355
+ catch (e) {
356
+ err = `not valid YAML: ${e.message}`;
357
+ }
358
+ if (err) {
359
+ retried = true;
360
+ text = clean(await chat(provider, system, `${user}\n\nYour previous draft failed validation:\n${err}\n\nPrevious draft:\n${text}\n\nReturn a corrected full YAML document only.`));
361
+ try {
362
+ err = check(text);
363
+ }
364
+ catch (e) {
365
+ err = `not valid YAML: ${e.message}`;
366
+ }
367
+ if (err)
368
+ throw new Error(`draft did not validate after retry:\n${err}\n\n${text}`);
369
+ }
370
+ // Force the name the user chose and normalise the header comment.
371
+ const doc = YAML.parse(text);
372
+ doc.name = name;
373
+ if (!doc.url)
374
+ doc.url = url;
375
+ const header = `# ${describe.trim()}\n# Drafted by ${provider.name} (${provider.model}) from a scouting pass of ${url}${dg ? ` and the source in ${dg.dir}` : ""}.\n# Read it, fix selectors/captions, run it. It is yours now.\n\n`;
376
+ return { yaml: header + YAML.stringify(doc), provider: `${provider.name} · ${provider.model}`, retried, digest: dg };
377
+ }
package/dist/digest.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Project digest — what a repo says about itself.
3
+ *
4
+ * Scouting a live page shows the DOM of *one* screen. Reading the project shows
5
+ * the map: every route, how it starts, whether there is a login, which
6
+ * selectors the code actually uses, and what will make a recording flaky. The
7
+ * digest is deterministic and local (no model), and it goes into the drafting
8
+ * prompt next to the scout.
9
+ *
10
+ * Deliberately bounded: a fixed file budget and a character cap, so a big repo
11
+ * cannot blow up the prompt.
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ const SKIP = new Set(["node_modules", ".git", ".next", "dist", "build", "out", "coverage", ".turbo", ".vercel", "vendor", "__pycache__", ".venv", "target", "outputs"]);
16
+ const CODE = /\.(tsx?|jsx?|mjs|cjs|svelte|vue|astro|html|php|erb)$/;
17
+ const MAX_FILES = 900;
18
+ const MAX_CHARS = 9000;
19
+ function walk(dir, depth = 0, out = []) {
20
+ if (depth > 6 || out.length > MAX_FILES)
21
+ return out;
22
+ let entries;
23
+ try {
24
+ entries = fs.readdirSync(dir, { withFileTypes: true });
25
+ }
26
+ catch {
27
+ return out;
28
+ }
29
+ for (const e of entries) {
30
+ if (out.length > MAX_FILES)
31
+ break;
32
+ if (e.name.startsWith(".") && e.name !== ".env.example")
33
+ continue;
34
+ const full = path.join(dir, e.name);
35
+ if (e.isDirectory()) {
36
+ if (SKIP.has(e.name))
37
+ continue;
38
+ walk(full, depth + 1, out);
39
+ }
40
+ else
41
+ out.push(full);
42
+ }
43
+ return out;
44
+ }
45
+ const read = (f, cap = 60_000) => {
46
+ try {
47
+ return fs.readFileSync(f, "utf8").slice(0, cap);
48
+ }
49
+ catch {
50
+ return "";
51
+ }
52
+ };
53
+ /** Framework conventions → the URL paths a person can actually visit. */
54
+ function routesFrom(files, root) {
55
+ const routes = new Set();
56
+ for (const f of files) {
57
+ const rel = path.relative(root, f).replace(/\\/g, "/");
58
+ // Next.js app router: app/**/page.tsx ; pages router: pages/**/*.tsx
59
+ let m = /(?:^|\/)app\/(.+)\/page\.(tsx?|jsx?)$/.exec(rel) ?? /(?:^|\/)app\/page\.(tsx?|jsx?)$/.exec(rel);
60
+ if (m) {
61
+ const seg = m[1] ?? "";
62
+ const url = "/" + seg.split("/").filter((s) => !/^\(.*\)$/.test(s)).join("/");
63
+ routes.add(url.replace(/\/+$/, "") || "/");
64
+ continue;
65
+ }
66
+ m = /(?:^|\/)pages\/(.+)\.(tsx?|jsx?)$/.exec(rel);
67
+ if (m && !/^_/.test(path.basename(m[1]))) {
68
+ routes.add("/" + m[1].replace(/\/index$/, "").replace(/^index$/, ""));
69
+ continue;
70
+ }
71
+ // SvelteKit / Remix-ish
72
+ m = /(?:^|\/)routes\/(.+)\/\+page\.svelte$/.exec(rel);
73
+ if (m)
74
+ routes.add("/" + m[1]);
75
+ // Plain static sites: public/*.html or top-level *.html
76
+ m = /(?:^|\/)(?:public|static|www|dist)?\/?([\w-]+)\.html$/.exec(rel);
77
+ if (m && rel.split("/").length <= 3)
78
+ routes.add("/" + (m[1] === "index" ? "" : m[1]));
79
+ }
80
+ return [...routes].sort().slice(0, 40);
81
+ }
82
+ export function digest(dir) {
83
+ const root = path.resolve(dir);
84
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory())
85
+ throw new Error(`not a folder: ${root}`);
86
+ const files = walk(root);
87
+ const rel = (f) => path.relative(root, f).replace(/\\/g, "/");
88
+ // --- how it starts -------------------------------------------------------
89
+ const pkgFile = path.join(root, "package.json");
90
+ let scripts = {};
91
+ let stack = [];
92
+ let name = path.basename(root);
93
+ if (fs.existsSync(pkgFile)) {
94
+ try {
95
+ const pkg = JSON.parse(read(pkgFile));
96
+ name = pkg.name ?? name;
97
+ scripts = pkg.scripts ?? {};
98
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
99
+ for (const k of ["next", "react", "vue", "svelte", "@sveltejs/kit", "astro", "vite", "remix", "nuxt", "express", "tailwindcss"])
100
+ if (deps[k])
101
+ stack.push(k);
102
+ }
103
+ catch { /* ignore */ }
104
+ }
105
+ if (!stack.length && files.some((f) => /\.html$/.test(f)))
106
+ stack.push("static html");
107
+ if (fs.existsSync(path.join(root, "vercel.json")) || fs.existsSync(path.join(root, "api")))
108
+ stack.push("vercel");
109
+ // --- what to visit -------------------------------------------------------
110
+ const routes = routesFrom(files, root);
111
+ // --- signals worth quoting ----------------------------------------------
112
+ const auth = new Set();
113
+ const selectors = new Set();
114
+ const flaky = new Set();
115
+ const envKeys = new Set();
116
+ let scanned = 0;
117
+ for (const f of files) {
118
+ if (!CODE.test(f) || scanned > 260)
119
+ continue;
120
+ const body = read(f, 220_000);
121
+ if (!body)
122
+ continue;
123
+ scanned++;
124
+ const r = rel(f);
125
+ if (/sign[-_ ]?in|log[-_ ]?in|password|session|middleware|auth/i.test(r) || /type="password"/i.test(body)) {
126
+ for (const mm of body.matchAll(/(?:id|name|placeholder|aria-label)=["']([^"']{2,40})["']/g)) {
127
+ if (/user|email|pass|login|sign|otp|code/i.test(mm[1]))
128
+ auth.add(`${r}: ${mm[0]}`);
129
+ }
130
+ if (/type="password"/i.test(body))
131
+ auth.add(`${r}: has a password field`);
132
+ if (/oauth|redirect_uri|nextauth|clerk|supabase\.auth|magic ?link/i.test(body))
133
+ auth.add(`${r}: OAuth/third-party auth mentioned`);
134
+ }
135
+ for (const mm of body.matchAll(/data-testid=["']([^"']+)["']/g))
136
+ selectors.add(`[data-testid="${mm[1]}"]`);
137
+ for (const mm of body.matchAll(/aria-label=["']([^"']{2,40})["']/g))
138
+ selectors.add(`[aria-label="${mm[1]}"]`);
139
+ if (/window\.open\(/.test(body))
140
+ flaky.add(`${r}: window.open — would need a second tab`);
141
+ if (/target=["']_blank["']/.test(body))
142
+ flaky.add(`${r}: target="_blank" links`);
143
+ if (/Math\.random\(/.test(body))
144
+ flaky.add(`${r}: Math.random — values differ every run`);
145
+ if (/animation:[^;]*infinite/.test(body))
146
+ flaky.add(`${r}: infinite animation (set reducedMotion)`);
147
+ if (/type=["']file["']/.test(body))
148
+ flaky.add(`${r}: file input (upload step available)`);
149
+ if (/download=|createObjectURL/.test(body))
150
+ flaky.add(`${r}: triggers a download (download step available)`);
151
+ for (const mm of body.matchAll(/process\.env\.([A-Z0-9_]{3,})/g))
152
+ envKeys.add(mm[1]);
153
+ }
154
+ for (const f of [".env.example", ".env.local.example"]) {
155
+ const p = path.join(root, f);
156
+ if (fs.existsSync(p))
157
+ for (const line of read(p, 4000).split("\n")) {
158
+ const k = /^([A-Z0-9_]+)=/.exec(line.trim());
159
+ if (k)
160
+ envKeys.add(k[1]);
161
+ }
162
+ }
163
+ const readmeFile = files.find((f) => /^readme\.md$/i.test(path.basename(f)));
164
+ const readme = readmeFile ? read(readmeFile, 1200) : "";
165
+ const d = {
166
+ dir: root, name, stack, scripts, routes,
167
+ auth: [...auth].slice(0, 12),
168
+ selectors: [...selectors].slice(0, 60),
169
+ flaky: [...flaky].slice(0, 12),
170
+ env: [...envKeys].slice(0, 25),
171
+ readme, files: files.length, text: "",
172
+ };
173
+ d.text = format(d);
174
+ return d;
175
+ }
176
+ function format(d) {
177
+ const L = [];
178
+ L.push(`Project: ${d.name} (${d.dir})`);
179
+ if (d.stack.length)
180
+ L.push(`Stack: ${d.stack.join(", ")}`);
181
+ const dev = Object.entries(d.scripts).filter(([k]) => /^(dev|start|serve|preview)$/.test(k));
182
+ if (dev.length)
183
+ L.push(`Start commands: ${dev.map(([k, v]) => `${k} → ${v}`).join(" | ")}`);
184
+ if (d.routes.length)
185
+ L.push(`Routes found in the code: ${d.routes.join(" ")}`);
186
+ if (d.auth.length)
187
+ L.push(`Sign-in signals:\n${d.auth.map((a) => "- " + a).join("\n")}`);
188
+ if (d.selectors.length)
189
+ L.push(`Stable selectors used in the code (prefer these):\n${d.selectors.map((s) => "- " + s).join("\n")}`);
190
+ if (d.flaky.length)
191
+ L.push(`Things that can break a recording:\n${d.flaky.map((s) => "- " + s).join("\n")}`);
192
+ if (d.env.length)
193
+ L.push(`Env vars referenced: ${d.env.join(", ")}`);
194
+ if (d.readme)
195
+ L.push(`README (start):\n${d.readme}`);
196
+ return L.join("\n\n").slice(0, MAX_CHARS);
197
+ }