@kheleshikhi/studio-agent 0.8.0 → 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
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
4
  import { realpathSync, watch as watchFile } from "node:fs";
5
5
  import { createRequire } from "node:module";
6
- import { access, appendFile, mkdir, readFile, rm, 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,18 +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
24
  import { parseScenario, parseExperienceScenario } from "./scenario.js";
20
25
  import { audioLevel, audioSegments, encode, encodeArguments, findEncoder, mix, parseRecipe, probe, RENDER_KIND, sha256, verifyProbe } from "./video.js";
21
26
  import { changedPaths } from "./presence.js";
27
+ import { inspectNotice, PROJECT_FLAGS, projectCommand, projectId, SCOPED_SEARCH_FLAGS, scopeWarningNotice, scopeWarnings, searchNotice, searchParameters } from "./project.js";
22
28
  import { capabilitiesView, compatibilityReport, contextSummary, experiencePreviewSummary, experienceSchemaNotice, mathPlaneRequirement, resolveCreateTarget, schemaTopicView } from "./context.js";
23
- const VERSION = "0.8.0";
29
+ const VERSION = "0.9.0";
24
30
  const AGENT_PROTOCOL = 1;
25
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`;
26
32
  // Presence-only options never consume the following positional argument.
27
- const BOOLEAN_FLAGS = new Set(["force", "full-page", "headless", "no-browser", "declarative", "assessment", "no-assessment", "scratch", "saved", "math-plane", "no-math-plane"]);
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"]);
28
34
  export function parse(argv) {
29
35
  const args = [];
30
36
  const flags = new Map();
@@ -70,13 +76,17 @@ export function assertSupportedFlags(parsed) {
70
76
  if (command === "_daemon")
71
77
  allowed = ["origin", "headless", "no-browser", "version-tag"];
72
78
  else if (command === "attempt" && action === "start")
73
- allowed = ["label", "mode"];
79
+ allowed = ["label", "mode", "project"];
74
80
  else if (command === "attempt" && action === "finish")
75
81
  allowed = ["outcome"];
76
82
  else if (command === "agent" && action === "init")
77
83
  allowed = ["provider"];
78
- 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")
79
87
  allowed = ["limit", "offset"];
88
+ else if (command === "project")
89
+ allowed = PROJECT_FLAGS[action || "list"] || [];
80
90
  else if (command === "world" && action === "inspect")
81
91
  allowed = ["zone"];
82
92
  else if (isLibraryKind(command)) {
@@ -99,6 +109,10 @@ export function assertSupportedFlags(parsed) {
99
109
  else if (action === "search")
100
110
  allowed = ["limit"];
101
111
  }
112
+ else if (command === "episode") {
113
+ if (["plan", "render", "status"].includes(action))
114
+ allowed = ["workspace", "out", "cache", "workers", "timeout"];
115
+ }
102
116
  else if (command === "studio") {
103
117
  if (action === "create")
104
118
  allowed = ["title", "target", ...BRIEF_FLAGS.filter((name) => name !== "no-assessment" && name !== "no-math-plane"), "command-id", "scratch", "starter"];
@@ -812,6 +826,67 @@ async function readRecipe(parsed) {
812
826
  throw new CliError("Recipe is not valid JSON.", "VIDEO_RECIPE_INVALID", 2);
813
827
  }
814
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
+ }
815
890
  /**
816
891
  * Mix, encode, verify and describe one render. The audio is mixed from the
817
892
  * canonical bytes the runtime would have played, at the times it logged;
@@ -819,6 +894,15 @@ async function readRecipe(parsed) {
819
894
  * derivative of and how it was made.
820
895
  */
821
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) {
822
906
  const { encoder, recipe, destination, frames } = input;
823
907
  const durationMs = Math.round((frames.frames / recipe.fps) * 1000);
824
908
  const log = (frames.audio?.events ?? []);
@@ -834,15 +918,21 @@ async function finishRender(input) {
834
918
  mixArgs = await mix(encoder, segments, new Map(Object.entries(sources).map(([key, value]) => [key, value.file])), durationMs, mixed);
835
919
  }
836
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.
837
923
  const output = path.join(destination, "video.mp4");
838
- await encode(encoder, recipe, path.join(destination, "frames", "%06d.png"), output, mixed);
839
- const probed = await probe(encoder, output);
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);
840
929
  verifyProbe(recipe, probed);
841
- const level = await audioLevel(encoder, output);
930
+ const level = await audioLevel(encoder, partial);
842
931
  if (segments.length && (level.max_db === null || level.max_db < -60)) {
843
932
  throw new CliError("Sounds were logged but the encoded audio track is silent.", "VIDEO_AUDIO_SILENT", 1, { segments: segments.length });
844
933
  }
845
- const bytes = await readFile(output);
934
+ setStage("manifest");
935
+ const bytes = await readFile(partial);
846
936
  const manifest = {
847
937
  kind: RENDER_KIND,
848
938
  subject: input.subject,
@@ -890,13 +980,16 @@ async function finishRender(input) {
890
980
  output: { file: "video.mp4", bytes: bytes.length, sha256: sha256(bytes), probe: probed },
891
981
  determinism: "Video times, learner actions and sound placements are exact. Pixels depend on the browser and GPU; compare renders by frame_sha256.",
892
982
  performance: { ...(frames.playback_timing ? { playback_timing: frames.playback_timing } : {}),
893
- capture_seconds: frames.capture_seconds, mix_seconds: (encodeStarted - mixStarted) / 1000,
983
+ capture_seconds: frames.capture_seconds, ...(frames.capture_phases ? { capture_phases: frames.capture_phases } : {}),
984
+ mix_seconds: (encodeStarted - mixStarted) / 1000,
894
985
  encode_seconds: (Date.now() - encodeStarted) / 1000,
895
986
  total_seconds: (Date.now() - input.started) / 1000, frames_per_second_captured: frames.frames / Math.max(frames.capture_seconds, 0.001),
896
987
  agent_rss_mb: Math.round(process.memoryUsage().rss / 1048576) },
897
988
  rendered_at: new Date().toISOString(),
898
989
  };
990
+ await rename(partial, output);
899
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 });
900
993
  if (!input.keepFrames)
901
994
  await rm(path.join(destination, "frames"), { recursive: true, force: true });
902
995
  if (mixed)
@@ -905,6 +998,93 @@ async function finishRender(input) {
905
998
  width: probed.width, height: probed.height, duration_seconds: probed.duration, audio_segments: segments.length,
906
999
  audio_max_dbfs: level.max_db, sha256: manifest.output.sha256 };
907
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
+ }
908
1088
  async function libraryInspectBrowser(parsed, kind, stableId) {
909
1089
  const target = libraryWorkspace(parsed, kind, stableId);
910
1090
  const viewport = flag(parsed, "viewport", "desktop");
@@ -1029,56 +1209,10 @@ async function library(parsed, kind, action, argument) {
1029
1209
  const recipe = parseRecipe(await readRecipe(parsed), "experience");
1030
1210
  const encoder = await findEncoder();
1031
1211
  await ensureCompatibility();
1032
- let candidate;
1033
- let candidateHash;
1034
- let expectedRevision;
1035
- let expectedHash;
1036
- let revision = null;
1037
- try {
1038
- const lock = await target.lock();
1039
- revision = lock.head_revision_public_id || null;
1040
- if (parsed.flags.has("saved")) {
1041
- expectedRevision = revision || undefined;
1042
- expectedHash = lock.source_hash;
1043
- }
1044
- }
1045
- catch { /* No local checkout: render the saved revision. */ }
1046
- if (!parsed.flags.has("saved")) {
1047
- try {
1048
- candidate = await target.source();
1049
- candidateHash = sourceHash(candidate);
1050
- }
1051
- catch { /* No local source: render the saved revision. */ }
1052
- }
1212
+ const source = await experienceRenderSource(target, parsed.flags.has("saved"));
1053
1213
  const destination = path.resolve(flag(parsed, "out", `render-experience-${stableId}-${Date.now()}`));
1054
- await mkdir(destination, { recursive: true });
1055
- const started = Date.now();
1056
- const response = await daemonRpc("/browser/render-experience-frames", {
1057
- method: "POST",
1058
- body: JSON.stringify({ asset: stableId, sourceHash: "", recipe, timeoutMs: Number(flag(parsed, "timeout", "30000")),
1059
- framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
1060
- out: path.join(destination, "mounted.png"),
1061
- library: { workspace: "experiences", selection: stableId, revision: expectedRevision, definitionHash: expectedHash },
1062
- ...(candidateHash ? { candidate, candidateHash } : {}) }),
1063
- });
1064
- const frames = response.result;
1065
- const context = await api("GET", "/v1/agent/context").catch(() => ({}));
1066
- const document = (candidate ?? {});
1067
- const scenes = document.scenes && typeof document.scenes === "object" ? document.scenes : null;
1068
- const result = await finishRender({
1069
- subject: "experience", encoder, recipe, destination, frames, started, keepFrames: parsed.flags.has("keep-frames"),
1070
- identity: {
1071
- experience: stableId,
1072
- source_hash: candidateHash ?? frames.source_hash ?? expectedHash ?? null,
1073
- source_origin: candidateHash ? "local" : "saved",
1074
- source_revision: revision,
1075
- scenes,
1076
- scene_runtime_fingerprint: context.runtime_fingerprint ?? null,
1077
- },
1078
- 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.",
1079
- clock: "video time (the Experience master timeline advances with it while it runs)",
1080
- extra: { scenes_shown: frames.scenes, final_state: frames.final_state, final_scene: frames.final_scene, timeline_ms: frames.timeline_ms, completed: frames.completed },
1081
- });
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 });
1082
1216
  return { ok: true, command: "experience.render-video", stable_id: stableId, ...result };
1083
1217
  }
1084
1218
  if (kind === "experience" && action === "watch") {
@@ -1123,6 +1257,173 @@ async function library(parsed, kind, action, argument) {
1123
1257
  return libraryExport(parsed, kind, stableId);
1124
1258
  return libraryInspectBrowser(parsed, kind, stableId);
1125
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
+ }
1126
1427
  async function run(parsed) {
1127
1428
  const [first, second, third] = parsed.args;
1128
1429
  assertSupportedFlags(parsed);
@@ -1144,13 +1445,14 @@ async function run(parsed) {
1144
1445
  if (await readAttemptPointer())
1145
1446
  throw new CliError("This workspace already has an Attempt.", "AGENT_ATTEMPT_ALREADY_ACTIVE", 3);
1146
1447
  const origin = (await readConfig()).apiOrigin;
1147
- 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) } : {}) });
1148
1450
  const pointer = await createAttemptPointer(server, origin);
1149
- 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 } : {}) };
1150
1452
  }
1151
1453
  if (second === "current") {
1152
1454
  const active = await readAttemptPointer();
1153
- 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 } : {}) };
1154
1456
  }
1155
1457
  if (second === "status") {
1156
1458
  const active = await readAttemptPointer();
@@ -1248,18 +1550,24 @@ async function run(parsed) {
1248
1550
  const plural = `${first}s`;
1249
1551
  if (second === "search") {
1250
1552
  const query = parsed.args.slice(2).join(" ");
1251
- const parameters = new URLSearchParams({ query });
1252
- const limit = flag(parsed, "limit");
1253
- const offset = flag(parsed, "offset");
1254
- if (limit)
1255
- parameters.set("limit", limit);
1256
- if (offset)
1257
- parameters.set("offset", offset);
1258
- 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 };
1259
1567
  }
1260
- if (second === "inspect")
1261
- return { ok: true, command: `${first}.inspect`, result: await api("GET", `/v1/agent/catalogue/${plural}/${encodeURIComponent(required(third, "ID is required."))}`) };
1262
1568
  }
1569
+ if (first === "project")
1570
+ return projectCommand(second, third, (name) => flag(parsed, name), api, (await readAttemptPointer())?.pointer.project);
1263
1571
  if (first === "world") {
1264
1572
  // Live World discovery; no World list is bundled with this package.
1265
1573
  if (!second || second === "list")
@@ -1276,6 +1584,8 @@ async function run(parsed) {
1276
1584
  if (result !== undefined)
1277
1585
  return result;
1278
1586
  }
1587
+ if (first === "episode")
1588
+ return episodeCommand(parsed, second, third);
1279
1589
  if (first === "studio") {
1280
1590
  const action = required(second, "Studio command is required.");
1281
1591
  if (action === "create") {
@@ -1360,8 +1670,15 @@ async function run(parsed) {
1360
1670
  }
1361
1671
  if (action === "source")
1362
1672
  return { ok: true, command: "studio.source", asset, source: await target.source() };
1363
- if (["validate", "compile", "preview"].includes(action))
1364
- 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
+ }
1365
1682
  if (action === "history")
1366
1683
  return { ok: true, command: "studio.history", asset, result: await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/history`) };
