@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/README.md +269 -13
- package/dist/attempt.d.ts +4 -0
- package/dist/attempt.js +11 -1
- package/dist/attempt.js.map +1 -1
- package/dist/browser.d.ts +89 -3
- package/dist/browser.js +536 -14
- package/dist/browser.js.map +1 -1
- package/dist/cli.d.ts +20 -0
- package/dist/cli.js +851 -73
- package/dist/cli.js.map +1 -1
- package/dist/context.d.ts +66 -0
- package/dist/context.js +207 -0
- package/dist/context.js.map +1 -0
- package/dist/daemon.js +109 -7
- package/dist/daemon.js.map +1 -1
- package/dist/episode-runner.d.ts +109 -0
- package/dist/episode-runner.js +381 -0
- package/dist/episode-runner.js.map +1 -0
- package/dist/episode.d.ts +332 -0
- package/dist/episode.js +648 -0
- package/dist/episode.js.map +1 -0
- package/dist/presence.d.ts +49 -0
- package/dist/presence.js +141 -0
- package/dist/presence.js.map +1 -0
- package/dist/project.d.ts +30 -0
- package/dist/project.js +204 -0
- package/dist/project.js.map +1 -0
- package/dist/render-jobs.d.ts +101 -0
- package/dist/render-jobs.js +241 -0
- package/dist/render-jobs.js.map +1 -0
- package/dist/scenario.d.ts +33 -1
- package/dist/scenario.js +143 -5
- package/dist/scenario.js.map +1 -1
- package/dist/shot-provenance.d.ts +66 -0
- package/dist/shot-provenance.js +114 -0
- package/dist/shot-provenance.js.map +1 -0
- package/dist/state.js +10 -6
- package/dist/state.js.map +1 -1
- package/dist/video.d.ts +197 -0
- package/dist/video.js +446 -0
- package/dist/video.js.map +1 -0
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
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\
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
770
|
+
? experiencePreviewSummary(preview)
|
|
642
771
|
: { id: preview.id ?? null, implementation: preview.implementation ?? null, outputs: count(preview.outputs) };
|
|
643
|
-
|
|
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
|
|
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"
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
881
|
-
const
|
|
882
|
-
const
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
955
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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> --
|
|
1127
|
-
"
|
|
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
|
|
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);
|