@kheleshikhi/studio-agent 0.6.1 → 0.8.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 +215 -13
- package/dist/browser.d.ts +74 -2
- package/dist/browser.js +472 -12
- package/dist/browser.js.map +1 -1
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +515 -55
- 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 +41 -3
- package/dist/daemon.js.map +1 -1
- package/dist/presence.d.ts +49 -0
- package/dist/presence.js +141 -0
- package/dist/presence.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/video.d.ts +164 -0
- package/dist/video.js +385 -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, 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";
|
|
@@ -16,12 +16,15 @@ import { daemonRpc, setCliTrace } from "./protocol.js";
|
|
|
16
16
|
import { cleanStaleDaemonMetadata, readConfig, readDaemonMetadata, stateDirectory, validateOrigin, writeConfig, } from "./state.js";
|
|
17
17
|
import { CliError } from "./types.js";
|
|
18
18
|
import { sourceHash, StudioWorkspace } from "./workspace.js";
|
|
19
|
-
import { parseScenario } from "./scenario.js";
|
|
20
|
-
|
|
19
|
+
import { parseScenario, parseExperienceScenario } from "./scenario.js";
|
|
20
|
+
import { audioLevel, audioSegments, encode, encodeArguments, findEncoder, mix, parseRecipe, probe, RENDER_KIND, sha256, verifyProbe } from "./video.js";
|
|
21
|
+
import { changedPaths } from "./presence.js";
|
|
22
|
+
import { capabilitiesView, compatibilityReport, contextSummary, experiencePreviewSummary, experienceSchemaNotice, mathPlaneRequirement, resolveCreateTarget, schemaTopicView } from "./context.js";
|
|
23
|
+
const VERSION = "0.8.0";
|
|
21
24
|
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\
|
|
25
|
+
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
26
|
// 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"]);
|
|
27
|
+
const BOOLEAN_FLAGS = new Set(["force", "full-page", "headless", "no-browser", "declarative", "assessment", "no-assessment", "scratch", "saved", "math-plane", "no-math-plane"]);
|
|
25
28
|
export function parse(argv) {
|
|
26
29
|
const args = [];
|
|
27
30
|
const flags = new Map();
|
|
@@ -59,7 +62,7 @@ function flag(parsed, name, fallback) {
|
|
|
59
62
|
const value = parsed.flags.get(name);
|
|
60
63
|
return typeof value === "string" ? value : fallback;
|
|
61
64
|
}
|
|
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"];
|
|
65
|
+
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
66
|
const BROWSER_FLAGS = ["view", "viewport", "timeout"];
|
|
64
67
|
export function assertSupportedFlags(parsed) {
|
|
65
68
|
const [command, action, detail] = parsed.args;
|
|
@@ -74,6 +77,8 @@ export function assertSupportedFlags(parsed) {
|
|
|
74
77
|
allowed = ["provider"];
|
|
75
78
|
else if (["component", "behavior", "example"].includes(command) && action === "search")
|
|
76
79
|
allowed = ["limit", "offset"];
|
|
80
|
+
else if (command === "world" && action === "inspect")
|
|
81
|
+
allowed = ["zone"];
|
|
77
82
|
else if (isLibraryKind(command)) {
|
|
78
83
|
if (action === "create")
|
|
79
84
|
allowed = ["workspace", "name", "category", "visual", "palette", "implementation", "media", "language", "description", "kind", "master-audio", "declarative", "duration-ms", "scene-asset", "scene-package", "scene", "document"];
|
|
@@ -83,6 +88,12 @@ export function assertSupportedFlags(parsed) {
|
|
|
83
88
|
allowed = ["workspace", "revision", "out"];
|
|
84
89
|
else if (action === "inspect-browser")
|
|
85
90
|
allowed = ["workspace", "viewport", "timeout"];
|
|
91
|
+
else if (command === "experience" && action === "drive")
|
|
92
|
+
allowed = ["workspace", "scenario", "viewport", "timeout", "out", "saved"];
|
|
93
|
+
else if (command === "experience" && action === "watch")
|
|
94
|
+
allowed = ["workspace", "once", "interval"];
|
|
95
|
+
else if (command === "experience" && action === "render-video")
|
|
96
|
+
allowed = ["workspace", "recipe", "out", "timeout", "keep-frames", "saved"];
|
|
86
97
|
else if (["status", "save", "validate", "certify", "preview"].includes(action))
|
|
87
98
|
allowed = ["workspace"];
|
|
88
99
|
else if (action === "search")
|
|
@@ -90,7 +101,7 @@ export function assertSupportedFlags(parsed) {
|
|
|
90
101
|
}
|
|
91
102
|
else if (command === "studio") {
|
|
92
103
|
if (action === "create")
|
|
93
|
-
allowed = ["title", "target", ...BRIEF_FLAGS.filter((name) => name !== "no-assessment"), "command-id", "scratch"];
|
|
104
|
+
allowed = ["title", "target", ...BRIEF_FLAGS.filter((name) => name !== "no-assessment" && name !== "no-math-plane"), "command-id", "scratch", "starter"];
|
|
94
105
|
else if (action === "brief" && detail === "update")
|
|
95
106
|
allowed = BRIEF_FLAGS;
|
|
96
107
|
else if (action === "checkout")
|
|
@@ -103,10 +114,14 @@ export function assertSupportedFlags(parsed) {
|
|
|
103
114
|
allowed = ["workspace", "scenario", "viewport", "timeout", "out", "view"];
|
|
104
115
|
else if (action === "capture-preview")
|
|
105
116
|
allowed = ["workspace", "out", "viewport", "mode", "duration", "fps", "timeout"];
|
|
117
|
+
else if (action === "render-video")
|
|
118
|
+
allowed = ["workspace", "recipe", "out", "timeout", "keep-frames", "measure-playback"];
|
|
106
119
|
else if (action === "certify")
|
|
107
120
|
allowed = ["workspace", ...BROWSER_FLAGS];
|
|
108
121
|
else if (action === "export")
|
|
109
122
|
allowed = ["workspace", "revision", "out"];
|
|
123
|
+
else if (action === "watch")
|
|
124
|
+
allowed = ["workspace", "once", "interval"];
|
|
110
125
|
else
|
|
111
126
|
allowed = ["workspace"];
|
|
112
127
|
}
|
|
@@ -128,33 +143,15 @@ function output(value, asJson) {
|
|
|
128
143
|
else
|
|
129
144
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
130
145
|
}
|
|
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
|
-
}
|
|
146
|
+
/** Refuse an incompatible CLI and warn when the server recommends a newer one. */
|
|
146
147
|
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
|
-
}
|
|
148
|
+
const report = compatibilityReport(context, VERSION, AGENT_PROTOCOL);
|
|
149
|
+
if (report.status === "protocol_incompatible")
|
|
150
|
+
throw new CliError(report.message, "AGENT_PROTOCOL_INCOMPATIBLE", 5, { ...context, compatibility: report });
|
|
151
|
+
if (report.status === "upgrade_required")
|
|
152
|
+
throw new CliError(report.message, "AGENT_CLI_UPGRADE_REQUIRED", 5, { ...context, compatibility: report });
|
|
153
|
+
if (report.status === "upgrade_recommended")
|
|
154
|
+
process.stderr.write(`nerdbro: ${report.message}\n`);
|
|
158
155
|
}
|
|
159
156
|
export function certificationRuntimeObservation(observation) {
|
|
160
157
|
const allowed = [
|
|
@@ -162,7 +159,12 @@ export function certificationRuntimeObservation(observation) {
|
|
|
162
159
|
"runtime_fingerprint", "runtime_part_id", "active_states",
|
|
163
160
|
"state_machine_error", "math_graph", "environment", "error_summary",
|
|
164
161
|
];
|
|
165
|
-
|
|
162
|
+
const certified = Object.fromEntries(allowed.filter((key) => observation[key] !== undefined).map((key) => [key, observation[key]]));
|
|
163
|
+
// The server refuses a probed observation; say honestly whether this was one.
|
|
164
|
+
const probe = observation.probe;
|
|
165
|
+
if (probe && typeof probe === "object")
|
|
166
|
+
certified.probe = { active: probe.active === true };
|
|
167
|
+
return certified;
|
|
166
168
|
}
|
|
167
169
|
export function structuredErrorDetails(value) {
|
|
168
170
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
@@ -236,12 +238,19 @@ async function uploadApi(route, extension, contentType, bytes) {
|
|
|
236
238
|
}
|
|
237
239
|
}
|
|
238
240
|
let compatibilityChecked = false;
|
|
241
|
+
// The last live context this process read: target, deprecation and capability
|
|
242
|
+
// authority for this command. Never persisted, never bundled.
|
|
243
|
+
let serverContext = null;
|
|
239
244
|
async function ensureCompatibility() {
|
|
240
245
|
const context = await rawApi("GET", "/v1/agent/context");
|
|
241
246
|
assertCompatibility(context);
|
|
242
247
|
compatibilityChecked = true;
|
|
248
|
+
serverContext = context;
|
|
243
249
|
return context;
|
|
244
250
|
}
|
|
251
|
+
async function liveContext() {
|
|
252
|
+
return serverContext ?? ensureCompatibility();
|
|
253
|
+
}
|
|
245
254
|
async function api(method, route, body) {
|
|
246
255
|
if (!compatibilityChecked && route !== "/v1/agent/context")
|
|
247
256
|
await ensureCompatibility();
|
|
@@ -249,6 +258,7 @@ async function api(method, route, body) {
|
|
|
249
258
|
if (route === "/v1/agent/context") {
|
|
250
259
|
assertCompatibility(result);
|
|
251
260
|
compatibilityChecked = true;
|
|
261
|
+
serverContext = result;
|
|
252
262
|
}
|
|
253
263
|
return result;
|
|
254
264
|
}
|
|
@@ -407,6 +417,109 @@ async function assertBrowserSource(parsed, asset) {
|
|
|
407
417
|
async function reportBrowserActivity(asset, operation, metadata) {
|
|
408
418
|
await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/activity`, { operation, ...metadata });
|
|
409
419
|
}
|
|
420
|
+
async function reportExperienceActivity(stableId, operation, metadata) {
|
|
421
|
+
await api("POST", `/v1/agent/authoring/experience/${encodeURIComponent(stableId)}/activity`, { operation, ...metadata });
|
|
422
|
+
}
|
|
423
|
+
/** Agent Presence is supplementary: a failure never fails the command. */
|
|
424
|
+
async function setPresence(patch) {
|
|
425
|
+
try {
|
|
426
|
+
await api("PUT", "/v1/agent/presence", patch);
|
|
427
|
+
}
|
|
428
|
+
catch { /* Presence never blocks authoring. */ }
|
|
429
|
+
}
|
|
430
|
+
const PRESENCE_STATUS = {
|
|
431
|
+
validate: "validating", compile: "compiling", preview: "previewing", save: "saving",
|
|
432
|
+
certify: "certifying", drive: "probing", inspect: "capturing", screenshot: "capturing",
|
|
433
|
+
observe: "capturing", "capture-preview": "capturing", "inspect-browser": "capturing",
|
|
434
|
+
"render-video": "capturing",
|
|
435
|
+
};
|
|
436
|
+
/** Which presence a command announces, if it is one the Maker can watch. */
|
|
437
|
+
export function presencePlan(args) {
|
|
438
|
+
const [command, action, id] = args;
|
|
439
|
+
const status = action ? PRESENCE_STATUS[action] : undefined;
|
|
440
|
+
if (!status || !id || !/^[A-Za-z0-9_.-]{1,80}$/.test(id))
|
|
441
|
+
return null;
|
|
442
|
+
if (command === "studio")
|
|
443
|
+
return { subject: `asset:${id}`, status, op: action };
|
|
444
|
+
if (command === "experience" && action !== "inspect")
|
|
445
|
+
return { subject: `experience:${id}`, status, op: action };
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
/** The bounded outcome a command reports to presence: verdicts, never documents. */
|
|
449
|
+
export function presenceResult(result) {
|
|
450
|
+
const value = result && typeof result === "object" ? result : {};
|
|
451
|
+
const inner = value.result && typeof value.result === "object" ? value.result : {};
|
|
452
|
+
const outcome = { ok: value.ok !== false };
|
|
453
|
+
for (const [key, item] of Object.entries({
|
|
454
|
+
valid: inner.valid, compiled: inner.compiled, certified: inner.certified,
|
|
455
|
+
draft_created: value.draft_created, evidence_mode: value.evidence_mode,
|
|
456
|
+
diagnostic_count: Array.isArray(inner.diagnostics) ? inner.diagnostics.length : undefined,
|
|
457
|
+
})) {
|
|
458
|
+
if (typeof item === "boolean" || typeof item === "number" || typeof item === "string")
|
|
459
|
+
outcome[key] = item;
|
|
460
|
+
}
|
|
461
|
+
return outcome;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Report where the Agent is working in its local, unsaved source: changed
|
|
465
|
+
* JSON Pointers against the saved head and a working hash. Never the source
|
|
466
|
+
* itself. `--once` reports one settled state; otherwise it follows edits
|
|
467
|
+
* with a debounce until interrupted.
|
|
468
|
+
*/
|
|
469
|
+
async function watchSource(parsed, subject, command, sourcePath, readLocal, readRemote, report) {
|
|
470
|
+
const remote = await readRemote();
|
|
471
|
+
const interval = Math.max(250, Math.min(5_000, Number(flag(parsed, "interval", "750"))));
|
|
472
|
+
let lastActivityAt = 0;
|
|
473
|
+
let last;
|
|
474
|
+
const settle = async (forceActivity) => {
|
|
475
|
+
let local;
|
|
476
|
+
try {
|
|
477
|
+
local = await readLocal();
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
await setPresence({ subject, status: "editing" });
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
const hash = sourceHash(local);
|
|
484
|
+
const paths = changedPaths(remote, local);
|
|
485
|
+
if (last && last.hash === hash && !forceActivity)
|
|
486
|
+
return last;
|
|
487
|
+
last = { hash, paths };
|
|
488
|
+
await setPresence({ subject, status: "editing", working_source_hash: hash, changed_paths: paths,
|
|
489
|
+
...(paths[0] ? { focused_path: paths[0] } : {}) });
|
|
490
|
+
// The activity feed keeps a bounded history; presence keeps only now.
|
|
491
|
+
if (forceActivity || Date.now() - lastActivityAt >= 10_000) {
|
|
492
|
+
lastActivityAt = Date.now();
|
|
493
|
+
await report({ status: "completed", working_source_hash: hash, changed_paths: paths, change_count: paths.length }).catch(() => undefined);
|
|
494
|
+
}
|
|
495
|
+
return last;
|
|
496
|
+
};
|
|
497
|
+
if (parsed.flags.has("once")) {
|
|
498
|
+
const settled = await settle(true);
|
|
499
|
+
return { ok: true, command, subject, working_source_hash: settled?.hash || null, changed_paths: settled?.paths || [], parse_error: !settled };
|
|
500
|
+
}
|
|
501
|
+
await settle(true);
|
|
502
|
+
let timer;
|
|
503
|
+
const watcher = watchFile(path.dirname(sourcePath), (_event, name) => {
|
|
504
|
+
if (name && path.basename(String(name)) !== path.basename(sourcePath))
|
|
505
|
+
return;
|
|
506
|
+
clearTimeout(timer);
|
|
507
|
+
timer = setTimeout(() => { void settle(false); }, interval);
|
|
508
|
+
});
|
|
509
|
+
// Presence expires on the server; a quiet editor still says it is here.
|
|
510
|
+
const heartbeat = setInterval(() => { void settle(true).catch(() => undefined); }, 30_000);
|
|
511
|
+
process.stderr.write(`Watching ${sourcePath}; press Ctrl+C to stop.\n`);
|
|
512
|
+
await new Promise((resolve) => {
|
|
513
|
+
const stop = () => resolve();
|
|
514
|
+
process.once("SIGINT", stop);
|
|
515
|
+
process.once("SIGTERM", stop);
|
|
516
|
+
});
|
|
517
|
+
watcher.close();
|
|
518
|
+
clearInterval(heartbeat);
|
|
519
|
+
clearTimeout(timer);
|
|
520
|
+
await setPresence({ subject, status: "idle" });
|
|
521
|
+
return { ok: true, command, subject, working_source_hash: last?.hash || null, changed_paths: last?.paths || [] };
|
|
522
|
+
}
|
|
410
523
|
async function capture(parsed, asset, inspect, activityOperation = inspect ? "browser.inspect" : "browser.capture") {
|
|
411
524
|
const { source, hash } = await assertBrowserSource(parsed, asset);
|
|
412
525
|
const view = flag(parsed, "view", "canvas");
|
|
@@ -570,7 +683,8 @@ async function libraryCheckout(parsed, kind, stableId) {
|
|
|
570
683
|
checked_out_at: now,
|
|
571
684
|
};
|
|
572
685
|
await target.write(document, lock);
|
|
573
|
-
|
|
686
|
+
const notice = kind === "experience" ? experienceSchemaNotice(document, serverContext) : null;
|
|
687
|
+
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
688
|
}
|
|
575
689
|
async function libraryStatus(parsed, kind, stableId) {
|
|
576
690
|
const target = libraryWorkspace(parsed, kind, stableId);
|
|
@@ -595,6 +709,7 @@ async function libraryStatus(parsed, kind, stableId) {
|
|
|
595
709
|
approved_revision: remote.approved_revision || null,
|
|
596
710
|
schema_version: remote.schema_version ?? null,
|
|
597
711
|
schema_changed: (remote.schema_version ?? null) !== lock.schema_version,
|
|
712
|
+
...(kind === "experience" && experienceSchemaNotice(source, serverContext) ? { schema_notice: experienceSchemaNotice(source, serverContext) } : {}),
|
|
598
713
|
};
|
|
599
714
|
}
|
|
600
715
|
async function librarySave(parsed, kind, stableId) {
|
|
@@ -638,9 +753,10 @@ async function libraryPreview(parsed, kind, stableId) {
|
|
|
638
753
|
await writeFile(target.previewPath, `${JSON.stringify(preview, null, 2)}\n`, "utf8");
|
|
639
754
|
const count = (value) => Array.isArray(value) ? value.length : value !== null && typeof value === "object" ? Object.keys(value).length : null;
|
|
640
755
|
const summary = kind === "experience"
|
|
641
|
-
?
|
|
756
|
+
? experiencePreviewSummary(preview)
|
|
642
757
|
: { id: preview.id ?? null, implementation: preview.implementation ?? null, outputs: count(preview.outputs) };
|
|
643
|
-
|
|
758
|
+
const notice = kind === "experience" ? experienceSchemaNotice(source, serverContext) : null;
|
|
759
|
+
return { ok: true, command: `${kind}.preview`, kind, stable_id: stableId, local_source_hash: sourceHash(source), preview: target.previewPath, summary, ...(notice ? { schema_notice: notice } : {}) };
|
|
644
760
|
}
|
|
645
761
|
async function libraryExport(parsed, kind, stableId) {
|
|
646
762
|
const revision = required(flag(parsed, "revision"), "--revision is required.");
|
|
@@ -685,6 +801,110 @@ async function audioUpload(file) {
|
|
|
685
801
|
next: `nerdbro audio create <stable-id> --name <text> --media ${media.sha256}`,
|
|
686
802
|
};
|
|
687
803
|
}
|
|
804
|
+
async function readRecipe(parsed) {
|
|
805
|
+
const recipeFile = path.resolve(required(flag(parsed, "recipe"), "--recipe <recipe.json> is required."));
|
|
806
|
+
if ((await stat(recipeFile)).size > 65536)
|
|
807
|
+
throw new CliError("Recipe exceeds 64 KiB.", "VIDEO_RECIPE_INVALID", 2);
|
|
808
|
+
try {
|
|
809
|
+
return JSON.parse(await readFile(recipeFile, "utf8"));
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
throw new CliError("Recipe is not valid JSON.", "VIDEO_RECIPE_INVALID", 2);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Mix, encode, verify and describe one render. The audio is mixed from the
|
|
817
|
+
* canonical bytes the runtime would have played, at the times it logged;
|
|
818
|
+
* the MP4 is probed against its recipe; `render.json` says what it is a
|
|
819
|
+
* derivative of and how it was made.
|
|
820
|
+
*/
|
|
821
|
+
async function finishRender(input) {
|
|
822
|
+
const { encoder, recipe, destination, frames } = input;
|
|
823
|
+
const durationMs = Math.round((frames.frames / recipe.fps) * 1000);
|
|
824
|
+
const log = (frames.audio?.events ?? []);
|
|
825
|
+
const sources = (frames.audio?.sources ?? {});
|
|
826
|
+
const mixStarted = Date.now();
|
|
827
|
+
let mixed;
|
|
828
|
+
let mixArgs = null;
|
|
829
|
+
// Logged times are video times from 0; the MP4 starts at `start`.
|
|
830
|
+
const startMs = Math.round(recipe.start * 1000);
|
|
831
|
+
const segments = recipe.audio === "scene" ? audioSegments(log, startMs + durationMs, startMs) : [];
|
|
832
|
+
if (segments.length) {
|
|
833
|
+
mixed = path.join(destination, "audio-mix.wav");
|
|
834
|
+
mixArgs = await mix(encoder, segments, new Map(Object.entries(sources).map(([key, value]) => [key, value.file])), durationMs, mixed);
|
|
835
|
+
}
|
|
836
|
+
const encodeStarted = Date.now();
|
|
837
|
+
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);
|
|
840
|
+
verifyProbe(recipe, probed);
|
|
841
|
+
const level = await audioLevel(encoder, output);
|
|
842
|
+
if (segments.length && (level.max_db === null || level.max_db < -60)) {
|
|
843
|
+
throw new CliError("Sounds were logged but the encoded audio track is silent.", "VIDEO_AUDIO_SILENT", 1, { segments: segments.length });
|
|
844
|
+
}
|
|
845
|
+
const bytes = await readFile(output);
|
|
846
|
+
const manifest = {
|
|
847
|
+
kind: RENDER_KIND,
|
|
848
|
+
subject: input.subject,
|
|
849
|
+
derivative: true,
|
|
850
|
+
canonical: false,
|
|
851
|
+
certification_evidence: false,
|
|
852
|
+
note: input.note,
|
|
853
|
+
...input.identity,
|
|
854
|
+
recipe,
|
|
855
|
+
recipe_sha256: sha256(JSON.stringify(recipe)),
|
|
856
|
+
scenario_sha256: sha256(JSON.stringify(recipe.actions)),
|
|
857
|
+
timing: {
|
|
858
|
+
clock: input.clock,
|
|
859
|
+
fps: recipe.fps,
|
|
860
|
+
start_seconds: recipe.start,
|
|
861
|
+
end_seconds: recipe.end,
|
|
862
|
+
frame_count: frames.frames,
|
|
863
|
+
frame_time: "start + k / fps (computed exactly, never accumulated); frame k exists iff start + k / fps < end",
|
|
864
|
+
first_frame_seconds: recipe.start,
|
|
865
|
+
last_frame_seconds: recipe.start + (frames.frames - 1) / recipe.fps,
|
|
866
|
+
},
|
|
867
|
+
presentation: {
|
|
868
|
+
host: "studio_present=1 (the learner runtime alone, no Studio chrome)",
|
|
869
|
+
profile: recipe.profile,
|
|
870
|
+
frame_size: frames.frame_size,
|
|
871
|
+
scaled_after_capture: false,
|
|
872
|
+
},
|
|
873
|
+
learner_surface: frames.learner_surface,
|
|
874
|
+
camera_shots: frames.camera_shots,
|
|
875
|
+
actions: frames.actions,
|
|
876
|
+
...(input.extra ?? {}),
|
|
877
|
+
frames_sha256: frames.frames_sha256,
|
|
878
|
+
audio: {
|
|
879
|
+
mode: recipe.audio,
|
|
880
|
+
codec: "aac",
|
|
881
|
+
method: "canonical assets mixed by FFmpeg at their logged video times; nothing was recorded from a speaker",
|
|
882
|
+
events: log,
|
|
883
|
+
sources: Object.fromEntries(Object.entries(sources).map(([key, value]) => [key, { sha256: value.sha256, bytes: value.bytes }])),
|
|
884
|
+
segments,
|
|
885
|
+
mix_arguments: mixArgs?.map((value) => value.startsWith(destination) ? path.relative(destination, value) : value) ?? null,
|
|
886
|
+
level_dbfs: level,
|
|
887
|
+
...(segments.length ? {} : { note: recipe.audio === "silent" ? "The recipe asked for silence." : "Nothing sounded during the rendered span." }),
|
|
888
|
+
},
|
|
889
|
+
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) },
|
|
890
|
+
output: { file: "video.mp4", bytes: bytes.length, sha256: sha256(bytes), probe: probed },
|
|
891
|
+
determinism: "Video times, learner actions and sound placements are exact. Pixels depend on the browser and GPU; compare renders by frame_sha256.",
|
|
892
|
+
performance: { ...(frames.playback_timing ? { playback_timing: frames.playback_timing } : {}),
|
|
893
|
+
capture_seconds: frames.capture_seconds, mix_seconds: (encodeStarted - mixStarted) / 1000,
|
|
894
|
+
encode_seconds: (Date.now() - encodeStarted) / 1000,
|
|
895
|
+
total_seconds: (Date.now() - input.started) / 1000, frames_per_second_captured: frames.frames / Math.max(frames.capture_seconds, 0.001),
|
|
896
|
+
agent_rss_mb: Math.round(process.memoryUsage().rss / 1048576) },
|
|
897
|
+
rendered_at: new Date().toISOString(),
|
|
898
|
+
};
|
|
899
|
+
await writeFile(path.join(destination, "render.json"), `${JSON.stringify({ ...manifest, frame_sha256: frames.frame_sha256 }, null, 2)}\n`);
|
|
900
|
+
if (!input.keepFrames)
|
|
901
|
+
await rm(path.join(destination, "frames"), { recursive: true, force: true });
|
|
902
|
+
if (mixed)
|
|
903
|
+
await rm(mixed, { force: true });
|
|
904
|
+
return { output, manifest: path.join(destination, "render.json"), frames: frames.frames,
|
|
905
|
+
width: probed.width, height: probed.height, duration_seconds: probed.duration, audio_segments: segments.length,
|
|
906
|
+
audio_max_dbfs: level.max_db, sha256: manifest.output.sha256 };
|
|
907
|
+
}
|
|
688
908
|
async function libraryInspectBrowser(parsed, kind, stableId) {
|
|
689
909
|
const target = libraryWorkspace(parsed, kind, stableId);
|
|
690
910
|
const viewport = flag(parsed, "viewport", "desktop");
|
|
@@ -728,6 +948,144 @@ async function libraryInspectBrowser(parsed, kind, stableId) {
|
|
|
728
948
|
return { ok: true, command: `${kind}.inspect-browser`, kind, stable_id: stableId, screenshot: target.screenshotPath, browser: browserPath, activity, activity_scope: "local", warning };
|
|
729
949
|
}
|
|
730
950
|
async function library(parsed, kind, action, argument) {
|
|
951
|
+
if (kind === "experience" && action === "drive") {
|
|
952
|
+
const stableId = required(argument, "Experience stable id is required.");
|
|
953
|
+
const target = libraryWorkspace(parsed, kind, stableId);
|
|
954
|
+
const scenarioFile = path.resolve(required(flag(parsed, "scenario"), "--scenario <file.json> is required."));
|
|
955
|
+
if ((await stat(scenarioFile)).size > 65536)
|
|
956
|
+
throw new CliError("Scenario exceeds 64 KiB.", "STUDIO_SCENARIO_INVALID", 2);
|
|
957
|
+
let document;
|
|
958
|
+
try {
|
|
959
|
+
document = JSON.parse(await readFile(scenarioFile, "utf8"));
|
|
960
|
+
}
|
|
961
|
+
catch {
|
|
962
|
+
throw new CliError("Scenario is not valid JSON.", "STUDIO_SCENARIO_INVALID", 2);
|
|
963
|
+
}
|
|
964
|
+
const scenario = parseExperienceScenario(document);
|
|
965
|
+
await ensureCompatibility();
|
|
966
|
+
const destination = path.resolve(flag(parsed, "out", `experience-drive-${stableId}-${Date.now()}`));
|
|
967
|
+
await mkdir(destination, { recursive: true });
|
|
968
|
+
// Drive the exact local source.json by default: an unsaved candidate is
|
|
969
|
+
// mounted in the Agent's own browser through the non-persisting Draft
|
|
970
|
+
// Preview and bound by its canonical hash. Nothing is saved, and nothing
|
|
971
|
+
// reaches learners. --saved drives the saved server revision instead.
|
|
972
|
+
let candidate;
|
|
973
|
+
let candidateHash;
|
|
974
|
+
let expectedRevision;
|
|
975
|
+
let expectedHash;
|
|
976
|
+
if (!parsed.flags.has("saved")) {
|
|
977
|
+
try {
|
|
978
|
+
candidate = await target.source();
|
|
979
|
+
candidateHash = sourceHash(candidate);
|
|
980
|
+
}
|
|
981
|
+
catch { /* No local checkout: drive the saved revision. */ }
|
|
982
|
+
}
|
|
983
|
+
if (candidateHash === undefined) {
|
|
984
|
+
try {
|
|
985
|
+
const lock = await target.lock();
|
|
986
|
+
expectedRevision = lock.head_revision_public_id || undefined;
|
|
987
|
+
expectedHash = lock.source_hash;
|
|
988
|
+
}
|
|
989
|
+
catch { /* An approved Experience can be driven without local checkout. */ }
|
|
990
|
+
}
|
|
991
|
+
const sourceOrigin = candidateHash ? "local" : "saved";
|
|
992
|
+
const scenarioHash = createHash("sha256").update(JSON.stringify(scenario)).digest("hex");
|
|
993
|
+
const driveMetadata = { scenario_hash: scenarioHash, step_count: scenario.steps.length,
|
|
994
|
+
viewport: flag(parsed, "viewport", "desktop"), source_origin: sourceOrigin,
|
|
995
|
+
...(candidateHash ?? expectedHash ? { source_hash: candidateHash ?? expectedHash } : {}) };
|
|
996
|
+
let response;
|
|
997
|
+
try {
|
|
998
|
+
response = await daemonRpc("/browser/experience-drive", {
|
|
999
|
+
method: "POST",
|
|
1000
|
+
body: JSON.stringify({ asset: stableId, sourceHash: "", out: path.join(destination, "initial.png"),
|
|
1001
|
+
viewport: flag(parsed, "viewport", "desktop"), timeoutMs: Number(flag(parsed, "timeout", "30000")),
|
|
1002
|
+
library: { workspace: "experiences", selection: stableId, revision: expectedRevision, definitionHash: expectedHash }, scenario,
|
|
1003
|
+
...(candidateHash ? { candidate, candidateHash } : {}),
|
|
1004
|
+
presenceSubject: `experience:${stableId}` }),
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
catch (error) {
|
|
1008
|
+
const failure = error instanceof CliError ? error : new CliError(String(error));
|
|
1009
|
+
const summaries = failure.details?.step_summaries;
|
|
1010
|
+
await reportExperienceActivity(stableId, "experience.drive", { ...driveMetadata, status: "failed", error_code: failure.code,
|
|
1011
|
+
...(Array.isArray(summaries) ? { steps: summaries.slice(0, 32), failed_step_index: summaries.length - 1 } : {}) }).catch(() => undefined);
|
|
1012
|
+
throw error;
|
|
1013
|
+
}
|
|
1014
|
+
const resultPath = path.join(destination, "result.json");
|
|
1015
|
+
await writeFile(resultPath, `${JSON.stringify(response.result, null, 2)}\n`);
|
|
1016
|
+
const evidenceMode = response.result.evidence_mode === "journey" ? "journey" : "probe";
|
|
1017
|
+
await reportExperienceActivity(stableId, "experience.drive", { ...driveMetadata, status: "completed", evidence_mode: evidenceMode,
|
|
1018
|
+
steps: response.result.step_summaries?.slice(0, 32) }).catch(() => undefined);
|
|
1019
|
+
return { ok: true, command: "experience.drive", stable_id: stableId, output: destination,
|
|
1020
|
+
result: resultPath, steps: scenario.steps.length, evidence_mode: evidenceMode,
|
|
1021
|
+
source_origin: sourceOrigin, ...(candidateHash ? { source_hash: candidateHash } : {}),
|
|
1022
|
+
...(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." } : {}) };
|
|
1023
|
+
}
|
|
1024
|
+
if (kind === "experience" && action === "render-video") {
|
|
1025
|
+
// A presentation derivative of an Experience: see video.ts. The real
|
|
1026
|
+
// Experience player, in the presentation host, driven by video time.
|
|
1027
|
+
const stableId = required(argument, "Experience stable id is required.");
|
|
1028
|
+
const target = libraryWorkspace(parsed, kind, stableId);
|
|
1029
|
+
const recipe = parseRecipe(await readRecipe(parsed), "experience");
|
|
1030
|
+
const encoder = await findEncoder();
|
|
1031
|
+
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
|
+
}
|
|
1053
|
+
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
|
+
});
|
|
1082
|
+
return { ok: true, command: "experience.render-video", stable_id: stableId, ...result };
|
|
1083
|
+
}
|
|
1084
|
+
if (kind === "experience" && action === "watch") {
|
|
1085
|
+
const stableId = required(argument, "Experience stable id is required.");
|
|
1086
|
+
const target = libraryWorkspace(parsed, kind, stableId);
|
|
1087
|
+
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));
|
|
1088
|
+
}
|
|
731
1089
|
if (kind === "audio" && action === "upload")
|
|
732
1090
|
return audioUpload(required(argument, "Audio file is required."));
|
|
733
1091
|
if (AUTHORING_SEARCH_KINDS.includes(kind) && action === "search") {
|
|
@@ -822,10 +1180,23 @@ async function run(parsed) {
|
|
|
822
1180
|
return daemonRpc("/disconnect", { method: "POST", body: "{}" });
|
|
823
1181
|
if (first === "whoami")
|
|
824
1182
|
return { ok: true, command: "whoami", result: await api("GET", "/v1/agent/whoami") };
|
|
825
|
-
if (first === "context"
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1183
|
+
if (first === "context") {
|
|
1184
|
+
const result = await api("GET", "/v1/agent/context");
|
|
1185
|
+
return { ok: true, command: "context", summary: contextSummary(result, VERSION, AGENT_PROTOCOL), result };
|
|
1186
|
+
}
|
|
1187
|
+
if (first === "capabilities") {
|
|
1188
|
+
const registry = await api("GET", "/v1/agent/capabilities");
|
|
1189
|
+
return { ok: true, command: "capabilities", ...capabilitiesView(registry, await liveContext()) };
|
|
1190
|
+
}
|
|
1191
|
+
if (first === "schema") {
|
|
1192
|
+
const result = await api("GET", second ? `/v1/agent/schema/${encodeURIComponent(second)}` : "/v1/agent/schema");
|
|
1193
|
+
if (!second)
|
|
1194
|
+
return { ok: true, command: "schema", result };
|
|
1195
|
+
const view = schemaTopicView(second, result, await liveContext());
|
|
1196
|
+
if (typeof view.deprecated === "string" && !parsed.json)
|
|
1197
|
+
process.stderr.write(`nerdbro: schema ${second} is frozen: ${view.deprecated}\n`);
|
|
1198
|
+
return { ok: true, command: "schema", result: view };
|
|
1199
|
+
}
|
|
829
1200
|
if (first === "guide") {
|
|
830
1201
|
const result = await api("GET", "/v1/agent/docs/agent-authoring-playbook");
|
|
831
1202
|
return {
|
|
@@ -889,6 +1260,17 @@ async function run(parsed) {
|
|
|
889
1260
|
if (second === "inspect")
|
|
890
1261
|
return { ok: true, command: `${first}.inspect`, result: await api("GET", `/v1/agent/catalogue/${plural}/${encodeURIComponent(required(third, "ID is required."))}`) };
|
|
891
1262
|
}
|
|
1263
|
+
if (first === "world") {
|
|
1264
|
+
// Live World discovery; no World list is bundled with this package.
|
|
1265
|
+
if (!second || second === "list")
|
|
1266
|
+
return { ok: true, command: "world.list", result: await api("GET", "/v1/agent/worlds") };
|
|
1267
|
+
if (second === "inspect") {
|
|
1268
|
+
const world = required(third, "World ID is required.");
|
|
1269
|
+
const zone = flag(parsed, "zone");
|
|
1270
|
+
const query = zone ? `?zone=${encodeURIComponent(zone)}` : "";
|
|
1271
|
+
return { ok: true, command: "world.inspect", result: await api("GET", `/v1/agent/worlds/${encodeURIComponent(world)}${query}`) };
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
892
1274
|
if (isLibraryKind(first)) {
|
|
893
1275
|
const result = await library(parsed, first, second, third);
|
|
894
1276
|
if (result !== undefined)
|
|
@@ -898,7 +1280,8 @@ async function run(parsed) {
|
|
|
898
1280
|
const action = required(second, "Studio command is required.");
|
|
899
1281
|
if (action === "create") {
|
|
900
1282
|
const title = required(flag(parsed, "title"), "--title is required.");
|
|
901
|
-
|
|
1283
|
+
// Default and frozen targets come from the live context, never from this package.
|
|
1284
|
+
const target = resolveCreateTarget(flag(parsed, "target"), await liveContext());
|
|
902
1285
|
const concept = required(flag(parsed, "concept"), "--concept is required.");
|
|
903
1286
|
const brief = {
|
|
904
1287
|
concept,
|
|
@@ -910,21 +1293,26 @@ async function run(parsed) {
|
|
|
910
1293
|
graph_y_semantics: flag(parsed, "graph-y"),
|
|
911
1294
|
graph_sync_semantics: flag(parsed, "graph-sync"),
|
|
912
1295
|
language: flag(parsed, "language", "bilingual"),
|
|
913
|
-
|
|
1296
|
+
// A graph is a Scene math plane, asked for by graph intent, not by a target.
|
|
1297
|
+
requires_graph: mathPlaneRequirement(parsed.flags),
|
|
914
1298
|
requires_assessment: parsed.flags.has("assessment"),
|
|
915
1299
|
additional_instruction: flag(parsed, "additional-instruction"),
|
|
916
1300
|
selected_components: flag(parsed, "components")?.split(",").map((value) => value.trim()).filter(Boolean),
|
|
917
1301
|
selected_behavior: flag(parsed, "behavior"),
|
|
918
1302
|
important_values: flag(parsed, "important-values")?.split(",").map((value) => value.trim()).filter(Boolean),
|
|
919
1303
|
};
|
|
1304
|
+
// A World zone starter (world:<world>/<zone>) is resolved by the server
|
|
1305
|
+
// from the live World registry: the same Scene the human Playground opens.
|
|
1306
|
+
const starter = flag(parsed, "starter");
|
|
920
1307
|
const result = await api("POST", "/v1/agent/studio", {
|
|
921
1308
|
client_command_id: flag(parsed, "command-id", randomUUID()),
|
|
922
1309
|
title, authoring_target: target, brief,
|
|
1310
|
+
...(starter ? { starter } : {}),
|
|
923
1311
|
});
|
|
924
1312
|
// A newly created Studio asset has no approved revision or draft; it is
|
|
925
1313
|
// already the isolated scratch surface. The flag makes that intent
|
|
926
1314
|
// explicit without inventing a second server-side lifecycle.
|
|
927
|
-
return { ok: true, command: "studio.create", scratch: parsed.flags.has("scratch"), result };
|
|
1315
|
+
return { ok: true, command: "studio.create", scratch: parsed.flags.has("scratch"), target, math_plane_requested: brief.requires_graph === true, result };
|
|
928
1316
|
}
|
|
929
1317
|
if (action === "brief") {
|
|
930
1318
|
const updating = third === "update";
|
|
@@ -951,8 +1339,9 @@ async function run(parsed) {
|
|
|
951
1339
|
brief.requires_assessment = true;
|
|
952
1340
|
if (parsed.flags.has("no-assessment"))
|
|
953
1341
|
brief.requires_assessment = false;
|
|
954
|
-
|
|
955
|
-
|
|
1342
|
+
const mathPlane = mathPlaneRequirement(parsed.flags);
|
|
1343
|
+
if (mathPlane !== undefined)
|
|
1344
|
+
brief.requires_graph = mathPlane;
|
|
956
1345
|
if (flag(parsed, "components"))
|
|
957
1346
|
brief.selected_components = flag(parsed, "components").split(",").map((value) => value.trim()).filter(Boolean);
|
|
958
1347
|
if (flag(parsed, "important-values"))
|
|
@@ -966,6 +1355,9 @@ async function run(parsed) {
|
|
|
966
1355
|
return checkout(parsed, asset);
|
|
967
1356
|
if (action === "status")
|
|
968
1357
|
return studioStatus(parsed, asset);
|
|
1358
|
+
if (action === "watch") {
|
|
1359
|
+
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));
|
|
1360
|
+
}
|
|
969
1361
|
if (action === "source")
|
|
970
1362
|
return { ok: true, command: "studio.source", asset, source: await target.source() };
|
|
971
1363
|
if (["validate", "compile", "preview"].includes(action))
|
|
@@ -1050,19 +1442,57 @@ async function run(parsed) {
|
|
|
1050
1442
|
response = await daemonRpc("/browser/drive", {
|
|
1051
1443
|
method: "POST",
|
|
1052
1444
|
body: JSON.stringify({ asset, sourceHash: remoteHash, candidate, candidateHash: hash, scenario,
|
|
1445
|
+
presenceSubject: `asset:${asset}`,
|
|
1053
1446
|
viewport: flag(parsed, "viewport", "desktop"),
|
|
1054
1447
|
out: path.join(destination, "initial.png"), timeoutMs: Number(flag(parsed, "timeout", "30000")) }),
|
|
1055
1448
|
});
|
|
1056
1449
|
}
|
|
1057
1450
|
catch (error) {
|
|
1058
1451
|
const failure = error instanceof CliError ? error : new CliError(String(error));
|
|
1059
|
-
|
|
1452
|
+
const summaries = failure.details?.step_summaries;
|
|
1453
|
+
await reportBrowserActivity(asset, "studio.drive", { ...driveMetadata, status: "failed", error_code: failure.code,
|
|
1454
|
+
...(Array.isArray(summaries) ? { steps: summaries.slice(0, 32), failed_step_index: summaries.length - 1 } : {}) }).catch(() => undefined);
|
|
1060
1455
|
throw error;
|
|
1061
1456
|
}
|
|
1062
1457
|
await writeFile(path.join(destination, "result.json"), `${JSON.stringify(response.result, null, 2)}\n`);
|
|
1063
|
-
|
|
1458
|
+
const evidenceMode = response.result.evidence_mode === "journey" ? "journey" : "probe";
|
|
1459
|
+
await reportBrowserActivity(asset, "studio.drive", { ...driveMetadata, status: "completed", evidence_mode: evidenceMode,
|
|
1460
|
+
steps: response.result.step_summaries?.slice(0, 32) });
|
|
1064
1461
|
return { ok: true, command: "studio.drive", asset, source_hash: hash, output: destination,
|
|
1065
|
-
result: path.join(destination, "result.json"), steps: scenario.steps.length
|
|
1462
|
+
result: path.join(destination, "result.json"), steps: scenario.steps.length, evidence_mode: evidenceMode,
|
|
1463
|
+
...(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." } : {}) };
|
|
1464
|
+
}
|
|
1465
|
+
if (action === "render-video") {
|
|
1466
|
+
// A presentation derivative, never content or evidence: see video.ts.
|
|
1467
|
+
const recipe = parseRecipe(await readRecipe(parsed), "scene");
|
|
1468
|
+
// Refuse before any browser work: no encoder, no render.
|
|
1469
|
+
const encoder = await findEncoder();
|
|
1470
|
+
const candidate = await target.source();
|
|
1471
|
+
const hash = sourceHash(candidate);
|
|
1472
|
+
const remote = await studioStatus(parsed, asset);
|
|
1473
|
+
const remoteHash = String(remote.remote_source_hash || "");
|
|
1474
|
+
if (!remoteHash && remote.remote_snapshot_public_id) {
|
|
1475
|
+
throw new CliError("Studio has a draft without a readable source.", "STUDIO_SOURCE_UNAVAILABLE", 3);
|
|
1476
|
+
}
|
|
1477
|
+
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
|
+
});
|
|
1495
|
+
return { ok: true, command: "studio.render-video", asset, source_hash: hash, ...result };
|
|
1066
1496
|
}
|
|
1067
1497
|
if (action === "capture-preview") {
|
|
1068
1498
|
const { hash } = await assertBrowserSource(parsed, asset);
|
|
@@ -1118,24 +1548,39 @@ function help() {
|
|
|
1118
1548
|
return [
|
|
1119
1549
|
`NerdBro Studio Agent ${VERSION}`,
|
|
1120
1550
|
"",
|
|
1121
|
-
"
|
|
1551
|
+
"Model: everything visual is a Scene (graphs are Scene math planes); interaction is an Experience.",
|
|
1552
|
+
"",
|
|
1553
|
+
"Commands: setup, connect, disconnect, whoami, guide",
|
|
1554
|
+
" context live contract versions, offered/frozen targets, deprecations, compatibility",
|
|
1555
|
+
" schema [topic] topic index, or one live projection (e.g. experience, scene.stateMachines)",
|
|
1556
|
+
" capabilities [--json] universal Experience capabilities and tools, plus the Studio capability registry",
|
|
1122
1557
|
" docs | docs search <query> | docs open <slug>",
|
|
1123
1558
|
" agent start|status|stop|instructions|init --provider claude|codex|generic",
|
|
1124
1559
|
" component|behavior|example search <query>|inspect <id>",
|
|
1125
1560
|
" studio checkout|status|source|history|validate|compile|preview|save <asset>",
|
|
1126
|
-
" studio create --scratch --title <text> --
|
|
1127
|
-
"
|
|
1561
|
+
" studio create --scratch --title <text> --concept <text> [--target sceneComposition] [--starter world:<world>/<zone>] [--command-id <id>]",
|
|
1562
|
+
" --target defaults to sceneComposition; targets the server lists as frozen (combined, mathGraph) are refused",
|
|
1563
|
+
" graph intent: --math-plane | --graph-purpose <text> [--graph-x <text>] [--graph-y <text>] [--graph-sync <text>]",
|
|
1564
|
+
" world list | world inspect <world> [--zone <zone>] live World discovery and zone starter Source",
|
|
1565
|
+
" studio brief <asset> | studio brief update <asset> [--concept <text> ...] [--math-plane | --no-math-plane]",
|
|
1128
1566
|
" studio open|screenshot|inspect|observe|capture-preview|certify <asset>",
|
|
1129
1567
|
" studio drive <asset> --scenario scenario.json [--workspace dir] [--viewport desktop|mobile] [--out dir]",
|
|
1568
|
+
" steps include previewState \"machine/state\" [+ setup], select \"kind:id\", restart; result says probe or journey",
|
|
1569
|
+
" studio watch <asset> [--once] [--interval ms] report changed source paths to the Maker's Studio (Agent Presence)",
|
|
1130
1570
|
" studio inspect <asset> --view final|learner --viewport desktop|mobile",
|
|
1131
1571
|
" studio capture-preview <asset> [--mode semantic|fixed] [--viewport 480x320] [--duration 8] [--fps 4] [--out dir]",
|
|
1572
|
+
" studio render-video <asset> --recipe recipe.json [--out dir] [--keep-frames] [--measure-playback 15]",
|
|
1573
|
+
" presentation MP4 (H.264/AAC) of the learner surface at exact SceneClock times; needs local FFmpeg; writes render.json",
|
|
1132
1574
|
" studio export <asset> --revision <revision> [--out file.kspkg]",
|
|
1133
1575
|
"",
|
|
1134
1576
|
"Library authoring (<kind> = experience|audio|component|behavior):",
|
|
1135
1577
|
" experience|audio search <query> [--limit n] | inspect <stable-id>",
|
|
1136
1578
|
" audio upload <file.m4a|file.mp4> AAC in MP4/M4A, at most 24 MiB",
|
|
1137
1579
|
" audio create <stable-id> --name <text> --media <sha256> [--category c] [--language l]",
|
|
1138
|
-
" experience create <stable-id> --name <text> --kind <poem|story|...>",
|
|
1580
|
+
" experience create <stable-id> --name <text> --kind <poem|story|...> (authored as the context's experience_schema, V3)",
|
|
1581
|
+
" experience drive <stable-id> --scenario scenario.json [--viewport desktop|mobile] [--out dir]",
|
|
1582
|
+
" experience render-video <stable-id> --recipe recipe.json [--saved] [--out dir] [--keep-frames]",
|
|
1583
|
+
" experience watch <stable-id> [--once] [--interval ms]",
|
|
1139
1584
|
" (--scene-asset <asset-id> | --scene-package <kind:id> --scene <scene-id>)",
|
|
1140
1585
|
" [--master-audio <audio-id>] [--duration-ms <n>]",
|
|
1141
1586
|
" component create <stable-id> --name <text> [--category c] [--visual v] [--palette p]",
|
|
@@ -1165,7 +1610,22 @@ async function main() {
|
|
|
1165
1610
|
commandId = `cmd_${randomUUID().replaceAll("-", "")}`;
|
|
1166
1611
|
setCliTrace({ attemptId: active?.pointer.attempt_id, commandId });
|
|
1167
1612
|
}
|
|
1168
|
-
const
|
|
1613
|
+
const plan = presencePlan(parsed.args);
|
|
1614
|
+
if (plan)
|
|
1615
|
+
await setPresence({ subject: plan.subject, status: plan.status, last_command: { op: plan.op } });
|
|
1616
|
+
let result;
|
|
1617
|
+
try {
|
|
1618
|
+
result = await run(parsed);
|
|
1619
|
+
}
|
|
1620
|
+
catch (error) {
|
|
1621
|
+
if (plan) {
|
|
1622
|
+
const code = error instanceof CliError ? error.code : "AGENT_COMMAND_FAILED";
|
|
1623
|
+
await setPresence({ subject: plan.subject, status: "idle", last_command: { op: plan.op }, last_result: { ok: false, code } });
|
|
1624
|
+
}
|
|
1625
|
+
throw error;
|
|
1626
|
+
}
|
|
1627
|
+
if (plan)
|
|
1628
|
+
await setPresence({ subject: plan.subject, status: "idle", last_command: { op: plan.op }, last_result: presenceResult(result) });
|
|
1169
1629
|
if (active && commandId && parsed.args[0] !== "attempt")
|
|
1170
1630
|
await saveCommandEvidence(active.pointer, commandId, parsed.args.slice(0, 2).join("."), result);
|
|
1171
1631
|
output(result, parsed.json);
|