1367
1684
  if (action === "save") {
@@ -1475,23 +1792,9 @@ async function run(parsed) {
1475
1792
  throw new CliError("Studio has a draft without a readable source.", "STUDIO_SOURCE_UNAVAILABLE", 3);
1476
1793
  }
1477
1794
  const destination = path.resolve(flag(parsed, "out", `render-${asset}-${Date.now()}`));
1478
- await mkdir(destination, { recursive: true });
1479
- const started = Date.now();
1480
- const response = await daemonRpc("/browser/render-frames", {
1481
- method: "POST",
1482
- body: JSON.stringify({ asset, sourceHash: remoteHash, candidate, candidateHash: hash, recipe,
1483
- framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
1484
- measurePlaybackSeconds: Number(flag(parsed, "measure-playback", "0")),
1485
- out: path.join(destination, "mounted.png"), timeoutMs: Number(flag(parsed, "timeout", "30000")) }),
1486
- });
1487
- const frames = response.result;
1488
- const result = await finishRender({
1489
- subject: "scene", encoder, recipe, destination, frames, started, keepFrames: parsed.flags.has("keep-frames"),
1490
- identity: { asset, source_hash: hash, source_revision: frames.source_revision ?? null, runtime_fingerprint: frames.runtime_fingerprint ?? null },
1491
- 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.",
1492
- clock: "video time; the SceneClock follows it only while it plays (an authored pause holds the Scene)",
1493
- extra: { scene_clock: frames.scene_clock },
1494
- });
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 });
1495
1798
  return { ok: true, command: "studio.render-video", asset, source_hash: hash, ...result };
1496
1799
  }
1497
1800
  if (action === "capture-preview") {
@@ -1557,6 +1860,15 @@ function help() {
1557
1860
  " docs | docs search <query> | docs open <slug>",
1558
1861
  " agent start|status|stop|instructions|init --provider claude|codex|generic",
1559
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
+ "",
1560
1872
  " studio checkout|status|source|history|validate|compile|preview|save <asset>",
1561
1873
  " studio create --scratch --title <text> --concept <text> [--target sceneComposition] [--starter world:<world>/<zone>] [--command-id <id>]",
1562
1874
  " --target defaults to sceneComposition; targets the server lists as frozen (combined, mathGraph) are refused",
@@ -1573,6 +1885,12 @@ function help() {
1573
1885
  " presentation MP4 (H.264/AAC) of the learner surface at exact SceneClock times; needs local FFmpeg; writes render.json",
1574
1886
  " studio export <asset> --revision <revision> [--out file.kspkg]",
1575
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
+ "",
1576
1894
  "Library authoring (<kind> = experience|audio|component|behavior):",
1577
1895
  " experience|audio search <query> [--limit n] | inspect <stable-id>",
1578
1896
  " audio upload <file.m4a|file.mp4> AAC in MP4/M4A, at most 24 MiB",