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,1112 @@
1
+ /**
2
+ * `retake ui` — the one-window loop: pick or describe a demo → edit → run →
3
+ * watch → fix → run. A tiny local HTTP server, no framework, no build step.
4
+ *
5
+ * GET / the page
6
+ * GET /api/demos [{name,title,file,lastTake}]
7
+ * GET /api/demos/:name { yaml }
8
+ * PUT /api/demos/:name { yaml } → validate + save
9
+ * POST /api/demos { name, url, describe } → starter manifest
10
+ * POST /api/describe { name, url, describe } → scout + model → draft manifest (saved)
11
+ * GET /api/provider { active, available }
12
+ * GET /api/presets [{name, ...}]
13
+ * GET/PUT/DELETE /api/drafts/:id an unfinished "new demo" (auto-saved as you go)
14
+ * DELETE /api/demos/:name remove a demo (manifest + outputs)
15
+ * PATCH /api/demos/:name/settings { preset?, layout?, camera?, cursor? } → edits keys, keeps comments
16
+ * PATCH /api/demos/:name/scene { label, caption?, camera?, zoom?, holdMs? } → edits one scene in place
17
+ * PATCH /api/demos/:name/trim { head, tail } → seconds cut from the finished video
18
+ * POST /api/run/:name start a run; returns { runId }
19
+ * GET /api/run/:name/stream SSE: log lines, then `done`
20
+ * GET /api/take/:name outputs/<name>/take.json (+ proof-log text)
21
+ * GET /out/:name/<file> artifacts (mp4/gif/png/md)
22
+ */
23
+ import http from "node:http";
24
+ import fs from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { execFile, execFileSync, spawn } from "node:child_process";
28
+ import { fileURLToPath } from "node:url";
29
+ import YAML from "yaml";
30
+ import { Manifest, loadManifest } from "../manifest.js";
31
+ import { PRESETS } from "../presets.js";
32
+ import { draftManifest, loadDotenv, pickProvider, proposeEdits, providerStatus, scout, suggestIdeas } from "../describe.js";
33
+ import { applyEdits, receiptsFor } from "../edits.js";
34
+ import { dryRun } from "../dryrun.js";
35
+ import { startOperator, getSession, addPending, answerPending, markDone, stopSession } from "../operator/run.js";
36
+ import { gifskiBin, makeGif } from "../render.js";
37
+ import { captureHash } from "../record.js";
38
+ import { digest } from "../digest.js";
39
+ import { startOffer, startApp, listeningPorts } from "../appserver.js";
40
+ import { PKG_ROOT, PROJECT_ROOT, VERSION, entry } from "../paths.js";
41
+ const ROOT = PROJECT_ROOT;
42
+ const DEMOS = path.join(ROOT, "demos");
43
+ /** Where takes land. Default outputs/ in the project; changeable in Settings
44
+ (RETAKE_OUT). Read lazily so a settings change applies without a restart. */
45
+ function outRoot() {
46
+ const v = process.env.RETAKE_OUT?.trim();
47
+ return v ? path.resolve(v.replace(/^~/, os.homedir())) : path.join(ROOT, "outputs");
48
+ }
49
+ const OUT_DEFAULT = path.join(ROOT, "outputs");
50
+ const DRAFTS = path.join(ROOT, ".drafts");
51
+ const DEMO_PROJECTS = path.join(DRAFTS, "demo-projects.json");
52
+ function listDrafts() {
53
+ if (!fs.existsSync(DRAFTS))
54
+ return [];
55
+ return fs.readdirSync(DRAFTS).filter((f) => f.endsWith(".json")).map((f) => { try {
56
+ return JSON.parse(fs.readFileSync(path.join(DRAFTS, f), "utf8"));
57
+ }
58
+ catch {
59
+ return null;
60
+ } }).filter((d) => !!d).sort((a, b) => b.updated.localeCompare(a.updated));
61
+ }
62
+ function nextProjectName() {
63
+ const taken = new Set([...listDrafts().map((d) => d.name), ...(fs.existsSync(DEMOS) ? fs.readdirSync(DEMOS).map((f) => f.replace(/\.ya?ml$/, "")) : [])]);
64
+ for (let i = 1;; i++)
65
+ if (!taken.has(`project-${i}`))
66
+ return `project-${i}`;
67
+ }
68
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
69
+ /** Push a line / stage into a run and to everyone streaming it. */
70
+ function emit(run, line) {
71
+ run.lines.push(line);
72
+ for (const res of run.listeners)
73
+ res.write(`data: ${JSON.stringify(line)}\n\n`);
74
+ }
75
+ function setStage(run, stage) {
76
+ run.stage = stage;
77
+ run.stageAt = Date.now();
78
+ for (const res of run.listeners)
79
+ res.write(`event: stage\ndata: ${JSON.stringify({ stage, at: run.stageAt, estimate: run.estimate, startedAt: run.startedAt })}\n\n`);
80
+ }
81
+ function finish(run, code) {
82
+ run.done = true;
83
+ run.code = code;
84
+ for (const res of run.listeners) {
85
+ res.write(`event: done\ndata: ${JSON.stringify({ code })}\n\n`);
86
+ res.end();
87
+ }
88
+ run.listeners.clear();
89
+ }
90
+ /** Deterministic repairs for what a dry run finds — no model needed.
91
+ Strict-mode collision → pick the first visible match. */
92
+ function autoRepair(yamlText, failures) {
93
+ const fixes = [];
94
+ const doc = YAML.parseDocument(yamlText);
95
+ const steps = doc.get("steps");
96
+ for (const f of failures) {
97
+ const m = /^✗ \[(\d+)\] \w+ (.+?) — .*strict mode violation/.exec(f);
98
+ if (!m || !steps)
99
+ continue;
100
+ const n = steps.items[Number(m[1])];
101
+ const sel = n && typeof n.get === "function" ? String(n.get("selector") ?? "") : "";
102
+ if (sel && !/>> nth=/.test(sel)) {
103
+ n.set("selector", `${sel} >> nth=0`);
104
+ fixes.push(`step ${m[1]}: "${sel}" matched several — using the first`);
105
+ }
106
+ }
107
+ return { yaml: doc.toString(), fixes };
108
+ }
109
+ const runs = new Map();
110
+ const json = (res, code, body) => {
111
+ res.writeHead(code, { "content-type": "application/json" });
112
+ res.end(JSON.stringify(body));
113
+ };
114
+ const readBody = (req) => new Promise((ok) => {
115
+ let b = "";
116
+ req.on("data", (c) => (b += c));
117
+ req.on("end", () => ok(b));
118
+ });
119
+ const safeName = (s) => /^[a-z0-9-]+$/.test(s);
120
+ const MIME = { ".mp4": "video/mp4", ".gif": "image/gif", ".png": "image/png", ".md": "text/markdown", ".json": "application/json", ".html": "text/html", ".yaml": "text/yaml" };
121
+ /** A short name for "the app at this URL": the last path segment if there
122
+ is one (demo.playwright.dev/todomvc → todomvc), else the host without
123
+ www and TLD (www.saucedemo.com → saucedemo). localhost keeps its port —
124
+ that is the only thing telling two local apps apart. */
125
+ function shortGroup(url) {
126
+ if (!url)
127
+ return "unsorted";
128
+ try {
129
+ const u = new URL(url);
130
+ const seg = u.pathname.split("/").filter(Boolean).pop();
131
+ if (seg)
132
+ return seg.slice(0, 24);
133
+ if (u.hostname === "localhost" || u.hostname === "127.0.0.1")
134
+ return `localhost:${u.port || "80"}`;
135
+ return u.hostname.replace(/^www\./, "").replace(/\.[a-z]{2,}$/i, "").slice(0, 24);
136
+ }
137
+ catch {
138
+ return url.replace(/^https?:\/\//, "").slice(0, 24);
139
+ }
140
+ }
141
+ function projectKey(project) {
142
+ return path.resolve(project.replace(/^~/, os.homedir()).trim());
143
+ }
144
+ function demoProjects() {
145
+ try {
146
+ return JSON.parse(fs.readFileSync(DEMO_PROJECTS, "utf8"));
147
+ }
148
+ catch {
149
+ return {};
150
+ }
151
+ }
152
+ function assignDemoProject(name, project) {
153
+ const map = demoProjects();
154
+ map[name] = projectKey(project);
155
+ fs.mkdirSync(DRAFTS, { recursive: true });
156
+ fs.writeFileSync(DEMO_PROJECTS, JSON.stringify(map, null, 2) + "\n");
157
+ }
158
+ function unassignDemoProject(name) {
159
+ const map = demoProjects();
160
+ if (!(name in map))
161
+ return;
162
+ delete map[name];
163
+ fs.writeFileSync(DEMO_PROJECTS, JSON.stringify(map, null, 2) + "\n");
164
+ }
165
+ function listDemos(project) {
166
+ if (!fs.existsSync(DEMOS))
167
+ return [];
168
+ const wanted = project?.trim() ? projectKey(project) : null;
169
+ const assignments = demoProjects();
170
+ const byUrl = new Map();
171
+ for (const f of fs.readdirSync(DEMOS).filter((x) => /\.ya?ml$/.test(x))) {
172
+ try {
173
+ const mm = loadManifest(path.join(DEMOS, f)).manifest;
174
+ const asg = assignments[mm.name];
175
+ if (asg && mm.url && !byUrl.has(mm.url))
176
+ byUrl.set(mm.url, asg);
177
+ }
178
+ catch { /* invalid manifest: no vote */ }
179
+ }
180
+ return fs
181
+ .readdirSync(DEMOS)
182
+ .filter((f) => /\.ya?ml$/.test(f))
183
+ .map((f) => {
184
+ const file = path.join(DEMOS, f);
185
+ let title = "", name = f.replace(/\.ya?ml$/, ""), valid = true, url = "", settings = {};
186
+ try {
187
+ const m = loadManifest(file).manifest;
188
+ title = m.title ?? "";
189
+ name = m.name;
190
+ url = m.url;
191
+ settings = {
192
+ preset: m.preset, layout: m.layout ?? "default",
193
+ camera: typeof m.camera === "string" ? m.camera : "auto",
194
+ cursor: m.cursor === false ? "none" : typeof m.cursor === "object" && m.cursor.style === "touch" ? "touch" : "default",
195
+ trim: m.trim,
196
+ tempo: m.tempo ?? 1,
197
+ scenes: m.steps.filter((st) => st.action === "scene").map((st) => ({ label: st.label, caption: st.caption ?? "", holdMs: st.holdMs ?? null, camera: st.camera === "static" ? "static" : typeof st.camera === "object" && st.camera.zoom ? st.camera.zoom : "auto" })),
198
+ };
199
+ }
200
+ catch {
201
+ valid = false;
202
+ }
203
+ // A demo nobody assigned to a folder inherits the folder of any demo
204
+ // that points at the same app — otherwise an agent's new draft for
205
+ // Capture lands in a second "localhost:3100" fold next to "capture".
206
+ const assigned = assignments[name] ?? (url ? byUrl.get(url) : undefined);
207
+ const group = assigned ? path.basename(assigned) : shortGroup(url);
208
+ const take = path.join(outRoot(), name, "take.json");
209
+ let lastTake = null;
210
+ // Does the browser need to run again, or is a re-render enough? The
211
+ // capture hash answers it, so the UI never has to ask the user.
212
+ let needsRecord = true;
213
+ if (fs.existsSync(take)) {
214
+ try {
215
+ const t = JSON.parse(fs.readFileSync(take, "utf8"));
216
+ lastTake = { finishedAt: t.finishedAt, ok: t.ok, partial: t.partial ?? null, duration: Math.round((t.duration - t.trimBefore) * 10) / 10 };
217
+ const m = loadManifest(file).manifest;
218
+ needsRecord = !t.captureHash || t.captureHash !== captureHash(m) || !!t.partial || !t.video || !fs.existsSync(t.video);
219
+ }
220
+ catch { /* ignore */ }
221
+ }
222
+ return { name, file: f, title, url, group, valid, settings, lastTake, needsRecord };
223
+ })
224
+ .filter((demo) => !wanted || assignments?.[demo.name] === wanted);
225
+ }
226
+ function starterManifest(name, url, describe) {
227
+ const doc = {
228
+ name,
229
+ title: describe.trim() || name,
230
+ url,
231
+ preset: "post-landscape",
232
+ steps: [
233
+ { action: "scene", label: "open", caption: describe.trim() ? describe.trim().slice(0, 60) : "Opening shot" },
234
+ { action: "wait", ms: 1500 },
235
+ { action: "click", selector: "TODO — a selector, e.g. button:has-text('Start')", pauseAfter: 800 },
236
+ { action: "scene", label: "result", caption: "What just happened." },
237
+ { action: "wait", ms: 2500 },
238
+ ],
239
+ outputs: { mp4: true, gif: true, thumbnail: { scene: "result" } },
240
+ };
241
+ return (`# ${describe.trim() || name}\n# Starter manifest — replace the TODO selector, add scenes. Run to see it.\n` +
242
+ `# Describe mode will draft these steps by scouting ${url} once a model is wired in.\n\n` +
243
+ YAML.stringify(doc));
244
+ }
245
+ /** How long the last take took, per stage — the progress bar's estimate. */
246
+ function estimateFor(name) {
247
+ const dir = path.join(outRoot(), name);
248
+ let capture = 90, render = 45;
249
+ try {
250
+ const t = JSON.parse(fs.readFileSync(path.join(dir, "take.json"), "utf8"));
251
+ if (t.captureSec)
252
+ capture = t.captureSec;
253
+ }
254
+ catch { /* default */ }
255
+ try {
256
+ const f = JSON.parse(fs.readFileSync(path.join(dir, "facts.json"), "utf8"));
257
+ const sum = Object.values(f.timings ?? {}).reduce((a, b) => a + Number(b), 0);
258
+ if (sum > 0)
259
+ render = sum + 3;
260
+ }
261
+ catch { /* default */ }
262
+ return { capture, render };
263
+ }
264
+ function startRun(name, mode = {}) {
265
+ const existing = runs.get(name);
266
+ if (existing && !existing.done)
267
+ return existing;
268
+ const file = fs.readdirSync(DEMOS).find((f) => f.replace(/\.ya?ml$/, "") === name);
269
+ if (!file)
270
+ throw new Error(`no demos/${name}.yaml`);
271
+ const cli = entry("cli");
272
+ const args = mode.renderOnly
273
+ ? [...cli.args, "render", path.join(outRoot(), name), ...(mode.gif ? ["--gif"] : [])]
274
+ : [...cli.args, "run", path.join(DEMOS, file), "--out", outRoot()];
275
+ if (!mode.renderOnly) {
276
+ if (mode.preview)
277
+ args.push("--preset", "preview-fast", "--reuse");
278
+ else {
279
+ if (mode.reuse)
280
+ args.push("--reuse");
281
+ if (mode.gif)
282
+ args.push("--gif");
283
+ }
284
+ }
285
+ const proc = spawn(cli.command, args, {
286
+ cwd: ROOT,
287
+ env: process.env,
288
+ stdio: ["ignore", "pipe", "pipe"],
289
+ });
290
+ const est = estimateFor(name);
291
+ if (mode.preview)
292
+ est.render = Math.min(est.render, 12);
293
+ if (mode.renderOnly)
294
+ est.capture = 0;
295
+ const run = { proc, lines: [], done: false, code: null, listeners: new Set(), stage: mode.renderOnly ? "render" : "starting", startedAt: Date.now(), stageAt: Date.now(), estimate: est };
296
+ const push = (chunk) => {
297
+ for (const raw of chunk.toString().split("\n")) {
298
+ const l = raw.trimEnd();
299
+ if (!l || /^\$ ffmpeg|zoom filter|filter graph|^>/.test(l))
300
+ continue;
301
+ const st = /^\[stage\] (\w+)(?: (\w+))?/.exec(l);
302
+ if (st) {
303
+ run.stage = st[2] === "skip" ? `${st[1]}-skip` : st[1];
304
+ run.stageAt = Date.now();
305
+ for (const res of run.listeners)
306
+ res.write(`event: stage\ndata: ${JSON.stringify({ stage: run.stage, at: run.stageAt, estimate: run.estimate })}\n\n`);
307
+ continue;
308
+ }
309
+ run.lines.push(l);
310
+ for (const res of run.listeners)
311
+ res.write(`data: ${JSON.stringify(l)}\n\n`);
312
+ }
313
+ };
314
+ proc.stdout?.on("data", push);
315
+ proc.stderr?.on("data", push);
316
+ proc.on("close", (code) => {
317
+ run.done = true;
318
+ run.code = code;
319
+ for (const res of run.listeners) {
320
+ res.write(`event: done\ndata: ${JSON.stringify({ code })}\n\n`);
321
+ res.end();
322
+ }
323
+ run.listeners.clear();
324
+ });
325
+ runs.set(name, run);
326
+ return run;
327
+ }
328
+ /** Read/write the .env in the project root — only the keys we own. */
329
+ function writeEnv(set) {
330
+ const file = path.join(ROOT, ".env");
331
+ const lines = fs.existsSync(file) ? fs.readFileSync(file, "utf8").split("\n") : [];
332
+ for (const [k, v] of Object.entries(set)) {
333
+ if (v === undefined)
334
+ continue;
335
+ const i = lines.findIndex((l) => l.startsWith(k + "="));
336
+ const line = `${k}=${v}`;
337
+ if (v === "") {
338
+ if (i >= 0)
339
+ lines.splice(i, 1);
340
+ }
341
+ else if (i >= 0)
342
+ lines[i] = line;
343
+ else
344
+ lines.push(line);
345
+ }
346
+ fs.writeFileSync(file, lines.filter((l, i, a) => l !== "" || i < a.length - 1).join("\n").replace(/\n*$/, "\n"));
347
+ }
348
+ function envSummary() {
349
+ return { localUrl: process.env.RETAKE_LOCAL_URL ?? "", localModel: process.env.RETAKE_LOCAL_MODEL ?? "", groq: !!process.env.GROQ_API_KEY, mistral: !!process.env.MISTRAL_API_KEY };
350
+ }
351
+ function modelSelection() {
352
+ return { claude: process.env.RETAKE_CLAUDE_MODEL ?? "", codex: process.env.RETAKE_CODEX_MODEL ?? "", codexReasoning: process.env.RETAKE_CODEX_REASONING ?? "", groq: process.env.RETAKE_GROQ_MODEL ?? "", mistral: process.env.RETAKE_MISTRAL_MODEL ?? "", local: process.env.RETAKE_LOCAL_MODEL ?? "" };
353
+ }
354
+ /** A navigation error, said the way a person would say it. */
355
+ function unreachable(url, e) {
356
+ const m = e.message;
357
+ if (/ERR_CONNECTION_REFUSED|ECONNREFUSED/.test(m)) {
358
+ const want = Number(new URL(url).port || 80);
359
+ const others = listeningPorts().filter((pt) => pt !== want && pt !== 4310);
360
+ const hint = others.length ? ` Things are running on ${others.slice(0, 6).map((pt) => ":" + pt).join(", ")} — one of those?` : "";
361
+ return `Nothing is running at ${url}.${hint}`;
362
+ }
363
+ if (/ERR_NAME_NOT_RESOLVED|ENOTFOUND/.test(m))
364
+ return `Could not find ${new URL(url).host}. Check the address.`;
365
+ if (/Timeout/.test(m))
366
+ return `${url} did not finish loading in time. Is it up?`;
367
+ return m.split("\n")[0];
368
+ }
369
+ /** Native folder selection for the local UI. Browsers intentionally hide an
370
+ absolute path, but Retake needs one so its operator can read the app. */
371
+ function pickFolder() {
372
+ return new Promise((done, fail) => {
373
+ let command, args;
374
+ if (process.platform === "darwin") {
375
+ command = "osascript";
376
+ args = ["-e", "POSIX path of (choose folder with prompt \"Choose the app Retake should record\")"];
377
+ }
378
+ else if (process.platform === "win32") {
379
+ command = "powershell";
380
+ args = ["-NoProfile", "-Command", "Add-Type -AssemblyName System.Windows.Forms; $d=New-Object System.Windows.Forms.FolderBrowserDialog; if($d.ShowDialog() -eq 'OK'){$d.SelectedPath}"];
381
+ }
382
+ else {
383
+ command = "zenity";
384
+ args = ["--file-selection", "--directory", "--title=Choose the app Retake should record"];
385
+ }
386
+ execFile(command, args, { encoding: "utf8", maxBuffer: 64_000 }, (error, stdout) => {
387
+ if (error) {
388
+ // Native pickers use a non-zero exit when the person cancels.
389
+ if (String(error.code) === "1")
390
+ return done(null);
391
+ return fail(error);
392
+ }
393
+ done(stdout.trim().replace(/\/$/, "") || null);
394
+ });
395
+ });
396
+ }
397
+ const BOOT = Date.now(); // pages compare this and reload themselves after a restart
398
+ const activity = { active: false, who: "", lines: [], startedAt: 0 };
399
+ const activityWatchers = new Set();
400
+ /** An agent that stops reporting without calling done would otherwise look
401
+ busy forever; ten quiet minutes means it is gone. */
402
+ function withStaleness(a) {
403
+ const last = a.lines.length ? a.startedAt : 0;
404
+ if (a.active && Date.now() - Math.max(last, a.startedAt) > 10 * 60_000 && !a.lines.length)
405
+ return { ...a, active: false, stale: true };
406
+ return a;
407
+ }
408
+ function noteActivity(b) {
409
+ if (!activity.active && !b.done) {
410
+ activity.active = true;
411
+ activity.startedAt = Date.now();
412
+ activity.lines = [];
413
+ activity.finishedAt = undefined;
414
+ }
415
+ if (b.who)
416
+ activity.who = b.who;
417
+ if (b.demo)
418
+ activity.demo = b.demo;
419
+ if (b.line) {
420
+ activity.lines.push(b.line);
421
+ if (activity.lines.length > 200)
422
+ activity.lines.shift();
423
+ }
424
+ if (b.done) {
425
+ activity.active = false;
426
+ activity.finishedAt = Date.now();
427
+ }
428
+ for (const w of activityWatchers)
429
+ w({ type: b.done ? "finished" : "line", data: { ...activity, latest: b.line } });
430
+ }
431
+ export function serve(port) {
432
+ loadDotenv(ROOT);
433
+ const page = fs.readFileSync(path.join(HERE, "index.html"), "utf8");
434
+ const chatPage = fs.readFileSync(path.join(HERE, "chat.html"), "utf8");
435
+ const guidePage = fs.readFileSync(path.join(HERE, "guide.html"), "utf8");
436
+ const server = http.createServer(async (req, res) => {
437
+ try {
438
+ const url = new URL(req.url ?? "/", "http://x");
439
+ const p = url.pathname;
440
+ if (p === "/" && req.method === "GET") {
441
+ res.writeHead(200, { "content-type": "text/html" });
442
+ return res.end(chatPage);
443
+ }
444
+ if (p === "/landing" && req.method === "GET") {
445
+ const f = path.join(PKG_ROOT, "site", "index.html");
446
+ if (!fs.existsSync(f))
447
+ return json(res, 404, { error: "no site/index.html" });
448
+ res.writeHead(200, { "content-type": "text/html" });
449
+ return res.end(fs.readFileSync(f));
450
+ }
451
+ const msite = /^\/landing\/media\/([A-Za-z0-9._-]+)$/.exec(p);
452
+ if (msite && (req.method === "GET" || req.method === "HEAD")) {
453
+ const f = path.join(PKG_ROOT, "site", "media", msite[1]);
454
+ if (!fs.existsSync(f))
455
+ return json(res, 404, { error: "not found" });
456
+ res.writeHead(200, { "content-type": MIME[path.extname(f)] ?? "application/octet-stream", "content-length": fs.statSync(f).size });
457
+ if (req.method === "HEAD")
458
+ return res.end();
459
+ return fs.createReadStream(f).pipe(res);
460
+ }
461
+ if (p === "/guide" && req.method === "GET") {
462
+ res.writeHead(200, { "content-type": "text/html" });
463
+ return res.end(guidePage);
464
+ }
465
+ if (p === "/classic" && req.method === "GET") {
466
+ res.writeHead(200, { "content-type": "text/html" });
467
+ return res.end(page);
468
+ }
469
+ if (p === "/manifest.webmanifest") {
470
+ res.writeHead(200, { "content-type": "application/manifest+json" });
471
+ return res.end(JSON.stringify({ name: "Retake", short_name: "Retake", description: "Rerun the demo, don't re-record it.", start_url: "/", scope: "/", display: "standalone", background_color: "#edefe8", theme_color: "#edefe8", icons: [{ src: "/icon-192.png", sizes: "192x192", type: "image/png" }, { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" }] }));
472
+ }
473
+ if (p === "/sw.js") {
474
+ res.writeHead(200, { "content-type": "application/javascript", "cache-control": "no-store" });
475
+ // Network-first, tiny: the app is local; the SW exists so the browser offers "Install".
476
+ return res.end(`self.addEventListener('install',()=>self.skipWaiting());self.addEventListener('activate',e=>e.waitUntil(clients.claim()));self.addEventListener('fetch',e=>{if(e.request.method!=='GET'||new URL(e.request.url).pathname.startsWith('/api/'))return;e.respondWith(fetch(e.request).catch(()=>caches.match(e.request)))});`);
477
+ }
478
+ const ic = /^\/icon-(192|512)\.png$/.exec(p);
479
+ if (ic) {
480
+ res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=86400" });
481
+ return res.end(fs.readFileSync(path.join(PKG_ROOT, "assets", `icon-${ic[1]}.png`)));
482
+ }
483
+ if (p === "/favicon.svg") {
484
+ const f = path.join(PKG_ROOT, "assets", "logo.svg");
485
+ res.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "max-age=86400" });
486
+ return res.end(fs.readFileSync(f));
487
+ }
488
+ if (p === "/api/demos" && req.method === "GET")
489
+ return json(res, 200, listDemos(url.searchParams.get("project") ?? undefined));
490
+ const mproject = /^\/api\/demos\/([a-z0-9-]+)\/project$/.exec(p);
491
+ if (mproject && req.method === "POST") {
492
+ const b = JSON.parse(await readBody(req));
493
+ if (!b.project?.trim())
494
+ return json(res, 400, { error: "project folder is required" });
495
+ if (!fs.existsSync(path.join(DEMOS, `${mproject[1]}.yaml`)))
496
+ return json(res, 404, { error: "demo not found" });
497
+ assignDemoProject(mproject[1], b.project);
498
+ return json(res, 200, { ok: true, name: mproject[1], project: projectKey(b.project) });
499
+ }
500
+ if (p === "/api/pick-folder" && req.method === "POST") {
501
+ try {
502
+ return json(res, 200, { dir: await pickFolder() });
503
+ }
504
+ catch (e) {
505
+ return json(res, 500, { error: `Could not open the folder picker: ${e.message}` });
506
+ }
507
+ }
508
+ if (p === "/api/provider" && req.method === "GET")
509
+ return json(res, 200, providerStatus());
510
+ if (p === "/api/presets" && req.method === "GET")
511
+ return json(res, 200, Object.values(PRESETS));
512
+ if (p === "/api/drafts" && req.method === "GET")
513
+ return json(res, 200, listDrafts());
514
+ const md = /^\/api\/drafts\/([a-z0-9-]+)$/.exec(p);
515
+ if (md) {
516
+ const file = path.join(DRAFTS, `${md[1]}.json`);
517
+ if (req.method === "GET")
518
+ return fs.existsSync(file) ? json(res, 200, JSON.parse(fs.readFileSync(file, "utf8"))) : json(res, 404, { error: "no such draft" });
519
+ if (req.method === "PUT") {
520
+ const b = JSON.parse(await readBody(req));
521
+ fs.mkdirSync(DRAFTS, { recursive: true });
522
+ const prev = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
523
+ const d = { id: md[1], name: b.name || prev.name || nextProjectName(), url: b.url ?? prev.url ?? "", project: b.project ?? prev.project ?? "", describe: b.describe ?? prev.describe ?? "", step: b.step ?? prev.step ?? 1, updated: new Date().toISOString() };
524
+ fs.writeFileSync(file, JSON.stringify(d, null, 2));
525
+ return json(res, 200, d);
526
+ }
527
+ if (req.method === "DELETE") {
528
+ fs.rmSync(file, { force: true });
529
+ return json(res, 200, { ok: true });
530
+ }
531
+ }
532
+ if (p === "/api/trash" && req.method === "GET") {
533
+ const trash = path.join(ROOT, ".trash");
534
+ const items = fs.existsSync(trash) ? fs.readdirSync(trash).filter((f) => f.endsWith(".yaml")).map((f) => { const m = /^(.+)\.(\d+)\.yaml$/.exec(f); return m ? { name: m[1], at: Number(m[2]) } : null; }).filter((x) => !!x).sort((a, b) => b.at - a.at) : [];
535
+ // one row per name — the newest is what restore would bring back
536
+ const seen = new Set();
537
+ return json(res, 200, items.filter((x) => !seen.has(x.name) && seen.add(x.name)));
538
+ }
539
+ if (p === "/api/trash/clear" && req.method === "POST") {
540
+ const trash = path.join(ROOT, ".trash");
541
+ let n = 0;
542
+ if (fs.existsSync(trash))
543
+ for (const f of fs.readdirSync(trash))
544
+ if (f.endsWith(".yaml")) {
545
+ fs.rmSync(path.join(trash, f), { force: true });
546
+ n++;
547
+ }
548
+ return json(res, 200, { ok: true, cleared: n });
549
+ }
550
+ const mres = /^\/api\/demos\/([a-z0-9-]+)\/restore$/.exec(p);
551
+ if (mres && req.method === "POST") {
552
+ // Bring back the most recent trashed manifest with this name.
553
+ const trash = path.join(ROOT, ".trash");
554
+ const cands = fs.existsSync(trash) ? fs.readdirSync(trash).filter((f) => f.startsWith(mres[1] + ".") && f.endsWith(".yaml")).sort() : [];
555
+ if (!cands.length)
556
+ return json(res, 404, { error: "nothing to restore" });
557
+ const from = path.join(trash, cands[cands.length - 1]);
558
+ fs.mkdirSync(DEMOS, { recursive: true });
559
+ fs.renameSync(from, path.join(DEMOS, `${mres[1]}.yaml`));
560
+ return json(res, 200, { ok: true, name: mres[1] });
561
+ }
562
+ const mdel = /^\/api\/demos\/([a-z0-9-]+)$/.exec(p);
563
+ if (mdel && req.method === "DELETE") {
564
+ const file = path.join(DEMOS, `${mdel[1]}.yaml`);
565
+ const out = path.join(outRoot(), mdel[1]);
566
+ if (fs.existsSync(path.join(out, ".retake-lock")))
567
+ return json(res, 409, { error: "a run is using this demo right now — wait for it to finish" });
568
+ // Never delete silently: the manifest goes to .trash/ with a timestamp so a slip is recoverable.
569
+ const trash = path.join(ROOT, ".trash");
570
+ fs.mkdirSync(trash, { recursive: true });
571
+ if (fs.existsSync(file))
572
+ fs.renameSync(file, path.join(trash, `${mdel[1]}.${Date.now()}.yaml`));
573
+ if (fs.existsSync(out))
574
+ fs.rmSync(out, { recursive: true, force: true });
575
+ unassignDemoProject(mdel[1]);
576
+ return json(res, 200, { ok: true });
577
+ }
578
+ const mg = /^\/api\/gif\/([a-z0-9-]+)$/.exec(p);
579
+ if (mg && req.method === "POST") {
580
+ const out = makeGif(path.join(outRoot(), mg[1]));
581
+ return json(res, 200, { file: path.basename(out), size: fs.statSync(out).size });
582
+ }
583
+ // "Tell Retake what to change": plain English → structured edits → re-render or re-record.
584
+ const mfix = /^\/api\/fix\/([a-z0-9-]+)$/.exec(p);
585
+ if (mfix && req.method === "POST") {
586
+ const b = JSON.parse(await readBody(req));
587
+ const file = path.join(DEMOS, `${mfix[1]}.yaml`);
588
+ if (!fs.existsSync(file))
589
+ return json(res, 404, { error: "not found" });
590
+ const provider = pickProvider();
591
+ if (!provider)
592
+ return json(res, 400, { error: "no model configured — pick one in Settings" });
593
+ const m = loadManifest(file).manifest;
594
+ const takePath = path.join(outRoot(), mfix[1], "take.json");
595
+ const take = fs.existsSync(takePath) ? JSON.parse(fs.readFileSync(takePath, "utf8")) : null;
596
+ const receipts = receiptsFor(take, m.steps);
597
+ const { edits, note } = await proposeEdits({ instruction: b.instruction, yaml: fs.readFileSync(file, "utf8"), receipts, provider });
598
+ if (!edits.length)
599
+ return json(res, 200, { applied: [], skipped: [], note: note || "Nothing to change for that.", rerecord: false });
600
+ const a = applyEdits(file, edits);
601
+ const m2 = loadManifest(file).manifest;
602
+ const rerecord = a.rerecord || !take || !take.captureHash || take.captureHash !== captureHash(m2);
603
+ return json(res, 200, { applied: a.done, skipped: a.skipped, note, rerecord, yaml: a.yaml });
604
+ }
605
+ // "Take it from here": scout → (read project) → draft → dry-run → repair → fast preview.
606
+ if (p === "/api/create" && req.method === "POST") {
607
+ const b = JSON.parse(await readBody(req));
608
+ if (!/^https?:\/\//.test(b.url))
609
+ return json(res, 400, { error: "url must start with http(s)://" });
610
+ if (!b.describe?.trim())
611
+ return json(res, 400, { error: "say what the demo should show" });
612
+ const provider = pickProvider();
613
+ if (!provider)
614
+ return json(res, 400, { error: "no model configured — pick one in Settings" });
615
+ const name = b.name && safeName(b.name) ? b.name : nextProjectName();
616
+ const file = path.join(DEMOS, `${name}.yaml`);
617
+ if (fs.existsSync(file))
618
+ return json(res, 409, { error: `"${name}" already exists` });
619
+ const run = { proc: null, lines: [], done: false, code: null, listeners: new Set(), stage: "scouting", startedAt: Date.now(), stageAt: Date.now(), estimate: { capture: 90, render: 12 } };
620
+ runs.set(name, run);
621
+ const project = b.project ? b.project.replace(/^~/, os.homedir()).trim() : undefined;
622
+ (async () => {
623
+ try {
624
+ setStage(run, "scouting");
625
+ let sc;
626
+ try {
627
+ sc = await scout(b.url);
628
+ }
629
+ catch (e) {
630
+ emit(run, `✗ ${unreachable(b.url, e)}`);
631
+ if (/ERR_CONNECTION_REFUSED|ECONNREFUSED/.test(e.message))
632
+ emit(run, "__DOWN__");
633
+ return finish(run, 2);
634
+ }
635
+ emit(run, `Found the page — ${sc.elements.length} controls${sc.headings.length ? `, “${sc.headings[0]}”` : ""}`);
636
+ if (project) {
637
+ try {
638
+ const d = digest(project);
639
+ emit(run, `Read ${d.name}: ${d.files} files, ${d.routes.length} routes, ${d.selectors.length} stable selectors`);
640
+ }
641
+ catch (e) {
642
+ emit(run, `Could not read ${project}: ${e.message}`);
643
+ }
644
+ }
645
+ setStage(run, "drafting");
646
+ emit(run, `Drafting with ${provider.name}…`);
647
+ const d = await draftManifest({ name, url: b.url, describe: b.describe, scout: sc, provider, project, demosDir: DEMOS });
648
+ fs.mkdirSync(DEMOS, { recursive: true });
649
+ fs.writeFileSync(file, d.yaml);
650
+ if (project)
651
+ assignDemoProject(name, project);
652
+ const m = loadManifest(file).manifest;
653
+ emit(run, `Drafted ${m.steps.length} steps, ${m.steps.filter((s) => s.action === "scene").length} scenes`);
654
+ setStage(run, "checking");
655
+ let r = await dryRun(m, DEMOS, () => { });
656
+ if (!r.ok) {
657
+ const rep = autoRepair(fs.readFileSync(file, "utf8"), r.lines);
658
+ if (rep.fixes.length) {
659
+ fs.writeFileSync(file, rep.yaml);
660
+ for (const f of rep.fixes)
661
+ emit(run, `Fixed ${f}`);
662
+ r = await dryRun(loadManifest(file).manifest, DEMOS, () => { });
663
+ }
664
+ }
665
+ if (!r.ok) {
666
+ // One model pass with the evidence, then stop and show the person.
667
+ emit(run, `${r.failures} step(s) would fail — asking ${provider.name} to repair…`);
668
+ const m1 = loadManifest(file).manifest;
669
+ const { edits, note } = await proposeEdits({ instruction: "These steps fail in a dry run. Repair them (usually a different selector, or a wait for something to appear). Failures:\n" + r.lines.join("\n"), yaml: fs.readFileSync(file, "utf8"), receipts: receiptsFor(null, m1.steps), provider });
670
+ if (edits.length) {
671
+ const a = applyEdits(file, edits);
672
+ for (const x of a.done)
673
+ emit(run, `Fixed: ${x}`);
674
+ r = await dryRun(loadManifest(file).manifest, DEMOS, () => { });
675
+ }
676
+ else
677
+ emit(run, note || "No repair proposed.");
678
+ }
679
+ if (!r.ok) {
680
+ for (const l of r.lines.slice(0, 6))
681
+ emit(run, l);
682
+ emit(run, `✗ Still ${r.failures} step(s) that would fail — open Advanced to fix them, then press Record.`);
683
+ return finish(run, 3);
684
+ }
685
+ emit(run, `Checked: all ${m.steps.length} steps resolve on the page`);
686
+ setStage(run, "capture");
687
+ emit(run, "Recording a fast preview…");
688
+ const child = spawn(entry("cli").command, [...entry("cli").args, "run", file, "--preset", "preview-fast", "--out", outRoot()], { cwd: ROOT, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
689
+ run.proc = child;
690
+ const push = (chunk) => { for (const raw of chunk.toString().split("\n")) {
691
+ const l = raw.trimEnd();
692
+ if (!l || /^\$ ffmpeg|zoom filter|filter graph|^>/.test(l))
693
+ continue;
694
+ const st = /^\[stage\] (\w+)/.exec(l);
695
+ if (st) {
696
+ setStage(run, st[1] === "done" ? "done" : st[1]);
697
+ continue;
698
+ }
699
+ emit(run, l);
700
+ } };
701
+ child.stdout.on("data", push);
702
+ child.stderr.on("data", push);
703
+ child.on("close", (code) => finish(run, code ?? 1));
704
+ }
705
+ catch (e) {
706
+ emit(run, `✗ ${e.message.split("\n")[0]}`);
707
+ finish(run, 1);
708
+ }
709
+ })();
710
+ return json(res, 200, { name, startedAt: run.startedAt, estimate: run.estimate });
711
+ }
712
+ // ---------- what somebody else's agent is doing right now ----------
713
+ // Claude Code or Codex can drive Retake over MCP from their own window.
714
+ // Without this, the app shows nothing until a file lands — so the tools
715
+ // report here, and the page watches, and the person can see the work.
716
+ if (p === "/api/activity" && req.method === "POST") {
717
+ const b = JSON.parse(await readBody(req));
718
+ noteActivity(b);
719
+ return json(res, 200, { ok: true });
720
+ }
721
+ if (p === "/api/activity" && req.method === "GET")
722
+ return json(res, 200, withStaleness(activity));
723
+ if (p === "/api/activity/stream" && req.method === "GET") {
724
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
725
+ res.write(`event: state\ndata: ${JSON.stringify({ ...activity, boot: BOOT })}\n\n`);
726
+ const send = (ev) => res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev.data)}\n\n`);
727
+ activityWatchers.add(send);
728
+ const beat = setInterval(() => res.write(": ping\n\n"), 25_000);
729
+ req.on("close", () => { activityWatchers.delete(send); clearInterval(beat); });
730
+ return;
731
+ }
732
+ // ---------- the operator: the coding agent drives Retake, fenced to its tools ----------
733
+ if (p === "/api/operator" && req.method === "POST") {
734
+ const b = JSON.parse(await readBody(req));
735
+ if (!b.describe?.trim())
736
+ return json(res, 400, { error: "say what the demo should show" });
737
+ const prov = pickProvider();
738
+ const which = prov?.name === "codex" ? "codex" : prov?.name === "claude-code" ? "claude-code" : null;
739
+ if (!which)
740
+ return json(res, 400, { error: "The operator needs Claude Code or Codex (Settings). With another model, use “Take it from here” — same result, fixed steps." });
741
+ const ui = `http://localhost:${req.socket.localPort || 4310}`;
742
+ const project = b.project ? b.project.replace(/^~/, os.homedir()).trim() : undefined;
743
+ const s = startOperator({ describe: b.describe, url: b.url?.trim() || undefined, project, name: b.name && safeName(b.name) ? b.name : undefined, ui, root: ROOT, provider: which, onDemo: (demo) => { if (project && safeName(demo))
744
+ assignDemoProject(demo, project); } });
745
+ return json(res, 200, { id: s.id, startedAt: s.startedAt, provider: which });
746
+ }
747
+ const mop = /^\/api\/operator\/([a-z0-9]+)(?:\/(log|pending|done|stream|stop|answer)(?:\/([a-z0-9]+))?)?$/.exec(p);
748
+ if (mop) {
749
+ const s = getSession(mop[1]);
750
+ if (!s)
751
+ return json(res, 404, { error: "no such session" });
752
+ const sub = mop[2];
753
+ if (sub === "log" && req.method === "POST") {
754
+ const b = JSON.parse(await readBody(req));
755
+ s.lines.push(b.line);
756
+ for (const l of s.listeners)
757
+ l({ type: "line", data: b.line });
758
+ return json(res, 200, { ok: true });
759
+ }
760
+ if (sub === "pending" && req.method === "POST") {
761
+ const b = JSON.parse(await readBody(req));
762
+ const pnd = addPending(s, b.kind, b.text, b.detail);
763
+ return json(res, 200, { id: pnd.id });
764
+ }
765
+ if (sub === "pending" && req.method === "GET" && mop[3]) {
766
+ const pnd = s.pending.find((x) => x.id === mop[3]);
767
+ return pnd ? json(res, 200, { answered: pnd.answered, answer: pnd.answer }) : json(res, 404, { error: "no such question" });
768
+ }
769
+ if (sub === "answer" && req.method === "POST") {
770
+ const b = JSON.parse(await readBody(req));
771
+ return json(res, answerPending(s, b.id, b.answer) ? 200 : 404, { ok: true });
772
+ }
773
+ if (sub === "done" && req.method === "POST") {
774
+ const b = JSON.parse(await readBody(req));
775
+ markDone(s, b.summary, b.demo);
776
+ return json(res, 200, { ok: true });
777
+ }
778
+ if (sub === "stop" && req.method === "POST") {
779
+ stopSession(s);
780
+ return json(res, 200, { ok: true });
781
+ }
782
+ if (sub === "stream" && req.method === "GET") {
783
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
784
+ const send = (ev) => res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev.data)}\n\n`);
785
+ for (const l of s.lines)
786
+ send({ type: "line", data: l });
787
+ for (const pnd of s.pending.filter((x) => !x.answered))
788
+ send({ type: "pending", data: pnd });
789
+ if (s.done) {
790
+ send({ type: "done", data: { code: s.code, summary: s.summary, demo: s.demo, cost: s.cost, turns: s.turns } });
791
+ return res.end();
792
+ }
793
+ s.listeners.add(send);
794
+ req.on("close", () => s.listeners.delete(send));
795
+ return;
796
+ }
797
+ if (!sub && req.method === "GET")
798
+ return json(res, 200, { id: s.id, done: s.done, code: s.code, summary: s.summary, demo: s.demo, pending: s.pending.filter((x) => !x.answered), lines: s.lines.slice(-40), cost: s.cost, turns: s.turns });
799
+ }
800
+ if (p === "/api/ideas" && req.method === "POST") {
801
+ const b = JSON.parse(await readBody(req));
802
+ if (!/^https?:\/\//.test(b.url))
803
+ return json(res, 400, { error: "url must start with http(s)://" });
804
+ const provider = pickProvider();
805
+ if (!provider)
806
+ return json(res, 400, { error: "no model configured — pick one in Settings" });
807
+ let sc;
808
+ try {
809
+ sc = await scout(b.url);
810
+ }
811
+ catch (e) {
812
+ const m = e.message;
813
+ return json(res, 400, { error: unreachable(b.url, e), down: /ERR_CONNECTION_REFUSED|ECONNREFUSED/.test(m) });
814
+ }
815
+ const project = b.project ? b.project.replace(/^~/, os.homedir()).trim() : undefined;
816
+ const { markdown, ideas } = await suggestIdeas({ url: b.url, scout: sc, provider, project });
817
+ const file = path.join(ROOT, "ideas", `${new URL(b.url).host.replace(/[^a-z0-9]+/gi, "-")}.md`);
818
+ fs.mkdirSync(path.dirname(file), { recursive: true });
819
+ fs.writeFileSync(file, `# Demo ideas — ${b.url}\n\n_Suggested by ${provider.name} (${provider.model})${project ? ` after reading ${project}` : ""}._\n\n${markdown}\n`);
820
+ return json(res, 200, { ideas, file: path.relative(ROOT, file) });
821
+ }
822
+ if (p === "/api/start" && req.method === "POST") {
823
+ const b = JSON.parse(await readBody(req));
824
+ const off = startOffer(b.dir);
825
+ const command = b.command?.trim() || off?.command;
826
+ if (!command)
827
+ return json(res, 400, { error: "No dev, start, serve, or preview script was found. Add a running app URL instead." });
828
+ const r = await startApp({ dir: b.dir, command, expectUrl: b.url });
829
+ return json(res, r.ok ? 200 : 400, r.ok ? { ok: true, url: r.url, pid: r.pid } : { error: r.why ?? "could not start", log: r.log.slice(-1200) });
830
+ }
831
+ if (p === "/api/project" && req.method === "POST") {
832
+ const b = JSON.parse(await readBody(req));
833
+ const dir = b.dir.replace(/^~/, os.homedir()).trim();
834
+ try {
835
+ const d = digest(dir);
836
+ const off = startOffer(dir);
837
+ return json(res, 200, { ok: true, dir: d.dir, name: d.name, stack: d.stack, files: d.files, routes: d.routes.slice(0, 12), selectors: d.selectors.length, auth: d.auth.length > 0, flaky: d.flaky.length, start: off });
838
+ }
839
+ catch (e) {
840
+ return json(res, 400, { error: e.message });
841
+ }
842
+ }
843
+ if (p === "/api/models" && req.method === "GET") {
844
+ const provider = url.searchParams.get("provider") || pickProvider()?.name;
845
+ if (provider === "codex") {
846
+ try {
847
+ const raw = execFileSync("codex", ["debug", "models", "--bundled"], { encoding: "utf8", timeout: 15_000, maxBuffer: 4e6 });
848
+ const catalog = JSON.parse(raw);
849
+ return json(res, 200, { provider, selected: process.env.RETAKE_CODEX_MODEL ?? "", reasoning: process.env.RETAKE_CODEX_REASONING ?? "", models: (catalog.models ?? []).filter((m) => m.visibility === "list").map((m) => ({ id: m.slug, name: m.display_name || m.slug, defaultReasoning: m.default_reasoning_level, reasoning: m.supported_reasoning_levels ?? [] })) });
850
+ }
851
+ catch (e) {
852
+ return json(res, 500, { error: `Could not read Codex models: ${e.message}` });
853
+ }
854
+ }
855
+ if (provider === "claude-code")
856
+ return json(res, 200, { provider, selected: process.env.RETAKE_CLAUDE_MODEL ?? "", models: [{ id: "", name: "Recommended" }, { id: "sonnet", name: "Sonnet" }, { id: "opus", name: "Opus" }, { id: "haiku", name: "Haiku" }] });
857
+ const key = provider === "groq" ? "RETAKE_GROQ_MODEL" : provider === "mistral" ? "RETAKE_MISTRAL_MODEL" : "RETAKE_LOCAL_MODEL";
858
+ const selected = process.env[key] ?? "";
859
+ return json(res, 200, { provider, selected, models: [{ id: selected, name: selected || "Configured default" }] });
860
+ }
861
+ if (p === "/api/settings" && req.method === "GET")
862
+ return json(res, 200, { ...providerStatus(), model: process.env.RETAKE_MODEL ?? "", selection: modelSelection(), gifski: !!gifskiBin(), env: envSummary(), outDir: outRoot(), outDefault: OUT_DEFAULT, version: VERSION });
863
+ const mopen = /^\/api\/open\/([a-z0-9-]+)$/.exec(p);
864
+ if (mopen && req.method === "POST") {
865
+ const dir = path.join(outRoot(), mopen[1]);
866
+ if (!fs.existsSync(dir))
867
+ return json(res, 404, { error: "no outputs yet" });
868
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
869
+ try {
870
+ spawn(opener, [dir], { stdio: "ignore", detached: true }).unref();
871
+ }
872
+ catch (e) {
873
+ return json(res, 500, { error: e.message });
874
+ }
875
+ return json(res, 200, { ok: true, dir });
876
+ }
877
+ if (p === "/api/settings" && req.method === "PUT") {
878
+ const b = JSON.parse(await readBody(req));
879
+ if (b.outDir !== undefined && b.outDir.trim()) {
880
+ const d = path.resolve(b.outDir.trim().replace(/^~/, os.homedir()));
881
+ try {
882
+ fs.mkdirSync(d, { recursive: true });
883
+ fs.accessSync(d, fs.constants.W_OK);
884
+ }
885
+ catch {
886
+ return json(res, 400, { error: `Can't write to ${d}` });
887
+ }
888
+ }
889
+ const set = { RETAKE_MODEL: b.model, RETAKE_LOCAL_URL: b.localUrl, RETAKE_LOCAL_MODEL: b.localModel, RETAKE_CLAUDE_MODEL: b.claudeModel, RETAKE_CODEX_MODEL: b.codexModel, RETAKE_CODEX_REASONING: b.codexReasoning, RETAKE_GROQ_MODEL: b.groqModel, RETAKE_MISTRAL_MODEL: b.mistralModel, GROQ_API_KEY: b.groqKey, MISTRAL_API_KEY: b.mistralKey, RETAKE_OUT: b.outDir === undefined ? undefined : b.outDir.trim() };
890
+ writeEnv(set);
891
+ for (const [k, v] of Object.entries(set))
892
+ if (v !== undefined) {
893
+ if (v === "")
894
+ delete process.env[k];
895
+ else
896
+ process.env[k] = v;
897
+ }
898
+ return json(res, 200, { ...providerStatus(), model: process.env.RETAKE_MODEL ?? "", selection: modelSelection(), env: envSummary(), outDir: outRoot(), outDefault: OUT_DEFAULT });
899
+ }
900
+ const ms = /^\/api\/demos\/([a-z0-9-]+)\/settings$/.exec(p);
901
+ if (ms && req.method === "PATCH") {
902
+ const file = path.join(DEMOS, `${ms[1]}.yaml`);
903
+ if (!fs.existsSync(file))
904
+ return json(res, 404, { error: "not found" });
905
+ const b = JSON.parse(await readBody(req));
906
+ const doc = YAML.parseDocument(fs.readFileSync(file, "utf8"));
907
+ for (const k of ["preset", "layout", "camera", "cursor", "title", "tempo"]) {
908
+ if (!(k in b))
909
+ continue;
910
+ if (b[k] === null || b[k] === "" || b[k] === "default")
911
+ doc.delete(k);
912
+ else
913
+ doc.set(k, b[k]);
914
+ }
915
+ const text = doc.toString();
916
+ const parsed = Manifest.safeParse(YAML.parse(text));
917
+ if (!parsed.success)
918
+ return json(res, 400, { error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("\n") });
919
+ fs.writeFileSync(file, text);
920
+ return json(res, 200, { ok: true, yaml: text });
921
+ }
922
+ if (p === "/api/describe" && req.method === "POST") {
923
+ const b = JSON.parse(await readBody(req));
924
+ if (!b.name)
925
+ b.name = nextProjectName();
926
+ if (!safeName(b.name))
927
+ return json(res, 400, { error: "name must be kebab-case" });
928
+ if (!/^https?:\/\//.test(b.url))
929
+ return json(res, 400, { error: "url must start with http(s)://" });
930
+ if (!b.describe?.trim())
931
+ return json(res, 400, { error: "describe what to record" });
932
+ const file = path.join(DEMOS, `${b.name}.yaml`);
933
+ if (fs.existsSync(file))
934
+ return json(res, 409, { error: "a demo with that name exists" });
935
+ const provider = pickProvider();
936
+ if (!provider)
937
+ return json(res, 400, { error: "no model configured — set GROQ_API_KEY, MISTRAL_API_KEY, or RETAKE_LOCAL_URL (see README)" });
938
+ let sc;
939
+ try {
940
+ sc = await scout(b.url);
941
+ }
942
+ catch (e) {
943
+ const m = e.message;
944
+ return json(res, 400, { error: unreachable(b.url, e), down: /ERR_CONNECTION_REFUSED|ECONNREFUSED/.test(m) });
945
+ }
946
+ const project = b.project ? b.project.replace(/^~/, os.homedir()).trim() : undefined;
947
+ const d = await draftManifest({ name: b.name, url: b.url, describe: b.describe, scout: sc, provider, project, demosDir: DEMOS });
948
+ fs.mkdirSync(DEMOS, { recursive: true });
949
+ fs.writeFileSync(file, d.yaml);
950
+ if (project)
951
+ assignDemoProject(b.name, project);
952
+ return json(res, 200, { name: b.name, provider: d.provider, retried: d.retried, scouted: sc.elements.length, read: d.digest ? d.digest.files : 0 });
953
+ }
954
+ if (p === "/api/demos" && req.method === "POST") {
955
+ const b = JSON.parse(await readBody(req));
956
+ if (!b.name)
957
+ b.name = nextProjectName();
958
+ if (!safeName(b.name))
959
+ return json(res, 400, { error: "name must be kebab-case" });
960
+ const file = path.join(DEMOS, `${b.name}.yaml`);
961
+ if (fs.existsSync(file))
962
+ return json(res, 409, { error: "a demo with that name exists" });
963
+ fs.mkdirSync(DEMOS, { recursive: true });
964
+ fs.writeFileSync(file, starterManifest(b.name, b.url, b.describe ?? ""));
965
+ if (b.project)
966
+ assignDemoProject(b.name, b.project);
967
+ return json(res, 200, { name: b.name });
968
+ }
969
+ let m = /^\/api\/demos\/([a-z0-9-]+)$/.exec(p);
970
+ if (m) {
971
+ const file = path.join(DEMOS, `${m[1]}.yaml`);
972
+ if (req.method === "GET") {
973
+ if (!fs.existsSync(file))
974
+ return json(res, 404, { error: "not found" });
975
+ return json(res, 200, { yaml: fs.readFileSync(file, "utf8") });
976
+ }
977
+ if (req.method === "PUT") {
978
+ const b = JSON.parse(await readBody(req));
979
+ const parsed = Manifest.safeParse(YAML.parse(b.yaml));
980
+ if (!parsed.success)
981
+ return json(res, 400, { error: parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("\n") });
982
+ if (parsed.data.name !== m[1])
983
+ return json(res, 400, { error: `name in yaml (${parsed.data.name}) must match ${m[1]}` });
984
+ fs.writeFileSync(file, b.yaml);
985
+ return json(res, 200, { ok: true, steps: parsed.data.steps.length, scenes: parsed.data.steps.filter((s) => s.action === "scene").length });
986
+ }
987
+ }
988
+ const msc = /^\/api\/demos\/([a-z0-9-]+)\/(scene|trim)$/.exec(p);
989
+ if (msc && req.method === "PATCH") {
990
+ const file = path.join(DEMOS, `${msc[1]}.yaml`);
991
+ if (!fs.existsSync(file))
992
+ return json(res, 404, { error: "not found" });
993
+ const b = JSON.parse(await readBody(req));
994
+ const doc = YAML.parseDocument(fs.readFileSync(file, "utf8"));
995
+ if (msc[2] === "trim") {
996
+ const head = Number(b.head ?? 0), tail = Number(b.tail ?? 0);
997
+ if (!head && !tail)
998
+ doc.delete("trim");
999
+ else
1000
+ doc.set("trim", { head: Math.max(0, Math.round(head * 100) / 100), tail: Math.max(0, Math.round(tail * 100) / 100) });
1001
+ }
1002
+ else {
1003
+ const steps = doc.get("steps");
1004
+ const node = steps?.items.find((it) => {
1005
+ const n = it;
1006
+ return typeof n.get === "function" && n.get("action") === "scene" && n.get("label") === b.label;
1007
+ });
1008
+ if (!node)
1009
+ return json(res, 404, { error: `scene "${b.label}" not found` });
1010
+ if (b.caption !== undefined) {
1011
+ if (b.caption === "")
1012
+ node.delete("caption");
1013
+ else
1014
+ node.set("caption", b.caption);
1015
+ }
1016
+ if (b.holdMs !== undefined) {
1017
+ if (!b.holdMs)
1018
+ node.delete("holdMs");
1019
+ else
1020
+ node.set("holdMs", Number(b.holdMs));
1021
+ }
1022
+ if (b.camera !== undefined) {
1023
+ if (b.camera === "static")
1024
+ node.set("camera", "static");
1025
+ else if (b.camera === "auto")
1026
+ node.delete("camera");
1027
+ else if (typeof b.camera === "object")
1028
+ node.set("camera", b.camera);
1029
+ }
1030
+ }
1031
+ const text = doc.toString();
1032
+ const parsed = Manifest.safeParse(YAML.parse(text));
1033
+ if (!parsed.success)
1034
+ return json(res, 400, { error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("\n") });
1035
+ fs.writeFileSync(file, text);
1036
+ return json(res, 200, { ok: true, yaml: text });
1037
+ }
1038
+ m = /^\/api\/run\/([a-z0-9-]+)$/.exec(p);
1039
+ if (m && req.method === "POST") {
1040
+ const lock = path.join(outRoot(), m[1], ".retake-lock");
1041
+ const existing = runs.get(m[1]);
1042
+ if ((!existing || existing.done) && fs.existsSync(lock)) {
1043
+ const pid = Number(fs.readFileSync(lock, "utf8"));
1044
+ try {
1045
+ process.kill(pid, 0);
1046
+ return json(res, 409, { error: `a run from the terminal (pid ${pid}) is already using outputs/${m[1]} — wait for it` });
1047
+ }
1048
+ catch { /* stale */ }
1049
+ }
1050
+ const mode = url.searchParams.get("mode");
1051
+ const run = startRun(m[1], { preview: mode === "preview", renderOnly: mode === "render", reuse: url.searchParams.get("reuse") === "1", gif: url.searchParams.get("gif") === "1" });
1052
+ return json(res, 200, { running: !run.done, estimate: run.estimate, startedAt: run.startedAt });
1053
+ }
1054
+ m = /^\/api\/run\/([a-z0-9-]+)\/stream$/.exec(p);
1055
+ if (m && req.method === "GET") {
1056
+ const run = runs.get(m[1]);
1057
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
1058
+ if (!run) {
1059
+ res.write(`event: done\ndata: ${JSON.stringify({ code: null })}\n\n`);
1060
+ return res.end();
1061
+ }
1062
+ res.write(`event: stage\ndata: ${JSON.stringify({ stage: run.stage, at: run.stageAt, estimate: run.estimate, startedAt: run.startedAt })}\n\n`);
1063
+ for (const l of run.lines)
1064
+ res.write(`data: ${JSON.stringify(l)}\n\n`);
1065
+ if (run.done) {
1066
+ res.write(`event: done\ndata: ${JSON.stringify({ code: run.code })}\n\n`);
1067
+ return res.end();
1068
+ }
1069
+ run.listeners.add(res);
1070
+ req.on("close", () => run.listeners.delete(res));
1071
+ return;
1072
+ }
1073
+ m = /^\/api\/take\/([a-z0-9-]+)$/.exec(p);
1074
+ if (m && req.method === "GET") {
1075
+ const dir = path.join(outRoot(), m[1]);
1076
+ const tp = path.join(dir, "take.json");
1077
+ if (!fs.existsSync(tp))
1078
+ return json(res, 404, { error: "no take yet" });
1079
+ const take = JSON.parse(fs.readFileSync(tp, "utf8"));
1080
+ const files = fs.readdirSync(dir).filter((f) => !f.startsWith(".")).flatMap((f) => f === "stills" ? fs.readdirSync(path.join(dir, "stills")).map((x) => `stills/${x}`) : [f]);
1081
+ const proof = fs.existsSync(path.join(dir, "proof-log.md")) ? fs.readFileSync(path.join(dir, "proof-log.md"), "utf8") : "";
1082
+ const facts = fs.existsSync(path.join(dir, "facts.json")) ? JSON.parse(fs.readFileSync(path.join(dir, "facts.json"), "utf8")) : null;
1083
+ const stamp = fs.statSync(tp).mtimeMs;
1084
+ return json(res, 200, { take, files, proof, facts, stamp, dir });
1085
+ }
1086
+ m = /^\/out\/([a-z0-9-]+)\/((?:stills\/)?[A-Za-z0-9._-]+)$/.exec(p);
1087
+ if (m && req.method === "GET") {
1088
+ const f = path.join(outRoot(), m[1], m[2]);
1089
+ if (!fs.existsSync(f))
1090
+ return json(res, 404, { error: "not found" });
1091
+ const stat = fs.statSync(f);
1092
+ const type = MIME[path.extname(f)] ?? "application/octet-stream";
1093
+ // Range support so <video> can seek.
1094
+ const range = /bytes=(\d*)-(\d*)/.exec(req.headers.range ?? "");
1095
+ if (range && type === "video/mp4") {
1096
+ const start = Number(range[1] || 0);
1097
+ const end = range[2] ? Number(range[2]) : stat.size - 1;
1098
+ res.writeHead(206, { "content-type": type, "content-range": `bytes ${start}-${end}/${stat.size}`, "accept-ranges": "bytes", "content-length": end - start + 1 });
1099
+ return fs.createReadStream(f, { start, end }).pipe(res);
1100
+ }
1101
+ res.writeHead(200, { "content-type": type, "content-length": stat.size, "cache-control": "no-store" });
1102
+ return fs.createReadStream(f).pipe(res);
1103
+ }
1104
+ json(res, 404, { error: "not found" });
1105
+ }
1106
+ catch (e) {
1107
+ json(res, 500, { error: e.message });
1108
+ }
1109
+ });
1110
+ server.listen(port, "127.0.0.1", () => process.stdout.write(`retake ui → http://localhost:${port}\n`));
1111
+ return server;
1112
+ }