feat(runtime): cmd:screenshot + cmd:force-incident + prismarine-viewer dep

Three live-verification surfaces on top of v0.1.0:

1. cmd:screenshot { reason?, frames? } → runtime/viewer.takeScreenshot
   - Headless POV render via prismarine-viewer.headless to
     state/<host>/screenshots/<ISO>-<reason>.mp4 (1 frame ≈ ~10 KB).
   - Lazy-loads the heavy GL stack on first call so the bot doesn't pay
     the cost on startup or in TUI-only sessions.
   - Returns { ok, path, error } over IPC LOG event.
   - Known limitation: needs node-canvas. node-canvas v3 (current npm
     default) is incompatible with prismarine-viewer's API; v2 doesn't
     build under Node 24 (node-pre-gyp fail). So today the feature is
     wired and the IPC contract is stable, but the underlying render
     fails fast with "createCanvas is not a function". A future cleanup
     can either fork the renderer or pin a Node 20 toolchain.

2. cmd:force-incident { kind?, reason? } → filePostCritique path
   - Operator-triggered demo of the critic → proposal → auto-improve →
     auto-patch chain. Was previously only observable when the bot
     genuinely got stuck. Now a single IPC call exercises the full
     loop on demand.
   - Verified live 2026-05-26: critic call returned a real, useful
     critique ("attack zombie returns done while target is alive →
     blocks gather.logs"), proposal landed with all sections including
     the Critic block, auto-improve picked it up within 10 s.

3. prismarine-viewer + canvas added to dependencies so npm install
   builds the deps once and the IPC surface is always available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 16:01:43 +03:00
co-authored by Claude Opus 4.7
parent 4ae63dabe1
commit 8bc9b41c01
5 changed files with 1645 additions and 9 deletions
+28 -1
View File
@@ -47,6 +47,7 @@ import { classifyIntent, INTENTS } from "./social/intent.js";
import { generateReply } from "./social/reply.js";
import { createChatMemory } from "./social/memory.js";
import { openConversation, peekConversation, listConversations } from "./social/conversation.js";
import { takeScreenshot } from "./viewer.js";
import { createStuckIncidentDetector, attachCritique } from "./stuck-incident.js";
import { requestCritique } from "./critic.js";
import { createSkillMetrics } from "./skill-metrics.js";
@@ -956,7 +957,7 @@ function handleCommand(msg, send) {
const { topic: topic_, text, intent, position } = msg.payload ?? {};
if (!topic_ || !text) { send(EVENT_TYPES.ERROR, { source: "conv", text: "topic and text required" }); return; }
try {
const h = openConversation(topic_, { speaker: cfg.username });
const h = openConversation(topic_, { speaker: config.username });
const turn = h.append({ text, intent, position: position ?? lastSnapshot?.position });
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "conv", text: `say to ${topic_}`, details: turn });
} catch (e) { send(EVENT_TYPES.ERROR, { source: "conv", text: e.message }); }
@@ -978,6 +979,32 @@ function handleCommand(msg, send) {
} catch (e) { send(EVENT_TYPES.ERROR, { source: "conv", text: e.message }); }
break;
}
case COMMAND_TYPES.SCREENSHOT: {
const { reason, frames } = msg.payload ?? {};
(async () => {
if (!bot) { send(EVENT_TYPES.ERROR, { source: "viewer", text: "bot not connected" }); return; }
const res = await takeScreenshot(bot, { reason: reason ?? "ipc", frames: frames ?? 1 });
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "viewer", text: res.ok ? `screenshot ok` : `screenshot fail`, details: res });
})();
break;
}
case COMMAND_TYPES.FORCE_INCIDENT: {
const { kind, reason } = msg.payload ?? {};
(async () => {
const fakeIncident = {
kind: kind ?? "force-demo",
summary: `forced incident: ${reason ?? "operator demo"}`,
body: stuckIncident._renderFake
? stuckIncident._renderFake({ snapshot: lastSnapshot, lastResult, reason: reason ?? "operator demo" })
: `# Forced incident\n\nOperator triggered via cmd:force-incident.\n\n## Snapshot\n\n\`\`\`json\n${JSON.stringify(lastSnapshot ?? {}, null, 2).slice(0, 2000)}\n\`\`\`\n\n## Last action\n\n${lastResult ? `\`${lastResult.label}\`${lastResult.code}` : "_(none)_"}\n\n## Suggested fix\n\nReview the snapshot and propose a productive next skill, or document why no productive action is possible from this state.\n\n## Edit scope\n\n- runtime/skills/\n- runtime/reflex.js\n`,
editScope: ["runtime/skills/", "runtime/reflex.js"],
};
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "force", text: `dispatching critic for ${fakeIncident.kind}` });
await filePostCritique(fakeIncident, "force");
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "force", text: `force-incident done — check state/proposals/` });
})();
break;
}
default:
warn("ipc", `unknown command type: ${msg.type}`);
}