@kheleshikhi/studio-agent 0.6.1 → 0.9.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/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
- import { realpathSync } from "node:fs";
4
+ import { realpathSync, watch as watchFile } from "node:fs";
5
5
  import { createRequire } from "node:module";
6
- import { access, appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
6
+ import { access, appendFile, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
7
7
  import os from "node:os";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
@@ -13,15 +13,24 @@ import { attemptDirectory, createAttemptPointer, finalizeClient, readAttemptPoin
13
13
  import { runDaemon } from "./daemon.js";
14
14
  import { audioUploadExtension, isLibraryKind, LIBRARY_STUDIO_WORKSPACE, libraryLockAfterSave, LibraryWorkspace, } from "./library.js";
15
15
  import { daemonRpc, setCliTrace } from "./protocol.js";
16
- import { cleanStaleDaemonMetadata, readConfig, readDaemonMetadata, stateDirectory, validateOrigin, writeConfig, } from "./state.js";
16
+ import { runRenderJob } from "./render-jobs.js";
17
+ import { compileDependencies, experienceProvenance } from "./shot-provenance.js";
18
+ import { digestOf } from "./shot-provenance.js";
19
+ import { episodeCues, episodeTimeline, parseEpisode } from "./episode.js";
20
+ import { planEpisode, renderEpisode } from "./episode-runner.js";
21
+ import { cacheDirectory, cleanStaleDaemonMetadata, readConfig, readDaemonMetadata, stateDirectory, validateOrigin, writeConfig, } from "./state.js";
17
22
  import { CliError } from "./types.js";
18
23
  import { sourceHash, StudioWorkspace } from "./workspace.js";
19
- import { parseScenario } from "./scenario.js";
20
- const VERSION = "0.6.1";
24
+ import { parseScenario, parseExperienceScenario } from "./scenario.js";
25
+ import { audioLevel, audioSegments, encode, encodeArguments, findEncoder, mix, parseRecipe, probe, RENDER_KIND, sha256, verifyProbe } from "./video.js";
26
+ import { changedPaths } from "./presence.js";
27
+ import { inspectNotice, PROJECT_FLAGS, projectCommand, projectId, SCOPED_SEARCH_FLAGS, scopeWarningNotice, scopeWarnings, searchNotice, searchParameters } from "./project.js";
28
+ import { capabilitiesView, compatibilityReport, contextSummary, experiencePreviewSummary, experienceSchemaNotice, mathPlaneRequirement, resolveCreateTarget, schemaTopicView } from "./context.js";
29
+ const VERSION = "0.9.0";
21
30
  const AGENT_PROTOCOL = 1;
22
- const INSTRUCTIONS = `# NerdBro Interactive Studio agent\n\nUse the provider-neutral \`nerdbro\` CLI. Before substantial authoring: read \`nerdbro context\`, fetch the live \`nerdbro guide\`, then search only relevant docs, exact contracts, Components, Behaviors and examples. Do not read the entire catalogue blindly.\n\nFor complex work, prove risky capabilities in a small spike first. Checkout once, edit only supported source, validate, compile, drive the learner scenario where supported, inspect visuals, save, certify, then stop for human approval. Never force a conflict, approve, publish, administer or deploy. Disconnect when work is over.\n\nIf this is a Git-backed workspace, inspect recent history first. Work in coherent batches; after each, run focused checks, inspect the diff, and commit the green checkpoint with intent and verification. Never force-push or rewrite another Agent's work.\n\nExperience is final learner content: it contains one or more Scenes, and each Scene owns local interaction and Bands. Read \`nerdbro schema experience\`, \`scene.stateMachines\`, and \`stateMachine.actions\` for current contracts. Library definitions (experience, audio, component, behavior) use \`create\` or \`checkout\`, edit \`source.json\`, then \`status\`, \`save\`, \`validate\` and \`certify\`. Audio must be AAC in MP4/M4A.\n\nCurrent live playbook: /docs/studio-language/agent-authoring-playbook\n`;
31
+ const INSTRUCTIONS = `# NerdBro Interactive Studio agent\n\nUse the provider-neutral \`nerdbro\` CLI. Before substantial authoring: read \`nerdbro context\`, fetch the live \`nerdbro guide\`, then search only relevant docs, exact contracts, Components, Behaviors and examples. Do not read the entire catalogue blindly.\n\nFor complex work, prove risky capabilities in a small spike first. Checkout once, edit only supported source, validate, compile, drive the learner scenario where supported, inspect visuals, save, certify, then stop for human approval. Never force a conflict, approve, publish, administer or deploy. Disconnect when work is over.\n\nIf this is a Git-backed workspace, inspect recent history first. Work in coherent batches; after each, run focused checks, inspect the diff, and commit the green checkpoint with intent and verification. Never force-push or rewrite another Agent's work.\n\nEverything visual is a Scene (\`studio create\` defaults to the sceneComposition target); a graph is a Scene math plane, never a separate target. Interaction is an Experience: final learner content containing one or more Scenes, with inputs, requests, readouts/exports, tools, rounds, presentation and assessment outcomes (game is scoring only). The targets and schemas \`nerdbro context\` lists as frozen (InteractiveScene, mathGraph, combined) are read for existing assets only. Read \`nerdbro schema experience\`, \`scene.stateMachines\`, and \`stateMachine.actions\` for current contracts. Library definitions (experience, audio, component, behavior) use \`create\` or \`checkout\`, edit \`source.json\`, then \`status\`, \`save\`, \`validate\` and \`certify\`. Audio must be AAC in MP4/M4A.\n\nCurrent live playbook: /docs/studio-language/agent-authoring-playbook\n`;
23
32
  // Presence-only options never consume the following positional argument.
24
- const BOOLEAN_FLAGS = new Set(["force", "full-page", "headless", "no-browser", "declarative", "assessment", "no-assessment", "scratch"]);
33
+ const BOOLEAN_FLAGS = new Set(["force", "full-page", "headless", "no-browser", "declarative", "assessment", "no-assessment", "scratch", "saved", "math-plane", "no-math-plane", "all-libraries", "full", "compact"]);
25
34
  export function parse(argv) {
26
35
  const args = [];
27
36
  const flags = new Map();
@@ -59,7 +68,7 @@ function flag(parsed, name, fallback) {
59
68
  const value = parsed.flags.get(name);
60
69
  return typeof value === "string" ? value : fallback;
61
70
  }
62
- const BRIEF_FLAGS = ["concept", "teaching-purpose", "learner-action", "expected-visual-behavior", "graph-purpose", "graph-x", "graph-y", "graph-sync", "language", "additional-instruction", "components", "behavior", "important-values", "assessment", "no-assessment"];
71
+ const BRIEF_FLAGS = ["concept", "teaching-purpose", "learner-action", "expected-visual-behavior", "graph-purpose", "graph-x", "graph-y", "graph-sync", "language", "additional-instruction", "components", "behavior", "important-values", "assessment", "no-assessment", "math-plane", "no-math-plane"];
63
72
  const BROWSER_FLAGS = ["view", "viewport", "timeout"];
64
73
  export function assertSupportedFlags(parsed) {
65
74
  const [command, action, detail] = parsed.args;
@@ -67,13 +76,19 @@ export function assertSupportedFlags(parsed) {
67
76
  if (command === "_daemon")
68
77
  allowed = ["origin", "headless", "no-browser", "version-tag"];
69
78
  else if (command === "attempt" && action === "start")
70
- allowed = ["label", "mode"];
79
+ allowed = ["label", "mode", "project"];
71
80
  else if (command === "attempt" && action === "finish")
72
81
  allowed = ["outcome"];
73
82
  else if (command === "agent" && action === "init")
74
83
  allowed = ["provider"];
75
- else if (["component", "behavior", "example"].includes(command) && action === "search")
84
+ else if (["component", "behavior"].includes(command) && action === "search")
85
+ allowed = SCOPED_SEARCH_FLAGS;
86
+ else if (command === "example" && action === "search")
76
87
  allowed = ["limit", "offset"];
88
+ else if (command === "project")
89
+ allowed = PROJECT_FLAGS[action || "list"] || [];
90
+ else if (command === "world" && action === "inspect")
91
+ allowed = ["zone"];
77
92
  else if (isLibraryKind(command)) {
78
93
  if (action === "create")
79
94
  allowed = ["workspace", "name", "category", "visual", "palette", "implementation", "media", "language", "description", "kind", "master-audio", "declarative", "duration-ms", "scene-asset", "scene-package", "scene", "document"];
@@ -83,14 +98,24 @@ export function assertSupportedFlags(parsed) {
83
98
  allowed = ["workspace", "revision", "out"];
84
99
  else if (action === "inspect-browser")
85
100
  allowed = ["workspace", "viewport", "timeout"];
101
+ else if (command === "experience" && action === "drive")
102
+ allowed = ["workspace", "scenario", "viewport", "timeout", "out", "saved"];
103
+ else if (command === "experience" && action === "watch")
104
+ allowed = ["workspace", "once", "interval"];
105
+ else if (command === "experience" && action === "render-video")
106
+ allowed = ["workspace", "recipe", "out", "timeout", "keep-frames", "saved"];
86
107
  else if (["status", "save", "validate", "certify", "preview"].includes(action))
87
108
  allowed = ["workspace"];
88
109
  else if (action === "search")
89
110
  allowed = ["limit"];
90
111
  }
112
+ else if (command === "episode") {
113
+ if (["plan", "render", "status"].includes(action))
114
+ allowed = ["workspace", "out", "cache", "workers", "timeout"];
115
+ }
91
116
  else if (command === "studio") {
92
117
  if (action === "create")
93
- allowed = ["title", "target", ...BRIEF_FLAGS.filter((name) => name !== "no-assessment"), "command-id", "scratch"];
118
+ allowed = ["title", "target", ...BRIEF_FLAGS.filter((name) => name !== "no-assessment" && name !== "no-math-plane"), "command-id", "scratch", "starter"];
94
119
  else if (action === "brief" && detail === "update")
95
120
  allowed = BRIEF_FLAGS;
96
121
  else if (action === "checkout")
@@ -103,10 +128,14 @@ export function assertSupportedFlags(parsed) {
103
128
  allowed = ["workspace", "scenario", "viewport", "timeout", "out", "view"];
104
129
  else if (action === "capture-preview")
105
130
  allowed = ["workspace", "out", "viewport", "mode", "duration", "fps", "timeout"];
131
+ else if (action === "render-video")
132
+ allowed = ["workspace", "recipe", "out", "timeout", "keep-frames", "measure-playback"];
106
133
  else if (action === "certify")
107
134
  allowed = ["workspace", ...BROWSER_FLAGS];
108
135
  else if (action === "export")
109
136
  allowed = ["workspace", "revision", "out"];
137
+ else if (action === "watch")
138
+ allowed = ["workspace", "once", "interval"];
110
139
  else
111
140
  allowed = ["workspace"];
112
141
  }
@@ -128,33 +157,15 @@ function output(value, asJson) {
128
157
  else
129
158
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
130
159
  }
131
- function versionParts(value) {
132
- const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value);
133
- if (!match)
134
- throw new CliError(`Server returned an invalid CLI version: ${value}`, "AGENT_VERSION_INVALID", 5);
135
- return match.slice(1).map(Number);
136
- }
137
- function compareVersions(left, right) {
138
- const a = versionParts(left);
139
- const b = versionParts(right);
140
- for (let index = 0; index < 3; index += 1) {
141
- if (a[index] !== b[index])
142
- return a[index] - b[index];
143
- }
144
- return 0;
145
- }
160
+ /** Refuse an incompatible CLI and warn when the server recommends a newer one. */
146
161
  export function assertCompatibility(context) {
147
- if (Number(context.agent_protocol) !== AGENT_PROTOCOL) {
148
- throw new CliError(`Agent protocol ${context.agent_protocol} is incompatible with CLI protocol ${AGENT_PROTOCOL}. Upgrade the NerdBro Studio Agent.`, "AGENT_PROTOCOL_INCOMPATIBLE", 5, context);
149
- }
150
- const minimum = String(context.minimum_cli_version || "");
151
- if (compareVersions(VERSION, minimum) < 0) {
152
- throw new CliError(`CLI ${VERSION} is too old; NerdBro requires ${minimum} or newer.`, "AGENT_CLI_UPGRADE_REQUIRED", 5, context);
153
- }
154
- const recommended = String(context.recommended_cli_version || minimum);
155
- if (compareVersions(VERSION, recommended) < 0) {
156
- process.stderr.write(`nerdbro: CLI ${recommended} is recommended (current ${VERSION}).\n`);
157
- }
162
+ const report = compatibilityReport(context, VERSION, AGENT_PROTOCOL);
163
+ if (report.status === "protocol_incompatible")
164
+ throw new CliError(report.message, "AGENT_PROTOCOL_INCOMPATIBLE", 5, { ...context, compatibility: report });
165
+ if (report.status === "upgrade_required")
166
+ throw new CliError(report.message, "AGENT_CLI_UPGRADE_REQUIRED", 5, { ...context, compatibility: report });
167
+ if (report.status === "upgrade_recommended")
168
+ process.stderr.write(`nerdbro: ${report.message}\n`);
158
169
  }
159
170
  export function certificationRuntimeObservation(observation) {
160
171
  const allowed = [
@@ -162,7 +173,12 @@ export function certificationRuntimeObservation(observation) {
162
173
  "runtime_fingerprint", "runtime_part_id", "active_states",
163
174
  "state_machine_error", "math_graph", "environment", "error_summary",
164
175
  ];
165
- return Object.fromEntries(allowed.filter((key) => observation[key] !== undefined).map((key) => [key, observation[key]]));
176
+ const certified = Object.fromEntries(allowed.filter((key) => observation[key] !== undefined).map((key) => [key, observation[key]]));
177
+ // The server refuses a probed observation; say honestly whether this was one.
178
+ const probe = observation.probe;
179
+ if (probe && typeof probe === "object")
180
+ certified.probe = { active: probe.active === true };
181
+ return certified;
166
182
  }
167
183
  export function structuredErrorDetails(value) {
168
184
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -236,12 +252,19 @@ async function uploadApi(route, extension, contentType, bytes) {
236
252
  }
237
253
  }
238
254
  let compatibilityChecked = false;
255
+ // The last live context this process read: target, deprecation and capability
256
+ // authority for this command. Never persisted, never bundled.
257
+ let serverContext = null;
239
258
  async function ensureCompatibility() {
240
259
  const context = await rawApi("GET", "/v1/agent/context");
241
260
  assertCompatibility(context);
242
261
  compatibilityChecked = true;
262
+ serverContext = context;
243
263
  return context;
244
264
  }
265
+ async function liveContext() {
266
+ return serverContext ?? ensureCompatibility();
267
+ }
245
268
  async function api(method, route, body) {
246
269
  if (!compatibilityChecked && route !== "/v1/agent/context")
247
270
  await ensureCompatibility();
@@ -249,6 +272,7 @@ async function api(method, route, body) {
249
272
  if (route === "/v1/agent/context") {
250
273
  assertCompatibility(result);
251
274
  compatibilityChecked = true;
275
+ serverContext = result;
252
276
  }
253
277
  return result;
254
278
  }
@@ -407,6 +431,109 @@ async function assertBrowserSource(parsed, asset) {
407
431
  async function reportBrowserActivity(asset, operation, metadata) {
408
432
  await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/activity`, { operation, ...metadata });
409
433
  }
434
+ async function reportExperienceActivity(stableId, operation, metadata) {
435
+ await api("POST", `/v1/agent/authoring/experience/${encodeURIComponent(stableId)}/activity`, { operation, ...metadata });
436
+ }
437
+ /** Agent Presence is supplementary: a failure never fails the command. */
438
+ async function setPresence(patch) {
439
+ try {
440
+ await api("PUT", "/v1/agent/presence", patch);
441
+ }
442
+ catch { /* Presence never blocks authoring. */ }
443
+ }
444
+ const PRESENCE_STATUS = {
445
+ validate: "validating", compile: "compiling", preview: "previewing", save: "saving",
446
+ certify: "certifying", drive: "probing", inspect: "capturing", screenshot: "capturing",
447
+ observe: "capturing", "capture-preview": "capturing", "inspect-browser": "capturing",
448
+ "render-video": "capturing",
449
+ };
450
+ /** Which presence a command announces, if it is one the Maker can watch. */
451
+ export function presencePlan(args) {
452
+ const [command, action, id] = args;
453
+ const status = action ? PRESENCE_STATUS[action] : undefined;
454
+ if (!status || !id || !/^[A-Za-z0-9_.-]{1,80}$/.test(id))
455
+ return null;
456
+ if (command === "studio")
457
+ return { subject: `asset:${id}`, status, op: action };
458
+ if (command === "experience" && action !== "inspect")
459
+ return { subject: `experience:${id}`, status, op: action };
460
+ return null;
461
+ }
462
+ /** The bounded outcome a command reports to presence: verdicts, never documents. */
463
+ export function presenceResult(result) {
464
+ const value = result && typeof result === "object" ? result : {};
465
+ const inner = value.result && typeof value.result === "object" ? value.result : {};
466
+ const outcome = { ok: value.ok !== false };
467
+ for (const [key, item] of Object.entries({
468
+ valid: inner.valid, compiled: inner.compiled, certified: inner.certified,
469
+ draft_created: value.draft_created, evidence_mode: value.evidence_mode,
470
+ diagnostic_count: Array.isArray(inner.diagnostics) ? inner.diagnostics.length : undefined,
471
+ })) {
472
+ if (typeof item === "boolean" || typeof item === "number" || typeof item === "string")
473
+ outcome[key] = item;
474
+ }
475
+ return outcome;
476
+ }
477
+ /**
478
+ * Report where the Agent is working in its local, unsaved source: changed
479
+ * JSON Pointers against the saved head and a working hash. Never the source
480
+ * itself. `--once` reports one settled state; otherwise it follows edits
481
+ * with a debounce until interrupted.
482
+ */
483
+ async function watchSource(parsed, subject, command, sourcePath, readLocal, readRemote, report) {
484
+ const remote = await readRemote();
485
+ const interval = Math.max(250, Math.min(5_000, Number(flag(parsed, "interval", "750"))));
486
+ let lastActivityAt = 0;
487
+ let last;
488
+ const settle = async (forceActivity) => {
489
+ let local;
490
+ try {
491
+ local = await readLocal();
492
+ }
493
+ catch {
494
+ await setPresence({ subject, status: "editing" });
495
+ return undefined;
496
+ }
497
+ const hash = sourceHash(local);
498
+ const paths = changedPaths(remote, local);
499
+ if (last && last.hash === hash && !forceActivity)
500
+ return last;
501
+ last = { hash, paths };
502
+ await setPresence({ subject, status: "editing", working_source_hash: hash, changed_paths: paths,
503
+ ...(paths[0] ? { focused_path: paths[0] } : {}) });
504
+ // The activity feed keeps a bounded history; presence keeps only now.
505
+ if (forceActivity || Date.now() - lastActivityAt >= 10_000) {
506
+ lastActivityAt = Date.now();
507
+ await report({ status: "completed", working_source_hash: hash, changed_paths: paths, change_count: paths.length }).catch(() => undefined);
508
+ }
509
+ return last;
510
+ };
511
+ if (parsed.flags.has("once")) {
512
+ const settled = await settle(true);
513
+ return { ok: true, command, subject, working_source_hash: settled?.hash || null, changed_paths: settled?.paths || [], parse_error: !settled };
514
+ }
515
+ await settle(true);
516
+ let timer;
517
+ const watcher = watchFile(path.dirname(sourcePath), (_event, name) => {
518
+ if (name && path.basename(String(name)) !== path.basename(sourcePath))
519
+ return;
520
+ clearTimeout(timer);
521
+ timer = setTimeout(() => { void settle(false); }, interval);
522
+ });
523
+ // Presence expires on the server; a quiet editor still says it is here.
524
+ const heartbeat = setInterval(() => { void settle(true).catch(() => undefined); }, 30_000);
525
+ process.stderr.write(`Watching ${sourcePath}; press Ctrl+C to stop.\n`);
526
+ await new Promise((resolve) => {
527
+ const stop = () => resolve();
528
+ process.once("SIGINT", stop);
529
+ process.once("SIGTERM", stop);
530
+ });
531
+ watcher.close();
532
+ clearInterval(heartbeat);
533
+ clearTimeout(timer);
534
+ await setPresence({ subject, status: "idle" });
535
+ return { ok: true, command, subject, working_source_hash: last?.hash || null, changed_paths: last?.paths || [] };
536
+ }
410
537
  async function capture(parsed, asset, inspect, activityOperation = inspect ? "browser.inspect" : "browser.capture") {
411
538
  const { source, hash } = await assertBrowserSource(parsed, asset);
412
539
  const view = flag(parsed, "view", "canvas");
@@ -570,7 +697,8 @@ async function libraryCheckout(parsed, kind, stableId) {
570
697
  checked_out_at: now,
571
698
  };
572
699
  await target.write(document, lock);
573
- return { ok: true, command: `${kind}.checkout`, kind, stable_id: stableId, source: target.sourcePath, lock: target.lockPath, source_hash: hash, head_revision: remote.head_revision || null, approved_revision: remote.approved_revision || null, schema_version: lock.schema_version };
700
+ const notice = kind === "experience" ? experienceSchemaNotice(document, serverContext) : null;
701
+ return { ok: true, command: `${kind}.checkout`, kind, stable_id: stableId, source: target.sourcePath, lock: target.lockPath, source_hash: hash, head_revision: remote.head_revision || null, approved_revision: remote.approved_revision || null, schema_version: lock.schema_version, ...(notice ? { schema_notice: notice } : {}) };
574
702
  }
575
703
  async function libraryStatus(parsed, kind, stableId) {
576
704
  const target = libraryWorkspace(parsed, kind, stableId);
@@ -595,6 +723,7 @@ async function libraryStatus(parsed, kind, stableId) {
595
723
  approved_revision: remote.approved_revision || null,
596
724
  schema_version: remote.schema_version ?? null,
597
725
  schema_changed: (remote.schema_version ?? null) !== lock.schema_version,
726
+ ...(kind === "experience" && experienceSchemaNotice(source, serverContext) ? { schema_notice: experienceSchemaNotice(source, serverContext) } : {}),
598
727
  };
599
728
  }
600
729
  async function librarySave(parsed, kind, stableId) {
@@ -638,9 +767,10 @@ async function libraryPreview(parsed, kind, stableId) {
638
767
  await writeFile(target.previewPath, `${JSON.stringify(preview, null, 2)}\n`, "utf8");
639
768
  const count = (value) => Array.isArray(value) ? value.length : value !== null && typeof value === "object" ? Object.keys(value).length : null;
640
769
  const summary = kind === "experience"
641
- ? { mode: preview.mode ?? null, source_hash: preview.sourceHash ?? null, scenes: count(preview.scenes), audio: count(preview.audio) }
770
+ ? experiencePreviewSummary(preview)
642
771
  : { id: preview.id ?? null, implementation: preview.implementation ?? null, outputs: count(preview.outputs) };
643
- return { ok: true, command: `${kind}.preview`, kind, stable_id: stableId, local_source_hash: sourceHash(source), preview: target.previewPath, summary };
772
+ const notice = kind === "experience" ? experienceSchemaNotice(source, serverContext) : null;
773
+ return { ok: true, command: `${kind}.preview`, kind, stable_id: stableId, local_source_hash: sourceHash(source), preview: target.previewPath, summary, ...(notice ? { schema_notice: notice } : {}) };
644
774
  }
645
775
  async function libraryExport(parsed, kind, stableId) {
646
776
  const revision = required(flag(parsed, "revision"), "--revision is required.");
@@ -685,6 +815,276 @@ async function audioUpload(file) {
685
815
  next: `nerdbro audio create <stable-id> --name <text> --media ${media.sha256}`,
686
816
  };
687
817
  }
818
+ async function readRecipe(parsed) {
819
+ const recipeFile = path.resolve(required(flag(parsed, "recipe"), "--recipe <recipe.json> is required."));
820
+ if ((await stat(recipeFile)).size > 65536)
821
+ throw new CliError("Recipe exceeds 64 KiB.", "VIDEO_RECIPE_INVALID", 2);
822
+ try {
823
+ return JSON.parse(await readFile(recipeFile, "utf8"));
824
+ }
825
+ catch {
826
+ throw new CliError("Recipe is not valid JSON.", "VIDEO_RECIPE_INVALID", 2);
827
+ }
828
+ }
829
+ const PARTIAL_VIDEO = "video.partial.mp4";
830
+ const FAILURE_MANIFEST = "render-failed.json";
831
+ /**
832
+ * A failed render, in one place: remove what it wrote (frames unless
833
+ * --keep-frames, sound copies, the mix and any unverified MP4), write
834
+ * `render-failed.json`, and return an error that names the source, the stage
835
+ * that failed and that no output is reusable. A render.json and video.mp4
836
+ * from an earlier successful render into the same directory are left alone;
837
+ * this attempt never replaced them.
838
+ */
839
+ async function renderFailure(error, context) {
840
+ const failure = error instanceof CliError ? error : new CliError(String(error), "VIDEO_RENDER_FAILED");
841
+ const details = failure.details && typeof failure.details === "object" ? failure.details : {};
842
+ const stage = typeof details.stage === "string" ? details.stage : context.stage;
843
+ const { destination } = context;
844
+ if (!context.keepFrames)
845
+ await rm(path.join(destination, "frames"), { recursive: true, force: true }).catch(() => undefined);
846
+ for (const leftover of ["audio", "audio-mix.wav", PARTIAL_VIDEO]) {
847
+ await rm(path.join(destination, leftover), { recursive: true, force: true }).catch(() => undefined);
848
+ }
849
+ const report = {
850
+ kind: "kheleshikhi.video-render-failure.v1",
851
+ subject: context.subject,
852
+ ...context.identity,
853
+ stage,
854
+ code: failure.code,
855
+ message: failure.message,
856
+ output_reusable: false,
857
+ canonical: false,
858
+ frames_kept: context.keepFrames,
859
+ failed_at: new Date().toISOString(),
860
+ };
861
+ await writeFile(path.join(destination, FAILURE_MANIFEST), `${JSON.stringify(report, null, 2)}\n`).catch(() => undefined);
862
+ return new CliError(`Render failed at ${stage}: ${failure.message}`, failure.code, failure.exitCode, { ...details, stage, source: context.identity, output_reusable: false, failure_report: path.join(destination, FAILURE_MANIFEST) });
863
+ }
864
+ /**
865
+ * Capture a render's frames through a daemon job (see render-jobs.ts): short
866
+ * status polls instead of one request held open for minutes. Ctrl-C cancels
867
+ * the job; progress goes to stderr on a terminal.
868
+ */
869
+ async function renderFramesThroughDaemon(kind, legacyRoute, request, json) {
870
+ const interrupt = new AbortController();
871
+ const onInterrupt = () => interrupt.abort("interrupted");
872
+ process.once("SIGINT", onInterrupt);
873
+ let reported = 0;
874
+ const onProgress = (job) => {
875
+ if (json || !process.stderr.isTTY || Date.now() - reported < 5_000)
876
+ return;
877
+ reported = Date.now();
878
+ const { stage, frame, frames } = job.progress;
879
+ process.stderr.write(`nerdbro: rendering ${stage}${frames ? ` ${frame ?? 0}/${frames} frames` : ""} (${Math.round(job.elapsed_ms / 1000)} s)\n`);
880
+ };
881
+ try {
882
+ return await runRenderJob(
883
+ // Polls are short; only the pre-job fallback route may run long.
884
+ (route, init) => daemonRpc(route, route === legacyRoute ? init : { ...init, signal: AbortSignal.timeout(60_000) }), kind, legacyRoute, request, { signal: interrupt.signal, onProgress });
885
+ }
886
+ finally {
887
+ process.removeListener("SIGINT", onInterrupt);
888
+ }
889
+ }
890
+ /**
891
+ * Mix, encode, verify and describe one render. The audio is mixed from the
892
+ * canonical bytes the runtime would have played, at the times it logged;
893
+ * the MP4 is probed against its recipe; `render.json` says what it is a
894
+ * derivative of and how it was made.
895
+ */
896
+ async function finishRender(input) {
897
+ let stage = "audio_mix";
898
+ try {
899
+ return await finishRenderStages(input, (value) => { stage = value; });
900
+ }
901
+ catch (error) {
902
+ throw await renderFailure(error, { destination: input.destination, keepFrames: input.keepFrames, identity: input.identity, subject: input.subject, stage });
903
+ }
904
+ }
905
+ async function finishRenderStages(input, setStage) {
906
+ const { encoder, recipe, destination, frames } = input;
907
+ const durationMs = Math.round((frames.frames / recipe.fps) * 1000);
908
+ const log = (frames.audio?.events ?? []);
909
+ const sources = (frames.audio?.sources ?? {});
910
+ const mixStarted = Date.now();
911
+ let mixed;
912
+ let mixArgs = null;
913
+ // Logged times are video times from 0; the MP4 starts at `start`.
914
+ const startMs = Math.round(recipe.start * 1000);
915
+ const segments = recipe.audio === "scene" ? audioSegments(log, startMs + durationMs, startMs) : [];
916
+ if (segments.length) {
917
+ mixed = path.join(destination, "audio-mix.wav");
918
+ mixArgs = await mix(encoder, segments, new Map(Object.entries(sources).map(([key, value]) => [key, value.file])), durationMs, mixed);
919
+ }
920
+ const encodeStarted = Date.now();
921
+ // The encode lands under a partial name and becomes video.mp4 only once it
922
+ // is verified: a failed render never leaves an MP4 that looks finished.
923
+ const output = path.join(destination, "video.mp4");
924
+ const partial = path.join(destination, PARTIAL_VIDEO);
925
+ setStage("encode");
926
+ await encode(encoder, recipe, path.join(destination, "frames", "%06d.png"), partial, mixed);
927
+ setStage("verify");
928
+ const probed = await probe(encoder, partial);
929
+ verifyProbe(recipe, probed);
930
+ const level = await audioLevel(encoder, partial);
931
+ if (segments.length && (level.max_db === null || level.max_db < -60)) {
932
+ throw new CliError("Sounds were logged but the encoded audio track is silent.", "VIDEO_AUDIO_SILENT", 1, { segments: segments.length });
933
+ }
934
+ setStage("manifest");
935
+ const bytes = await readFile(partial);
936
+ const manifest = {
937
+ kind: RENDER_KIND,
938
+ subject: input.subject,
939
+ derivative: true,
940
+ canonical: false,
941
+ certification_evidence: false,
942
+ note: input.note,
943
+ ...input.identity,
944
+ recipe,
945
+ recipe_sha256: sha256(JSON.stringify(recipe)),
946
+ scenario_sha256: sha256(JSON.stringify(recipe.actions)),
947
+ timing: {
948
+ clock: input.clock,
949
+ fps: recipe.fps,
950
+ start_seconds: recipe.start,
951
+ end_seconds: recipe.end,
952
+ frame_count: frames.frames,
953
+ frame_time: "start + k / fps (computed exactly, never accumulated); frame k exists iff start + k / fps < end",
954
+ first_frame_seconds: recipe.start,
955
+ last_frame_seconds: recipe.start + (frames.frames - 1) / recipe.fps,
956
+ },
957
+ presentation: {
958
+ host: "studio_present=1 (the learner runtime alone, no Studio chrome)",
959
+ profile: recipe.profile,
960
+ frame_size: frames.frame_size,
961
+ scaled_after_capture: false,
962
+ },
963
+ learner_surface: frames.learner_surface,
964
+ camera_shots: frames.camera_shots,
965
+ actions: frames.actions,
966
+ ...(input.extra ?? {}),
967
+ frames_sha256: frames.frames_sha256,
968
+ audio: {
969
+ mode: recipe.audio,
970
+ codec: "aac",
971
+ method: "canonical assets mixed by FFmpeg at their logged video times; nothing was recorded from a speaker",
972
+ events: log,
973
+ sources: Object.fromEntries(Object.entries(sources).map(([key, value]) => [key, { sha256: value.sha256, bytes: value.bytes }])),
974
+ segments,
975
+ mix_arguments: mixArgs?.map((value) => value.startsWith(destination) ? path.relative(destination, value) : value) ?? null,
976
+ level_dbfs: level,
977
+ ...(segments.length ? {} : { note: recipe.audio === "silent" ? "The recipe asked for silence." : "Nothing sounded during the rendered span." }),
978
+ },
979
+ encoder: { name: "ffmpeg", version: encoder.version, video: "libx264 High, yuv420p, CFR", crf: recipe.crf, arguments: encodeArguments(recipe, "frames/%06d.png", "video.mp4", mixed ? "audio-mix.wav" : undefined) },
980
+ output: { file: "video.mp4", bytes: bytes.length, sha256: sha256(bytes), probe: probed },
981
+ determinism: "Video times, learner actions and sound placements are exact. Pixels depend on the browser and GPU; compare renders by frame_sha256.",
982
+ performance: { ...(frames.playback_timing ? { playback_timing: frames.playback_timing } : {}),
983
+ capture_seconds: frames.capture_seconds, ...(frames.capture_phases ? { capture_phases: frames.capture_phases } : {}),
984
+ mix_seconds: (encodeStarted - mixStarted) / 1000,
985
+ encode_seconds: (Date.now() - encodeStarted) / 1000,
986
+ total_seconds: (Date.now() - input.started) / 1000, frames_per_second_captured: frames.frames / Math.max(frames.capture_seconds, 0.001),
987
+ agent_rss_mb: Math.round(process.memoryUsage().rss / 1048576) },
988
+ rendered_at: new Date().toISOString(),
989
+ };
990
+ await rename(partial, output);
991
+ await writeFile(path.join(destination, "render.json"), `${JSON.stringify({ ...manifest, frame_sha256: frames.frame_sha256 }, null, 2)}\n`);
992
+ await rm(path.join(destination, FAILURE_MANIFEST), { force: true });
993
+ if (!input.keepFrames)
994
+ await rm(path.join(destination, "frames"), { recursive: true, force: true });
995
+ if (mixed)
996
+ await rm(mixed, { force: true });
997
+ return { output, manifest: path.join(destination, "render.json"), frames: frames.frames,
998
+ width: probed.width, height: probed.height, duration_seconds: probed.duration, audio_segments: segments.length,
999
+ audio_max_dbfs: level.max_db, sha256: manifest.output.sha256 };
1000
+ }
1001
+ /** One Scene presentation render (see video.ts): capture through a daemon job, then mix, encode, verify, describe. */
1002
+ async function renderSceneVideo(input) {
1003
+ const { asset, candidate, recipe, encoder, destination, keepFrames } = input;
1004
+ const hash = sourceHash(candidate);
1005
+ await mkdir(destination, { recursive: true });
1006
+ const started = Date.now();
1007
+ const frames = await renderFramesThroughDaemon("scene", "/browser/render-frames", {
1008
+ asset, sourceHash: input.remoteHash, candidate, candidateHash: hash, recipe,
1009
+ framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
1010
+ measurePlaybackSeconds: input.measurePlaybackSeconds ?? 0, keepFramesOnFailure: keepFrames,
1011
+ out: path.join(destination, "mounted.png"), timeoutMs: input.timeoutMs
1012
+ }, input.json).catch(async (error) => {
1013
+ throw await renderFailure(error, { destination, keepFrames, subject: "scene", stage: "capture", identity: { asset, source_hash: hash } });
1014
+ });
1015
+ // The exact Component, Behavior and Audio definitions this source
1016
+ // compiles against right now (a shot cache keys on its digest).
1017
+ const compiledDependencies = await compileDependencies(api, asset, candidate).catch(() => null);
1018
+ return finishRender({
1019
+ subject: "scene", encoder, recipe, destination, frames, started, keepFrames,
1020
+ identity: { asset, source_hash: hash, source_revision: frames.source_revision ?? null, runtime_fingerprint: frames.runtime_fingerprint ?? null,
1021
+ dependencies_digest: compiledDependencies?.dependencies?.digest ?? null, dependencies: compiledDependencies?.dependencies ?? null },
1022
+ note: "A presentation derivative of the Scene below. The Scene Source stays the authority and stays interactive; this file is never Experience content, never certification evidence and never part of a KSPKG.",
1023
+ clock: "video time; the SceneClock follows it only while it plays (an authored pause holds the Scene)",
1024
+ extra: { scene_clock: frames.scene_clock },
1025
+ });
1026
+ }
1027
+ /** What an Experience render plays: the local source.json, or (with saved) the saved head the lock names. */
1028
+ async function experienceRenderSource(target, saved) {
1029
+ const result = { revision: null };
1030
+ try {
1031
+ const lock = await target.lock();
1032
+ result.revision = lock.head_revision_public_id || null;
1033
+ if (saved) {
1034
+ result.expectedRevision = result.revision || undefined;
1035
+ result.expectedHash = lock.source_hash;
1036
+ }
1037
+ }
1038
+ catch { /* No local checkout: render the saved revision. */ }
1039
+ if (!saved) {
1040
+ try {
1041
+ result.candidate = await target.source();
1042
+ result.candidateHash = sourceHash(result.candidate);
1043
+ }
1044
+ catch { /* No local source: render the saved revision. */ }
1045
+ }
1046
+ return result;
1047
+ }
1048
+ /** One Experience presentation render (see video.ts). */
1049
+ async function renderExperienceVideo(input) {
1050
+ const { stableId, recipe, encoder, destination, keepFrames, candidate, candidateHash, expectedRevision, expectedHash, revision } = input;
1051
+ await mkdir(destination, { recursive: true });
1052
+ const started = Date.now();
1053
+ const frames = await renderFramesThroughDaemon("experience", "/browser/render-experience-frames", {
1054
+ asset: stableId, sourceHash: "", recipe, timeoutMs: input.timeoutMs,
1055
+ framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
1056
+ out: path.join(destination, "mounted.png"), keepFramesOnFailure: keepFrames,
1057
+ library: { workspace: "experiences", selection: stableId, revision: expectedRevision, definitionHash: expectedHash },
1058
+ ...(candidateHash ? { candidate, candidateHash } : {})
1059
+ }, input.json).catch(async (error) => {
1060
+ throw await renderFailure(error, { destination, keepFrames, subject: "experience", stage: "capture",
1061
+ identity: { experience: stableId, source_hash: candidateHash ?? expectedHash ?? null, source_origin: candidateHash ? "local" : "saved" } });
1062
+ });
1063
+ const context = await api("GET", "/v1/agent/context").catch(() => ({}));
1064
+ const document = (candidate ?? {});
1065
+ const scenes = document.scenes && typeof document.scenes === "object" ? document.scenes : null;
1066
+ // Per-Scene identity (approved revision, stored manifest, source hash and
1067
+ // dependency digest) and every sound's sha256: what a shot cache keys on.
1068
+ const sceneProvenance = await (async () => {
1069
+ const rendered = candidate ?? (await api("GET", `${authoringRoute("experience", stableId)}?purpose=inspect`)).document;
1070
+ return experienceProvenance(api, rendered);
1071
+ })().catch((error) => ({ error: error instanceof Error ? error.message : String(error) }));
1072
+ return finishRender({
1073
+ subject: "experience", encoder, recipe, destination, frames, started, keepFrames,
1074
+ identity: {
1075
+ experience: stableId,
1076
+ source_hash: candidateHash ?? frames.source_hash ?? expectedHash ?? null,
1077
+ source_origin: candidateHash ? "local" : "saved",
1078
+ source_revision: revision,
1079
+ scenes,
1080
+ scene_provenance: sceneProvenance,
1081
+ scene_runtime_fingerprint: context.runtime_fingerprint ?? null,
1082
+ },
1083
+ note: "A presentation derivative of the Experience below. The Experience Source and its Scenes stay the authority and stay interactive; this file is never Experience content, never certification evidence and never part of a KSPKG.",
1084
+ clock: "video time (the Experience master timeline advances with it while it runs)",
1085
+ extra: { scenes_shown: frames.scenes, final_state: frames.final_state, final_scene: frames.final_scene, timeline_ms: frames.timeline_ms, completed: frames.completed },
1086
+ });
1087
+ }
688
1088
  async function libraryInspectBrowser(parsed, kind, stableId) {
689
1089
  const target = libraryWorkspace(parsed, kind, stableId);
690
1090
  const viewport = flag(parsed, "viewport", "desktop");
@@ -728,6 +1128,98 @@ async function libraryInspectBrowser(parsed, kind, stableId) {
728
1128
  return { ok: true, command: `${kind}.inspect-browser`, kind, stable_id: stableId, screenshot: target.screenshotPath, browser: browserPath, activity, activity_scope: "local", warning };
729
1129
  }
730
1130
  async function library(parsed, kind, action, argument) {
1131
+ if (kind === "experience" && action === "drive") {
1132
+ const stableId = required(argument, "Experience stable id is required.");
1133
+ const target = libraryWorkspace(parsed, kind, stableId);
1134
+ const scenarioFile = path.resolve(required(flag(parsed, "scenario"), "--scenario <file.json> is required."));
1135
+ if ((await stat(scenarioFile)).size > 65536)
1136
+ throw new CliError("Scenario exceeds 64 KiB.", "STUDIO_SCENARIO_INVALID", 2);
1137
+ let document;
1138
+ try {
1139
+ document = JSON.parse(await readFile(scenarioFile, "utf8"));
1140
+ }
1141
+ catch {
1142
+ throw new CliError("Scenario is not valid JSON.", "STUDIO_SCENARIO_INVALID", 2);
1143
+ }
1144
+ const scenario = parseExperienceScenario(document);
1145
+ await ensureCompatibility();
1146
+ const destination = path.resolve(flag(parsed, "out", `experience-drive-${stableId}-${Date.now()}`));
1147
+ await mkdir(destination, { recursive: true });
1148
+ // Drive the exact local source.json by default: an unsaved candidate is
1149
+ // mounted in the Agent's own browser through the non-persisting Draft
1150
+ // Preview and bound by its canonical hash. Nothing is saved, and nothing
1151
+ // reaches learners. --saved drives the saved server revision instead.
1152
+ let candidate;
1153
+ let candidateHash;
1154
+ let expectedRevision;
1155
+ let expectedHash;
1156
+ if (!parsed.flags.has("saved")) {
1157
+ try {
1158
+ candidate = await target.source();
1159
+ candidateHash = sourceHash(candidate);
1160
+ }
1161
+ catch { /* No local checkout: drive the saved revision. */ }
1162
+ }
1163
+ if (candidateHash === undefined) {
1164
+ try {
1165
+ const lock = await target.lock();
1166
+ expectedRevision = lock.head_revision_public_id || undefined;
1167
+ expectedHash = lock.source_hash;
1168
+ }
1169
+ catch { /* An approved Experience can be driven without local checkout. */ }
1170
+ }
1171
+ const sourceOrigin = candidateHash ? "local" : "saved";
1172
+ const scenarioHash = createHash("sha256").update(JSON.stringify(scenario)).digest("hex");
1173
+ const driveMetadata = { scenario_hash: scenarioHash, step_count: scenario.steps.length,
1174
+ viewport: flag(parsed, "viewport", "desktop"), source_origin: sourceOrigin,
1175
+ ...(candidateHash ?? expectedHash ? { source_hash: candidateHash ?? expectedHash } : {}) };
1176
+ let response;
1177
+ try {
1178
+ response = await daemonRpc("/browser/experience-drive", {
1179
+ method: "POST",
1180
+ body: JSON.stringify({ asset: stableId, sourceHash: "", out: path.join(destination, "initial.png"),
1181
+ viewport: flag(parsed, "viewport", "desktop"), timeoutMs: Number(flag(parsed, "timeout", "30000")),
1182
+ library: { workspace: "experiences", selection: stableId, revision: expectedRevision, definitionHash: expectedHash }, scenario,
1183
+ ...(candidateHash ? { candidate, candidateHash } : {}),
1184
+ presenceSubject: `experience:${stableId}` }),
1185
+ });
1186
+ }
1187
+ catch (error) {
1188
+ const failure = error instanceof CliError ? error : new CliError(String(error));
1189
+ const summaries = failure.details?.step_summaries;
1190
+ await reportExperienceActivity(stableId, "experience.drive", { ...driveMetadata, status: "failed", error_code: failure.code,
1191
+ ...(Array.isArray(summaries) ? { steps: summaries.slice(0, 32), failed_step_index: summaries.length - 1 } : {}) }).catch(() => undefined);
1192
+ throw error;
1193
+ }
1194
+ const resultPath = path.join(destination, "result.json");
1195
+ await writeFile(resultPath, `${JSON.stringify(response.result, null, 2)}\n`);
1196
+ const evidenceMode = response.result.evidence_mode === "journey" ? "journey" : "probe";
1197
+ await reportExperienceActivity(stableId, "experience.drive", { ...driveMetadata, status: "completed", evidence_mode: evidenceMode,
1198
+ steps: response.result.step_summaries?.slice(0, 32) }).catch(() => undefined);
1199
+ return { ok: true, command: "experience.drive", stable_id: stableId, output: destination,
1200
+ result: resultPath, steps: scenario.steps.length, evidence_mode: evidenceMode,
1201
+ source_origin: sourceOrigin, ...(candidateHash ? { source_hash: candidateHash } : {}),
1202
+ ...(evidenceMode === "probe" ? { evidence_note: "Probe evidence: the run jumped, fired, seeked or used synthetic setup. It does not prove a learner can reach this point; prove that with a journey from the initial State." } : {}) };
1203
+ }
1204
+ if (kind === "experience" && action === "render-video") {
1205
+ // A presentation derivative of an Experience: see video.ts. The real
1206
+ // Experience player, in the presentation host, driven by video time.
1207
+ const stableId = required(argument, "Experience stable id is required.");
1208
+ const target = libraryWorkspace(parsed, kind, stableId);
1209
+ const recipe = parseRecipe(await readRecipe(parsed), "experience");
1210
+ const encoder = await findEncoder();
1211
+ await ensureCompatibility();
1212
+ const source = await experienceRenderSource(target, parsed.flags.has("saved"));
1213
+ const destination = path.resolve(flag(parsed, "out", `render-experience-${stableId}-${Date.now()}`));
1214
+ const result = await renderExperienceVideo({ stableId, ...source, recipe, encoder, destination,
1215
+ keepFrames: parsed.flags.has("keep-frames"), timeoutMs: Number(flag(parsed, "timeout", "30000")), json: parsed.json });
1216
+ return { ok: true, command: "experience.render-video", stable_id: stableId, ...result };
1217
+ }
1218
+ if (kind === "experience" && action === "watch") {
1219
+ const stableId = required(argument, "Experience stable id is required.");
1220
+ const target = libraryWorkspace(parsed, kind, stableId);
1221
+ return watchSource(parsed, `experience:${stableId}`, "experience.watch", target.sourcePath, () => target.source(), async () => (await api("GET", `${authoringRoute(kind, stableId)}?purpose=inspect`)).document, (metadata) => reportExperienceActivity(stableId, "experience.source.edit", metadata));
1222
+ }
731
1223
  if (kind === "audio" && action === "upload")
732
1224
  return audioUpload(required(argument, "Audio file is required."));
733
1225
  if (AUTHORING_SEARCH_KINDS.includes(kind) && action === "search") {
@@ -765,6 +1257,173 @@ async function library(parsed, kind, action, argument) {
765
1257
  return libraryExport(parsed, kind, stableId);
766
1258
  return libraryInspectBrowser(parsed, kind, stableId);
767
1259
  }
1260
+ // ---------------------------------------------------------------------------
1261
+ // Episodes (see episode.ts): validate | plan | render | status <episode.json>
1262
+ // ---------------------------------------------------------------------------
1263
+ export const EPISODE_MAX_WORKERS = 4;
1264
+ /**
1265
+ * Shots rendered at once: what was asked, but never more than the daemon has
1266
+ * render browsers. A daemon started without `NERDBRO_RENDER_WORKERS` owns one,
1267
+ * and a second concurrent shot would fail `VIDEO_RENDER_BUSY` mid-episode.
1268
+ */
1269
+ export function effectiveEpisodeWorkers(requested, daemonRenderWorkers) {
1270
+ const available = typeof daemonRenderWorkers === "number" && Number.isInteger(daemonRenderWorkers) && daemonRenderWorkers >= 1
1271
+ ? daemonRenderWorkers : 1;
1272
+ if (requested <= available)
1273
+ return { workers: requested, note: null };
1274
+ return {
1275
+ workers: available,
1276
+ note: `--workers ${requested} reduced to ${available}: the running daemon has ${available} render browser(s). `
1277
+ + `Restart it with NERDBRO_RENDER_WORKERS=${requested} (nerdbro agent stop) to render ${requested} shots at once.`,
1278
+ };
1279
+ }
1280
+ async function readEpisodeFile(file) {
1281
+ const episodePath = path.resolve(required(file, "episode.json is required: nerdbro episode <validate|plan|render|status> <episode.json>."));
1282
+ if ((await stat(episodePath)).size > 1_048_576)
1283
+ throw new CliError("episode.json exceeds 1 MiB.", "EPISODE_INVALID", 2);
1284
+ let raw;
1285
+ try {
1286
+ raw = JSON.parse(await readFile(episodePath, "utf8"));
1287
+ }
1288
+ catch {
1289
+ throw new CliError("episode.json is not valid JSON.", "EPISODE_INVALID", 2);
1290
+ }
1291
+ return { episodePath, episode: parseEpisode(raw) };
1292
+ }
1293
+ /** Resolve one shot's exact identity from the live server (never from file times). */
1294
+ async function resolveEpisodeShot(parsed, shot) {
1295
+ const failed = (message, code = "EPISODE_SHOT_UNRESOLVED") => new CliError(`Shot ${shot.id}: ${message}`, code, 2, { shot: shot.id, stage: "resolve" });
1296
+ if (shot.scene) {
1297
+ const { asset } = shot.scene;
1298
+ let candidate;
1299
+ let remoteHash = "";
1300
+ let revision = null;
1301
+ if (shot.scene.source === "approved") {
1302
+ const loaded = await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}`);
1303
+ if (loaded.source_origin !== "approved" || !loaded.source)
1304
+ throw failed("the asset's current source is not its approved revision (a draft is open); render source local or approve it first.");
1305
+ if (shot.scene.revision && loaded.base_revision_public_id !== shot.scene.revision)
1306
+ throw failed(`revision ${shot.scene.revision} is not the asset's approved head ${loaded.base_revision_public_id}.`);
1307
+ candidate = loaded.source;
1308
+ remoteHash = String(loaded.source_hash || "");
1309
+ revision = loaded.base_revision_public_id ?? null;
1310
+ }
1311
+ else {
1312
+ candidate = await workspace(parsed, asset).source();
1313
+ const status = await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/status`);
1314
+ remoteHash = String(status.source_hash || "");
1315
+ }
1316
+ const hash = sourceHash(candidate);
1317
+ const compiled = await compileDependencies(api, asset, candidate);
1318
+ if (!compiled.compiled)
1319
+ throw failed("the Scene source does not compile; run `nerdbro studio validate`.", "EPISODE_SHOT_SOURCE_INVALID");
1320
+ const audio = Object.fromEntries((compiled.dependencies?.audio ?? []).map((item) => [item.id, item.sha256 ?? "unresolved"]));
1321
+ return {
1322
+ sources: { main: hash },
1323
+ dependency_digests: { main: compiled.dependencies?.digest ?? null },
1324
+ audio_sha256: audio,
1325
+ identity: { asset, source: shot.scene.source, source_hash: hash, revision, dependencies_digest: compiled.dependencies?.digest ?? null,
1326
+ components: compiled.dependencies?.components ?? [], behaviors: compiled.dependencies?.behaviors ?? [] },
1327
+ render: { asset, candidate, remoteHash },
1328
+ };
1329
+ }
1330
+ const id = shot.experience.id;
1331
+ const target = libraryWorkspace(parsed, "experience", id);
1332
+ const source = await experienceRenderSource(target, shot.experience.source === "saved");
1333
+ const document = source.candidate ?? (await api("GET", `${authoringRoute("experience", id)}?purpose=inspect`)).document;
1334
+ if (!document)
1335
+ throw failed("the Experience has no readable source.");
1336
+ const provenance = await experienceProvenance(api, document);
1337
+ const sources = { experience: source.candidateHash ?? source.expectedHash ?? sourceHash(document) };
1338
+ const digests = {};
1339
+ for (const [scene, identity] of Object.entries(provenance.scenes)) {
1340
+ sources[`scene:${scene}`] = digestOf({ asset: identity.asset ?? null, revision: identity.revision ?? null, manifest: identity.manifest_path ?? null,
1341
+ package: identity.package ?? null, package_sha256: identity.package_sha256 ?? null, source_hash: identity.source_hash ?? null });
1342
+ digests[scene] = identity.dependencies_digest;
1343
+ }
1344
+ return {
1345
+ sources, dependency_digests: digests, audio_sha256: provenance.audio,
1346
+ identity: { experience: id, source: shot.experience.source, scene_provenance: provenance },
1347
+ render: source,
1348
+ };
1349
+ }
1350
+ async function renderEpisodeShot(parsed, encoder, shot, recipe, resolved, destination) {
1351
+ const timeoutMs = Number(flag(parsed, "timeout", "30000"));
1352
+ if (shot.scene) {
1353
+ const render = resolved.render;
1354
+ await renderSceneVideo({ ...render, recipe, encoder, destination, keepFrames: false, timeoutMs, json: true });
1355
+ }
1356
+ else {
1357
+ await renderExperienceVideo({ stableId: shot.experience.id, ...resolved.render, recipe, encoder, destination,
1358
+ keepFrames: false, timeoutMs, json: true });
1359
+ }
1360
+ return { output: path.join(destination, "video.mp4"), manifest: path.join(destination, "render.json") };
1361
+ }
1362
+ async function episodeCommand(parsed, action, file) {
1363
+ if (!action || !["validate", "plan", "render", "status"].includes(action)) {
1364
+ throw new CliError("Usage: nerdbro episode <validate|plan|render|status> <episode.json>.", "AGENT_COMMAND_UNKNOWN", 2);
1365
+ }
1366
+ const { episodePath, episode } = await readEpisodeFile(file);
1367
+ const timeline = episodeTimeline(episode);
1368
+ if (action === "validate") {
1369
+ return { ok: true, command: "episode.validate", episode: episode.id, title: episode.title, shots: episode.shots.length,
1370
+ total_frames: timeline.total_frames, total_seconds: timeline.total_seconds,
1371
+ timeline: timeline.shots.map((item) => ({ id: item.id, frames: item.frames, offset_seconds: item.offset_seconds })),
1372
+ subtitles: episodeCues(episode, timeline).length };
1373
+ }
1374
+ const out = path.resolve(flag(parsed, "out", path.join(path.dirname(episodePath), `episode-out-${episode.id}`)));
1375
+ const cache = path.resolve(flag(parsed, "cache", path.join(cacheDirectory(), "episode-shots")));
1376
+ const workers = Number(flag(parsed, "workers", "1"));
1377
+ if (!Number.isInteger(workers) || workers < 1 || workers > EPISODE_MAX_WORKERS) {
1378
+ throw new CliError(`--workers must be 1 to ${EPISODE_MAX_WORKERS}.`, "AGENT_ARGUMENT_INVALID", 2);
1379
+ }
1380
+ const encoder = await findEncoder();
1381
+ await ensureCompatibility();
1382
+ const context = await api("GET", "/v1/agent/context");
1383
+ const deps = {
1384
+ encoder, runtimeFingerprint: context.runtime_fingerprint ?? null,
1385
+ resolveShot: (shot) => resolveEpisodeShot(parsed, shot),
1386
+ renderShot: (shot, recipe, resolved, destination) => renderEpisodeShot(parsed, encoder, shot, recipe, resolved, destination),
1387
+ log: (message) => { if (!parsed.json && process.stderr.isTTY)
1388
+ process.stderr.write(`nerdbro: ${message}\n`); },
1389
+ };
1390
+ const options = { episode, baseDirectory: path.dirname(episodePath), out, cache, workers, deps };
1391
+ if (action === "plan" || action === "status") {
1392
+ const plan = await planEpisode(options);
1393
+ let state = null;
1394
+ try {
1395
+ state = JSON.parse(await readFile(path.join(out, "episode-state.json"), "utf8"));
1396
+ }
1397
+ catch { /* never rendered here */ }
1398
+ let output = null;
1399
+ try {
1400
+ const manifest = JSON.parse(await readFile(path.join(out, "episode.json"), "utf8"));
1401
+ const current = manifest.shots?.map((shot) => shot.key).join() === plan.shots.map((shot) => shot.key).join();
1402
+ output = { file: path.join(out, `${episode.id}.mp4`), sha256: manifest.output?.sha256 ?? null, matches_current_shots: current,
1403
+ qc_passed: Array.isArray(manifest.qc) ? manifest.qc.every((check) => check.pass) : null };
1404
+ }
1405
+ catch { /* no assembled output */ }
1406
+ return { ok: true, command: `episode.${action}`, episode: episode.id, out, cache,
1407
+ total_frames: plan.timeline.total_frames, total_seconds: plan.timeline.total_seconds,
1408
+ shots: plan.shots.map((shot) => ({ id: shot.id, subject: shot.subject, key: shot.key, frames: shot.frames, cache: shot.cache,
1409
+ ...(shot.cache_reason ? { cache_reason: shot.cache_reason } : {}), offset_seconds: shot.offset_seconds })),
1410
+ would_render: plan.shots.filter((shot) => shot.cache !== "valid").map((shot) => shot.id),
1411
+ ...(action === "status" ? { state, output } : {}) };
1412
+ }
1413
+ const daemon = await startDaemon(true);
1414
+ const concurrency = effectiveEpisodeWorkers(workers, daemon.render_workers);
1415
+ if (concurrency.note)
1416
+ deps.log?.(concurrency.note);
1417
+ const result = await renderEpisode({ ...options, workers: concurrency.workers });
1418
+ if (!result.ok) {
1419
+ throw new CliError("The episode was assembled but failed QC; see qc.json.", "EPISODE_QC_FAILED", 1, { output: result.output, manifest: result.manifest, qc: result.qc?.filter((check) => !check.pass) });
1420
+ }
1421
+ return { ok: result.ok, command: "episode.render", episode: episode.id, output: result.output, srt: result.srt, manifest: result.manifest,
1422
+ state: result.state, reused: result.reused, rendered: result.rendered,
1423
+ shots: result.shots.map((shot) => ({ id: shot.id, key: shot.key, state: shot.state, render_seconds: shot.render_seconds ?? null })),
1424
+ qc: result.qc, timings: result.timings, workers: concurrency.workers,
1425
+ ...(concurrency.note ? { workers_note: concurrency.note } : {}) };
1426
+ }
768
1427
  async function run(parsed) {
769
1428
  const [first, second, third] = parsed.args;
770
1429
  assertSupportedFlags(parsed);
@@ -786,13 +1445,14 @@ async function run(parsed) {
786
1445
  if (await readAttemptPointer())
787
1446
  throw new CliError("This workspace already has an Attempt.", "AGENT_ATTEMPT_ALREADY_ACTIVE", 3);
788
1447
  const origin = (await readConfig()).apiOrigin;
789
- const server = await api("POST", "/v1/agent/attempts", { label, mode, server_origin: origin });
1448
+ const project = flag(parsed, "project");
1449
+ const server = await api("POST", "/v1/agent/attempts", { label, mode, server_origin: origin, ...(project ? { project: projectId(project) } : {}) });
790
1450
  const pointer = await createAttemptPointer(server, origin);
791
- return { ok: true, command: "attempt.start", attempt_id: pointer.attempt_id, label, mode, folder: pointer.attempt_root, pointer: path.join(process.cwd(), ".nerdbro-attempt.json") };
1451
+ return { ok: true, command: "attempt.start", attempt_id: pointer.attempt_id, label, mode, folder: pointer.attempt_root, pointer: path.join(process.cwd(), ".nerdbro-attempt.json"), ...(project ? { project: pointer.project || null, project_scope: server.project_scope ?? null } : {}) };
792
1452
  }
793
1453
  if (second === "current") {
794
1454
  const active = await readAttemptPointer();
795
- return { ok: true, command: "attempt.current", attempt_id: active?.pointer.attempt_id || null };
1455
+ return { ok: true, command: "attempt.current", attempt_id: active?.pointer.attempt_id || null, ...(active?.pointer.project ? { project: active.pointer.project } : {}) };
796
1456
  }
797
1457
  if (second === "status") {
798
1458
  const active = await readAttemptPointer();
@@ -822,10 +1482,23 @@ async function run(parsed) {
822
1482
  return daemonRpc("/disconnect", { method: "POST", body: "{}" });
823
1483
  if (first === "whoami")
824
1484
  return { ok: true, command: "whoami", result: await api("GET", "/v1/agent/whoami") };
825
- if (first === "context" || first === "capabilities")
826
- return { ok: true, command: first, result: await api("GET", `/v1/agent/${first}`) };
827
- if (first === "schema")
828
- return { ok: true, command: "schema", result: await api("GET", second ? `/v1/agent/schema/${encodeURIComponent(second)}` : "/v1/agent/schema") };
1485
+ if (first === "context") {
1486
+ const result = await api("GET", "/v1/agent/context");
1487
+ return { ok: true, command: "context", summary: contextSummary(result, VERSION, AGENT_PROTOCOL), result };
1488
+ }
1489
+ if (first === "capabilities") {
1490
+ const registry = await api("GET", "/v1/agent/capabilities");
1491
+ return { ok: true, command: "capabilities", ...capabilitiesView(registry, await liveContext()) };
1492
+ }
1493
+ if (first === "schema") {
1494
+ const result = await api("GET", second ? `/v1/agent/schema/${encodeURIComponent(second)}` : "/v1/agent/schema");
1495
+ if (!second)
1496
+ return { ok: true, command: "schema", result };
1497
+ const view = schemaTopicView(second, result, await liveContext());
1498
+ if (typeof view.deprecated === "string" && !parsed.json)
1499
+ process.stderr.write(`nerdbro: schema ${second} is frozen: ${view.deprecated}\n`);
1500
+ return { ok: true, command: "schema", result: view };
1501
+ }
829
1502
  if (first === "guide") {
830
1503
  const result = await api("GET", "/v1/agent/docs/agent-authoring-playbook");
831
1504
  return {
@@ -877,28 +1550,48 @@ async function run(parsed) {
877
1550
  const plural = `${first}s`;
878
1551
  if (second === "search") {
879
1552
  const query = parsed.args.slice(2).join(" ");
880
- const parameters = new URLSearchParams({ query });
881
- const limit = flag(parsed, "limit");
882
- const offset = flag(parsed, "offset");
883
- if (limit)
884
- parameters.set("limit", limit);
885
- if (offset)
886
- parameters.set("offset", offset);
887
- return { ok: true, command: `${first}.search`, result: await api("GET", `/v1/agent/catalogue/${plural}/search?${parameters}`) };
1553
+ // Project Attempts get the compact scoped view by default (server-side); see project.ts.
1554
+ const parameters = searchParameters(query, (name) => flag(parsed, name), (name) => parsed.flags.has(name));
1555
+ const result = await api("GET", `/v1/agent/catalogue/${plural}/search?${parameters}`);
1556
+ const notice = searchNotice(result, first);
1557
+ if (notice && !parsed.json)
1558
+ process.stderr.write(`nerdbro: ${notice}\n`);
1559
+ return { ok: true, command: `${first}.search`, result };
1560
+ }
1561
+ if (second === "inspect") {
1562
+ const result = await api("GET", `/v1/agent/catalogue/${plural}/${encodeURIComponent(required(third, "ID is required."))}`);
1563
+ const notice = inspectNotice(result, first);
1564
+ if (notice && !parsed.json)
1565
+ process.stderr.write(`nerdbro: ${notice}\n`);
1566
+ return { ok: true, command: `${first}.inspect`, result };
1567
+ }
1568
+ }
1569
+ if (first === "project")
1570
+ return projectCommand(second, third, (name) => flag(parsed, name), api, (await readAttemptPointer())?.pointer.project);
1571
+ if (first === "world") {
1572
+ // Live World discovery; no World list is bundled with this package.
1573
+ if (!second || second === "list")
1574
+ return { ok: true, command: "world.list", result: await api("GET", "/v1/agent/worlds") };
1575
+ if (second === "inspect") {
1576
+ const world = required(third, "World ID is required.");
1577
+ const zone = flag(parsed, "zone");
1578
+ const query = zone ? `?zone=${encodeURIComponent(zone)}` : "";
1579
+ return { ok: true, command: "world.inspect", result: await api("GET", `/v1/agent/worlds/${encodeURIComponent(world)}${query}`) };
888
1580
  }
889
- if (second === "inspect")
890
- return { ok: true, command: `${first}.inspect`, result: await api("GET", `/v1/agent/catalogue/${plural}/${encodeURIComponent(required(third, "ID is required."))}`) };
891
1581
  }
892
1582
  if (isLibraryKind(first)) {
893
1583
  const result = await library(parsed, first, second, third);
894
1584
  if (result !== undefined)
895
1585
  return result;
896
1586
  }
1587
+ if (first === "episode")
1588
+ return episodeCommand(parsed, second, third);
897
1589
  if (first === "studio") {
898
1590
  const action = required(second, "Studio command is required.");
899
1591
  if (action === "create") {
900
1592
  const title = required(flag(parsed, "title"), "--title is required.");
901
- const target = required(flag(parsed, "target"), "--target is required.");
1593
+ // Default and frozen targets come from the live context, never from this package.
1594
+ const target = resolveCreateTarget(flag(parsed, "target"), await liveContext());
902
1595
  const concept = required(flag(parsed, "concept"), "--concept is required.");
903
1596
  const brief = {
904
1597
  concept,
@@ -910,21 +1603,26 @@ async function run(parsed) {
910
1603
  graph_y_semantics: flag(parsed, "graph-y"),
911
1604
  graph_sync_semantics: flag(parsed, "graph-sync"),
912
1605
  language: flag(parsed, "language", "bilingual"),
913
- requires_graph: target === "combined" || Boolean(flag(parsed, "graph-purpose")),
1606
+ // A graph is a Scene math plane, asked for by graph intent, not by a target.
1607
+ requires_graph: mathPlaneRequirement(parsed.flags),
914
1608
  requires_assessment: parsed.flags.has("assessment"),
915
1609
  additional_instruction: flag(parsed, "additional-instruction"),
916
1610
  selected_components: flag(parsed, "components")?.split(",").map((value) => value.trim()).filter(Boolean),
917
1611
  selected_behavior: flag(parsed, "behavior"),
918
1612
  important_values: flag(parsed, "important-values")?.split(",").map((value) => value.trim()).filter(Boolean),
919
1613
  };
1614
+ // A World zone starter (world:<world>/<zone>) is resolved by the server
1615
+ // from the live World registry: the same Scene the human Playground opens.
1616
+ const starter = flag(parsed, "starter");
920
1617
  const result = await api("POST", "/v1/agent/studio", {
921
1618
  client_command_id: flag(parsed, "command-id", randomUUID()),
922
1619
  title, authoring_target: target, brief,
1620
+ ...(starter ? { starter } : {}),
923
1621
  });
924
1622
  // A newly created Studio asset has no approved revision or draft; it is
925
1623
  // already the isolated scratch surface. The flag makes that intent
926
1624
  // explicit without inventing a second server-side lifecycle.
927
- return { ok: true, command: "studio.create", scratch: parsed.flags.has("scratch"), result };
1625
+ return { ok: true, command: "studio.create", scratch: parsed.flags.has("scratch"), target, math_plane_requested: brief.requires_graph === true, result };
928
1626
  }
929
1627
  if (action === "brief") {
930
1628
  const updating = third === "update";
@@ -951,8 +1649,9 @@ async function run(parsed) {
951
1649
  brief.requires_assessment = true;
952
1650
  if (parsed.flags.has("no-assessment"))
953
1651
  brief.requires_assessment = false;
954
- if (flag(parsed, "graph-purpose"))
955
- brief.requires_graph = true;
1652
+ const mathPlane = mathPlaneRequirement(parsed.flags);
1653
+ if (mathPlane !== undefined)
1654
+ brief.requires_graph = mathPlane;
956
1655
  if (flag(parsed, "components"))
957
1656
  brief.selected_components = flag(parsed, "components").split(",").map((value) => value.trim()).filter(Boolean);
958
1657
  if (flag(parsed, "important-values"))
@@ -966,10 +1665,20 @@ async function run(parsed) {
966
1665
  return checkout(parsed, asset);
967
1666
  if (action === "status")
968
1667
  return studioStatus(parsed, asset);
1668
+ if (action === "watch") {
1669
+ return watchSource(parsed, `asset:${asset}`, "studio.watch", target.sourcePath, () => target.source(), async () => (await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}`)).source, (metadata) => reportBrowserActivity(asset, "studio.source.edit", metadata));
1670
+ }
969
1671
  if (action === "source")
970
1672
  return { ok: true, command: "studio.source", asset, source: await target.source() };
971
- if (["validate", "compile", "preview"].includes(action))
972
- return { ok: true, command: `studio.${action}`, asset, source_hash: sourceHash(await target.source()), result: await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/${action}`, { source: await target.source() }) };
1673
+ if (["validate", "compile", "preview"].includes(action)) {
1674
+ const result = await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/${action}`, { source: await target.source() });
1675
+ // Project scope: out-of-scope dependencies warn here and block approval later.
1676
+ const warnings = scopeWarnings(result);
1677
+ const notice = scopeWarningNotice(warnings);
1678
+ if (notice && !parsed.json)
1679
+ process.stderr.write(`nerdbro: ${notice}\n`);
1680
+ return { ok: true, command: `studio.${action}`, asset, source_hash: sourceHash(await target.source()), result, ...(warnings.length ? { scope_warnings: warnings } : {}) };
1681
+ }
973
1682
  if (action === "history")
974
1683
  return { ok: true, command: "studio.history", asset, result: await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/history`) };
975
1684
  if (action === "save") {
@@ -1050,19 +1759,43 @@ async function run(parsed) {
1050
1759
  response = await daemonRpc("/browser/drive", {
1051
1760
  method: "POST",
1052
1761
  body: JSON.stringify({ asset, sourceHash: remoteHash, candidate, candidateHash: hash, scenario,
1762
+ presenceSubject: `asset:${asset}`,
1053
1763
  viewport: flag(parsed, "viewport", "desktop"),
1054
1764
  out: path.join(destination, "initial.png"), timeoutMs: Number(flag(parsed, "timeout", "30000")) }),
1055
1765
  });
1056
1766
  }
1057
1767
  catch (error) {
1058
1768
  const failure = error instanceof CliError ? error : new CliError(String(error));
1059
- await reportBrowserActivity(asset, "studio.drive", { ...driveMetadata, status: "failed", error_code: failure.code }).catch(() => undefined);
1769
+ const summaries = failure.details?.step_summaries;
1770
+ await reportBrowserActivity(asset, "studio.drive", { ...driveMetadata, status: "failed", error_code: failure.code,
1771
+ ...(Array.isArray(summaries) ? { steps: summaries.slice(0, 32), failed_step_index: summaries.length - 1 } : {}) }).catch(() => undefined);
1060
1772
  throw error;
1061
1773
  }
1062
1774
  await writeFile(path.join(destination, "result.json"), `${JSON.stringify(response.result, null, 2)}\n`);
1063
- await reportBrowserActivity(asset, "studio.drive", { ...driveMetadata, status: "completed" });
1775
+ const evidenceMode = response.result.evidence_mode === "journey" ? "journey" : "probe";
1776
+ await reportBrowserActivity(asset, "studio.drive", { ...driveMetadata, status: "completed", evidence_mode: evidenceMode,
1777
+ steps: response.result.step_summaries?.slice(0, 32) });
1064
1778
  return { ok: true, command: "studio.drive", asset, source_hash: hash, output: destination,
1065
- result: path.join(destination, "result.json"), steps: scenario.steps.length };
1779
+ result: path.join(destination, "result.json"), steps: scenario.steps.length, evidence_mode: evidenceMode,
1780
+ ...(evidenceMode === "probe" ? { evidence_note: "Probe evidence: the run jumped, fired, seeked or used synthetic setup. It does not prove a learner can reach this point; prove that with a journey from the initial State." } : {}) };
1781
+ }
1782
+ if (action === "render-video") {
1783
+ // A presentation derivative, never content or evidence: see video.ts.
1784
+ const recipe = parseRecipe(await readRecipe(parsed), "scene");
1785
+ // Refuse before any browser work: no encoder, no render.
1786
+ const encoder = await findEncoder();
1787
+ const candidate = await target.source();
1788
+ const hash = sourceHash(candidate);
1789
+ const remote = await studioStatus(parsed, asset);
1790
+ const remoteHash = String(remote.remote_source_hash || "");
1791
+ if (!remoteHash && remote.remote_snapshot_public_id) {
1792
+ throw new CliError("Studio has a draft without a readable source.", "STUDIO_SOURCE_UNAVAILABLE", 3);
1793
+ }
1794
+ const destination = path.resolve(flag(parsed, "out", `render-${asset}-${Date.now()}`));
1795
+ const result = await renderSceneVideo({ asset, candidate, remoteHash, recipe, encoder, destination,
1796
+ keepFrames: parsed.flags.has("keep-frames"), timeoutMs: Number(flag(parsed, "timeout", "30000")),
1797
+ measurePlaybackSeconds: Number(flag(parsed, "measure-playback", "0")), json: parsed.json });
1798
+ return { ok: true, command: "studio.render-video", asset, source_hash: hash, ...result };
1066
1799
  }
1067
1800
  if (action === "capture-preview") {
1068
1801
  const { hash } = await assertBrowserSource(parsed, asset);
@@ -1118,24 +1851,54 @@ function help() {
1118
1851
  return [
1119
1852
  `NerdBro Studio Agent ${VERSION}`,
1120
1853
  "",
1121
- "Commands: setup, connect, disconnect, whoami, context, guide, schema [topic], capabilities",
1854
+ "Model: everything visual is a Scene (graphs are Scene math planes); interaction is an Experience.",
1855
+ "",
1856
+ "Commands: setup, connect, disconnect, whoami, guide",
1857
+ " context live contract versions, offered/frozen targets, deprecations, compatibility",
1858
+ " schema [topic] topic index, or one live projection (e.g. experience, scene.stateMachines)",
1859
+ " capabilities [--json] universal Experience capabilities and tools, plus the Studio capability registry",
1122
1860
  " docs | docs search <query> | docs open <slug>",
1123
1861
  " agent start|status|stop|instructions|init --provider claude|codex|generic",
1124
1862
  " component|behavior|example search <query>|inspect <id>",
1863
+ " component|behavior search [--limit n] [--offset n] [--all-libraries] [--full | --compact]",
1864
+ " in a project Attempt search is compact and scoped; stderr names related ids outside the scope",
1865
+ "",
1866
+ "Authoring projects (the libraries an Attempt may author from):",
1867
+ " project list | project libraries [<project>] | project show [<project>] | project current",
1868
+ " project create --title <text> --output-target lesson|game|experience|video --home-library <slug> [--purpose <text>] [--permitted a,b]",
1869
+ " attempt start --label <text> --project <project> scope every later command in this workspace",
1870
+ " project import <stable-id> [--kind component|behavior|audio|experience] --reason <text> [--project <project>]",
1871
+ "",
1125
1872
  " studio checkout|status|source|history|validate|compile|preview|save <asset>",
1126
- " studio create --scratch --title <text> --target <target> --concept <text> [--command-id <id>]",
1127
- " studio brief <asset> | studio brief update <asset> [--concept <text> ...]",
1873
+ " studio create --scratch --title <text> --concept <text> [--target sceneComposition] [--starter world:<world>/<zone>] [--command-id <id>]",
1874
+ " --target defaults to sceneComposition; targets the server lists as frozen (combined, mathGraph) are refused",
1875
+ " graph intent: --math-plane | --graph-purpose <text> [--graph-x <text>] [--graph-y <text>] [--graph-sync <text>]",
1876
+ " world list | world inspect <world> [--zone <zone>] live World discovery and zone starter Source",
1877
+ " studio brief <asset> | studio brief update <asset> [--concept <text> ...] [--math-plane | --no-math-plane]",
1128
1878
  " studio open|screenshot|inspect|observe|capture-preview|certify <asset>",
1129
1879
  " studio drive <asset> --scenario scenario.json [--workspace dir] [--viewport desktop|mobile] [--out dir]",
1880
+ " steps include previewState \"machine/state\" [+ setup], select \"kind:id\", restart; result says probe or journey",
1881
+ " studio watch <asset> [--once] [--interval ms] report changed source paths to the Maker's Studio (Agent Presence)",
1130
1882
  " studio inspect <asset> --view final|learner --viewport desktop|mobile",
1131
1883
  " studio capture-preview <asset> [--mode semantic|fixed] [--viewport 480x320] [--duration 8] [--fps 4] [--out dir]",
1884
+ " studio render-video <asset> --recipe recipe.json [--out dir] [--keep-frames] [--measure-playback 15]",
1885
+ " presentation MP4 (H.264/AAC) of the learner surface at exact SceneClock times; needs local FFmpeg; writes render.json",
1132
1886
  " studio export <asset> --revision <revision> [--out file.kspkg]",
1133
1887
  "",
1888
+ "Episodes (kheleshikhi.episode.v1: ordered Scene/Experience shots, narration, music, subtitles):",
1889
+ " episode validate <episode.json> check the manifest and its timeline",
1890
+ " episode plan|status <episode.json> [--out dir] [--cache dir] shot keys, cache hits, what would render",
1891
+ " episode render <episode.json> [--out dir] [--cache dir] [--workers 1-4]",
1892
+ " renders changed shots only, then concat + ducked mix + loudnorm + SRT + QC; needs local FFmpeg",
1893
+ "",
1134
1894
  "Library authoring (<kind> = experience|audio|component|behavior):",
1135
1895
  " experience|audio search <query> [--limit n] | inspect <stable-id>",
1136
1896
  " audio upload <file.m4a|file.mp4> AAC in MP4/M4A, at most 24 MiB",
1137
1897
  " audio create <stable-id> --name <text> --media <sha256> [--category c] [--language l]",
1138
- " experience create <stable-id> --name <text> --kind <poem|story|...>",
1898
+ " experience create <stable-id> --name <text> --kind <poem|story|...> (authored as the context's experience_schema, V3)",
1899
+ " experience drive <stable-id> --scenario scenario.json [--viewport desktop|mobile] [--out dir]",
1900
+ " experience render-video <stable-id> --recipe recipe.json [--saved] [--out dir] [--keep-frames]",
1901
+ " experience watch <stable-id> [--once] [--interval ms]",
1139
1902
  " (--scene-asset <asset-id> | --scene-package <kind:id> --scene <scene-id>)",
1140
1903
  " [--master-audio <audio-id>] [--duration-ms <n>]",
1141
1904
  " component create <stable-id> --name <text> [--category c] [--visual v] [--palette p]",
@@ -1165,7 +1928,22 @@ async function main() {
1165
1928
  commandId = `cmd_${randomUUID().replaceAll("-", "")}`;
1166
1929
  setCliTrace({ attemptId: active?.pointer.attempt_id, commandId });
1167
1930
  }
1168
- const result = await run(parsed);
1931
+ const plan = presencePlan(parsed.args);
1932
+ if (plan)
1933
+ await setPresence({ subject: plan.subject, status: plan.status, last_command: { op: plan.op } });
1934
+ let result;
1935
+ try {
1936
+ result = await run(parsed);
1937
+ }
1938
+ catch (error) {
1939
+ if (plan) {
1940
+ const code = error instanceof CliError ? error.code : "AGENT_COMMAND_FAILED";
1941
+ await setPresence({ subject: plan.subject, status: "idle", last_command: { op: plan.op }, last_result: { ok: false, code } });
1942
+ }
1943
+ throw error;
1944
+ }
1945
+ if (plan)
1946
+ await setPresence({ subject: plan.subject, status: "idle", last_command: { op: plan.op }, last_result: presenceResult(result) });
1169
1947
  if (active && commandId && parsed.args[0] !== "attempt")
1170
1948
  await saveCommandEvidence(active.pointer, commandId, parsed.args.slice(0, 2).join("."), result);
1171
1949
  output(result, parsed.json);