romdevtools 0.28.0 → 0.30.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/AGENTS.md +53 -43
- package/CHANGELOG.md +91 -0
- package/README.md +3 -3
- package/examples/README.md +7 -7
- package/examples/atari2600/templates/platformer.asm +1225 -332
- package/examples/atari2600/templates/puzzle.asm +1056 -0
- package/examples/atari2600/templates/racing.asm +906 -275
- package/examples/atari2600/templates/shmup.asm +1031 -239
- package/examples/atari2600/templates/sports.asm +1135 -253
- package/examples/atari7800/templates/platformer.c +991 -156
- package/examples/atari7800/templates/puzzle.c +1091 -148
- package/examples/atari7800/templates/racing.c +952 -124
- package/examples/atari7800/templates/shmup.c +812 -134
- package/examples/atari7800/templates/sports.c +820 -184
- package/examples/c64/templates/platformer.c +879 -164
- package/examples/c64/templates/puzzle.c +855 -178
- package/examples/c64/templates/racing.c +873 -97
- package/examples/c64/templates/shmup.c +757 -161
- package/examples/c64/templates/sports.c +755 -100
- package/examples/gb/templates/platformer.c +841 -179
- package/examples/gb/templates/puzzle.c +986 -246
- package/examples/gb/templates/racing.c +754 -174
- package/examples/gb/templates/shmup.c +673 -175
- package/examples/gb/templates/sports.c +790 -159
- package/examples/gba/templates/platformer.c +626 -165
- package/examples/gba/templates/puzzle.c +519 -269
- package/examples/gba/templates/racing.c +511 -206
- package/examples/gba/templates/shmup.c +564 -179
- package/examples/gba/templates/sports.c +454 -174
- package/examples/gbc/templates/platformer.c +944 -180
- package/examples/gbc/templates/puzzle.c +363 -109
- package/examples/gbc/templates/racing.c +884 -180
- package/examples/gbc/templates/shmup.c +821 -185
- package/examples/gbc/templates/sports.c +870 -162
- package/examples/genesis/templates/platformer.c +747 -129
- package/examples/genesis/templates/puzzle.c +694 -261
- package/examples/genesis/templates/racing.c +726 -203
- package/examples/genesis/templates/shmup.c +535 -142
- package/examples/genesis/templates/sports.c +495 -158
- package/examples/gg/templates/platformer.c +880 -215
- package/examples/gg/templates/puzzle.c +875 -216
- package/examples/gg/templates/racing.c +915 -172
- package/examples/gg/templates/shmup.c +714 -191
- package/examples/gg/templates/sports.c +732 -129
- package/examples/lynx/templates/platformer.c +604 -69
- package/examples/lynx/templates/puzzle.c +498 -158
- package/examples/lynx/templates/racing.c +538 -102
- package/examples/lynx/templates/shmup.c +458 -131
- package/examples/lynx/templates/sports.c +496 -72
- package/examples/msx/platformer/main.c +649 -162
- package/examples/msx/puzzle/main.c +742 -240
- package/examples/msx/racing/main.c +669 -178
- package/examples/msx/shmup/main.c +460 -178
- package/examples/msx/sports/main.c +592 -126
- package/examples/nes/templates/platformer.c +589 -171
- package/examples/nes/templates/puzzle.c +563 -242
- package/examples/nes/templates/racing.c +502 -208
- package/examples/nes/templates/shmup.c +339 -145
- package/examples/nes/templates/sports.c +341 -183
- package/examples/pce/platformer/main.c +874 -205
- package/examples/pce/puzzle/main.c +802 -287
- package/examples/pce/racing/main.c +783 -208
- package/examples/pce/shmup/main.c +638 -212
- package/examples/pce/sports/main.c +586 -169
- package/examples/porting-across-platforms/README.md +1 -1
- package/examples/sms/templates/platformer.c +762 -177
- package/examples/sms/templates/puzzle.c +752 -212
- package/examples/sms/templates/racing.c +808 -145
- package/examples/sms/templates/shmup.c +599 -162
- package/examples/sms/templates/sports.c +630 -122
- package/examples/snes/templates/music_demo.c +7 -0
- package/examples/snes/templates/platformer-data.asm +123 -24
- package/examples/snes/templates/platformer-hdr.asm +57 -0
- package/examples/snes/templates/platformer.c +586 -165
- package/examples/snes/templates/puzzle-data.asm +116 -21
- package/examples/snes/templates/puzzle-hdr.asm +57 -0
- package/examples/snes/templates/puzzle.c +614 -235
- package/examples/snes/templates/racing-data.asm +390 -32
- package/examples/snes/templates/racing-hdr.asm +57 -0
- package/examples/snes/templates/racing.c +807 -196
- package/examples/snes/templates/shmup-data.asm +87 -29
- package/examples/snes/templates/shmup-hdr.asm +57 -0
- package/examples/snes/templates/shmup.c +459 -198
- package/examples/snes/templates/sports-data.asm +48 -2
- package/examples/snes/templates/sports-hdr.asm +57 -0
- package/examples/snes/templates/sports.c +414 -163
- package/package.json +12 -12
- package/src/cores/wasm/bluemsx_libretro.js +1 -1
- package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
- package/src/cores/wasm/fceumm_libretro.js +1 -1
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/cores/wasm/gambatte_libretro.js +1 -1
- package/src/cores/wasm/gambatte_libretro.wasm +0 -0
- package/src/cores/wasm/geargrafx_libretro.js +1 -1
- package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
- package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
- package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
- package/src/cores/wasm/handy_libretro.js +1 -1
- package/src/cores/wasm/handy_libretro.wasm +0 -0
- package/src/cores/wasm/mgba_libretro.js +1 -1
- package/src/cores/wasm/mgba_libretro.wasm +0 -0
- package/src/cores/wasm/prosystem_libretro.js +1 -1
- package/src/cores/wasm/prosystem_libretro.wasm +0 -0
- package/src/cores/wasm/snes9x_libretro.js +1 -1
- package/src/cores/wasm/snes9x_libretro.wasm +0 -0
- package/src/cores/wasm/stella2014_libretro.js +1 -1
- package/src/cores/wasm/stella2014_libretro.wasm +0 -0
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +84 -8
- package/src/http/tool-registry.js +11 -11
- package/src/mcp/tools/cheats.js +2 -1
- package/src/mcp/tools/frame.js +3 -2
- package/src/mcp/tools/index.js +3 -3
- package/src/mcp/tools/input.js +5 -4
- package/src/mcp/tools/lifecycle.js +6 -4
- package/src/mcp/tools/memory.js +131 -24
- package/src/mcp/tools/platform-docs.js +1 -1
- package/src/mcp/tools/preview-tile.js +6 -2
- package/src/mcp/tools/project.js +1098 -130
- package/src/mcp/tools/record.js +6 -7
- package/src/mcp/tools/rom-id.js +5 -1
- package/src/mcp/tools/run-until.js +12 -4
- package/src/mcp/tools/snippets.js +6 -6
- package/src/mcp/tools/sprite-pipeline.js +14 -2
- package/src/mcp/tools/state.js +2 -1
- package/src/mcp/tools/tile-inspect.js +8 -1
- package/src/mcp/tools/toolchain.js +12 -1
- package/src/mcp/tools/watch-memory.js +53 -10
- package/src/observer/bus.js +73 -0
- package/src/observer/livestream.html +4 -2
- package/src/observer/tool-wrap.js +17 -14
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +32 -3
- package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
- package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
- package/src/platforms/c64/MENTAL_MODEL.md +11 -4
- package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
- package/src/platforms/gb/MENTAL_MODEL.md +3 -3
- package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
- package/src/platforms/gb/lib/c/README.md +10 -11
- package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
- package/src/platforms/gb/lib/c/patch-header.js +13 -3
- package/src/platforms/gba/MENTAL_MODEL.md +4 -4
- package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
- package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
- package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
- package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
- package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
- package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
- package/src/platforms/gbc/lib/c/README.md +10 -11
- package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
- package/src/platforms/gbc/lib/c/patch-header.js +13 -3
- package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
- package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
- package/src/platforms/gg/MENTAL_MODEL.md +4 -4
- package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
- package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
- package/src/platforms/gg/lib/c/joypad_read.c +29 -0
- package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
- package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
- package/src/platforms/msx/MENTAL_MODEL.md +5 -5
- package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
- package/src/platforms/msx/lib/c/msx_hw.h +1 -0
- package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
- package/src/platforms/nes/MENTAL_MODEL.md +2 -2
- package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
- package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
- package/src/platforms/pce/MENTAL_MODEL.md +5 -5
- package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
- package/src/platforms/pce/lib/c/pce_hw.h +11 -0
- package/src/platforms/pce/lib/c/pce_video.c +32 -0
- package/src/platforms/sms/MENTAL_MODEL.md +6 -6
- package/src/platforms/snes/MENTAL_MODEL.md +2 -2
- package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
- package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
- package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
- package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
- package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
- package/src/toolchains/index.js +27 -11
package/src/mcp/tools/record.js
CHANGED
|
@@ -12,13 +12,12 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { getHost } from "../state.js";
|
|
14
14
|
import { jsonContent, safeTool } from "../util.js";
|
|
15
|
-
import { MemoryRegionToRetro } from "../../host/types.js";
|
|
16
|
-
|
|
17
|
-
// Single source of truth for memorySamples regions — the same canonical set
|
|
18
|
-
// readMemory accepts. Previously hardcoded to 8 NES regions, so Genesis and
|
|
19
|
-
// hardware-register regions (nes_apu_regs, etc.) couldn't be batch-sampled.
|
|
20
|
-
const SAMPLE_REGIONS = /** @type {[string, ...string[]]} */ (Object.keys(MemoryRegionToRetro));
|
|
21
15
|
|
|
16
|
+
// memorySamples regions accept the same canonical set readMemory accepts (incl.
|
|
17
|
+
// hardware-register regions like nes_apu_regs). The region is a runtime-validated
|
|
18
|
+
// string rather than an inlined ~62-value schema enum — the per-sample
|
|
19
|
+
// host.readMemory(region,…) lookup throws on an unknown region with a clear
|
|
20
|
+
// message, so the schema enum was pure deferred-load weight (0.28.0 feedback #5).
|
|
22
21
|
export function registerRecordTools(server, z, sessionKey) {
|
|
23
22
|
const inputShape = z.object({
|
|
24
23
|
up: z.boolean().optional(), down: z.boolean().optional(),
|
|
@@ -54,7 +53,7 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
54
53
|
.array(
|
|
55
54
|
z.object({
|
|
56
55
|
label: z.string(),
|
|
57
|
-
region: z.
|
|
56
|
+
region: z.string().describe("memory region (full readMemory set incl. hardware registers; validated at runtime)"),
|
|
58
57
|
offset: z.number().int().min(0),
|
|
59
58
|
length: z.number().int().min(1).max(256),
|
|
60
59
|
}),
|
package/src/mcp/tools/rom-id.js
CHANGED
|
@@ -305,13 +305,17 @@ export async function extractSpriteSheetCore({ platform, path: romPath, offset,
|
|
|
305
305
|
const path = await import("node:path");
|
|
306
306
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
307
307
|
await writeFile(outputPath, png);
|
|
308
|
-
|
|
308
|
+
// Livestream sideband: show the rendered sheet even though the agent
|
|
309
|
+
// only gets the path.
|
|
310
|
+
const out = jsonContent({
|
|
309
311
|
path: outputPath,
|
|
310
312
|
intent: d.intent,
|
|
311
313
|
bytes: png.length,
|
|
312
314
|
paletteSource,
|
|
313
315
|
note,
|
|
314
316
|
});
|
|
317
|
+
out._observerImages = [{ kind: "image", mimeType: "image/png", base64: png.toString("base64") }];
|
|
318
|
+
return out;
|
|
315
319
|
}
|
|
316
320
|
return {
|
|
317
321
|
content: [
|
|
@@ -10,11 +10,18 @@
|
|
|
10
10
|
|
|
11
11
|
import { getHost } from "../state.js";
|
|
12
12
|
import { jsonContent, safeTool } from "../util.js";
|
|
13
|
+
import { attachObserverFrame } from "./watch-memory.js";
|
|
13
14
|
|
|
14
15
|
export function registerRunUntilTools(server, z, sessionKey) {
|
|
16
|
+
// Condition `region` is a runtime-validated string, not a schema enum. It was
|
|
17
|
+
// an inlined 8-value list — which both bloated the schema AND silently rejected
|
|
18
|
+
// valid non-NES regions (genesis_*, c64_*, *_apu_regs) that host.readMemory
|
|
19
|
+
// accepts. The readMemory(region,…) call in the handler validates and throws a
|
|
20
|
+
// clear message on an unknown region (full canonical set, same as `memory`).
|
|
21
|
+
const regionStr = z.string().describe("memory region (full readMemory set, e.g. system_ram, nes_oam, genesis_vram, c64_color_ram; validated at runtime)");
|
|
15
22
|
const memoryCondition = z.object({
|
|
16
23
|
type: z.literal("memory"),
|
|
17
|
-
region:
|
|
24
|
+
region: regionStr,
|
|
18
25
|
offset: z.number().int().min(0),
|
|
19
26
|
equals: z.number().int().min(0).max(255).optional(),
|
|
20
27
|
notEquals: z.number().int().min(0).max(255).optional(),
|
|
@@ -23,7 +30,7 @@ export function registerRunUntilTools(server, z, sessionKey) {
|
|
|
23
30
|
|
|
24
31
|
const memoryChangedCondition = z.object({
|
|
25
32
|
type: z.literal("memoryChanged"),
|
|
26
|
-
region:
|
|
33
|
+
region: regionStr,
|
|
27
34
|
offset: z.number().int().min(0),
|
|
28
35
|
length: z.number().int().min(1).max(8192).default(1),
|
|
29
36
|
}).describe("Stop when memory[region][offset..offset+length] changes from its initial value.");
|
|
@@ -75,11 +82,12 @@ export function registerRunUntilTools(server, z, sessionKey) {
|
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
|
78
|
-
|
|
85
|
+
// Livestream: the frame where the condition was met (or where we gave up).
|
|
86
|
+
return attachObserverFrame(jsonContent({
|
|
79
87
|
conditionMet: met,
|
|
80
88
|
framesStepped,
|
|
81
89
|
finalValue,
|
|
82
|
-
});
|
|
90
|
+
}), host, met ? "runUntil: condition met" : "runUntil: gave up");
|
|
83
91
|
}),
|
|
84
92
|
);
|
|
85
93
|
}
|
|
@@ -97,11 +97,11 @@ export function registerSnippetTools(_server, _z) {
|
|
|
97
97
|
platform, languages, snippets: filtered,
|
|
98
98
|
note: filtered.length === 0
|
|
99
99
|
? `No snippets matched for '${platform}'${language ? ` (language=${language})` : ""}.`
|
|
100
|
-
: `Fetch one with
|
|
100
|
+
: `Fetch one with examples({ op:'snippets', platform, mode:'get', snippetName${language ? ", language" : ""} }), or all with mode:'getAll'.`,
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
103
|
async function snippetsGet(platform, name, language) {
|
|
104
|
-
if (!name) throw new Error("
|
|
104
|
+
if (!name) throw new Error("examples({op:'snippets'}) mode:'get' requires `snippetName`.");
|
|
105
105
|
if (name.includes("..") || (name.includes("/") && !/^[a-z]+\/[\w.-]+$/i.test(name))) {
|
|
106
106
|
throw new Error("snippet name must not contain '..' or arbitrary path separators");
|
|
107
107
|
}
|
|
@@ -124,7 +124,7 @@ export function registerSnippetTools(_server, _z) {
|
|
|
124
124
|
return textContent(`No snippets available for '${platform}'${language ? ` (language=${language})` : ""}.`);
|
|
125
125
|
}
|
|
126
126
|
if (!inline && !outputPath) {
|
|
127
|
-
throw new Error("
|
|
127
|
+
throw new Error("examples({op:'snippets'}) mode:'getAll': pass outputPath (write the joined snippets to disk, returns {path}) or inline:true (return `combined` in the response). Or use examples({op:'copySnippets'}) to write each snippet as its own file.");
|
|
128
128
|
}
|
|
129
129
|
const parts = [];
|
|
130
130
|
for (const s of filtered) {
|
|
@@ -161,7 +161,7 @@ export function registerSnippetTools(_server, _z) {
|
|
|
161
161
|
if (filtered.length === 0) {
|
|
162
162
|
const langPart = language ? ` (language=${language})` : "";
|
|
163
163
|
const includePart = include ? ` (include=${JSON.stringify(include)})` : "";
|
|
164
|
-
throw new Error(`
|
|
164
|
+
throw new Error(`examples({op:'copySnippets'}): no snippets matched for platform '${platform}'${langPart}${includePart}.`);
|
|
165
165
|
}
|
|
166
166
|
await mkdir(destinationDir, { recursive: true });
|
|
167
167
|
const written = [];
|
|
@@ -201,9 +201,9 @@ export function registerSnippetTools(_server, _z) {
|
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
// starterSnippets/copyStarterSnippets folded into the `
|
|
204
|
+
// starterSnippets/copyStarterSnippets folded into the `examples` tool. The cores
|
|
205
205
|
// are assigned inside registerSnippetTools (they close over the local helpers);
|
|
206
|
-
//
|
|
206
|
+
// examples imports these and calls them. registerSnippetTools registers NO tools
|
|
207
207
|
// now — it just wires the cores.
|
|
208
208
|
export let starterSnippetsCore = async () => { throw new Error("snippet cores not initialized — registerSnippetTools must run first"); };
|
|
209
209
|
export let copyStarterSnippetsCore = async () => { throw new Error("snippet cores not initialized — registerSnippetTools must run first"); };
|
|
@@ -143,6 +143,7 @@ async function cropSpriteSheetImpl({ path, tileX, tileY, tileW, tileH, tileSize
|
|
|
143
143
|
}
|
|
144
144
|
await writeFile(outputPath, outBuf);
|
|
145
145
|
return {
|
|
146
|
+
_observerImages: [{ kind: "image", mimeType: "image/png", base64: outBuf.toString("base64") }],
|
|
146
147
|
path: outputPath,
|
|
147
148
|
intent: d.intent,
|
|
148
149
|
width: pxW,
|
|
@@ -157,6 +158,16 @@ async function cropSpriteSheetImpl({ path, tileX, tileY, tileW, tileH, tileSize
|
|
|
157
158
|
};
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
// Lift a plain core result's livestream sideband OUT of the JSON body and
|
|
162
|
+
// onto the MCP result object (it must never serialize into agent-visible text).
|
|
163
|
+
function liftObserverImages(r) {
|
|
164
|
+
const sideband = r._observerImages;
|
|
165
|
+
delete r._observerImages;
|
|
166
|
+
const out = jsonContent(r);
|
|
167
|
+
if (sideband) out._observerImages = sideband;
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
// ── quantizePngForPlatform ──────────────────────────────────────────
|
|
161
172
|
|
|
162
173
|
/**
|
|
@@ -247,6 +258,7 @@ async function quantizePngForPlatformImpl({ path, platform, outputPath, intent,
|
|
|
247
258
|
const outBuf = writeIndexedPng(png.width, png.height, indices, palette);
|
|
248
259
|
await writeFile(outputPath, outBuf);
|
|
249
260
|
return {
|
|
261
|
+
_observerImages: [{ kind: "image", mimeType: "image/png", base64: outBuf.toString("base64") }],
|
|
250
262
|
path: outputPath,
|
|
251
263
|
intent: d.intent,
|
|
252
264
|
width: png.width,
|
|
@@ -596,12 +608,12 @@ export function registerSpritePipelineTools(server, z, _sessionKey) {
|
|
|
596
608
|
case "quantize": {
|
|
597
609
|
if (!args.platform) throw new Error("encodeArt({stage:'quantize'}): `platform` is required.");
|
|
598
610
|
if (!args.path || !args.outputPath) throw new Error("encodeArt({stage:'quantize'}): `path` and `outputPath` are required.");
|
|
599
|
-
return
|
|
611
|
+
return liftObserverImages(await quantizePngForPlatformImpl(args));
|
|
600
612
|
}
|
|
601
613
|
case "crop": {
|
|
602
614
|
if (!args.path || !args.outputPath) throw new Error("encodeArt({stage:'crop'}): `path` and `outputPath` are required.");
|
|
603
615
|
if (args.tileX == null || args.tileY == null || args.tileW == null || args.tileH == null) throw new Error("encodeArt({stage:'crop'}): `tileX`, `tileY`, `tileW`, `tileH` are required.");
|
|
604
|
-
return
|
|
616
|
+
return liftObserverImages(await cropSpriteSheetImpl(args));
|
|
605
617
|
}
|
|
606
618
|
case "tiles": {
|
|
607
619
|
if (!args.platform) throw new Error("encodeArt({stage:'tiles'}): `platform` is required.");
|
package/src/mcp/tools/state.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { getHost } from "../state.js";
|
|
4
|
+
import { attachObserverFrame } from "./watch-memory.js";
|
|
4
5
|
import { jsonContent, safeTool } from "../util.js";
|
|
5
6
|
|
|
6
7
|
// Resolve a state-file `path`. An ABSOLUTE path is used as-is. A RELATIVE path
|
|
@@ -342,7 +343,7 @@ export function registerStateTools(server, z, sessionKey) {
|
|
|
342
343
|
safeTool(async (args) => {
|
|
343
344
|
switch (args.op) {
|
|
344
345
|
case "save": return jsonContent(await saveStateCore(args, sessionKey));
|
|
345
|
-
case "load": return jsonContent(await loadStateCore(args, sessionKey));
|
|
346
|
+
case "load": return attachObserverFrame(jsonContent(await loadStateCore(args, sessionKey)), getHost(sessionKey), `state load ${args.name ?? args.path ?? ""}`.trim());
|
|
346
347
|
case "list": return jsonContent(listStatesCore(args, sessionKey));
|
|
347
348
|
case "export": {
|
|
348
349
|
if (!args.fromSlot) throw new Error("state({op:'export'}): `fromSlot` is required.");
|
|
@@ -284,7 +284,14 @@ export function registerTileInspectTools(server, z, sessionKey) {
|
|
|
284
284
|
if (r.pngBase64) {
|
|
285
285
|
return { content: [imageContent(r.pngBase64), textContent(JSON.stringify({ ...r, pngBase64: undefined }))] };
|
|
286
286
|
}
|
|
287
|
-
|
|
287
|
+
// Lift the livestream sideband OUT of the core's plain result before
|
|
288
|
+
// it's serialized — it must ride on the MCP result object, never in
|
|
289
|
+
// the agent-visible JSON text.
|
|
290
|
+
const sideband = r._observerImages;
|
|
291
|
+
delete r._observerImages;
|
|
292
|
+
const out = jsonContent(r);
|
|
293
|
+
if (sideband) out._observerImages = sideband;
|
|
294
|
+
return out;
|
|
288
295
|
}
|
|
289
296
|
default: throw new Error(`tiles: unknown op '${args.op}'`);
|
|
290
297
|
}
|
|
@@ -703,7 +703,7 @@ export function registerToolchainTools(server, z, sessionKey) {
|
|
|
703
703
|
"• output:'rom' (default) — assemble or compile `source` (single) / `sources` ({name:contents}) / `sourcePath` / `sourcesPaths`. Returns the ROM (path by default; `inline:true` for binaryBase64) + build log. **`binaryIncludes`/`binaryIncludePaths` (base64/path CHR-ROM, music blobs for `.incbin`) — WITHOUT them no game with external assets builds.** `includes`/`includePaths` for `.include`d text. `linkerConfig` (cc65; NES preset 'chr-ram-runtime' RECOMMENDED). `crt0`/`crt0Path`/`codeLoc`/`dataLoc` (SDCC). `runtime`/`maxmod`/`rebuildSdk` (GBA/Genesis SDK). **`lint:'strict'` fails the build (stage:'lint', no binary) if the pre-flight SDCC crash-pattern scan flags anything (e.g. the uint8 loop-bound trap); 'advisory' (default) just lists hits in issues[].** **`includeSymbols:true` returns the .map text inline on a PLAIN rom build — distinct from output:'romWithDebug' which writes .dbg/.map FILES.** Language is inferred from extension/content — usually OMIT `language`.\n" +
|
|
704
704
|
"• output:'romWithDebug' — like 'rom' but also emits linker debug info for the `symbols` tool: cc65 → `.dbg`, SDCC → sdld `.map`, Genesis m68k → GNU ld map (find where a RAM var landed). DEFAULT writes ROM + debug file + log to disk (`outputPath` required unless `inline:true`). **`resolveSymbols:['grid','score']` folds those names' addresses ({resolvedSymbols:{grid:{address,hex,region?,ramOffset?}}}) straight into the result — the cheap way to a WRAM variable's address without loading the whole map (or round-tripping it through `symbols`).**\n" +
|
|
705
705
|
"• output:'run' — BUILD + LOAD + RUN + SCREENSHOT in one round trip — the fastest iteration loop. Same build args; runs `frames` frames and returns the screenshot INLINE. `holdInputs` holds controller state; `screenshotPath` writes the PNG to disk instead; `projectName` titles the playtest window.\n" +
|
|
706
|
-
"• output:'project' — build a project DIRECTORY (`path`) without re-passing the file manifest each call. Entry point is `main.c` (C/SGDK Genesis, GBA, cc65/SDCC C) OR `main.s`/`main.asm` (asm). Every `.c`/`.s`/`.asm` in the dir is a translation unit (linked together), every `.h`/`.inc` an include, and `.bin/.chr/.pcm/.brr/.vgm/...` become binaryIncludes (for `.incbin`). Iterate an on-disk project by re-calling with just `{path, platform}`. **This is the no-boilerplate path for
|
|
706
|
+
"• output:'project' — build a project DIRECTORY (`path`) without re-passing the file manifest each call. Entry point is `main.c` (C/SGDK Genesis, GBA, cc65/SDCC C) OR `main.s`/`main.asm` (asm). Every `.c`/`.s`/`.asm` in the dir is a translation unit (linked together), every `.h`/`.inc` an include, and `.bin/.chr/.pcm/.brr/.vgm/...` become binaryIncludes (for `.incbin`). Iterate an on-disk project by re-calling with just `{path, platform}`. **This is the no-boilerplate path for an examples({op:'fork'}) dir: the per-platform recipe auto-supplies the crt0 + load address — GB/GBC default `gb_crt0.s` + `codeLoc:0x150` (don't hand-pass them!), MSX routes `msx_crt0.s` + `codeLoc:0x4010`, SMS/GG auto-inject their bundled crt0, NES applies the chr-ram-runtime preset. PREFER this over re-passing `crt0Path`/`codeLoc` to output:'rom' for a forked project.**",
|
|
707
707
|
{
|
|
708
708
|
output: z.enum(["rom", "romWithDebug", "run", "project"])
|
|
709
709
|
.describe("rom=produce a ROM (default); romWithDebug=ROM + .dbg/.map debug files; run=build+load+run+screenshot; project=build a project directory."),
|
|
@@ -838,6 +838,17 @@ export function projectBuildRecipe(platform, names) {
|
|
|
838
838
|
// commercial ROMs boot in the same host; only our scaffolds failed). Routing
|
|
839
839
|
// msx_crt0.s through crt0 makes ITS header + init the cartridge entry.
|
|
840
840
|
if (has("msx_crt0.s")) { r.crt0File = "msx_crt0.s"; r.codeLoc = 0x4010; }
|
|
841
|
+
} else if (platform === "pce") {
|
|
842
|
+
// PCE example projects (they ship pce_hw.h) build on the 'rom32k' preset:
|
|
843
|
+
// a 32KB HuCard with bank 0 (STARTUP/VECTORS) FIRST in the file at $E000
|
|
844
|
+
// and banks 1-3 (CODE/RODATA) at $8000-$DFFF, where cc65's pce crt0 TAMs
|
|
845
|
+
// them before main(). cc65's stock pce.cfg is an 8KB boot bank — too small
|
|
846
|
+
// for a complete example game — and its documented 32K variant places the
|
|
847
|
+
// vectors in the LAST file bank, which a HuCard never maps at reset
|
|
848
|
+
// (verified black screen on geargrafx). An 8KB-sized program still links
|
|
849
|
+
// and boots identically under this preset, so it's safe for every
|
|
850
|
+
// pce_hw.h-style project. Bare hand-rolled dirs are left alone.
|
|
851
|
+
if (has("pce_hw.h")) r.linkerConfig = "rom32k";
|
|
841
852
|
} else if (platform === "sms" || platform === "gg") {
|
|
842
853
|
// SMS/GG: route the project's *_crt0.s through the crt0 channel (like
|
|
843
854
|
// GB/MSX), NOT as a plain source TU. The OLD recipe skipped it on the
|
|
@@ -56,7 +56,7 @@ async function maybeRestoreState(host, fromState, fromStatePath) {
|
|
|
56
56
|
// observer wrapper encodes it ASYNCHRONOUSLY, after the agent's response has
|
|
57
57
|
// already gone out. The provider is stripped from the agent-visible result. The
|
|
58
58
|
// frame is captured by reference now (correct frozen state) but rasterized later.
|
|
59
|
-
export function attachObserverFrame(json, host) {
|
|
59
|
+
export function attachObserverFrame(json, host, caption) {
|
|
60
60
|
json._observerFrameProvider = () => {
|
|
61
61
|
try {
|
|
62
62
|
const shot = host.screenshot(); // { pngBase64, width, height }
|
|
@@ -65,6 +65,7 @@ export function attachObserverFrame(json, host) {
|
|
|
65
65
|
: null;
|
|
66
66
|
} catch { return null; }
|
|
67
67
|
};
|
|
68
|
+
if (caption) json._observerFrameCaption = String(caption);
|
|
68
69
|
return json;
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -138,6 +139,19 @@ export function makePressDriver(host, presses) {
|
|
|
138
139
|
// never disagree again.
|
|
139
140
|
const MEMORY_REGIONS = /** @type {[string, ...string[]]} */ (Object.keys(MemoryRegionToRetro));
|
|
140
141
|
|
|
142
|
+
// A region param that does NOT inline the full ~62-value enum into the JSON
|
|
143
|
+
// schema. The enum array is ~214 tokens PER param site; inlining it on every
|
|
144
|
+
// secondary region sub-param across this file was the dominant tool-schema
|
|
145
|
+
// bloat (0.28.0 feedback #5). Used on SECONDARY/sub params; the PRIMARY region
|
|
146
|
+
// inputs keep z.enum so the full list stays discoverable where the region IS
|
|
147
|
+
// the choice. A plain string — validated at RUNTIME by the handler (the
|
|
148
|
+
// host.readMemory / MemoryRegionToRetro lookup throws on an unknown region with
|
|
149
|
+
// a clear message), so dropping the schema enum here costs no safety.
|
|
150
|
+
// NOTE: `z` is passed into registerWatchMemoryTools (not a module import), so
|
|
151
|
+
// this factory takes `z` and is invoked once inside the register fn.
|
|
152
|
+
const makeRegionStr = (z) => (desc) =>
|
|
153
|
+
z.string().describe(desc + " (validated at runtime against the canonical region set).");
|
|
154
|
+
|
|
141
155
|
// Abort-guard for input-driven watchpoint runs: sample caller-named bytes each
|
|
142
156
|
// frame; the FIRST one to change stops the run with {label,addr,before,after}.
|
|
143
157
|
// Lets a derailed driven scenario (player died, scene flipped) return immediately
|
|
@@ -265,8 +279,9 @@ function downsample(arr, n) {
|
|
|
265
279
|
}
|
|
266
280
|
|
|
267
281
|
export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
282
|
+
const regionStr = makeRegionStr(z);
|
|
268
283
|
const rangeShape = z.object({
|
|
269
|
-
region:
|
|
284
|
+
region: regionStr("memory region for THIS range (same canonical set `memory` uses)"),
|
|
270
285
|
offset: z.number().int().min(0),
|
|
271
286
|
length: z.number().int().min(1).max(4096).default(1),
|
|
272
287
|
label: z.string().optional().describe("Name echoed on every event from this range — tells disjoint ranges apart in one stream."),
|
|
@@ -509,7 +524,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
509
524
|
|
|
510
525
|
// breakpoint({on:write|read|pc}) STOP-on-first. on:write precision:exact=bpFindWriter
|
|
511
526
|
// (core watchpoint, true PC under IRQ), precision:sampled=bpRunUntilWrite (frame PC).
|
|
512
|
-
async function bpFindWriter({ address, maxFrames = 600, pressDuring, abortIf }) {
|
|
527
|
+
async function bpFindWriter({ address, maxFrames = 600, pressDuring, abortIf, condition, conditionValue }) {
|
|
513
528
|
const host = getHost(sessionKey);
|
|
514
529
|
if (!host.watchpointSupported || !host.watchpointSupported()) {
|
|
515
530
|
return jsonContent({
|
|
@@ -518,7 +533,18 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
518
533
|
"Use watchMemory/runUntilWrite here — their pc is frame-sampled, so cross-check the value trace.",
|
|
519
534
|
});
|
|
520
535
|
}
|
|
521
|
-
|
|
536
|
+
if (condition === "equals" && conditionValue == null) {
|
|
537
|
+
throw new Error("breakpoint({on:'write', condition:'equals'}): `conditionValue` (the byte to stop on) is required.");
|
|
538
|
+
}
|
|
539
|
+
// Pass the condition to the core's watchpoint so its hook only COUNTS +
|
|
540
|
+
// records writes that satisfy it (qualifying writes), ignoring restoring/
|
|
541
|
+
// churn writes — and so the reported PC is a meaningful write, not just the
|
|
542
|
+
// last write of the frame. Core support is feature-detected; if the loaded
|
|
543
|
+
// core build predates condition support, we fall back to a host-side
|
|
544
|
+
// 'equals' filter on the reported value (inc/dec need the core's old byte).
|
|
545
|
+
const wantCond = condition != null;
|
|
546
|
+
const coreCond = host.setWatchpoint(address, true, wantCond ? { condition, value: conditionValue } : undefined);
|
|
547
|
+
const coreHandledCond = wantCond && coreCond && coreCond.conditionApplied === true;
|
|
522
548
|
const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
|
|
523
549
|
const pressDriver = makePressDriver(host, presses);
|
|
524
550
|
// Abort-guard: sample caller-named "still valid?" bytes each frame; if any
|
|
@@ -532,7 +558,17 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
532
558
|
pressDriver.applyForFrame(i);
|
|
533
559
|
host.stepFrames(1);
|
|
534
560
|
const w = host.getWatchpoint();
|
|
535
|
-
if (w.hits > 0) {
|
|
561
|
+
if (w.hits > 0) {
|
|
562
|
+
// Host-side fallback for condition:'equals' on a core that didn't
|
|
563
|
+
// apply the condition itself: only accept when the reported (last)
|
|
564
|
+
// written value equals the target; otherwise keep waiting. (inc/dec
|
|
565
|
+
// can't be faked host-side — they need the core's pre-write byte, so
|
|
566
|
+
// we only reach here for them when the core DID handle the condition.)
|
|
567
|
+
if (wantCond && !coreHandledCond && condition === "equals" && (w.lastValue & 0xFF) !== (conditionValue & 0xFF)) {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
result = { ...w, framesStepped: i + 1 }; break;
|
|
571
|
+
}
|
|
536
572
|
const ab = guard.check();
|
|
537
573
|
if (ab) { aborted = { ...ab, framesStepped: i + 1 }; break; }
|
|
538
574
|
}
|
|
@@ -583,12 +619,17 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
583
619
|
// address — a word/long store shows only its byte here, not the operand
|
|
584
620
|
// (a real session read 0x00 as "the move.l wrote zero").
|
|
585
621
|
valueByte: "0x" + result.lastValue.toString(16).toUpperCase().padStart(2, "0"),
|
|
622
|
+
...(result.lastOldValue != null ? { oldValueByte: "0x" + (result.lastOldValue & 0xFF).toString(16).toUpperCase().padStart(2, "0") } : {}),
|
|
623
|
+
...(condition ? { condition, ...(coreHandledCond ? {} : { conditionAppliedBy: "host" }) } : {}),
|
|
586
624
|
hits: result.hits,
|
|
587
625
|
framesStepped: result.framesStepped,
|
|
588
626
|
...(wpRegs ? { registersAtHit: wpRegs } : {}),
|
|
589
627
|
...(bankInfo ? bankInfo : {}),
|
|
590
628
|
...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
|
|
591
629
|
note: "pc is the EXACT writing instruction (captured in the CPU write path), not a frame sample. " +
|
|
630
|
+
(condition
|
|
631
|
+
? `condition:'${condition}' filtered to the MEANINGFUL write — pc/valueByte/hits reflect only qualifying writes${result.lastOldValue != null ? ` (oldValueByte→valueByte = ${"0x" + (result.lastOldValue & 0xFF).toString(16)}→${"0x" + result.lastValue.toString(16)})` : ""}. `
|
|
632
|
+
: "Without a `condition`, on:'write' runs to END OF FRAME and reports the LAST matching write of the frame (NOT the first) — `hits` is the count of all matching writes that frame. If a restoring/churn write hides the change you want, pass condition:'increase'|'decrease'|'equals'. ") +
|
|
592
633
|
"valueByte is the single byte written to the watched address (a 16/32-bit store shows only its byte here). " +
|
|
593
634
|
"hits counts watched-byte writes during the hit frame — the same instruction looping twice in one frame is hits:2, one event. " +
|
|
594
635
|
(wpRegs ? "registersAtHit is the register file frozen AT the write (the live regs drift for the rest of the frame — don't cpu({op:'read'}) instead). " : "") +
|
|
@@ -829,9 +870,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
829
870
|
precision: z.enum(["exact", "sampled"]).default("exact")
|
|
830
871
|
.describe("on:'write' ONLY. exact=core watchpoint, the real writing instruction PC even under interrupts (uses `address`). sampled=cheap frame-boundary PC (uses region/offset/length) — NOT the writer under IRQ. Ignored for on:read/pc (always exact)."),
|
|
831
872
|
address: z.number().int().min(0).optional().describe("on:'write' exact / on:'read' / on:'pc' — CPU address to break on (write target, read target, or instruction boundary). Required for those."),
|
|
832
|
-
region:
|
|
873
|
+
region: regionStr("on:'write' precision:'sampled' — region whose byte to watch for change.").optional(),
|
|
833
874
|
offset: z.number().int().min(0).optional().describe("on:'write' precision:'sampled' — offset within the region."),
|
|
834
875
|
length: z.number().int().min(1).max(4096).default(1).describe("on:'write' precision:'sampled' — bytes to watch from offset."),
|
|
876
|
+
condition: z.enum(["increase", "decrease", "equals"]).optional().describe("on:'write' precision:'exact' ONLY — stop only on the MEANINGFUL write, ignoring restoring/churn writes. 'decrease'/'increase' = the stored byte actually went down/up (e.g. a real lives−1, not a per-frame pointer-arithmetic restore); 'equals' = the byte became `value` (e.g. $00→$01 respawn re-arm). Without it, on:'write' reports the LAST matching write of the frame, which may be the churn, not the change you want."),
|
|
877
|
+
conditionValue: z.number().int().min(0).max(255).optional().describe("on:'write' condition:'equals' — the byte value to stop on (the NEW value written)."),
|
|
835
878
|
maxFrames: z.number().int().min(1).max(1_000_000).default(600).describe("Max frames to run while waiting for the condition."),
|
|
836
879
|
pressDuring: z.array(z.object({
|
|
837
880
|
frame: z.number().int().min(0),
|
|
@@ -840,12 +883,12 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
840
883
|
holdFrames: z.number().int().min(1).default(2),
|
|
841
884
|
})).optional().describe("Schedule input while waiting (drive the game to the state that triggers the condition). If OMITTED, this run inherits whatever input({op:'set'}) last held — same as frame({op:'step'}). If GIVEN, the schedule OWNS the pad for the whole run (a prior input({op:'set'}) is ignored); use it to drive the watched window itself. Entries with OVERLAPPING windows on the same port are OR'd into a chord (e.g. b+right held while a fires mid-window), not overwritten."),
|
|
842
885
|
abortIf: z.array(z.object({
|
|
843
|
-
region:
|
|
886
|
+
region: regionStr("memory region (default system_ram)").optional(),
|
|
844
887
|
offset: z.number().int().min(0).describe("byte offset within the region"),
|
|
845
888
|
label: z.string().optional().describe("human name for this guard byte"),
|
|
846
889
|
})).optional().describe("on:'write' exact — ABORT GUARD for a pressDuring run: caller-named 'is this scenario still valid?' bytes (e.g. the area/scene id, the player object-active flag). If ANY changes mid-run the watchpoint stops IMMEDIATELY and returns {aborted:true, abortedBy, before, after} — so a driven scenario that derailed (player died → title screen) doesn't burn all maxFrames and return a meaningless found:false. Each is sampled once per frame (cheap)."),
|
|
847
890
|
captureMemory: z.array(z.object({
|
|
848
|
-
region:
|
|
891
|
+
region: regionStr("memory region to read"),
|
|
849
892
|
offset: z.number().int().min(0).describe("byte offset within the region"),
|
|
850
893
|
length: z.number().int().min(1).max(256).default(1).describe("bytes to read"),
|
|
851
894
|
label: z.string().optional().describe("human name for this read (else 'region+offset')"),
|
|
@@ -927,7 +970,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
927
970
|
? `Stopped at ${r.stoppedAtPC} (your stopAtPC) with PARTIAL output — readMemory the dst to see what's been written so far.`
|
|
928
971
|
: "Did not return within maxFrames AND the watchdog didn't trip — this usually means the entry FELL BACK INTO THE GAME (a wrapper PC with a wrong source, so it never reaches the sentinel) and the game is just free-running. finalPC is inside the main loop, not your routine. Re-check the entry PC (use the routine body, not a wrapper) and the source regs; or lower maxInstructions to fail fast while probing. Bump maxFrames/maxInstructions only if you're sure it's a legitimately huge decompress.")
|
|
929
972
|
+ frameLogicCaveat;
|
|
930
|
-
return jsonContent({
|
|
973
|
+
return attachObserverFrame(jsonContent({
|
|
931
974
|
returned: r.returned, framesRun: r.framesRun, sandbox,
|
|
932
975
|
...(r.pure ? { pure: true, pureMode: r.pureMode } : {}),
|
|
933
976
|
...(r.watchdog ? { watchdog: true, reason: r.reason } : {}),
|
|
@@ -935,7 +978,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
935
978
|
...(r.finalPC ? { finalPC: r.finalPC } : {}),
|
|
936
979
|
...(r.finalRegs ? { finalRegs: r.finalRegs } : {}),
|
|
937
980
|
note,
|
|
938
|
-
});
|
|
981
|
+
}), host, "cpu call");
|
|
939
982
|
}
|
|
940
983
|
|
|
941
984
|
async function cpuDecompress({ entryPC, sourceAddress, destAddress, maxFrames = 600 }) {
|
package/src/observer/bus.js
CHANGED
|
@@ -80,6 +80,79 @@ class ObserverBus extends EventEmitter {
|
|
|
80
80
|
|
|
81
81
|
export const observer = new ObserverBus();
|
|
82
82
|
|
|
83
|
+
// ── Throttled deferred-frame emission ───────────────────────────────────────
|
|
84
|
+
// `call_frame` events carry a freshly-rasterized framebuffer PNG for the
|
|
85
|
+
// human's livestream. Tools attach a PROVIDER thunk (attachObserverFrame) and
|
|
86
|
+
// both transports route it here. Two guarantees:
|
|
87
|
+
// 1. The PNG encode NEVER runs on the agent's critical path (deferred via
|
|
88
|
+
// setImmediate / the trailing timer).
|
|
89
|
+
// 2. Rate-limited to one frame per FRAME_MIN_INTERVAL_MS **per
|
|
90
|
+
// (session, tool)** — frame({op:'step'}) called 120× in a narrowing loop
|
|
91
|
+
// emits at most every 2s, but a step followed immediately by a DIFFERENT
|
|
92
|
+
// tool's frame (input, state load, …) still shows: distinct tools don't
|
|
93
|
+
// throttle each other. Trailing-edge: the LAST suppressed frame in a
|
|
94
|
+
// burst always lands when the window reopens (rendered at fire time =
|
|
95
|
+
// the current screen, which is exactly what the human wants to converge
|
|
96
|
+
// on).
|
|
97
|
+
let FRAME_MIN_INTERVAL_MS = 2000;
|
|
98
|
+
export function _setFrameThrottleForTest(ms) { FRAME_MIN_INTERVAL_MS = ms; }
|
|
99
|
+
|
|
100
|
+
/** @type {Map<string, {lastTs: number, timer: any, pending: null | {provider: Function, meta: object}}>} */
|
|
101
|
+
const _frameThrottle = new Map();
|
|
102
|
+
|
|
103
|
+
function _emitFrame(provider, meta) {
|
|
104
|
+
try {
|
|
105
|
+
const img = provider();
|
|
106
|
+
if (img) {
|
|
107
|
+
observer.push({
|
|
108
|
+
type: "call_frame",
|
|
109
|
+
sessionKey: meta.sessionKey ?? "http",
|
|
110
|
+
platform: typeof meta.resolvePlatform === "function" ? (meta.resolvePlatform() ?? meta.platform ?? null) : (meta.platform ?? null),
|
|
111
|
+
ts: meta.ts ?? Date.now(),
|
|
112
|
+
tool: meta.tool,
|
|
113
|
+
...(meta.caption ? { caption: meta.caption } : {}),
|
|
114
|
+
images: [img],
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
} catch { /* livestream is best-effort; never affects the agent */ }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Queue a deferred framebuffer for the livestream, throttled per
|
|
122
|
+
* (session, tool). `meta`: { sessionKey, tool, ts?, platform?,
|
|
123
|
+
* resolvePlatform?, caption? } — resolvePlatform (a thunk) is preferred so
|
|
124
|
+
* the platform label reflects post-call state (loadMedia sets it DURING the
|
|
125
|
+
* call). `provider` returns {kind:'image', mimeType, base64} or null; it is
|
|
126
|
+
* invoked OFF the agent's critical path.
|
|
127
|
+
*/
|
|
128
|
+
export function pushObserverFrame(meta, provider) {
|
|
129
|
+
const key = `${meta.sessionKey ?? "http"}|${meta.tool ?? "?"}`;
|
|
130
|
+
let st = _frameThrottle.get(key);
|
|
131
|
+
if (!st) { st = { lastTs: 0, timer: null, pending: null }; _frameThrottle.set(key, st); }
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
if (!st.timer && now - st.lastTs >= FRAME_MIN_INTERVAL_MS) {
|
|
134
|
+
st.lastTs = now;
|
|
135
|
+
setImmediate(() => _emitFrame(provider, meta));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Inside the window: stash as the pending trailing frame (latest wins) and
|
|
139
|
+
// arm the trailing timer once.
|
|
140
|
+
st.pending = { provider, meta };
|
|
141
|
+
if (!st.timer) {
|
|
142
|
+
const delay = Math.max(1, st.lastTs + FRAME_MIN_INTERVAL_MS - now);
|
|
143
|
+
st.timer = setTimeout(() => {
|
|
144
|
+
st.timer = null;
|
|
145
|
+
const p = st.pending;
|
|
146
|
+
st.pending = null;
|
|
147
|
+
if (p) {
|
|
148
|
+
st.lastTs = Date.now();
|
|
149
|
+
_emitFrame(p.provider, p.meta);
|
|
150
|
+
}
|
|
151
|
+
}, delay);
|
|
152
|
+
if (st.timer.unref) st.timer.unref(); // never hold the process open
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
83
156
|
/**
|
|
84
157
|
* Extract image payloads from an MCP tool result. MCP tool results have
|
|
85
158
|
* `content: [{type:'text'|'image', ...}]`. We pull out images so the UI
|
|
@@ -305,7 +305,8 @@
|
|
|
305
305
|
// One latest image per "tool" (kind = tool name); ev.tool
|
|
306
306
|
// identifies which inspect call produced it.
|
|
307
307
|
s.latestByKind[ev.tool] = { ts: ev.ts, base64: img.base64,
|
|
308
|
-
mimeType: img.mimeType, tool: ev.tool, platform: ev.platform ?? null
|
|
308
|
+
mimeType: img.mimeType, tool: ev.tool, platform: ev.platform ?? null,
|
|
309
|
+
caption: ev.caption ?? null };
|
|
309
310
|
}
|
|
310
311
|
}
|
|
311
312
|
// screenshotAscii pushes both the PNG (above) AND the raw ANSI
|
|
@@ -416,7 +417,8 @@
|
|
|
416
417
|
card.className = "image-card";
|
|
417
418
|
const dt = new Date(img.ts).toLocaleTimeString();
|
|
418
419
|
const platBadge = img.platform ? `<span class="plat">${escapeHtml(img.platform)}</span> ` : "";
|
|
419
|
-
|
|
420
|
+
const label = img.caption ? `${img.tool} — ${img.caption}` : img.tool;
|
|
421
|
+
card.innerHTML = `<div class="meta"><span>${platBadge}${escapeHtml(label)}</span><span>${dt}</span></div>`;
|
|
420
422
|
const el = document.createElement("img");
|
|
421
423
|
el.src = `data:${img.mimeType};base64,${img.base64}`;
|
|
422
424
|
el.alt = img.tool;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Idempotent per server instance — installs once, repeats are no-ops.
|
|
7
7
|
|
|
8
|
-
import { observer, extractImages, summarizeForLog } from "./bus.js";
|
|
8
|
+
import { observer, extractImages, summarizeForLog, pushObserverFrame } from "./bus.js";
|
|
9
9
|
import { getHostOrNull } from "../mcp/state.js";
|
|
10
10
|
|
|
11
11
|
const INSTALLED = Symbol.for("romdev.observer-installed");
|
|
@@ -54,6 +54,7 @@ export function installObserverMiddleware(server, sessionKey) {
|
|
|
54
54
|
const platform = sessionPlatform(sessionKey); // which console this call drives
|
|
55
55
|
let event;
|
|
56
56
|
let frameProvider = null; // deferred framebuffer thunk (encoded async below)
|
|
57
|
+
let frameCaption = null; // optional human label for the call_frame event
|
|
57
58
|
if (thrown) {
|
|
58
59
|
event = {
|
|
59
60
|
type: "call",
|
|
@@ -98,6 +99,10 @@ export function installObserverMiddleware(server, sessionKey) {
|
|
|
98
99
|
frameProvider = result._observerFrameProvider;
|
|
99
100
|
delete result._observerFrameProvider;
|
|
100
101
|
}
|
|
102
|
+
if (result && typeof result === "object" && typeof result._observerFrameCaption === "string") {
|
|
103
|
+
frameCaption = result._observerFrameCaption;
|
|
104
|
+
delete result._observerFrameCaption;
|
|
105
|
+
}
|
|
101
106
|
const inlineImages = extractImages(result);
|
|
102
107
|
const images = inlineImages.length > 0 ? inlineImages : sidebandImages;
|
|
103
108
|
const resultSummary = summarizeForLog(result);
|
|
@@ -121,20 +126,18 @@ export function installObserverMiddleware(server, sessionKey) {
|
|
|
121
126
|
// tool response on observer delivery.
|
|
122
127
|
try { observer.push(event); } catch { /* never let observer kill the tool */ }
|
|
123
128
|
|
|
124
|
-
// Deferred frame:
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
+
// Deferred frame: encoded + pushed AFTER the agent's response goes out,
|
|
130
|
+
// throttled to one per 2s PER (session, tool) with a trailing-edge
|
|
131
|
+
// emit (bus.js pushObserverFrame) — frame-step loops can't flood the
|
|
132
|
+
// stream, distinct tools never throttle each other, and the last frame
|
|
133
|
+
// of a burst always lands. Best-effort — never throws into the tool
|
|
134
|
+
// path.
|
|
129
135
|
if (frameProvider) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
} catch { /* livestream is best-effort; never affects the agent */ }
|
|
137
|
-
});
|
|
136
|
+
pushObserverFrame({
|
|
137
|
+
sessionKey, tool: name, ts: startedAt, platform,
|
|
138
|
+
resolvePlatform: () => sessionPlatform(sessionKey),
|
|
139
|
+
...(frameCaption ? { caption: frameCaption } : {}),
|
|
140
|
+
}, frameProvider);
|
|
138
141
|
}
|
|
139
142
|
|
|
140
143
|
if (thrown) throw thrown;
|