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.
- package/.env.example +19 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/assets/icon-192.png +0 -0
- package/assets/icon-512.png +0 -0
- package/assets/logo.svg +13 -0
- package/demos/example.yaml +65 -0
- package/dist/appserver.js +316 -0
- package/dist/cli.js +467 -0
- package/dist/describe.js +377 -0
- package/dist/digest.js +197 -0
- package/dist/dryrun.js +196 -0
- package/dist/edits.js +229 -0
- package/dist/manifest.js +358 -0
- package/dist/operator/run.js +231 -0
- package/dist/operator/tools.js +412 -0
- package/dist/paths.js +39 -0
- package/dist/presets.js +75 -0
- package/dist/record.js +814 -0
- package/dist/render.js +661 -0
- package/dist/ui/chat.html +385 -0
- package/dist/ui/guide.html +85 -0
- package/dist/ui/index.html +722 -0
- package/dist/ui/server.js +1112 -0
- package/package.json +67 -0
- package/site/index.html +158 -0
- package/skill/SKILL.md +154 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator's hands: Retake exposed as MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* The operator is the signed-in coding agent (Claude Code or Codex) driving
|
|
5
|
+
* Retake — the way a person drives it from a terminal, except it can ONLY do
|
|
6
|
+
* what these tools allow. No shell. Starting the app and asking the person a
|
|
7
|
+
* question are tools too, and both block until the UI answers, so the human
|
|
8
|
+
* stays in the loop exactly where it matters and nowhere else.
|
|
9
|
+
*
|
|
10
|
+
* Runs as a stdio MCP server, spawned by the CLI. It reports progress back to
|
|
11
|
+
* the UI over HTTP (RETAKE_UI + RETAKE_SESSION) so the person sees what is
|
|
12
|
+
* happening in plain sentences.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
import YAML from "yaml";
|
|
21
|
+
import { loadManifest } from "../manifest.js";
|
|
22
|
+
import { record, captureHash, acquireLock, releaseLock } from "../record.js";
|
|
23
|
+
import { render, check, ffmpegBin } from "../render.js";
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { dryRun } from "../dryrun.js";
|
|
26
|
+
import { scout, draftManifest, suggestIdeas, pickProvider, loadDotenv } from "../describe.js";
|
|
27
|
+
import { digest } from "../digest.js";
|
|
28
|
+
import { startApp as reallyStartApp, listeningPorts as ports, waitForUrl as waitUrl } from "../appserver.js";
|
|
29
|
+
import { applyEdits, receiptsFor } from "../edits.js";
|
|
30
|
+
const ROOT = process.env.RETAKE_ROOT || process.cwd();
|
|
31
|
+
const DEMOS = path.join(ROOT, "demos");
|
|
32
|
+
const OUT = process.env.RETAKE_OUT ? path.resolve(process.env.RETAKE_OUT.replace(/^~/, os.homedir())) : path.join(ROOT, "outputs");
|
|
33
|
+
let LAST_DEMO = ""; // what the agent is working on
|
|
34
|
+
const UI = process.env.RETAKE_UI || ""; // e.g. http://localhost:4310
|
|
35
|
+
const SESSION = process.env.RETAKE_SESSION || ""; // operator session id
|
|
36
|
+
const PROJECT = process.env.RETAKE_PROJECT || "";
|
|
37
|
+
loadDotenv(ROOT);
|
|
38
|
+
// A selected project already owns the environment used by its dev server. Make
|
|
39
|
+
// those same values available to ${ENV_VAR} references during dry/run without
|
|
40
|
+
// copying secrets into manifests or exposing them to the coding-agent process.
|
|
41
|
+
if (PROJECT)
|
|
42
|
+
loadDotenv(PROJECT, [".env.local", ".env"]);
|
|
43
|
+
// --- talking to the UI ------------------------------------------------------
|
|
44
|
+
async function tell(line) {
|
|
45
|
+
if (!UI) {
|
|
46
|
+
process.stderr.write(line + "\n");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Driven from somebody else's agent there is no session, but the app is
|
|
50
|
+
// still open on the desk — report to the activity feed so it can be watched.
|
|
51
|
+
if (!SESSION) {
|
|
52
|
+
await fetch(`${UI}/api/activity`, { method: "POST", body: JSON.stringify({ line, who: process.env.RETAKE_WHO || "your agent", demo: LAST_DEMO || undefined }) }).catch(() => { });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
await fetch(`${UI}/api/operator/${SESSION}/log`, { method: "POST", body: JSON.stringify({ line }) }).catch(() => { });
|
|
56
|
+
}
|
|
57
|
+
/** Block until the person answers in the UI. `kind` = question | approve.
|
|
58
|
+
With no UI attached (Retake driven by someone's own agent) there is nobody
|
|
59
|
+
here to click, so the caller must have asked its own user instead. */
|
|
60
|
+
export class NoUI extends Error {
|
|
61
|
+
}
|
|
62
|
+
async function waitForHuman(kind, text, detail) {
|
|
63
|
+
if (!UI || !SESSION)
|
|
64
|
+
throw new NoUI(kind);
|
|
65
|
+
const r = await fetch(`${UI}/api/operator/${SESSION}/pending`, { method: "POST", body: JSON.stringify({ kind, text, detail }) });
|
|
66
|
+
const { id } = (await r.json());
|
|
67
|
+
for (;;) {
|
|
68
|
+
await new Promise((ok) => setTimeout(ok, 1200));
|
|
69
|
+
const s = await fetch(`${UI}/api/operator/${SESSION}/pending/${id}`).then((x) => x.json()).catch(() => null);
|
|
70
|
+
if (s?.answered)
|
|
71
|
+
return s.answer ?? "";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const text = (s) => ({ content: [{ type: "text", text: s }] });
|
|
75
|
+
const safe = (s) => /^[a-z0-9-]+$/.test(s);
|
|
76
|
+
const manifestPath = (name) => path.join(DEMOS, `${name}.yaml`);
|
|
77
|
+
function summariseTake(take) {
|
|
78
|
+
const dur = take.duration - take.trimBefore;
|
|
79
|
+
const fails = take.timeline.filter((t) => !t.ok);
|
|
80
|
+
const lines = [`${take.ok ? "All" : `${take.timeline.length - fails.length} of`} ${take.timeline.length} steps passed · ${dur.toFixed(1)}s${take.partial ? ` · PARTIAL: ${take.partial}` : ""}`];
|
|
81
|
+
for (const f of fails)
|
|
82
|
+
lines.push(` ✗ step ${f.index}: ${f.summary} — ${f.error}`);
|
|
83
|
+
if (take.stubbed?.length)
|
|
84
|
+
lines.push(` stubbed: ${take.stubbed.join(", ")}`);
|
|
85
|
+
return lines.join("\n");
|
|
86
|
+
}
|
|
87
|
+
// --- the server --------------------------------------------------------------
|
|
88
|
+
const server = new McpServer({ name: "retake", version: "0.1.0" });
|
|
89
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
90
|
+
const READ_LIVE_APP = { ...READ_ONLY, openWorldHint: true };
|
|
91
|
+
const RETAKE_WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
92
|
+
server.registerTool("ports", { description: "Which local TCP ports have something listening. Use this before assuming an app is down — dev servers often come up one port over (3000 → 3001/3022).", inputSchema: {}, annotations: READ_ONLY }, async () => {
|
|
93
|
+
const ps = ports();
|
|
94
|
+
return text(ps.length ? `Listening: ${ps.map((p) => ":" + p).join(", ")}` : "Nothing is listening on any local port.");
|
|
95
|
+
});
|
|
96
|
+
server.registerTool("read_project", { description: "Read an app's source folder: stack, how it starts, routes, sign-in fields, stable selectors, env vars, things that make recordings flaky. Cheap and local. Do this first when a folder is given.", inputSchema: { dir: z.string().describe("path to the project folder") }, annotations: READ_ONLY }, async ({ dir }) => {
|
|
97
|
+
const d = digest(dir.replace(/^~/, os.homedir()));
|
|
98
|
+
await tell(`Read ${d.name}: ${d.files} files · ${d.routes.length} routes · ${d.selectors.length} stable selectors · ${d.flaky.length} things to watch`);
|
|
99
|
+
return text(d.text);
|
|
100
|
+
});
|
|
101
|
+
server.registerTool("scout", { description: "Open a URL headlessly and list what is on it: headings, visible controls with unique selectors, page text. Use the selectors it returns verbatim.", inputSchema: { url: z.string().url() }, annotations: READ_LIVE_APP }, async ({ url }) => {
|
|
102
|
+
const sc = await scout(url);
|
|
103
|
+
await tell(`Looked at ${url}: ${sc.elements.length} controls${sc.headings[0] ? ` · “${sc.headings[0]}”` : ""}`);
|
|
104
|
+
return text([`title: ${sc.title}`, `headings: ${sc.headings.join(" | ")}`, "controls (tag · selector · text):", ...sc.elements.map((e) => `- ${e.tag} · ${e.selector} · ${e.text || e.placeholder || e.href || ""}`), "", `text: ${sc.text.slice(0, 1200)}`].join("\n"));
|
|
105
|
+
});
|
|
106
|
+
server.registerTool("wait_for_url", { description: "Wait until a URL answers (up to the timeout). Use after starting an app.", inputSchema: { url: z.string().url(), seconds: z.number().int().min(5).max(180).default(60) }, annotations: READ_LIVE_APP }, async ({ url, seconds }) => {
|
|
107
|
+
await tell(`Waiting for ${url}…`);
|
|
108
|
+
const ok = await waitUrl(url, seconds * 1000);
|
|
109
|
+
await tell(ok ? `${url} is answering.` : `${url} did not answer within ${seconds}s.`);
|
|
110
|
+
return text(ok ? `up: ${url}` : `still down after ${seconds}s: ${url}`);
|
|
111
|
+
});
|
|
112
|
+
server.registerTool("start_app", {
|
|
113
|
+
description: "Start the person's app from its folder and report the URL it actually came up on. Requests initiated inside Retake are already authorized by the project workflow. When driven from an external agent, RETAKE_ALLOW_START=1 remains required.",
|
|
114
|
+
inputSchema: { dir: z.string(), command: z.string().describe("e.g. npm run dev, or vercel dev --listen 3200"), expect_url: z.string().url().optional().describe("if you know the URL, wait for it") },
|
|
115
|
+
annotations: RETAKE_WRITE,
|
|
116
|
+
}, async ({ dir, command, expect_url }) => {
|
|
117
|
+
const cwd = dir.replace(/^~/, os.homedir());
|
|
118
|
+
// A Retake UI session exists only after the person has selected this project
|
|
119
|
+
// and asked for a scan/recording. Outside the UI, preserve the explicit gate.
|
|
120
|
+
if ((!UI || !SESSION) && process.env.RETAKE_ALLOW_START !== "1") {
|
|
121
|
+
return text(`NOT RUN. Retake will not start processes when it is driven from outside its own window, unless the person allowed it when they set Retake up. Tell them: to let this work, add RETAKE_ALLOW_START=1 to Retake's MCP config env and restart.`);
|
|
122
|
+
}
|
|
123
|
+
await tell(`Starting: ${command}`);
|
|
124
|
+
const r = await reallyStartApp({ dir: cwd, command, expectUrl: expect_url, onProgress: (l) => void tell(l) });
|
|
125
|
+
if (r.ok) {
|
|
126
|
+
await tell(`App is up at ${r.url}`);
|
|
127
|
+
return text(`started (pid ${r.pid}) · answering at ${r.url}`);
|
|
128
|
+
}
|
|
129
|
+
await tell(`Could not start it: ${r.why}`);
|
|
130
|
+
return text(`FAILED: ${r.why}\nlog tail:\n${r.log.slice(-800)}`);
|
|
131
|
+
});
|
|
132
|
+
server.registerTool("ask", { description: "Ask the person ONE question and wait for the answer. Use only when genuinely blocked (which app, a credential name, a choice between two things). Keep it one sentence, with the evidence.", inputSchema: { question: z.string() }, annotations: RETAKE_WRITE }, async ({ question }) => {
|
|
133
|
+
try {
|
|
134
|
+
const a = await waitForHuman("question", question);
|
|
135
|
+
return text(`They answered: ${a}`);
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
if (!(e instanceof NoUI))
|
|
139
|
+
throw e;
|
|
140
|
+
return text(`No Retake window is open, so ask the person yourself: “${question}” Then carry on with their answer.`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
server.registerTool("style", {
|
|
144
|
+
description: "Read or set the person's standing style for this project's demos (demos/style.md) — camera, cursor, captions, pacing, format. Set it the first time they express taste ('no zooms', 'hide the cursor') so they never repeat it; every draft reads it. Call with no text to read.",
|
|
145
|
+
inputSchema: { text: z.string().optional().describe("the style note to save; omit to read") },
|
|
146
|
+
annotations: RETAKE_WRITE,
|
|
147
|
+
}, async ({ text: t }) => {
|
|
148
|
+
const file = path.join(DEMOS, "style.md");
|
|
149
|
+
if (t === undefined) {
|
|
150
|
+
try {
|
|
151
|
+
return text(fs.readFileSync(file, "utf8"));
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return text("no style note yet — defaults apply: still camera, cursor shown, plain captions");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
fs.mkdirSync(DEMOS, { recursive: true });
|
|
158
|
+
fs.writeFileSync(file, t.trim() + "\n");
|
|
159
|
+
await tell("Style saved — every draft here will follow it.");
|
|
160
|
+
return text("saved to demos/style.md");
|
|
161
|
+
});
|
|
162
|
+
server.registerTool("ideas", {
|
|
163
|
+
description: "What is worth recording. Reads the live page (and the source folder if given) and returns a short list of demo ideas, each a title and a one-sentence story. Use this when the person asks what demos to make — do not invent ideas yourself. Saves the list to demos/ideas.md.",
|
|
164
|
+
inputSchema: { url: z.string().url(), project: z.string().optional() },
|
|
165
|
+
annotations: READ_ONLY,
|
|
166
|
+
}, async ({ url, project }) => {
|
|
167
|
+
LAST_DEMO = "";
|
|
168
|
+
await tell("Looking for demos worth making…");
|
|
169
|
+
const provider = pickProvider();
|
|
170
|
+
if (!provider)
|
|
171
|
+
return text("NO PROVIDER — Retake has no model configured. You know the app better than a blind draft would: propose ideas yourself from read_project.");
|
|
172
|
+
const sc = await scout(url);
|
|
173
|
+
const r = await suggestIdeas({ url, scout: sc, provider, project: project?.replace(/^~/, os.homedir()) });
|
|
174
|
+
fs.mkdirSync(DEMOS, { recursive: true });
|
|
175
|
+
fs.writeFileSync(path.join(DEMOS, "ideas.md"), r.markdown);
|
|
176
|
+
await tell(`${r.ideas.length} ideas → demos/ideas.md`);
|
|
177
|
+
return text(r.ideas.map((i, n) => `${n + 1}. ${i.title} — ${i.story} (${i.length})`).join("\n") || r.markdown);
|
|
178
|
+
});
|
|
179
|
+
// The plan is what makes "record them all" one job instead of five the model
|
|
180
|
+
// has to remember. It lives on disk, so a new session can pick up where the
|
|
181
|
+
// last one stopped.
|
|
182
|
+
const PLAN = () => path.join(DEMOS, "plan.json");
|
|
183
|
+
function readPlan() { try {
|
|
184
|
+
return JSON.parse(fs.readFileSync(PLAN(), "utf8"));
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return [];
|
|
188
|
+
} }
|
|
189
|
+
function planText(items) {
|
|
190
|
+
if (!items.length)
|
|
191
|
+
return "no plan — make one with plan_set";
|
|
192
|
+
const left = items.filter((i) => i.status === "todo" || i.status === "failed").length;
|
|
193
|
+
return items.map((i) => `[${i.status}] ${i.name} — ${i.story}${i.note ? ` (${i.note})` : ""}`).join("\n") + (left ? `\n\n${left} still to do.` : "\n\nAll done.");
|
|
194
|
+
}
|
|
195
|
+
server.registerTool("plan_set", {
|
|
196
|
+
description: "Write the list of demos to record (kebab-case names + one-sentence stories). Replaces the plan. Then work through it: draft → dry → run for each, marking progress with plan_mark.",
|
|
197
|
+
inputSchema: { items: z.array(z.object({ name: z.string(), story: z.string() })) },
|
|
198
|
+
annotations: RETAKE_WRITE,
|
|
199
|
+
}, async ({ items }) => {
|
|
200
|
+
const old = readPlan();
|
|
201
|
+
const merged = items.map((i) => ({ name: i.name, story: i.story, status: old.find((o) => o.name === i.name)?.status ?? "todo" }));
|
|
202
|
+
fs.mkdirSync(DEMOS, { recursive: true });
|
|
203
|
+
fs.writeFileSync(PLAN(), JSON.stringify(merged, null, 2));
|
|
204
|
+
await tell(`Plan: ${merged.length} demos.`);
|
|
205
|
+
return text(planText(merged));
|
|
206
|
+
});
|
|
207
|
+
server.registerTool("plan", {
|
|
208
|
+
description: "The current plan and what is left. Check this at the start of a batch session — an unfinished plan from before continues here.",
|
|
209
|
+
inputSchema: {},
|
|
210
|
+
annotations: READ_ONLY,
|
|
211
|
+
}, async () => text(planText(readPlan())));
|
|
212
|
+
server.registerTool("plan_mark", {
|
|
213
|
+
description: "Record progress on one plan item: recorded (preview done), final (full-quality render done), failed (with a note saying why), or todo to reset it.",
|
|
214
|
+
inputSchema: { name: z.string(), status: z.enum(["todo", "recorded", "final", "failed"]), note: z.string().optional() },
|
|
215
|
+
annotations: RETAKE_WRITE,
|
|
216
|
+
}, async ({ name, status, note }) => {
|
|
217
|
+
const items = readPlan();
|
|
218
|
+
const it = items.find((i) => i.name === name);
|
|
219
|
+
if (!it)
|
|
220
|
+
return text(`"${name}" is not in the plan. Items: ${items.map((i) => i.name).join(", ") || "none"}`);
|
|
221
|
+
it.status = status;
|
|
222
|
+
it.note = note;
|
|
223
|
+
fs.writeFileSync(PLAN(), JSON.stringify(items, null, 2));
|
|
224
|
+
const left = items.filter((i) => i.status === "todo" || i.status === "failed").length;
|
|
225
|
+
await tell(`${name}: ${status}${note ? ` — ${note}` : ""} · ${left} to go`);
|
|
226
|
+
return text(planText(items));
|
|
227
|
+
});
|
|
228
|
+
server.registerTool("list_demos", { description: "Demos that exist, with their last take.", inputSchema: {}, annotations: READ_ONLY }, async () => {
|
|
229
|
+
if (!fs.existsSync(DEMOS))
|
|
230
|
+
return text("no demos yet");
|
|
231
|
+
const rows = fs.readdirSync(DEMOS).filter((f) => /\.ya?ml$/.test(f)).map((f) => {
|
|
232
|
+
const name = f.replace(/\.ya?ml$/, "");
|
|
233
|
+
const tp = path.join(OUT, name, "take.json");
|
|
234
|
+
let last = "no take";
|
|
235
|
+
if (fs.existsSync(tp)) {
|
|
236
|
+
try {
|
|
237
|
+
const t = JSON.parse(fs.readFileSync(tp, "utf8"));
|
|
238
|
+
last = `${t.ok ? "ok" : "failed"} · ${(t.duration - t.trimBefore).toFixed(0)}s`;
|
|
239
|
+
}
|
|
240
|
+
catch { /* ignore */ }
|
|
241
|
+
}
|
|
242
|
+
return `- ${name} (${last})`;
|
|
243
|
+
});
|
|
244
|
+
return text(rows.join("\n") || "no demos yet");
|
|
245
|
+
});
|
|
246
|
+
server.registerTool("read_manifest", { description: "The YAML of a demo.", inputSchema: { name: z.string() }, annotations: READ_ONLY }, async ({ name }) => {
|
|
247
|
+
LAST_DEMO = name;
|
|
248
|
+
if (!safe(name) || !fs.existsSync(manifestPath(name)))
|
|
249
|
+
return text(`no demo "${name}"`);
|
|
250
|
+
return text(fs.readFileSync(manifestPath(name), "utf8"));
|
|
251
|
+
});
|
|
252
|
+
server.registerTool("write_manifest", { description: "Create or replace a demo's YAML. It is validated; errors come back instead of being written. Prefer `edit` for small changes to an existing demo.", inputSchema: { name: z.string(), yaml: z.string() }, annotations: RETAKE_WRITE }, async ({ name, yaml }) => {
|
|
253
|
+
LAST_DEMO = name;
|
|
254
|
+
if (!safe(name))
|
|
255
|
+
return text("name must be kebab-case");
|
|
256
|
+
let parsed;
|
|
257
|
+
try {
|
|
258
|
+
parsed = YAML.parse(yaml);
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
return text(`not valid YAML: ${e.message}`);
|
|
262
|
+
}
|
|
263
|
+
const { Manifest } = await import("../manifest.js");
|
|
264
|
+
const r = Manifest.safeParse(parsed);
|
|
265
|
+
if (!r.success)
|
|
266
|
+
return text("invalid manifest:\n" + r.error.issues.map((i) => `- ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n"));
|
|
267
|
+
fs.mkdirSync(DEMOS, { recursive: true });
|
|
268
|
+
fs.writeFileSync(manifestPath(name), yaml);
|
|
269
|
+
await tell(`Wrote demos/${name}.yaml — ${r.data.steps.length} steps, ${r.data.steps.filter((s) => s.action === "scene").length} scenes`);
|
|
270
|
+
return text(`written: demos/${name}.yaml (${r.data.steps.length} steps)`);
|
|
271
|
+
});
|
|
272
|
+
server.registerTool("draft", { description: "Let Retake draft a manifest from a sentence: scouts the URL (and reads the project if given) and writes demos/<name>.yaml. Then dry-run it.", inputSchema: { name: z.string(), url: z.string().url(), describe: z.string(), project: z.string().optional() }, annotations: RETAKE_WRITE }, async ({ name, url, describe, project }) => {
|
|
273
|
+
LAST_DEMO = name;
|
|
274
|
+
if (!safe(name))
|
|
275
|
+
return text("name must be kebab-case");
|
|
276
|
+
const provider = pickProvider();
|
|
277
|
+
if (!provider)
|
|
278
|
+
return text("no drafting model configured; write the manifest yourself with write_manifest");
|
|
279
|
+
const sc = await scout(url);
|
|
280
|
+
const d = await draftManifest({ name, url, describe, scout: sc, provider, project: project?.replace(/^~/, os.homedir()), demosDir: DEMOS });
|
|
281
|
+
fs.mkdirSync(DEMOS, { recursive: true });
|
|
282
|
+
fs.writeFileSync(manifestPath(name), d.yaml);
|
|
283
|
+
const m = loadManifest(manifestPath(name)).manifest;
|
|
284
|
+
await tell(`Drafted demos/${name}.yaml — ${m.steps.length} steps, ${m.steps.filter((s) => s.action === "scene").length} scenes`);
|
|
285
|
+
return text(`drafted demos/${name}.yaml (${m.steps.length} steps). Next: dry.\n\n${d.yaml}`);
|
|
286
|
+
});
|
|
287
|
+
server.registerTool("edit", { description: "Make small structured changes to a demo without rewriting it: captions, camera, holds, trim, waits, selectors, text; delete_step; insert_step {after, step:{action,…}} to add one step; set_step {step, value:{action,…}} to replace one. Steps are validated. Keeps comments. Returns what changed and whether it needs re-recording. Prefer this over write_manifest — one verb costs a line, a rewrite costs the file.", inputSchema: { name: z.string(), edits: z.array(z.object({ op: z.string() }).passthrough()) }, annotations: RETAKE_WRITE }, async ({ name, edits }) => {
|
|
288
|
+
LAST_DEMO = name;
|
|
289
|
+
if (!safe(name) || !fs.existsSync(manifestPath(name)))
|
|
290
|
+
return text(`no demo "${name}"`);
|
|
291
|
+
const a = applyEdits(manifestPath(name), edits);
|
|
292
|
+
for (const d of a.done)
|
|
293
|
+
await tell(d);
|
|
294
|
+
return text([`applied: ${a.done.join("; ") || "nothing"}`, a.skipped.length ? `skipped: ${a.skipped.join("; ")}` : "", a.rerecord ? "needs re-record" : "render-only change"].filter(Boolean).join("\n"));
|
|
295
|
+
});
|
|
296
|
+
server.registerTool("dry", { description: "Run a demo with no camera: every selector and wait, strict. Seconds, not minutes. ALWAYS do this before run. Failures include what was on screen.", inputSchema: { name: z.string() }, annotations: RETAKE_WRITE }, async ({ name }) => {
|
|
297
|
+
LAST_DEMO = name;
|
|
298
|
+
if (!safe(name) || !fs.existsSync(manifestPath(name)))
|
|
299
|
+
return text(`no demo "${name}"`);
|
|
300
|
+
const { manifest } = loadManifest(manifestPath(name));
|
|
301
|
+
await tell(`Checking every step of ${name} on the page…`);
|
|
302
|
+
const r = await dryRun(manifest, DEMOS, () => { });
|
|
303
|
+
await tell(r.ok ? `Checked: all ${manifest.steps.length} steps resolve` : `${r.failures} step(s) would fail`);
|
|
304
|
+
return text(r.ok ? `ok: all ${manifest.steps.length} steps resolve` : r.lines.join("\n"));
|
|
305
|
+
});
|
|
306
|
+
server.registerTool("run", { description: "Record the demo and render it. Slow (the demo is performed in real time). preview=true for a fast low-cost render to check the story. Returns the receipts.", inputSchema: { name: z.string(), preview: z.boolean().default(true), until: z.string().optional().describe("record up to the end of this scene label, then stop — for iterating on one beat of a long demo") }, annotations: RETAKE_WRITE }, async ({ name, preview, until }) => {
|
|
307
|
+
LAST_DEMO = name;
|
|
308
|
+
if (!safe(name) || !fs.existsSync(manifestPath(name)))
|
|
309
|
+
return text(`no demo "${name}"`);
|
|
310
|
+
const loaded = loadManifest(manifestPath(name));
|
|
311
|
+
const manifest = preview ? { ...loaded.manifest, preset: "preview-fast" } : loaded.manifest;
|
|
312
|
+
const outDir = path.join(OUT, name);
|
|
313
|
+
acquireLock(outDir);
|
|
314
|
+
try {
|
|
315
|
+
for (const f of fs.existsSync(outDir) ? fs.readdirSync(outDir) : [])
|
|
316
|
+
if (f !== ".retake-lock")
|
|
317
|
+
fs.rmSync(path.join(outDir, f), { recursive: true, force: true });
|
|
318
|
+
await tell(`Recording ${name}${preview ? " (preview)" : ""}…`);
|
|
319
|
+
const take = await record(manifest, { outDir, manifestDir: DEMOS, locked: true, until, log: () => { } });
|
|
320
|
+
await tell(`Rendering…`);
|
|
321
|
+
const a = await render(manifest, take, outDir, {});
|
|
322
|
+
const c = check(outDir, manifest);
|
|
323
|
+
const failed = take.timeline.find((e) => e.ok === false);
|
|
324
|
+
await tell(failed ? `Stopped at step ${failed.index}: ${failed.summary}` : `Take done — all steps ok · ${(take.duration - take.trimBefore).toFixed(0)}s`);
|
|
325
|
+
// A failed take is a finding, not a result: say exactly where and what
|
|
326
|
+
// the page showed, so even a small model gets a one-line repair.
|
|
327
|
+
if (failed)
|
|
328
|
+
return text(`FAILED at step ${failed.index} — ${failed.summary}: ${failed.error ?? "error"}${failed.screen ? `\nOn screen at that moment: “${failed.screen}”` : ""}\nThe camera stopped there (${(take.duration - take.trimBefore).toFixed(0)}s recorded). This is not a demo yet: edit the manifest, dry, run again. Do NOT call done on a failed take.\n\n${summariseTake(take)}`);
|
|
329
|
+
return text(`${summariseTake(take)}\nvideo: ${a.mp4 ?? "none"}\ncheck: ${c.ok ? "pass" : "FAIL"}\n${c.lines.filter((l) => l.startsWith("FAIL")).join("\n")}`);
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
releaseLock(outDir);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
server.registerTool("render", { description: "Re-render the last recording of a demo with the current manifest (captions, camera, trim, format). No browser, seconds.", inputSchema: { name: z.string() }, annotations: RETAKE_WRITE }, async ({ name }) => {
|
|
336
|
+
LAST_DEMO = name;
|
|
337
|
+
const outDir = path.join(OUT, name);
|
|
338
|
+
const tp = path.join(outDir, "take.json");
|
|
339
|
+
if (!fs.existsSync(tp))
|
|
340
|
+
return text("no recording yet — run first");
|
|
341
|
+
const { manifest } = loadManifest(manifestPath(name));
|
|
342
|
+
const take = JSON.parse(fs.readFileSync(tp, "utf8"));
|
|
343
|
+
if (take.captureHash && take.captureHash !== captureHash(manifest))
|
|
344
|
+
return text("the recording no longer matches the manifest's steps — use run, not render");
|
|
345
|
+
await tell(`Re-rendering ${name}…`);
|
|
346
|
+
const a = await render(manifest, take, outDir, { force: true });
|
|
347
|
+
return text(`rendered: ${a.mp4}`);
|
|
348
|
+
});
|
|
349
|
+
server.registerTool("receipts", { description: "What happened in the last take of a demo: per-step pass/fail with timings, scene times, stubs. Read this before deciding what to change.", inputSchema: { name: z.string() }, annotations: READ_ONLY }, async ({ name }) => {
|
|
350
|
+
LAST_DEMO = name;
|
|
351
|
+
const tp = path.join(OUT, name, "take.json");
|
|
352
|
+
if (!fs.existsSync(tp) || !fs.existsSync(manifestPath(name)))
|
|
353
|
+
return text("no take yet");
|
|
354
|
+
const take = JSON.parse(fs.readFileSync(tp, "utf8"));
|
|
355
|
+
const { manifest } = loadManifest(manifestPath(name));
|
|
356
|
+
return text(receiptsFor(take, manifest.steps));
|
|
357
|
+
});
|
|
358
|
+
// "All steps passed" is not "looks right". The stills are how a reader with
|
|
359
|
+
// eyes judges a take — a badge in the corner, a board full of leftovers, a
|
|
360
|
+
// caption over the button. The agent gets the same eyes here: scene stills
|
|
361
|
+
// as images, or any frame by timestamp, scaled down so a look costs little.
|
|
362
|
+
server.registerTool("look", {
|
|
363
|
+
description: "SEE the last take of a demo: returns one image per scene (the frame mid-scene), or the frame at a given second. Use after run/render, before deciding what to change — receipts tell you what happened, look tells you how it looks. Downscaled to keep it cheap; ask for a single scene or a specific second to go closer.",
|
|
364
|
+
inputSchema: { name: z.string(), scene: z.string().optional().describe("one scene label only"), at: z.number().optional().describe("a second into the video instead of scenes"), frame: z.enum(["mid", "end"]).default("end").describe("end = the scene's last moment (did it happen?), mid = halfway (what was it doing?)"), width: z.number().int().min(320).max(1280).default(800) },
|
|
365
|
+
annotations: READ_ONLY,
|
|
366
|
+
}, async ({ name, scene, at, frame: which, width }) => {
|
|
367
|
+
LAST_DEMO = name;
|
|
368
|
+
const dir = path.join(OUT, name);
|
|
369
|
+
const mp4 = path.join(dir, "demo.mp4");
|
|
370
|
+
if (!fs.existsSync(mp4))
|
|
371
|
+
return text("no rendered take yet — run first");
|
|
372
|
+
const frame = (seconds, label) => {
|
|
373
|
+
const out = path.join(os.tmpdir(), `retake-look-${process.pid}-${label.replace(/[^a-z0-9-]+/gi, "-")}.jpg`);
|
|
374
|
+
execFileSync(ffmpegBin(), ["-y", "-loglevel", "error", "-ss", seconds.toFixed(2), "-i", mp4, "-frames:v", "1", "-vf", `scale=${width}:-2`, "-q:v", "5", out]);
|
|
375
|
+
const b64 = fs.readFileSync(out).toString("base64");
|
|
376
|
+
fs.rmSync(out, { force: true });
|
|
377
|
+
return { type: "image", data: b64, mimeType: "image/jpeg" };
|
|
378
|
+
};
|
|
379
|
+
if (at !== undefined)
|
|
380
|
+
return { content: [{ type: "text", text: `${name} at ${at.toFixed(1)}s` }, frame(at, `at-${at}`)] };
|
|
381
|
+
const tp = path.join(dir, "take.json");
|
|
382
|
+
const take = fs.existsSync(tp) ? JSON.parse(fs.readFileSync(tp, "utf8")) : null;
|
|
383
|
+
const scenes = (take?.timeline ?? []).filter((e) => e.action === "scene");
|
|
384
|
+
if (!take || !scenes.length)
|
|
385
|
+
return { content: [{ type: "text", text: `${name}: no scenes — showing the middle` }, frame(1, "mid")] };
|
|
386
|
+
const end = take.duration - take.trimBefore;
|
|
387
|
+
const picked = scene ? scenes.filter((e) => e.label === scene) : scenes;
|
|
388
|
+
if (!picked.length)
|
|
389
|
+
return text(`no scene "${scene}" — scenes: ${scenes.map((e) => e.label).join(", ")}`);
|
|
390
|
+
const content = [];
|
|
391
|
+
for (const [i, sc] of picked.entries()) {
|
|
392
|
+
const idx = scenes.indexOf(sc);
|
|
393
|
+
const from = sc.start - take.trimBefore;
|
|
394
|
+
const next = scenes[idx + 1] ? scenes[idx + 1].start - take.trimBefore : end;
|
|
395
|
+
const t = which === "end"
|
|
396
|
+
? Math.min(Math.max(from + 0.4, next - 0.6), Math.max(0, end - 0.2))
|
|
397
|
+
: Math.min(Math.max(0, from + Math.max(0.8, (next - from) / 2)), Math.max(0, end - 0.2));
|
|
398
|
+
content.push({ type: "text", text: `scene ${idx + 1}/${scenes.length} “${sc.label}” · ${from.toFixed(1)}–${next.toFixed(1)}s${sc.caption ? ` · caption: ${sc.caption}` : ""}` });
|
|
399
|
+
content.push(frame(t, `${i}-${sc.label}`));
|
|
400
|
+
}
|
|
401
|
+
return { content };
|
|
402
|
+
});
|
|
403
|
+
server.registerTool("done", { description: "Call when the demo is recorded and acceptable (or when you are stopping). One sentence for the person.", inputSchema: { summary: z.string(), demo: z.string().optional() }, annotations: RETAKE_WRITE }, async ({ summary, demo }) => {
|
|
404
|
+
await tell(`Done: ${summary}`);
|
|
405
|
+
if (UI && !SESSION)
|
|
406
|
+
await fetch(`${UI}/api/activity`, { method: "POST", body: JSON.stringify({ line: summary, demo, done: true }) }).catch(() => { });
|
|
407
|
+
if (UI && SESSION)
|
|
408
|
+
await fetch(`${UI}/api/operator/${SESSION}/done`, { method: "POST", body: JSON.stringify({ summary, demo }) }).catch(() => { });
|
|
409
|
+
return text("ok");
|
|
410
|
+
});
|
|
411
|
+
const transport = new StdioServerTransport();
|
|
412
|
+
await server.connect(transport);
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where things are. Two roots, kept apart on purpose:
|
|
3
|
+
*
|
|
4
|
+
* PKG_ROOT the installed Retake package — its skill, landing page,
|
|
5
|
+
* assets, example manifest, and the code itself
|
|
6
|
+
* PROJECT_ROOT the workspace Retake works in — demos/, outputs/, .env,
|
|
7
|
+
* .auth/, the UI's drafts and trash
|
|
8
|
+
*
|
|
9
|
+
* From a clone the two are the same folder. From `npm install -g retake-demos`
|
|
10
|
+
* they never are, and every path that used to hang off process.cwd() has to
|
|
11
|
+
* pick a side.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url)); // src/ (tsx) or dist/ (built)
|
|
19
|
+
/** True when running the TypeScript source through tsx rather than dist/. */
|
|
20
|
+
export const FROM_SOURCE = /\.ts$/.test(fileURLToPath(import.meta.url));
|
|
21
|
+
export const PKG_ROOT = path.resolve(HERE, "..");
|
|
22
|
+
export const PROJECT_ROOT = path.resolve((process.env.RETAKE_ROOT || process.cwd()).replace(/^~/, os.homedir()));
|
|
23
|
+
export const VERSION = (() => {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8")).version ?? "dev";
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return "dev";
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
/** How to spawn one of Retake's own entry points as a child process — the
|
|
32
|
+
same way whether we are the source tree or the built package. */
|
|
33
|
+
export function entry(name) {
|
|
34
|
+
const file = path.join(HERE, `${name}.${FROM_SOURCE ? "ts" : "js"}`);
|
|
35
|
+
if (!FROM_SOURCE)
|
|
36
|
+
return { command: process.execPath, args: [file] };
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
return { command: process.execPath, args: [require.resolve("tsx/cli"), file] };
|
|
39
|
+
}
|
package/dist/presets.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export const PRESETS = {
|
|
2
|
+
draft: {
|
|
3
|
+
/* Iteration mode: the SAME 960px layout as preview-fast and post-landscape
|
|
4
|
+
(so selectors, wrapping and timing match the final exactly) at a quarter
|
|
5
|
+
of the pixels — the encoder stops competing with the app being recorded
|
|
6
|
+
on a small machine. Never ship a draft; look at it, then re-record at a
|
|
7
|
+
post preset. */
|
|
8
|
+
name: "draft",
|
|
9
|
+
width: 960, height: 540, scale: 1, fps: 15, crf: 0,
|
|
10
|
+
gif: false, layout: "band", cameraZoom: 1.2,
|
|
11
|
+
cursorSize: 36, captionFontSize: 24, bandHeight: 76,
|
|
12
|
+
encoder: "videotoolbox", master: false,
|
|
13
|
+
description: "Fastest. Same layout as the final at a quarter of the pixels — for iterating, never for shipping.",
|
|
14
|
+
},
|
|
15
|
+
"preview-fast": {
|
|
16
|
+
name: "preview-fast",
|
|
17
|
+
width: 1920, height: 1080, scale: 2, fps: 24, crf: 0,
|
|
18
|
+
gif: false, layout: "band", cameraZoom: 1.25,
|
|
19
|
+
cursorSize: 72, captionFontSize: 46, bandHeight: 150,
|
|
20
|
+
encoder: "videotoolbox", master: false,
|
|
21
|
+
description: "Same framing as post, hardware-encoded MP4 only. For checking timing and framing fast.",
|
|
22
|
+
},
|
|
23
|
+
"proof-fast": {
|
|
24
|
+
name: "proof-fast",
|
|
25
|
+
width: 960, height: 720, scale: 1, fps: 24, crf: 24,
|
|
26
|
+
gif: false, layout: "band", cameraZoom: 1.2,
|
|
27
|
+
cursorSize: 40, captionFontSize: 26, bandHeight: 84,
|
|
28
|
+
encoder: "videotoolbox", master: false,
|
|
29
|
+
description: "Small, quick internal check.",
|
|
30
|
+
},
|
|
31
|
+
"post-landscape": {
|
|
32
|
+
name: "post-landscape",
|
|
33
|
+
width: 1920, height: 1080, scale: 2, fps: 30, crf: 17,
|
|
34
|
+
gif: false, layout: "band", cameraZoom: 1.25,
|
|
35
|
+
cursorSize: 72, captionFontSize: 46, bandHeight: 150,
|
|
36
|
+
encoder: "x264", master: true,
|
|
37
|
+
description: "1080p, 30fps, CRF 17. The general-purpose demo. GIF on request.",
|
|
38
|
+
},
|
|
39
|
+
"post-square": {
|
|
40
|
+
name: "post-square",
|
|
41
|
+
width: 1080, height: 1080, scale: 1.6, fps: 30, crf: 17,
|
|
42
|
+
gif: false, layout: "band", cameraZoom: 1.25,
|
|
43
|
+
cursorSize: 60, captionFontSize: 40, bandHeight: 130,
|
|
44
|
+
encoder: "x264", master: true,
|
|
45
|
+
description: "1080×1080, feed-friendly.",
|
|
46
|
+
},
|
|
47
|
+
"post-vertical": {
|
|
48
|
+
name: "post-vertical",
|
|
49
|
+
width: 1080, height: 1920, scale: 2, fps: 30, crf: 17,
|
|
50
|
+
gif: false, layout: "band", cameraZoom: 1.3,
|
|
51
|
+
cursorSize: 60, captionFontSize: 44, bandHeight: 170,
|
|
52
|
+
encoder: "x264", master: true,
|
|
53
|
+
description: "9:16 for shorts/reels. Apps with a narrow column suit it best.",
|
|
54
|
+
},
|
|
55
|
+
"docs-gif": {
|
|
56
|
+
name: "docs-gif",
|
|
57
|
+
width: 1440, height: 900, scale: 1.5, fps: 24, crf: 20,
|
|
58
|
+
gif: { width: 900, fps: 15 }, layout: "band", cameraZoom: 1.2,
|
|
59
|
+
cursorSize: 48, captionFontSize: 32, bandHeight: 100,
|
|
60
|
+
encoder: "x264", master: false,
|
|
61
|
+
description: "README/docs GIF, 900px wide.",
|
|
62
|
+
},
|
|
63
|
+
master: {
|
|
64
|
+
name: "master",
|
|
65
|
+
width: 1920, height: 1080, scale: 2, fps: 30, crf: 12,
|
|
66
|
+
gif: false, layout: "band", cameraZoom: 1.25,
|
|
67
|
+
cursorSize: 72, captionFontSize: 46, bandHeight: 150,
|
|
68
|
+
encoder: "x264", master: true,
|
|
69
|
+
description: "Highest quality, slowest. Archive/source render.",
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
export const DEFAULT_PRESET = "post-landscape";
|
|
73
|
+
export function presetNames() {
|
|
74
|
+
return Object.keys(PRESETS);
|
|
75
|
+
}
|