@mevdragon/vidfarm-devcli 0.20.1 → 0.20.3

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/dist/src/cli.js CHANGED
@@ -25,8 +25,8 @@ import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
25
25
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
26
26
  // `serve` command boots the FULL editor locally (single origin, disk-backed
27
27
  // records + storage) so power users edit compositions on disk while a browser
28
- // editor and a coding agent share one source of truth; render stays on the
29
- // cloud. The remaining commands are thin 1:1 wrappers over the REST API.
28
+ // editor and a coding agent share one source of truth. Render can run locally
29
+ // in-process or hand off to cloud; the remaining commands wrap the REST API.
30
30
  const DEFAULT_HOST = "https://vidfarm.cc";
31
31
  const DEFAULT_PORT = 4321;
32
32
  // Agent skill files the `update-skill` command can install from the live host
@@ -35,18 +35,21 @@ const SKILL_TARGETS = {
35
35
  "vidfarm-director": { route: "/skill/vidfarm-director", bundled: "SKILL.director.md" },
36
36
  "vidfarm-platform": { route: "/skill/vidfarm-platform", bundled: "SKILL.platform.md" }
37
37
  };
38
- // Every command below is a thin wrapper over ONE Vidfarm REST call (the `→`
39
- // line documents the exact route). The devcli adds nothing the raw API can't
40
- // do it just resolves your fork, sets the `vidfarm-api-key` header, prints
41
- // the prod frontend URL to open, and handles the multi-step upload/download +
42
- // render-polling flows that are awkward to do by hand. Prefer the raw REST API
43
- // (or `vidfarm api <METHOD> <path>`) whenever you want full control; reach for
44
- // the named commands when you want the ergonomics and the frontend links.
38
+ // Most commands below are thin wrappers over ONE Vidfarm REST call (the `→`
39
+ // line documents the exact route). A few convenience commands compose the raw
40
+ // API for you when that is the ergonomic default. The devcli adds nothing the
41
+ // raw API can't do it just resolves your fork, sets the `vidfarm-api-key`
42
+ // header, prints the prod frontend URL to open, and handles the multi-step
43
+ // upload/download + render-polling flows that are awkward to do by hand.
44
+ // Prefer the raw REST API (or `vidfarm api <METHOD> <path>`) whenever you want
45
+ // full control; reach for the named commands when you want the ergonomics and
46
+ // the frontend links.
45
47
  const HELP = `vidfarm-devcli — command-line bridge for the Vidfarm video studio
46
48
 
47
- Every named command maps 1:1 to a single REST route (shown as → below). Use
48
- raw REST (or \`vidfarm api\`) when you want control; use named commands for the
49
- ergonomics + prod frontend links. Auth: --api-key <key> or VIDFARM_API_KEY.
49
+ Most named commands map 1:1 to a REST route (shown as → below). A few compose
50
+ the same routes for file-backed scripting. Use raw REST (or \`vidfarm api\`) when
51
+ you want control; use named commands for ergonomics + prod frontend links.
52
+ Auth: --api-key <key> or VIDFARM_API_KEY.
50
53
 
51
54
  Usage:
52
55
  vidfarm <template_id> [opts] Start local editor session (default)
@@ -118,7 +121,7 @@ Composition lifecycle (fork → decompose → snapshot → render):
118
121
  decompose <forkId> Split the fork's source into scenes (smart) → POST .../compositions/:forkId/auto-decompose
119
122
 
120
123
  Generate AI media and drop it on the timeline (for local coding agents):
121
- generate <image|video> Generate AI media, poll to the finished URL → POST /api/v1/primitives/{images,videos}/generate
124
+ generate <image|video> Generate AI media, poll to the finished public URL → POST /api/v1/primitives/{images,videos}/generate
122
125
  --prompt <text> What to generate (required)
123
126
  --ref <url|@file> Reference image (repeatable): input_references
124
127
  for video, prompt_attachments for image. @file
@@ -372,9 +375,17 @@ Fine timeline control (trackpad-level verbs on a pulled/served composition — l
372
375
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
373
376
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
374
377
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
378
+ --dir <dir|composition.html> pushes local
379
+ composition.html + composition.json first
380
+ --target local|cloud chooses in-process or
381
+ cloud handoff when the backend supports both
375
382
  Cloud render costs ~$0.01-$0.10; against a local
376
383
  'serve' host it renders in-process for FREE.
377
384
  render-status <forkId> <renderId> Poll one render job → GET .../compositions/:forkId/renders/:renderId
385
+ template run <template_id> <operation_name>
386
+ Run one template operation via REST → POST /api/v1/templates/:templateId/operations/:operationName
387
+ --payload-file / --payload supply the input
388
+ --wait polls GET /api/v1/user/me/jobs/:jobId
378
389
  visibility <forkId> <private|public> Set fork visibility → PATCH .../compositions/:forkId/visibility
379
390
  clone <forkId> [--from current|default] [--version N]
380
391
  New fork: --from current (default) branches this
@@ -495,7 +506,10 @@ Cost spectrum (default to the cheapest approach that works; see SKILL.director.m
495
506
  Escape hatch — call ANY route directly:
496
507
  api <METHOD> <path> Raw REST call with auth + pretty errors
497
508
  --data <json> JSON request body (or --data-file <path>)
509
+ --body-file <path> Literal request body file (for HTML/text payloads)
510
+ --content-type <mime> Content-Type for --body-file (default text/plain)
498
511
  --query k=v Repeatable query param
512
+ --raw Print the exact response body, not pretty JSON
499
513
  Examples:
500
514
  vidfarm api GET /discover/feed
501
515
  vidfarm api POST /api/v1/compositions --data '{"template_id":"template_..."}'
@@ -643,6 +657,9 @@ async function main() {
643
657
  case "render-status":
644
658
  await runRenderStatusCommand(rest);
645
659
  return;
660
+ case "template":
661
+ await runTemplateCommand(rest);
662
+ return;
646
663
  case "visibility":
647
664
  await runVisibilityCommand(rest);
648
665
  return;
@@ -818,8 +835,14 @@ async function apiRequest(input) {
818
835
  const headers = { ...buildAuthHeaders(input.auth), accept: "application/json" };
819
836
  let body;
820
837
  if (input.body !== undefined) {
821
- headers["content-type"] = "application/json";
822
- body = JSON.stringify(input.body);
838
+ if (typeof input.body === "string") {
839
+ body = input.body;
840
+ headers["content-type"] = input.contentType ?? "text/plain; charset=utf-8";
841
+ }
842
+ else {
843
+ headers["content-type"] = input.contentType ?? "application/json";
844
+ body = JSON.stringify(input.body);
845
+ }
823
846
  }
824
847
  const res = await fetch(url, { method: input.method.toUpperCase(), headers, body });
825
848
  const text = await res.text();
@@ -1329,9 +1352,12 @@ async function fetchCompositionFiles(input) {
1329
1352
  // literal + viral DNA an agent needs to REPLACE a scene from the clip library
1330
1353
  // or RE-CREATE it from scratch (recreation_prompt + recreation_notes).
1331
1354
  // editor-harness.json carries the technical EDITING DIRECTION (pace,
1332
- // typography, b-roll, transitions, audio, scene roles under `.harness`) so an
1333
- // agent can one-shot a clean, on-style edit — the "how to edit like this"
1334
- // companion to the video-context viral DNA.
1355
+ // typography, b-roll, transitions, audio, scene roles, PLUS an `emotional`
1356
+ // block comedic_timing/intonation/vibe_anchors for keeping the FEELING,
1357
+ // all under `.harness`) so an agent can one-shot a clean, on-style edit — the
1358
+ // "how to edit like this" companion to the video-context viral DNA (whose
1359
+ // `emotional_punch` captures the joke/vibe/intonation and, being decomposed
1360
+ // over the transcript too, the trending sound driving the emotion).
1335
1361
  const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json", "scene-annotations.json", "editor-harness.json"];
1336
1362
  for (const filename of files) {
1337
1363
  const target = path.join(input.dir, filename);
@@ -1631,7 +1657,15 @@ async function runApiCommand(argv) {
1631
1657
  const parsed = parseArgs({
1632
1658
  args: argv,
1633
1659
  allowPositionals: true,
1634
- options: { ...commonOptions(), data: { type: "string" }, "data-file": { type: "string" }, query: { type: "string", multiple: true } }
1660
+ options: {
1661
+ ...commonOptions(),
1662
+ data: { type: "string" },
1663
+ "data-file": { type: "string" },
1664
+ "body-file": { type: "string" },
1665
+ "content-type": { type: "string" },
1666
+ query: { type: "string", multiple: true },
1667
+ raw: { type: "boolean", default: false }
1668
+ }
1635
1669
  });
1636
1670
  const method = parsed.positionals[0];
1637
1671
  const routePath = parsed.positionals[1];
@@ -1639,10 +1673,16 @@ async function runApiCommand(argv) {
1639
1673
  throw new Error(`api requires <METHOD> <path>.\n\nExample: vidfarm api GET /discover/feed`);
1640
1674
  }
1641
1675
  const ctx = commonContext(parsed.values);
1676
+ if (parsed.values["data-file"] && parsed.values["body-file"]) {
1677
+ throw new Error("api accepts either --data-file (JSON) or --body-file (literal), not both.");
1678
+ }
1642
1679
  let body;
1643
1680
  if (parsed.values["data-file"]) {
1644
1681
  body = JSON.parse(readFileSync(path.resolve(process.cwd(), String(parsed.values["data-file"])), "utf8"));
1645
1682
  }
1683
+ else if (parsed.values["body-file"]) {
1684
+ body = readFileSync(path.resolve(process.cwd(), String(parsed.values["body-file"])), "utf8");
1685
+ }
1646
1686
  else if (parsed.values.data) {
1647
1687
  body = JSON.parse(String(parsed.values.data));
1648
1688
  }
@@ -1652,16 +1692,139 @@ async function runApiCommand(argv) {
1652
1692
  path: routePath,
1653
1693
  auth: ctx.auth,
1654
1694
  query: collectKeyValues(parsed.values.query),
1655
- body
1695
+ body,
1696
+ contentType: parsed.values["body-file"] ? parsed.values["content-type"] : undefined
1656
1697
  });
1657
1698
  if (!ctx.json) {
1658
1699
  const color = result.ok ? GREEN : RED;
1659
1700
  console.log(`${color}${method.toUpperCase()} ${routePath} → ${result.status}${RESET}`);
1660
1701
  }
1661
- printJson(result.json ?? result.text);
1702
+ if (parsed.values.raw)
1703
+ process.stdout.write(result.text);
1704
+ else
1705
+ printJson(result.json ?? result.text);
1662
1706
  if (!result.ok)
1663
1707
  process.exitCode = 1;
1664
1708
  }
1709
+ function parseJsonInput(raw, label) {
1710
+ if (raw === undefined) {
1711
+ return undefined;
1712
+ }
1713
+ try {
1714
+ return JSON.parse(raw);
1715
+ }
1716
+ catch {
1717
+ throw new Error(`${label} must be valid JSON.`);
1718
+ }
1719
+ }
1720
+ async function waitForJobCompletion(ctx, jobId, pollMs) {
1721
+ const intervalMs = Number.isFinite(pollMs) && pollMs > 0 ? pollMs : 2000;
1722
+ while (true) {
1723
+ const result = await dispatch(ctx, {
1724
+ method: "GET",
1725
+ path: `/api/v1/user/me/jobs/${encodeURIComponent(jobId)}`
1726
+ }, ctx.target === "cloud" ? "cloud" : "local");
1727
+ if (!result.ok) {
1728
+ return result;
1729
+ }
1730
+ const status = String(result.json?.status ?? "").toLowerCase();
1731
+ if (!["queued", "running", "waiting_for_provider", "waiting_for_render", "waiting_for_child", "waiting_for_human"].includes(status)) {
1732
+ return result;
1733
+ }
1734
+ await sleep(intervalMs);
1735
+ }
1736
+ }
1737
+ async function runTemplateCommand(argv) {
1738
+ const parsed = parseArgs({
1739
+ args: argv,
1740
+ allowPositionals: true,
1741
+ options: {
1742
+ ...commonOptions(),
1743
+ tracer: { type: "string" },
1744
+ wait: { type: "boolean", default: false },
1745
+ "poll-ms": { type: "string" },
1746
+ body: { type: "string" },
1747
+ "body-file": { type: "string" },
1748
+ payload: { type: "string" },
1749
+ "payload-file": { type: "string" },
1750
+ "webhook-url": { type: "string" }
1751
+ }
1752
+ });
1753
+ const subcommand = parsed.positionals[0];
1754
+ if (subcommand === "help" || subcommand === "--help" || subcommand === "-h" || !subcommand) {
1755
+ console.log(`vidfarm template — run template operations via REST
1756
+ template run <template_id> <operation_name> [--payload-file payload.json | --payload '{...}']
1757
+ [--tracer <id>] [--webhook-url <url>] [--wait] [--poll-ms 2000]
1758
+ --body-file / --body can send the exact REST envelope instead of auto-wrapping
1759
+ `);
1760
+ return;
1761
+ }
1762
+ if (subcommand !== "run") {
1763
+ throw new Error(`Unknown template subcommand: ${subcommand}`);
1764
+ }
1765
+ const templateId = parsed.positionals[1];
1766
+ const operationName = parsed.positionals[2];
1767
+ if (!templateId || !operationName) {
1768
+ throw new Error("template run requires <template_id> <operation_name>.");
1769
+ }
1770
+ const ctx = commonContext(parsed.values);
1771
+ if (ctx.target === "both") {
1772
+ throw new Error("template run only supports one backend at a time. Use --cloud or omit it for the local backend.");
1773
+ }
1774
+ const rawBody = parsed.values["body-file"]
1775
+ ? readFileSync(path.resolve(process.cwd(), String(parsed.values["body-file"])), "utf8")
1776
+ : typeof parsed.values.body === "string"
1777
+ ? parsed.values.body
1778
+ : null;
1779
+ const payloadSource = parsed.values["payload-file"]
1780
+ ? readFileSync(path.resolve(process.cwd(), String(parsed.values["payload-file"])), "utf8")
1781
+ : typeof parsed.values.payload === "string"
1782
+ ? parsed.values.payload
1783
+ : null;
1784
+ let body;
1785
+ if (rawBody !== null) {
1786
+ body = parseJsonInput(rawBody, "template body");
1787
+ }
1788
+ else {
1789
+ const payload = payloadSource === null ? {} : parseJsonInput(payloadSource, "template payload");
1790
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
1791
+ throw new Error("template payload must be a JSON object.");
1792
+ }
1793
+ body = {
1794
+ tracer: typeof parsed.values.tracer === "string" && parsed.values.tracer.trim()
1795
+ ? parsed.values.tracer.trim()
1796
+ : `template_run_${templateId}_${operationName}_${Date.now()}`,
1797
+ payload,
1798
+ ...(typeof parsed.values["webhook-url"] === "string" && parsed.values["webhook-url"].trim()
1799
+ ? { webhook_url: parsed.values["webhook-url"].trim() }
1800
+ : {})
1801
+ };
1802
+ }
1803
+ const result = await dispatch(ctx, {
1804
+ method: "POST",
1805
+ path: `/api/v1/templates/${encodeURIComponent(templateId)}/operations/${encodeURIComponent(operationName)}`,
1806
+ body
1807
+ }, ctx.target);
1808
+ if (!result.ok && result.status !== 202) {
1809
+ assertApiOk(result, `template run ${templateId}:${operationName}`);
1810
+ }
1811
+ if (!parsed.values.wait) {
1812
+ emitResult(result, ctx.json);
1813
+ return;
1814
+ }
1815
+ const jobId = result.json?.job_id;
1816
+ if (typeof jobId !== "string" || !jobId.trim()) {
1817
+ emitResult(result, ctx.json);
1818
+ return;
1819
+ }
1820
+ const pollMs = Number(parsed.values["poll-ms"] ?? 2000);
1821
+ const final = await waitForJobCompletion(ctx, jobId, pollMs);
1822
+ emitResult(final, ctx.json);
1823
+ const finalStatus = String(final.json?.status ?? "").toLowerCase();
1824
+ if (!final.ok || !["succeeded", "completed"].includes(finalStatus)) {
1825
+ process.exitCode = 1;
1826
+ }
1827
+ }
1665
1828
  // ── Discover & inspiration ──────────────────────────────────────────────────
1666
1829
  // `discover` lists TEMPLATES; `--query` keyword-searches the catalog so an agent
1667
1830
  // CLI (Claude Code / Codex) can answer "which templates suit my <offer>?". Each
@@ -2168,18 +2331,40 @@ async function runRenderCommand(argv) {
2168
2331
  const parsed = parseArgs({
2169
2332
  args: argv,
2170
2333
  allowPositionals: true,
2171
- options: { ...commonOptions(), title: { type: "string" }, tracer: { type: "string" }, wait: { type: "boolean", default: false } }
2334
+ options: {
2335
+ ...commonOptions(),
2336
+ dir: { type: "string" },
2337
+ title: { type: "string" },
2338
+ tracer: { type: "string" },
2339
+ target: { type: "string", default: "local" },
2340
+ wait: { type: "boolean", default: false }
2341
+ }
2172
2342
  });
2173
2343
  const forkId = parsed.positionals[0];
2174
2344
  if (!forkId)
2175
2345
  throw new Error("render requires a fork id.");
2176
2346
  const ctx = commonContext(parsed.values);
2347
+ const renderTarget = String(parsed.values.target ?? "local");
2348
+ if (renderTarget !== "local" && renderTarget !== "cloud") {
2349
+ throw new Error(`render --target must be local or cloud, got: ${renderTarget}`);
2350
+ }
2351
+ const sourceDir = parsed.values.dir ? resolveCompositionSourceDir(String(parsed.values.dir)) : null;
2352
+ if (sourceDir) {
2353
+ await pushCompositionFromDir({
2354
+ forkId,
2355
+ rootDir: sourceDir,
2356
+ host: ctx.host,
2357
+ auth: ctx.auth,
2358
+ json: ctx.json,
2359
+ action: "render"
2360
+ });
2361
+ }
2177
2362
  const result = await apiRequest({
2178
2363
  method: "POST",
2179
2364
  host: ctx.host,
2180
2365
  path: `/api/v1/compositions/${encodeURIComponent(forkId)}/render`,
2181
2366
  auth: ctx.auth,
2182
- body: { title: parsed.values.title, tracer: parsed.values.tracer }
2367
+ body: { title: parsed.values.title, tracer: parsed.values.tracer, render_target: renderTarget }
2183
2368
  });
2184
2369
  assertApiOk(result, "render");
2185
2370
  const renderId = result.json?.renderId;
@@ -2187,7 +2372,7 @@ async function runRenderCommand(argv) {
2187
2372
  if (!ctx.json && renderId) {
2188
2373
  console.log(`${DIM}Poll with: vidfarm render-status ${forkId} ${renderId} (or add --wait next time).${RESET}`);
2189
2374
  }
2190
- emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.outputUrl]]);
2375
+ emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.expectedOutputPublicUrl ?? result.json?.outputUrl]]);
2191
2376
  return;
2192
2377
  }
2193
2378
  // --wait: poll the render job until it settles.
@@ -2206,10 +2391,52 @@ async function runRenderCommand(argv) {
2206
2391
  if (status === "SUCCEEDED" || status === "FAILED")
2207
2392
  break;
2208
2393
  }
2209
- emitResult(last, ctx.json, [["Rendered MP4 ", last.json?.outputUrl]]);
2394
+ emitResult(last, ctx.json, [["Rendered MP4 ", last.json?.expectedOutputPublicUrl ?? last.json?.outputUrl]]);
2210
2395
  if (String(last.json?.status) === "FAILED")
2211
2396
  process.exitCode = 1;
2212
2397
  }
2398
+ function resolveCompositionSourceDir(inputPath) {
2399
+ const absolute = path.resolve(process.cwd(), inputPath);
2400
+ if (!existsSync(absolute)) {
2401
+ throw new Error(`render --dir path does not exist: ${absolute}`);
2402
+ }
2403
+ const stat = statSync(absolute);
2404
+ if (stat.isDirectory())
2405
+ return absolute;
2406
+ if (stat.isFile() && path.basename(absolute) === "composition.html")
2407
+ return path.dirname(absolute);
2408
+ throw new Error("render --dir must point to a directory or composition.html.");
2409
+ }
2410
+ async function pushCompositionFromDir(input) {
2411
+ const htmlPath = path.join(input.rootDir, "composition.html");
2412
+ if (!existsSync(htmlPath)) {
2413
+ throw new Error(`No composition.html at ${htmlPath}.`);
2414
+ }
2415
+ const html = readFileSync(htmlPath, "utf8");
2416
+ if (!html.includes("data-composition-id=")) {
2417
+ throw new Error("composition.html is missing data-composition-id — refusing to render a malformed composition.");
2418
+ }
2419
+ const jsonPath = path.join(input.rootDir, "composition.json");
2420
+ const compositionJson = existsSync(jsonPath) ? readFileSync(jsonPath, "utf8") : null;
2421
+ if (compositionJson !== null) {
2422
+ try {
2423
+ JSON.parse(compositionJson || "{}");
2424
+ }
2425
+ catch (error) {
2426
+ throw new Error(`composition.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
2427
+ }
2428
+ }
2429
+ if (!input.json)
2430
+ console.log(`[vidfarm] ${input.action}: pushing ${input.rootDir} → ${input.forkId}`);
2431
+ await putComposition(`${input.host}/api/v1/compositions/${encodeURIComponent(input.forkId)}/composition.html`, "PUT", html, "text/html; charset=utf-8", input.auth);
2432
+ if (!input.json)
2433
+ console.log(`[vidfarm] pushed composition.html (${Buffer.byteLength(html)} bytes)`);
2434
+ if (compositionJson !== null) {
2435
+ await putComposition(`${input.host}/api/v1/compositions/${encodeURIComponent(input.forkId)}/composition.json`, "PATCH", compositionJson, "application/json", input.auth);
2436
+ if (!input.json)
2437
+ console.log(`[vidfarm] pushed composition.json (${Buffer.byteLength(compositionJson)} bytes)`);
2438
+ }
2439
+ }
2213
2440
  async function runRenderStatusCommand(argv) {
2214
2441
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
2215
2442
  const forkId = parsed.positionals[0];
@@ -2219,7 +2446,7 @@ async function runRenderStatusCommand(argv) {
2219
2446
  const ctx = commonContext(parsed.values);
2220
2447
  const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/renders/${encodeURIComponent(renderId)}`, auth: ctx.auth });
2221
2448
  assertApiOk(result, "render-status");
2222
- emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.outputUrl]]);
2449
+ emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.expectedOutputPublicUrl ?? result.json?.outputUrl]]);
2223
2450
  }
2224
2451
  // ── AI generation + timeline placement (for local coding agents) ────────────
2225
2452
  // `generate` submits an AI image/video primitive job and (by default) polls it
@@ -66,16 +66,42 @@ async function readBytes(ctx, space, item) {
66
66
  const { withLocalBackend } = await import("./local-backend.js");
67
67
  const backend = await withLocalBackend({ home: ctx.home, apiKey: ctx.apiKey });
68
68
  const u = new URL(item.viewUrl, "http://vidfarm.local");
69
+ if (!isLocalFileViewPath(u.pathname)) {
70
+ throw new Error(`Refusing local sync read outside Vidfarm file storage: ${item.path}`);
71
+ }
69
72
  const res = await backend.app.request(u.pathname + u.search, { headers: authHeaders(ctx.apiKey) });
70
73
  if (!res.ok)
71
74
  throw new Error(`local read ${item.path} → HTTP ${res.status}`);
72
75
  return Buffer.from(await res.arrayBuffer());
73
76
  }
74
- const res = await fetch(item.viewUrl, { headers: authHeaders(ctx.apiKey) });
77
+ const url = validateCloudFileViewUrl(ctx, item);
78
+ const res = await fetch(url, { headers: authHeaders(ctx.apiKey) });
75
79
  if (!res.ok)
76
80
  throw new Error(`cloud read ${item.path} → HTTP ${res.status}`);
77
81
  return Buffer.from(await res.arrayBuffer());
78
82
  }
83
+ function isLocalFileViewPath(pathname) {
84
+ return pathname === "/api/v1/user/me/files/view" || pathname === "/template-media" || pathname.startsWith("/storage/");
85
+ }
86
+ function validateCloudFileViewUrl(ctx, item) {
87
+ let url;
88
+ try {
89
+ url = new URL(item.viewUrl || "", ctx.host);
90
+ }
91
+ catch {
92
+ throw new Error(`Invalid cloud view URL for ${item.path}`);
93
+ }
94
+ const host = new URL(ctx.host);
95
+ const isSameOrigin = url.origin === host.origin;
96
+ const isS3 = /^https:\/\/[^/]+\.s3[.-][a-z0-9-]+\.amazonaws\.com$/i.test(url.origin)
97
+ || /^https:\/\/s3[.-][a-z0-9-]+\.amazonaws\.com$/i.test(url.origin)
98
+ || /\.s3[.-][a-z0-9-]+\.amazonaws\.com$/i.test(url.hostname);
99
+ if (isSameOrigin && isLocalFileViewPath(url.pathname))
100
+ return url;
101
+ if (isS3)
102
+ return url;
103
+ throw new Error(`Refusing cloud sync read from non-Vidfarm URL for ${item.path}`);
104
+ }
79
105
  /** Upload bytes into a space's My Files at folderPath/fileName (multipart — the
80
106
  * one transport that works on both S3 and local storage). */
81
107
  async function uploadBytes(ctx, space, input) {
@@ -213,9 +239,10 @@ export async function runSyncCommand(argv) {
213
239
  home: values.home
214
240
  };
215
241
  // Scope: a /files subtree (default the whole root).
216
- let rootPath = (positionals[0] ?? "/files").trim();
217
- if (!rootPath.startsWith("/files")) {
218
- throw new Error(`sync currently covers the /files (My Files) root — got "${rootPath}". Raws sync is a follow-up.`);
242
+ const rawRootPath = positionals[0] ?? "/files";
243
+ const rootPath = normalizeSyncRoot(rawRootPath);
244
+ if (!rootPath) {
245
+ throw new Error(`sync currently covers the /files (My Files) root — got "${rawRootPath}". Raws sync is a follow-up.`);
219
246
  }
220
247
  const srcSpace = direction === "push" ? "local" : "cloud";
221
248
  const dstSpace = direction === "push" ? "cloud" : "local";
@@ -235,7 +262,7 @@ export async function runSyncCommand(argv) {
235
262
  console.log(` ${GREEN}+ new${RESET} ${src.path}`);
236
263
  }
237
264
  else {
238
- await transfer(ctx, srcSpace, dstSpace, src, src.name);
265
+ await transfer(ctx, srcSpace, dstSpace, src, fileNameFromSyncPath(src.path));
239
266
  console.log(` ${GREEN}✓ sent${RESET} ${src.path}`);
240
267
  }
241
268
  transferred += 1;
@@ -260,14 +287,15 @@ export async function runSyncCommand(argv) {
260
287
  continue;
261
288
  }
262
289
  if (resolution === "keep-both") {
263
- await transfer(ctx, srcSpace, dstSpace, src, suffixName(src.name, srcSpace));
264
- console.log(` ${YELLOW}✓ both${RESET} ${src.path} ${DIM}→ ${suffixName(src.name, srcSpace)}${RESET}`);
290
+ const keepBothName = suffixName(fileNameFromSyncPath(src.path), srcSpace);
291
+ await transfer(ctx, srcSpace, dstSpace, src, keepBothName);
292
+ console.log(` ${YELLOW}✓ both${RESET} ${src.path} ${DIM}→ ${keepBothName}${RESET}`);
265
293
  }
266
294
  else {
267
295
  // Overwrite: drop the target copy first so we replace rather than dup.
268
296
  if (dst.id)
269
297
  await deleteAttachment(ctx, dstSpace, dst.id);
270
- await transfer(ctx, srcSpace, dstSpace, src, src.name);
298
+ await transfer(ctx, srcSpace, dstSpace, src, fileNameFromSyncPath(src.path));
271
299
  console.log(` ${GREEN}✓ over${RESET} ${src.path}`);
272
300
  }
273
301
  transferred += 1;
@@ -277,15 +305,44 @@ export async function runSyncCommand(argv) {
277
305
  console.log(` ${DIM}(dry run — nothing was written)${RESET}`);
278
306
  }
279
307
  async function transfer(ctx, from, to, item, fileName) {
308
+ if (!item.path.startsWith("/files/")) {
309
+ throw new Error(`Refusing to sync item outside /files: ${item.path}`);
310
+ }
311
+ const folderPath = folderPathFromSyncPath(item.path);
280
312
  const bytes = await readBytes(ctx, from, item);
281
313
  await uploadBytes(ctx, to, {
282
314
  fileName,
283
- folderPath: item.folderPath.replace(/^files\/?/, "").replace(/^\/+|\/+$/g, ""),
315
+ folderPath,
284
316
  contentType: item.contentType,
285
317
  notes: item.notes,
286
318
  bytes
287
319
  });
288
320
  }
321
+ function normalizeSyncRoot(value) {
322
+ const raw = value.trim();
323
+ const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
324
+ const normalized = path.posix.normalize(withSlash).replace(/\/+$/, "") || "/files";
325
+ if (normalized === "/files" || normalized.startsWith("/files/"))
326
+ return normalized;
327
+ return null;
328
+ }
329
+ function fileNameFromSyncPath(value) {
330
+ const name = path.posix.basename(value);
331
+ if (!name || name === "." || name === ".." || name.includes("/")) {
332
+ throw new Error(`Invalid sync file path: ${value}`);
333
+ }
334
+ return name;
335
+ }
336
+ function folderPathFromSyncPath(value) {
337
+ if (!value.startsWith("/files/"))
338
+ throw new Error(`Invalid sync file path: ${value}`);
339
+ const dir = path.posix.dirname(value);
340
+ if (dir === "/files")
341
+ return "";
342
+ if (!dir.startsWith("/files/"))
343
+ throw new Error(`Invalid sync file path: ${value}`);
344
+ return dir.slice("/files/".length);
345
+ }
289
346
  function normalizePolicy(v) {
290
347
  const s = String(v ?? "").trim().toLowerCase();
291
348
  if (["newest", "skip", "keep-both", "local", "cloud"].includes(s))
@@ -87,7 +87,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
87
87
  "READ THE EXACT MARKUP WHEN YOU NEED IT: editor_context is a derived summary, not the raw HTML. When you need the precise current composition markup — to author or fix a replace_composition_html, to inspect exact inline styles, class names, or data-* attributes on a layer, or to copy an existing structure — fetch it with http_request GET /api/v1/compositions/{composition_id}/composition.html (composition_id is in editor_context). Prefer the targeted editor_action verbs for ordinary edits; pull the HTML only when you genuinely need byte-level detail.",
88
88
  "FORKING IS AUTOMATIC — you never manually 'create a fork' before editing. The editor_context reports the project state so you know what you're holding: composition_origin ('inspiration' = a decomposed catalog template opened as a structural reference — the common case; 'raw' = the user's own new project), has_saved_fork (whether edits are currently persisting to a real editable copy), and is_new_project. When has_saved_fork is false on a REAL template (a template_… id, a catalog reference, or any decomposed composition), just start editing — the FIRST mutating editor_action mints the user their OWN private editable fork and every edit after it persists normally; do NOT ask permission to fork, wait for the user to fork, or announce 'forking'. The ONE exception is is_new_project:true (composition_id \"original\"): that is a BLANK project with no template behind it yet, so there is nothing to fork and editor_action edits will NOT persist. In that state don't promise saves — mint a real composition first: on a blank project the create_video tool IS available (call it to build a brand-new video from the user's idea via mode=generate, or to recreate a pasted TikTok/YouTube/Instagram/X URL via mode=replicate — it runs the ingest→decompose→fork pipeline and drops an 'Open in editor' link), or point the user at a fitting existing template (GET /discover/feed?q=<their idea>). Once a real composition is open, edit it normally. A save_status:\"error\" / is_read_only immediately after editing a brand-new project means the auto-fork itself failed — say so plainly instead of claiming the edit saved. The ONLY time you fork ON PURPOSE is when the user EXPLICITLY asks to branch / duplicate / 'save as' / 'make a copy' / 'try a variation' / 'keep this version and start another' — then call editor_action action_type=fork_composition with fork_from='current' (branch a new copy that keeps all current edits) or fork_from='default' (start a fresh copy from the original template, discarding the current edits). This mints a NEW fork and opens it in the editor; it is the same 'New fork' action available in the editor's ⋯ menu. Never call fork_composition just to begin editing — that is what the automatic first-edit fork is for.",
89
89
  "AGENTIC VIDEO EDITING — THE THREE AXES (your core mental model for this editor). Almost every /editor session is the user taking a template, fork, or project and RE-WORKING it, and a re-work only ever touches three independent axes: SCENES (the video/image clips that carry the visuals), AUDIO (narration/voiceover, music, SFX), and TEXT (on-screen captions, titles, overlays). On each axis the intent sits somewhere on a SWAP↔REPLACE spectrum, and the three axes in one request can sit at DIFFERENT points — read the request and place each axis before you act. SWAP (light, cheap, the common case) keeps the existing structure and changes content IN PLACE: rewrite a caption/text layer (set_layer_text / set_captions), swap one clip's media for another of the same kind while keeping its timing and geometry (set_layer_media), or re-voice the narration (regenerate-speech, then mute the original span and add_layer the new audio). Many sessions are ONLY a text swap — that is a two-minute job, so just do it and confirm. REPLACE (heavy) throws out that axis's content and rebuilds it: restructure the scenes outright (new clip count / new beats via remove_layer + add_layer / generate_layer, or one replace_composition_html), lay down a brand-new audio bed, or rewrite every caption. The HARDEST end is a full re-theme where the ONLY thing preserved is the VIRAL DNA — the hook shape, pacing, scene-count rhythm, and transition/caption style — while every scene, every word, and the audio are all replaced for the user's new subject. Name your plan back to the user in these terms (e.g. 'I'll SWAP the captions and REPLACE the scenes'), then execute axis by axis. Be proactive at the heavy end: propose the whole re-work and drive it scene by scene instead of waiting to be micro-managed one layer at a time. This is first-class agentic editing — you are expected to carry a total transformation, not just nudge single layers.",
90
- "EDITOR HARNESS — YOUR STYLE-EXECUTION BRIEF (read it before any nontrivial edit). editor_context.editor_harness is the decompose pass's TECHNICAL EDITING DIRECTION: how to edit so the result recreates the source's STYLE. It is the 'how to edit like this' companion to composition_context's viral DNA ('why it works'). When present it carries: one_liner (the style in a sentence); pacing (cut_rhythm fast|medium|slow|varied, avg_scene_seconds, cuts_per_10s, energy_curve); typography (caption_style, placement, font_character, emphasis, text_density); broll (reliance, sourcing, shot_kinds, cadence); transitions (default, intro, outro, usage); audio (voiceover, music, sfx, captions_from); scenes[] (each a beat's role + importance + must_keep + edit_bias); important_scenes; editing_bias; do; dont. USE IT to pick concrete moves: map typography.caption_style straight to a set_captions caption_style preset (karaoke/word-pop/spotlight/stack-up/neon/bounce); map transitions.default/intro/outro to a set_transitions call; honor pacing (keep cut rhythm / avg_scene_seconds when adding or splitting scenes); follow broll.reliance + sourcing to decide whether to HUNT raws (/clips/scan) vs generate_layer and at what cadence; lay audio per audio.*; and above all PROTECT the beats in important_scenes and any scene with must_keep:true or importance 'critical' (swapping their subject is fine, but preserve their timing, role, and caption cadence — that is where the style lives). Treat editing_bias/do/dont as hard constraints for this composition. The harness sets defaults, not a straitjacket — an explicit user instruction always wins. The local devcli agent reads the SAME brief from editor-harness.json (under `.harness`); the web editor gets it inline here. If editor_harness is absent, the fork wasn't decomposed with the harness pass — fall back to composition_context + the video_context tool.",
90
+ "EDITOR HARNESS — YOUR STYLE-EXECUTION BRIEF (read it before any nontrivial edit). editor_context.editor_harness is the decompose pass's TECHNICAL EDITING DIRECTION: how to edit so the result recreates the source's STYLE. It is the 'how to edit like this' companion to composition_context's viral DNA ('why it works'). When present it carries: one_liner (the style in a sentence); pacing (cut_rhythm fast|medium|slow|varied, avg_scene_seconds, cuts_per_10s, energy_curve); typography (caption_style, placement, font_character, emphasis, text_density); broll (reliance, sourcing, shot_kinds, cadence); transitions (default, intro, outro, usage); audio (voiceover, music, sfx, captions_from); emotional (target_feeling, tone, comedic_timing, intonation, vibe_anchors — HOW to edit so the FEELING survives); scenes[] (each a beat's role + importance + must_keep + edit_bias); important_scenes; editing_bias; do; dont. USE IT to pick concrete moves: map typography.caption_style straight to a set_captions caption_style preset (karaoke/word-pop/spotlight/stack-up/neon/bounce); map transitions.default/intro/outro to a set_transitions call; honor pacing (keep cut rhythm / avg_scene_seconds when adding or splitting scenes); follow broll.reliance + sourcing to decide whether to HUNT raws (/clips/scan) vs generate_layer and at what cadence; lay audio per audio.*; treat emotional.* as a HARD constraint — the vibe, the joke, and the intonation are the first things a remix flattens, so preserve emotional.comedic_timing (the held beat / pause / hard cut that sells the joke), keep the delivery cadence in emotional.intonation when you rewrite or re-voice narration, and honor every one of emotional.vibe_anchors when you re-cut or re-time; and above all PROTECT the beats in important_scenes and any scene with must_keep:true or importance 'critical' (swapping their subject is fine, but preserve their timing, role, and caption cadence — that is where the style lives). Treat editing_bias/do/dont as hard constraints for this composition. The harness sets defaults, not a straitjacket — an explicit user instruction always wins. The local devcli agent reads the SAME brief from editor-harness.json (under `.harness`); the web editor gets it inline here. If editor_harness is absent, the fork wasn't decomposed with the harness pass — fall back to composition_context + the video_context tool.",
91
91
  "RE-THEME / REUSE A TEMPLATE AS A REFERENCE — the single most common editor intent. Users open a template to keep its VIRAL DNA (structure, scene count, pacing, hook shape, transitions, caption style) while changing the SUBJECT to their own topic — e.g. 'make this book-recap template about \"The Richest Man in Babylon\"', 'do this one for my coffee brand'. Treat it as a first-class flow: PRESERVE the DNA and format (read composition_context and, when you need the scene beats/arc, the video_context tool) and REPLACE the content — rewrite every on-screen text/caption layer to the new subject IN THE TEMPLATE'S VOICE (set_layer_text / set_captions, per the format rule above), regenerate the narration in the SAME speaker's voice for the new script (/audio/regenerate-speech, then swap per the speech rule), and swap the visuals to match (generate_layer to replace scene clips, or reuse the user's own footage from My Files / raws via browse_files). Keep the clip timings, transitions, and caption animation intact so the proven format survives the swap. You don't ask permission to fork — the first edit auto-forks it into the user's own copy (see FORKING IS AUTOMATIC). Before rewriting, PROPOSE a short plan and ask ONLY what you can't infer: the specifics of the new subject (e.g. which of the book's ideas to feature — offer to pick the strongest N to match the scene count), whether to keep the current narrator voice, any brand assets/images they want used (else you'll generate them), and a target length if it differs from the template. Don't interrogate — one concise proposal plus a couple of questions, then execute scene by scene. Example opener for the book case: 'I'll keep this template's N-scene structure, pacing and caption style and re-theme it to The Richest Man in Babylon — rewriting the narration around the book's core money lessons and swapping the visuals to match. Any specific lessons you want featured (or I'll pick the strongest N)? Keep the current narrator voice? Have a cover image or should I generate the visuals?'",
92
92
  "FUEL A SCENE REPLACE WITH RAW CLIPS (don't default to expensive AI video). A heavy REPLACE of the SCENES axis needs footage, and you have three sources in cost order: (1) the user's own library — browse_files search across /raws and /files for clips that already fit the new scenes; (2) HUNTING new raws out of a long-form source — the cheap way to get REAL footage; (3) AI generation (generate_layer) — the expensive last resort, only for scenes no real clip can cover. When a scene replace needs footage the user could plausibly supply and their library doesn't already have it, PROACTIVELY offer to mine it: if they have or name a podcast, stream VOD, webinar, or any YouTube/TikTok/IG/X URL, call POST /clips/scan (alias /raws/scan) with body { tracer, source_url|temp_file_id|attachment_id, prompt: <what the new scenes need>, aspect: <the editor_context aspect_ratio>, target_duration_sec?, avoid_text?, ranges? } — an async hunt (202 + scan_id; bills AWS compute only, its BYOK AI tagging is free) that cuts the source into short, tagged, searchable raws. Poll GET /clips/scan/:scanId, then browse GET /raws/feed?source=<source_video_id> or POST /raws/search { query } and drop the picks onto the timeline (set_layer_media to swap a clip in place keeping timing+geometry, add_layer for net-new scenes). Hunting real clips is dramatically cheaper than AI-generating every scene and usually more authentic, so reach for it FIRST when a heavy scene REPLACE needs footage — reserve generate_layer for the gaps real footage cannot fill. If the user describes a big scene re-work but hasn't given you footage, ASK them for a source video to hunt (or point them at their /raws library) BEFORE reaching for AI generation.",
93
93
  "CRITICAL — verify your edits actually landed. An editor_action tool result only echoes the arguments you sent; it is NOT proof the change was applied or saved. The real outcome shows up in the NEXT <editor_context>. Before telling the user an edit succeeded, check that block: (1) if it contains save_status:\"error\" (or is_read_only:true / a save_error message), the composition is NOT being saved — do NOT claim any edit worked; tell the user exactly what save_error says (e.g. the video is read-only from this link and they should open their own copy to edit) and stop making mutating editor_action calls until it clears; (2) if it contains recent_action_results, each entry with ok:false is an edit from your previous turn that FAILED (read its error) — acknowledge the failure and retry or explain it rather than repeating a false success; (3) otherwise confirm the intended change is reflected in the layers (e.g. the new text on the target layer) before saying it's done. Never report a timeline edit as successful without this check.",
@@ -117,7 +117,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
117
117
  "KNOWLEDGE ON DEMAND (load_skill): deeper authoring knowledge ships as vidfarm skill packs you can read mid-conversation with the load_skill tool — YOUR OWN capability map for this editor (editor-capabilities: the editor_action verb catalog, editor_context fields, forking rules, the three-axes SWAP↔REPLACE re-theme model, and fueling a scenes replace with raws — load it before a full re-theme, a multi-scene rebuild, or when you need an exact verb/param), the composition HTML contract (hyperframes-core), motion/animation craft and transition doctrine (hyperframes-animation), seek-safe keyframe patterns (hyperframes-keyframes), creative direction/palettes/typography (hyperframes-creative), caption identities (embedded-captions), media/audio resolution (vidfarm-media), and end-to-end workflow playbooks (product-launch-video, faceless-explainer, website-to-video, general-video, motion-graphics, slideshow, talking-head-recut). Call load_skill with the pack name to read its SKILL.md; it lists reference files you can then load by relative path (e.g. file='references/step-4-vo.md'). Use it when a request needs craft beyond the built-in preset vocabulary — designing a whole composition via replace_composition_html, picking motion or transition language for a style, or following a named workflow. NOTE: hyperframes-animation and hyperframes-keyframes describe BOTH script-driven adapters (anime.js/GSAP/Lottie/Three.js) AND script-free CSS techniques — in the web editor apply only the CSS @keyframes / declarative-preset half (the JS adapters are stripped on save; see the web-editor motion rule), and reserve the adapter half for local devcli work. Load only what the task needs, never bulk-load packs, and never paste large skill content back to the user.",
118
118
  "replace_composition_html is validated before it reaches the editor: when the HTML violates the composition contract (missing data-composition-id, invalid clip timing, same-track overlaps, unknown preset names, media without src) the tool result comes back with rejected=true and lint_errors, and NOTHING is applied — fix the reported issues and call it again. lint_warnings are advisory and the action still applies.",
119
119
  "MOTION IN THE WEB EDITOR IS DECLARATIVE / CSS ONLY — no author <script>. This editor strips every <script> tag on save (stored-XSS defense), so replace_composition_html that contains a <script> is REJECTED (rejected=true), not applied. That means the JavaScript runtime animation adapters — anime.js, GSAP, Lottie, Three.js, TypeGPU, WAAPI-driven code — are NOT available here; they are a local-devcli-only capability. Author all motion for the web editor with the durable, script-free vocabulary: (1) built-in preset actions — set_layer_media ken_burns (still-image pan/zoom), set_transitions / transition + transition_out (scene entrances/exits), set_captions / caption_animation (word-by-word captions); and (2) plain CSS — @keyframes rules in a <style> block plus an inline animation on the layer element (the renderer scrubs and bakes CSS @keyframes deterministically into the final MP4, exactly like the preview). Never try to drive motion with anime.js/GSAP/etc. in the web editor, and never claim you added a scripted animation here. If the user explicitly wants a JS-adapter animation (a Lottie file, a GSAP timeline, a Three.js scene), tell them that is a local devcli / hyperframes-CLI workflow and offer the CSS/preset equivalent for the web editor instead.",
120
- "TEMPLATE FORMAT — MATCH IT WHEN WRITING TEXT: the COMPOSITION BRIEF (stable template context in the system prompt) carries composition_context — WHAT THIS TEMPLATE IS: its format/genre and narrative arc (trend_tagline, hook, retention, payoff, preserve, avoid). ALWAYS read it before writing or adjusting ANY captions, text layers, or on-screen copy, and make your copy fit the template's format and voice — do NOT default to generic product/app ad copy. The format dictates the caption style: a text-message / DM / iMessage conversation template's captions must read like short back-and-forth chat messages between people (in-character, conversational), a 'story time' / talking-head template reads like first-person spoken narration, a listicle reads like punchy numbered items, a fake-news / headline template reads like a news chyron, etc. `preserve` usually names format cues that must stay (e.g. the messaging-bubble layout); `avoid` names what to keep out. When the template's format is a conversation, skit, or roleplay, keep the captions in that voice and only weave the product in the way that format allows (subtly, in-dialogue) rather than replacing the messages with feature bullets. If the brief's composition_context is thin or you are unsure what the video actually shows, call video_context (scene visual descriptions + transcript) to ground the format BEFORE writing captions.",
120
+ "TEMPLATE FORMAT — MATCH IT WHEN WRITING TEXT: the COMPOSITION BRIEF (stable template context in the system prompt) carries composition_context — WHAT THIS TEMPLATE IS: its format/genre and narrative arc (trend_tagline, hook, retention, payoff, preserve, avoid) plus emotional_punch (core_emotion, tone, arc, peak_moment, mechanism, humor, delivery, preserve) — the FEELING the format delivers and what makes it land. ALWAYS read it before writing or adjusting ANY captions, text layers, or on-screen copy, and make your copy fit the template's format and voice — do NOT default to generic product/app ad copy. emotional_punch is the part a remix most often flattens: if the format is a joke, keep emotional_punch.humor's mechanic (rebuild the SAME joke around the new subject, don't drop it for a feature bullet); match emotional_punch.tone and emotional_punch.delivery so intonation, comedic timing, and vibe survive; and never trade the peak_moment's payoff for a flat product plug. The format dictates the caption style: a text-message / DM / iMessage conversation template's captions must read like short back-and-forth chat messages between people (in-character, conversational), a 'story time' / talking-head template reads like first-person spoken narration, a listicle reads like punchy numbered items, a fake-news / headline template reads like a news chyron, etc. `preserve` usually names format cues that must stay (e.g. the messaging-bubble layout); `avoid` names what to keep out. When the template's format is a conversation, skit, or roleplay, keep the captions in that voice and only weave the product in the way that format allows (subtly, in-dialogue) rather than replacing the messages with feature bullets. If the brief's composition_context is thin or you are unsure what the video actually shows, call video_context (scene visual descriptions + transcript) to ground the format BEFORE writing captions.",
121
121
  "FILE DIRECTORY: the user's files live in ONE navigable directory with three canonical roots, each with nested subfolders and a copyable path per file: (1) /files — durable 'My Files' assets they keep and reuse (videos mp4/mov/webm, images png/jpg/jpeg/gif/webp/svg, audio mp3/wav/m4a/aac, documents pdf/md/txt/csv), vector-searchable via metadata notes; (2) /temp — scratch space auto-foldered by date (YYYY-MM-DD) and auto-deleted after 30 days; (3) /raws — the reusable clip / source-footage library, foldered by source (e.g. /raws/<source-slug>/). A file's canonical path looks like /raws/product-demos/clip.mp4 or /files/acme-skincare/logo.png. Use the browse_files tool to navigate, search, read, write, and reorganize it: action=list with path (e.g. path='/', '/raws', '/files/brand-assets') enumerates that folder's subfolders + files as canonical items (path, name, kind, view_url); action=search with a plain-language query finds files by MEANING across roots — it combines semantic vector match, substring, and absolute-path match (scope with path='/raws' for footage, '/files' for durable assets, '/' for everything; force one signal with mode=semantic|substring|path). Prefer search over paging list when the library is large or the user references an asset vaguely. When you omit path entirely, list/search now default to '/' (the whole directory) so /raws and /temp are never hidden — but pass path='/raws' to stay in footage. For /raws you can add content_type to filter by EXACT shot kind (talking_head, b_roll, product_shot, screen_recording, demo, reaction, interview, establishing, lifestyle, text_graphic; comma-separate to OR them) — this is an exact tag filter, distinct from the semantic query. For a big folder, list returns next_offset; pass it back as offset to page deeper (with limit up to 500). action=read with a file_id (copied from a prior list/search) fetches one file; action=write with file_name + content (or source_url) SAVES a text/media file into /files; action=annotate with file_id + notes attaches vector-embedded metadata to a /files entry; action=move with file_id + a target path (e.g. path='/raws/demos') reorganizes a raw between /raws folders; action=rename with path + new_name (+ file_id for a file) renames a /files or /temp file or folder in place — use it to keep the directory tidy (e.g. fix a character folder or file name). When the user references their own footage, brand assets, logos, music, a brief, a script, or raw clips ('use my product photo', 'the video I uploaded', 'my brand logo', 'that clip of the founder'), browse_files search or list first to find it rather than asking them to re-upload or paste a URL. For text files (md/txt/csv/srt/vtt/json), read returns the full text_content inline so you can read a brief or script directly. For images/video/audio/pdf, read returns only a durable view_url — use that URL as media: drop it into the timeline via editor_action add_layer/set_layer_media, or pass it into primitive routes (images/edit source_image_url, videos/generate input_references, videos/download). Never invent file ids, paths, or view URLs — always list or search to discover the real ones first.",
122
122
  "SAVING CONTEXT TO MY FILES: browse_files action=write persists durable, reusable context the user (and future chats/agents) can read back. write saves text files (md/txt/csv/json/srt/vtt) via content, and IMPORTS media into the library via source_url (a durable URL from a finished primitive job or existing asset) — use source_url to persist a generated asset the user will want again (a character sprite card, a recurring background, a logo variant) instead of leaving it stranded in a job result. Use write to capture the Getting Started artifacts as Markdown: a product About.md (basic offer/product context) or a deeper Interview.md, plus awareness-levels.md, persuasive-angles.md, and ad-hooks.md distilled from the matching brainstorm outputs. When a brainstorm job returns strategy the user wants to keep, offer to save it (or save it) as the corresponding .md so it is not lost when the chat ends. Namescope every write under the right product/offer folder (see MY FILES IS MULTI-OFFER) — e.g. folder_path='acme-skincare', file_name='About.md'. Pass notes on write (or annotate afterwards) for any asset worth finding again: notes describe what the file is, who/what it depicts, and when to use it, and they are vector-embedded so action=search finds the file by meaning in future sessions. Before overwriting an existing file of the same name in the same folder, confirm with the user (writing the same name replaces it).",
123
123
  "RECURRING CHARACTERS ARE FIRST-CLASS: mascots, spokespeople, avatars, and product characters that must look the same across videos live in a dedicated, browsable home — the /files/characters/ folder, one subfolder per character keyed by a URL-safe SLUG (lowercase, hyphenated), e.g. /files/characters/zara/. Each character is a TRIO of files in that folder: (1) <character_id>.json — the machine-readable MANIFEST, named after the character's id which is 'character_'+slug (e.g. slug 'zara' → id 'character_zara' → file character_zara.json; the id ALREADY carries the 'character_' prefix, so never double it to character_character_zara.json): { id:'character_<slug>', slug, name, role, appearance (face/build/hair/skin), wardrobe, palette (signature colors), voice (tone/accent/energy), do, dont, sprite_card_path:'/files/characters/<slug>/character_sprite_card.png', about_path:'/files/characters/<slug>/character_about.md', created_at }; (2) character_sprite_card.png — ONE reference-sheet image (full body front/side/back plus a face close-up on a neutral background); (3) character_about.md — the prose the .json summarizes, for richer wording when prompting. AWARENESS: when a user refers to 'our mascot', 'the same character', 'the fox', or names a character, FIRST browse_files list path='/files/characters' (or search) to see who already exists and read that character's manifest + about — never re-imagine a saved character from memory; that is how characters drift off-model. CONSISTENCY: on every generation featuring the character, pass the sprite card's view_url as the reference input (prompt_attachments for images/generate + images/edit, input_references for videos/generate and generate_layer) and lift wording from the manifest/about into the prompt.",