Skill Tracer
Skills › HyperFrames › media-use › recipe-store.mjs

recipe-store.mjs

371 lines · skills/media-use/scripts/lib/recipe-store.mjs at a595755

1import {
2 cpSync,
3 existsSync,
4 mkdirSync,
5 readdirSync,
6 readFileSync,
7 renameSync,
8 rmSync,
9 writeFileSync,
10} from "node:fs";
11import { basename, join, resolve } from "node:path";
12import { globalMediaDir } from "./media-home.mjs";
13import { appendRecord, mediaDir, nextId } from "./manifest.mjs";
14import { regenerateIndex } from "./index-gen.mjs";
15import { mergedPreferences } from "./prefs-store.mjs";
17// A filter keeps Node 22 off its native copy, which writes nothing under a non-ASCII path on Windows.
18const NON_ASCII_SAFE_DIR_COPY = { recursive: true, force: true, filter: () => true };
20/**
21 * Recipes — the heavyweight tier of HyperFrames user memory.
22 *
23 * A recipe is the full confirmed bundle for one video type: the frozen design
24 * spec (`frame.md`), the storyboard skeleton (structure with the content
25 * blanked), and the confirmed brief values — frozen after the run's final
26 * approval, reused to start the next video of the same type from everything
27 * already approved.
28 *
29 * Storage is **named folders**, not content-addressed cache entries: a recipe
30 * is an evolving bundle with a `version`, so re-freezing the same name bumps
31 * the version and archives the old folder as `<name>@v<N>`. Two tiers, same
32 * split as everything else in media-use: project `.media/recipes/<name>/`
33 * (committed) and user `~/.media/recipes/<name>/` (a freeze is already a
34 * confirmed bundle, so it promotes immediately — no two-project rule here).
35 */
37/** Frontmatter keys that describe THIS video, not the reusable type. */
38const FRONTMATTER_CONTENT_KEYS = new Set(["message", "audience", "mode"]);
40/** BRIEF.md frontmatter keys that describe this run, not the reusable type —
41 * a recipe never locks the run's shape, so these are always asked again. */
42const BRIEF_CONTENT_KEYS = new Set(["flow", "storyboard", "message", "audience"]);
44/** Per-frame metadata that is content, not structure. */
45const FRAME_CONTENT_KEYS = new Set([
46 "voiceover",
47 "vo",
48 "voice_over",
49 "narration",
50 "scene",
51 "description",
52 "summary",
53 "caption",
54 "asset_candidates",
55]);
57const FRAME_HEADING_RE = /^(#{2,3})\s+(?:frame|beat|scene)\s+\d+/i;
59export function projectRecipesDir(projectDir) {
60 return join(mediaDir(projectDir), "recipes");
63export function userRecipesDir() {
64 return join(globalMediaDir(), "recipes");
67export function slugifyRecipeName(name) {
68 const slug = String(name ?? "")
69 .trim()
70 .toLowerCase()
71 .replace(/[\s_]+/g, "-")
72 .replace(/[^a-z0-9-]/g, "")
73 .replace(/-+/g, "-")
74 .replace(/^-|-$/g, "");
75 if (!slug) throw new Error(`recipe name "${name}" has no usable characters`);
76 return slug;
79function frameTitle(headingLine) {
80 const dash = headingLine.split(/\s+—\s+/)[1];
81 if (dash && dash.trim()) return dash.trim();
82 return headingLine.replace(/^#+\s*/, "").trim();
85/** Frontmatter: drop the content keys, keep structure/style keys verbatim. */
86function skeletonFrontmatter(lines, out, contentKeys = FRONTMATTER_CONTENT_KEYS) {
87 if (lines[0]?.trim() !== "---") return 0;
88 out.push(lines[0]);
89 let i = 1;
90 while (i < lines.length && lines[i].trim() !== "---") {
91 const key = lines[i].match(/^(\w+)\s*:/)?.[1]?.toLowerCase();
92 if (!key || !contentKeys.has(key)) out.push(lines[i]);
93 i++;
94 }
95 if (i < lines.length) {
96 out.push(lines[i]); // closing ---
97 i++;
98 }
99 return i;
102/** One line inside a frame section — returns the replacement lines (may be none). */
103function skeletonFrameLine(line, state, out) {
104 const bulletKey = line.match(/^-\s+(\w+)\s*:/)?.[1]?.toLowerCase();
105 if (bulletKey) {
106 if (bulletKey === "status") out.push("- status: outline");
107 else if (!FRAME_CONTENT_KEYS.has(bulletKey)) out.push(line);
108 return;
109 }
110 if (!line.trim()) {
111 out.push(line);
112 return;
113 }
114 // Frame prose: one placeholder per frame in place of the narrative.
115 if (!state.proseReplaced) {
116 out.push(
117 `<fill in: this video's content for the "${state.title}" beat — keep the layout role, replace the words.>`,
118 );
119 state.proseReplaced = true;
120 }
123/**
124 * Skeletonize a STORYBOARD.md: keep the reusable structure (frame count,
125 * durations, transitions, src paths, the Video direction block, style-ish
126 * frontmatter), reset every status to `outline`, and blank the content
127 * (message/audience, narration guides, per-frame prose) down to a fill-in
128 * placeholder that names the frame's role.
129 */
130/**
131 * Skeletonize a BRIEF.md: keep the frontmatter's reusable keys (workflow,
132 * destination, aspect, language, length, angle…), drop the run-shape and
133 * content keys (flow, storyboard, message, audience), and blank each body
134 * section down to a fill-in placeholder under its kept heading.
135 */
136export function skeletonizeBrief(source) {
137 const lines = String(source ?? "").split(/\r?\n/);
138 const out = [];
139 let i = skeletonFrontmatter(lines, out, BRIEF_CONTENT_KEYS);
140 for (; i < lines.length; i++) {
141 const heading = lines[i].match(/^##\s+(.+)$/);
142 if (heading) {
143 out.push(lines[i], "");
144 out.push(
145 `<fill in: this video's ${heading[1].trim().toLowerCase()} — the recipe keeps the shape, this run supplies the specifics.>`,
146 );
147 out.push("");
148 }
149 }
150 return out.join("\n").replace(/\n{3,}/g, "\n\n");
153export function skeletonizeStoryboard(source) {
154 const lines = String(source ?? "").split(/\r?\n/);
155 const out = [];
156 const state = { inFrame: false, proseReplaced: false, title: "" };
157 for (let i = skeletonFrontmatter(lines, out); i < lines.length; i++) {
158 const line = lines[i];
159 if (/^#{2,3}\s/.test(line)) {
160 state.inFrame = FRAME_HEADING_RE.test(line);
161 state.proseReplaced = false;
162 state.title = state.inFrame ? frameTitle(line) : "";
163 out.push(line);
164 } else if (!state.inFrame) {
165 out.push(line);
166 } else {
167 skeletonFrameLine(line, state, out);
168 }
169 }
170 return out.join("\n").replace(/\n{3,}/g, "\n\n");
173/** The run's workflow as BRIEF.md records it — the source of truth a freeze
174 * must not contradict. Undefined when no BRIEF.md (or no `workflow:`) exists. */
175function briefWorkflow(root) {
176 const brief = join(root, "BRIEF.md");
177 if (!existsSync(brief)) return undefined;
178 const lines = readFileSync(brief, "utf8").split(/\r?\n/);
179 if (lines[0]?.trim() !== "---") return undefined;
180 for (let i = 1; i < lines.length && lines[i].trim() !== "---"; i++) {
181 const match = lines[i].match(/^workflow\s*:\s*(.+?)\s*$/);
182 if (match) return match[1].replace(/^["']|["']$/g, "") || undefined;
183 }
184 return undefined;
187function readRecipeJson(dir) {
188 try {
189 const parsed = JSON.parse(readFileSync(join(dir, "recipe.json"), "utf8"));
190 if (typeof parsed !== "object" || parsed === null || typeof parsed.name !== "string") {
191 return null;
192 }
193 return parsed;
194 } catch {
195 return null;
196 }
199function prefValue(prefs, key) {
200 return prefs[key]?.value;
203/**
204 * Freeze the current project's approved run as a named recipe. Writes the
205 * project-tier folder + a manifest record, then copies to the user tier (a
206 * freeze is already confirmed — it promotes immediately).
207 */
208export function freezeRecipe({ projectDir, name, workflow, blocks }) {
209 const slug = slugifyRecipeName(name);
210 const root = resolve(projectDir);
211 const fromBrief = briefWorkflow(root);
212 const fromFlag = workflow && String(workflow).trim() ? String(workflow).trim() : undefined;
213 // BRIEF.md decides; the flag only covers projects briefed before it existed.
214 const resolvedWorkflow = fromBrief ?? fromFlag;
215 if (!resolvedWorkflow) {
216 throw new Error("no workflow found — BRIEF.md names none and no --workflow was given");
217 }
218 const frameSpec = join(root, "frame.md");
219 const storyboard = join(root, "STORYBOARD.md");
220 if (!existsSync(frameSpec)) throw new Error("no frame.md to freeze — run the design step first");
221 if (!existsSync(storyboard)) throw new Error("no STORYBOARD.md to freeze");
223 const dir = join(projectRecipesDir(root), slug);
224 let version = 1;
225 const previous = existsSync(dir) ? readRecipeJson(dir) : null;
226 if (previous) {
227 version = (Number.isInteger(previous.version) ? previous.version : 1) + 1;
228 const archive = `${dir}@v${previous.version ?? 1}`;
229 rmSync(archive, { recursive: true, force: true });
230 renameSync(dir, archive);
231 }
232 mkdirSync(dir, { recursive: true });
234 const prefs = mergedPreferences(root);
235 const recipe = {
236 version,
237 name: slug,
238 workflow: resolvedWorkflow,
239 approved_at: new Date().toISOString(),
240 source_project: basename(root),
241 destination: prefValue(prefs, "destination"),
242 aspect: prefValue(prefs, "aspect"),
243 language: prefValue(prefs, "language"),
244 voice: prefValue(prefs, "voice"),
245 // The bare-key fallback tolerates records made before the store required
246 // style_preset to be workflow-scoped.
247 style_preset:
248 prefValue(prefs, `style_preset.${resolvedWorkflow}`) ?? prefValue(prefs, "style_preset"),
249 blocks: Array.isArray(blocks) && blocks.length > 0 ? blocks : undefined,
250 };
252 writeFileSync(join(dir, "recipe.json"), `${JSON.stringify(recipe, null, 2)}\n`);
253 cpSync(frameSpec, join(dir, "frame.md"));
254 writeFileSync(
255 join(dir, "storyboard-skeleton.md"),
256 `${skeletonizeStoryboard(readFileSync(storyboard, "utf8")).trimEnd()}\n`,
257 );
259 // Best-effort fourth artifact — projects briefed before BRIEF.md existed
260 // (or by workflows that don't write one) freeze fine without it.
261 const brief = join(root, "BRIEF.md");
262 const briefSkeleton = existsSync(brief);
263 if (briefSkeleton) {
264 writeFileSync(
265 join(dir, "brief-skeleton.md"),
266 `${skeletonizeBrief(readFileSync(brief, "utf8")).trimEnd()}\n`,
267 );
268 }
270 const id = nextId(root, "recipe");
271 appendRecord(root, {
272 id,
273 type: "recipe",
274 path: `.media/recipes/${slug}/recipe.json`,
275 entity: slug,
276 description: `recipe: ${slug} (${recipe.workflow}, v${version})`,
277 provenance: { provider: "recipe.freeze", version, source_project: recipe.source_project },
278 });
279 regenerateIndex(root);
281 // User tier — best-effort, like every other promotion.
282 try {
283 const userDir = join(userRecipesDir(), slug);
284 mkdirSync(userDir, { recursive: true });
285 cpSync(dir, userDir, NON_ASCII_SAFE_DIR_COPY);
286 if (!briefSkeleton) rmSync(join(userDir, "brief-skeleton.md"), { force: true });
287 } catch {
288 // The project-tier freeze already landed.
289 }
291 return {
292 id,
293 slug,
294 version,
295 dir,
296 briefSkeleton,
297 workflow: resolvedWorkflow,
298 workflowOverridden: Boolean(fromBrief && fromFlag && fromBrief !== fromFlag),
299 };
302function scanRecipesDir(dir, source) {
303 if (!existsSync(dir)) return [];
304 const found = [];
305 for (const entry of readdirSync(dir, { withFileTypes: true })) {
306 if (!entry.isDirectory() || entry.name.includes("@v")) continue;
307 const recipe = readRecipeJson(join(dir, entry.name));
308 if (recipe) found.push({ ...recipe, source, dir: join(dir, entry.name) });
309 }
310 return found;
313/** Two-tier merged listing (project wins), newest approval first. */
314export function listRecipes({ projectDir, workflow }) {
315 const merged = new Map();
316 for (const recipe of scanRecipesDir(userRecipesDir(), "user")) merged.set(recipe.name, recipe);
317 for (const recipe of scanRecipesDir(projectRecipesDir(resolve(projectDir)), "project")) {
318 merged.set(recipe.name, recipe);
319 }
320 let list = [...merged.values()];
321 if (workflow) list = list.filter((r) => r.workflow === workflow);
322 return list.sort((a, b) =>
323 String(b.approved_at ?? "").localeCompare(String(a.approved_at ?? "")),
324 );
327/**
328 * Adopt a recipe into the current project: import the folder from the user
329 * tier when the project doesn't have it, copy its frame.md over the project's,
330 * and hand back the values + the skeleton path for the storyboard draft.
331 */
332export function useRecipe({ projectDir, name }) {
333 const slug = slugifyRecipeName(name);
334 const root = resolve(projectDir);
335 let dir = join(projectRecipesDir(root), slug);
337 if (!readRecipeJson(dir)) {
338 const userDir = join(userRecipesDir(), slug);
339 if (!readRecipeJson(userDir)) {
340 const known = listRecipes({ projectDir: root }).map((r) => r.name);
341 throw new Error(
342 `no recipe named "${slug}"${known.length ? ` (known: ${known.join(", ")})` : ""}`,
343 );
344 }
345 mkdirSync(dir, { recursive: true });
346 cpSync(userDir, dir, NON_ASCII_SAFE_DIR_COPY);
347 const imported = readRecipeJson(dir);
348 appendRecord(root, {
349 id: nextId(root, "recipe"),
350 type: "recipe",
351 path: `.media/recipes/${slug}/recipe.json`,
352 entity: slug,
353 description: `recipe: ${slug} (${imported.workflow}, v${imported.version})`,
354 provenance: { provider: "recipe.local", imported_from: "user-tier" },
355 });
356 regenerateIndex(root);
357 }
359 const recipe = readRecipeJson(dir);
360 cpSync(join(dir, "frame.md"), join(root, "frame.md"));
361 return {
362 recipe,
363 dir,
364 frameSpecPath: "frame.md",
365 skeletonPath: `.media/recipes/${slug}/storyboard-skeleton.md`,
366 // Recipes frozen before BRIEF.md existed have no brief skeleton — degrade.
367 briefSkeletonPath: existsSync(join(dir, "brief-skeleton.md"))
368 ? `.media/recipes/${slug}/brief-skeleton.md`
369 : undefined,
370 };