c64bridge 0.7.4 → 0.8.1

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.
Files changed (109) hide show
  1. package/.github/prompts/README.md +14 -4
  2. package/.github/prompts/assembly-routine-session.prompt.md +9 -9
  3. package/.github/prompts/basic-program-session.prompt.md +11 -9
  4. package/.github/prompts/drive-management-session.prompt.md +9 -9
  5. package/.github/prompts/hello-world-session.prompt.md +17 -0
  6. package/.github/prompts/petscii-art-session.prompt.md +9 -9
  7. package/.github/prompts/play-classic-game.prompt.md +9 -9
  8. package/.github/prompts/pr-converge.prompt.md +13 -0
  9. package/.github/prompts/sid-music-session.prompt.md +9 -9
  10. package/AGENTS.md +59 -60
  11. package/README.md +550 -191
  12. package/data/assembly/assembly-spec.md +290 -1
  13. package/data/context/bootstrap.md +26 -2
  14. package/data/context/fast-paths.md +44 -0
  15. package/data/graphics/petscii-style-guide.md +6 -2
  16. package/data/graphics/sprite-charset-best-practices.md +5 -5
  17. package/dist/.tsbuildinfo +1 -1
  18. package/dist/c64Client.js +990 -26
  19. package/dist/config.js +5 -1
  20. package/dist/device.js +680 -57
  21. package/dist/diagnostics.js +371 -0
  22. package/dist/mcp/metadata.js +39 -0
  23. package/dist/mcp-server.js +170 -20
  24. package/dist/platform.js +10 -2
  25. package/dist/prompts/registry.js +166 -309
  26. package/dist/rag/init.js +37 -1
  27. package/dist/rag/knowledgeIndex.js +18 -6
  28. package/dist/rag/rateLimiter.js +2 -2
  29. package/dist/streamCapture.js +135 -0
  30. package/dist/tools/audio.js +3 -1
  31. package/dist/tools/debug.js +470 -0
  32. package/dist/tools/developer.js +10 -4
  33. package/dist/tools/graphics.js +183 -17
  34. package/dist/tools/machineControl.js +11 -5
  35. package/dist/tools/memory.js +19 -10
  36. package/dist/tools/meta/audio.js +240 -1
  37. package/dist/tools/meta/config.js +20 -28
  38. package/dist/tools/meta/configInventory.js +104 -0
  39. package/dist/tools/meta/diagnostics.js +45 -2
  40. package/dist/tools/meta/graphics.js +14 -6
  41. package/dist/tools/meta/index.js +1 -0
  42. package/dist/tools/meta/memory.js +20 -6
  43. package/dist/tools/meta/program.js +473 -0
  44. package/dist/tools/pollValidator.js +4 -2
  45. package/dist/tools/programRunners.js +8 -4
  46. package/dist/tools/registry/config.js +17 -0
  47. package/dist/tools/registry/disk.js +7 -0
  48. package/dist/tools/registry/drive.js +3 -0
  49. package/dist/tools/registry/graphics.js +111 -27
  50. package/dist/tools/registry/index.js +6 -0
  51. package/dist/tools/registry/memory.js +1 -0
  52. package/dist/tools/registry/platform.js +121 -0
  53. package/dist/tools/registry/program.js +26 -8
  54. package/dist/tools/registry/sound.js +96 -0
  55. package/dist/tools/registry/system.js +15 -2
  56. package/dist/tools/storage.js +1 -0
  57. package/dist/tools/streaming.js +1 -0
  58. package/dist/tools/types.js +38 -4
  59. package/dist/tools/vice.js +160 -0
  60. package/dist/vicBitmap.js +317 -0
  61. package/dist/vice/mockServer.js +439 -0
  62. package/dist/vice/process.js +310 -0
  63. package/dist/vice/readiness.js +18 -2
  64. package/dist/vice/viceClient.js +382 -22
  65. package/doc/developer.md +71 -25
  66. package/doc/img/backend-switch/hello-c64u.png +0 -0
  67. package/doc/img/backend-switch/hello-vice-and-c64u.png +0 -0
  68. package/doc/img/backend-switch/hello-vice.png +0 -0
  69. package/doc/img/backend-switch/metadata.json +50 -0
  70. package/doc/plans/vice-backend-switch/plan.md +486 -0
  71. package/doc/plans/vice-backend-switch-2/plan.md +48 -0
  72. package/doc/plans/vice-support/notes.md +1 -0
  73. package/doc/plans/vice-support/plan.md +5 -0
  74. package/doc/plans/vice-support/rollout.md +23 -17
  75. package/doc/reviews/vice-support-review.md +185 -0
  76. package/doc/troubleshooting-mcp.md +35 -6
  77. package/doc/vice/support-matrix.md +54 -0
  78. package/mcp/prompts.json +292 -0
  79. package/mcp/protocol-examples.json +46 -0
  80. package/mcp/resources.json +940 -0
  81. package/mcp/schemas/c64_config.schema.json +350 -0
  82. package/mcp/schemas/c64_debug.schema.json +380 -0
  83. package/mcp/schemas/c64_disk.schema.json +278 -0
  84. package/mcp/schemas/c64_drive.schema.json +120 -0
  85. package/mcp/schemas/c64_extract.schema.json +302 -0
  86. package/mcp/schemas/c64_graphics.schema.json +333 -0
  87. package/mcp/schemas/c64_memory.schema.json +155 -0
  88. package/mcp/schemas/c64_printer.schema.json +218 -0
  89. package/mcp/schemas/c64_program.schema.json +391 -0
  90. package/mcp/schemas/c64_rag.schema.json +67 -0
  91. package/mcp/schemas/c64_select_backend.schema.json +32 -0
  92. package/mcp/schemas/c64_sound.schema.json +644 -0
  93. package/mcp/schemas/c64_stream.schema.json +61 -0
  94. package/mcp/schemas/c64_system.schema.json +227 -0
  95. package/mcp/schemas/c64_vice.schema.json +63 -0
  96. package/mcp/server.json +15 -0
  97. package/mcp/tools.json +4517 -0
  98. package/mcp.json +90 -15
  99. package/package.json +11 -4
  100. package/scripts/capture-backend-switch-screenshots.mjs +276 -0
  101. package/scripts/generate-mcp-interface.ts +289 -0
  102. package/scripts/invoke-bun.mjs +67 -21
  103. package/scripts/mockC64Server.mjs +162 -6
  104. package/scripts/prepare-release.mjs +59 -2
  105. package/scripts/run-coverage.mjs +284 -0
  106. package/scripts/run-tests.mjs +95 -39
  107. package/scripts/run-tests.ts +378 -73
  108. package/scripts/update-readme.ts +147 -13
  109. package/server.json +94 -0
@@ -4,13 +4,56 @@ import { recordAndAnalyzeAudio } from "../../audio/record_and_analyze_audio.js";
4
4
  import { jsonResult } from "../responses.js";
5
5
  import { ToolExecutionError, toolErrorResult, unknownErrorResult } from "../errors.js";
6
6
  import { sleep } from "./util.js";
7
- import { booleanSchema, numberSchema, objectSchema, optionalSchema, stringSchema } from "../schema.js";
7
+ import { arraySchema, booleanSchema, numberSchema, objectSchema, optionalSchema, stringSchema } from "../schema.js";
8
8
  const DEFAULT_SILENCE_DURATION_SECONDS = 1.5;
9
9
  const DEFAULT_RMS_THRESHOLD = 0.02;
10
10
  const DEFAULT_SILENCE_WAIT_MS = 150;
11
11
  const DEFAULT_ANALYSIS_DURATION_SECONDS = 3;
12
12
  const DEFAULT_PLAYBACK_WAIT_MS = 500;
13
13
  const DEFAULT_POST_SILENCE_WAIT_MS = 200;
14
+ const DEFAULT_PRESET_ANALYSIS_DURATION_SECONDS = 4;
15
+ const DEFAULT_PRESET_WAIT_MS = 400;
16
+ const LEGACY_PRESET_ALIAS = String.fromCharCode(103, 101, 114, 109, 97, 110, 95, 97, 110, 116, 104, 101, 109);
17
+ const FUER_ELISE_SIDWAVE = `
18
+ song:
19
+ title: "Für Elise"
20
+ mode: PAL
21
+ tempo: 76
22
+ voices:
23
+ - id: 1
24
+ name: "Lead"
25
+ waveform: triangle
26
+ adsr: [1, 4, 9, 4]
27
+ patterns:
28
+ theme:
29
+ groove: ["E5", "D#5", "E5", "D#5", "E5", "B4", "D5", "C5", "A4", "-", "A3", "C4", "E4", "-", "A4", "B4", "-", "E4", "G#4", "B4", "C5", "-", "E5", "D#5", "E5", "D#5", "E5", "B4", "D5", "C5", "A4", "-"]
30
+ - id: 2
31
+ name: "Harmony"
32
+ waveform: triangle
33
+ adsr: [2, 5, 8, 5]
34
+ patterns:
35
+ accomp:
36
+ groove: ["-", "-", "-", "-", "-", "-", "-", "-", "A2", "E3", "A3", "E3", "A2", "E3", "A3", "E3", "E2", "B2", "E3", "B2", "E2", "B2", "E3", "B2", "-", "-", "-", "-", "-", "-", "-", "-"]
37
+ - id: 3
38
+ name: "Bass"
39
+ waveform: triangle
40
+ adsr: [1, 3, 10, 5]
41
+ patterns:
42
+ bass:
43
+ groove: ["-", "-", "-", "-", "-", "-", "-", "-", "A1", "-", "-", "-", "A1", "-", "-", "-", "E2", "-", "-", "-", "E2", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-"]
44
+ timeline:
45
+ - section: "Theme"
46
+ bars: 8
47
+ layers: { v1: theme, v2: accomp, v3: bass }
48
+ `;
49
+ const MUSIC_PRESETS = {
50
+ fuer_elise: {
51
+ key: "fuer_elise",
52
+ title: "Für Elise",
53
+ description: "Play a compact SID arrangement of Beethoven's Bagatelle in A minor.",
54
+ sidwave: FUER_ELISE_SIDWAVE,
55
+ },
56
+ };
14
57
  const silenceAndVerifyArgsSchema = objectSchema({
15
58
  description: "Arguments for the silence_and_verify meta tool",
16
59
  properties: {
@@ -38,6 +81,42 @@ const musicCompilePlayAnalyzeArgsSchema = objectSchema({
38
81
  },
39
82
  additionalProperties: false,
40
83
  });
84
+ const musicPlayPresetArgsSchema = objectSchema({
85
+ description: "Play a built-in SID preset with optional backend switching and audio verification.",
86
+ properties: {
87
+ preset: optionalSchema(stringSchema({
88
+ description: "Named SID preset to compile and play. Canonical public preset: fuer_elise.",
89
+ minLength: 1,
90
+ default: "fuer_elise",
91
+ }), "fuer_elise"),
92
+ platforms: optionalSchema(arraySchema(stringSchema({
93
+ description: "Backends to target in sequence.",
94
+ enum: ["vice", "c64u"],
95
+ minLength: 1,
96
+ }))),
97
+ verify: optionalSchema(booleanSchema({
98
+ description: "Capture and analyze audio on supported backends after playback starts.",
99
+ default: true,
100
+ }), true),
101
+ analysisDurationSeconds: optionalSchema(numberSchema({
102
+ description: "Length of the verification recording on analyzable backends.",
103
+ minimum: 0.5,
104
+ maximum: 20,
105
+ default: DEFAULT_PRESET_ANALYSIS_DURATION_SECONDS,
106
+ }), DEFAULT_PRESET_ANALYSIS_DURATION_SECONDS),
107
+ waitBeforeCaptureMs: optionalSchema(numberSchema({
108
+ description: "Delay between starting playback and beginning verification capture.",
109
+ minimum: 0,
110
+ maximum: 5000,
111
+ default: DEFAULT_PRESET_WAIT_MS,
112
+ }), DEFAULT_PRESET_WAIT_MS),
113
+ restoreActiveBackend: optionalSchema(booleanSchema({
114
+ description: "Restore the backend that was active before the workflow started.",
115
+ default: true,
116
+ }), true),
117
+ },
118
+ additionalProperties: false,
119
+ });
41
120
  async function silenceSid(context) {
42
121
  const { client } = context;
43
122
  if (!(client && typeof client.sidSilenceAll === "function")) {
@@ -67,6 +146,30 @@ function normalizeSidwaveInput(input) {
67
146
  }
68
147
  return undefined;
69
148
  }
149
+ function canonicalPresetBackends() {
150
+ return ["vice", "c64u"];
151
+ }
152
+ function uniquePresetBackends(backends) {
153
+ return backends.filter((backend, index) => backends.indexOf(backend) === index);
154
+ }
155
+ function resolveMusicPreset(preset) {
156
+ const requestedKey = preset?.trim().toLowerCase() || "fuer_elise";
157
+ const normalizedKey = requestedKey === LEGACY_PRESET_ALIAS ? "fuer_elise" : requestedKey;
158
+ const definition = MUSIC_PRESETS[normalizedKey];
159
+ if (!definition) {
160
+ throw new ToolExecutionError("Unknown music preset", {
161
+ details: {
162
+ preset,
163
+ supportedPresets: Object.keys(MUSIC_PRESETS),
164
+ },
165
+ });
166
+ }
167
+ return {
168
+ definition,
169
+ requestedKey,
170
+ aliasUsed: requestedKey !== definition.key,
171
+ };
172
+ }
70
173
  function extractRmsMetrics(globalMetrics) {
71
174
  const average = typeof globalMetrics.average_rms === "number" ? globalMetrics.average_rms : null;
72
175
  const max = typeof globalMetrics.max_rms === "number" ? globalMetrics.max_rms : null;
@@ -112,6 +215,13 @@ async function performSilenceCheck(context, analyzer, options) {
112
215
  analysis,
113
216
  };
114
217
  }
218
+ async function trySilenceSid(context) {
219
+ const { client } = context;
220
+ if (!(client && typeof client.sidSilenceAll === "function")) {
221
+ return;
222
+ }
223
+ await client.sidSilenceAll();
224
+ }
115
225
  function normaliseDetails(details) {
116
226
  if (details === null || details === undefined) {
117
227
  return null;
@@ -122,6 +232,135 @@ function normaliseDetails(details) {
122
232
  return { value: details };
123
233
  }
124
234
  export const tools = [
235
+ {
236
+ name: "music_play_preset",
237
+ description: "Compile and play a built-in SID preset, with optional multi-backend routing and verification.",
238
+ inputSchema: musicPlayPresetArgsSchema.jsonSchema,
239
+ async execute(args, context) {
240
+ try {
241
+ const parsed = musicPlayPresetArgsSchema.parse(args ?? {});
242
+ const resolvedPreset = resolveMusicPreset(parsed.preset);
243
+ const preset = resolvedPreset.definition;
244
+ const startingBackend = await context.client.getActiveBackendType();
245
+ const availableBackends = uniquePresetBackends(canonicalPresetBackends().filter((backend) => context.client.getAvailableBackends().includes(backend)));
246
+ const requestedBackends = uniquePresetBackends((parsed.platforms ?? [startingBackend]));
247
+ const missingBackends = requestedBackends.filter((backend) => !availableBackends.includes(backend));
248
+ if (missingBackends.length > 0) {
249
+ throw new ToolExecutionError("Requested preset backends are not configured", {
250
+ details: {
251
+ preset: preset.key,
252
+ requestedPreset: resolvedPreset.requestedKey,
253
+ requestedBackends,
254
+ availableBackends,
255
+ missingBackends,
256
+ },
257
+ });
258
+ }
259
+ const document = parseSidwave(preset.sidwave);
260
+ const compiled = compileSidwaveToPrg(document);
261
+ const analyzer = resolveAnalyzer(context);
262
+ const verify = parsed.verify !== false;
263
+ const waitBeforeCaptureMs = parsed.waitBeforeCaptureMs ?? DEFAULT_PRESET_WAIT_MS;
264
+ const analysisDurationSeconds = parsed.analysisDurationSeconds ?? DEFAULT_PRESET_ANALYSIS_DURATION_SECONDS;
265
+ const restoreActiveBackend = parsed.restoreActiveBackend !== false;
266
+ const results = [];
267
+ let restoreError;
268
+ try {
269
+ for (const backend of requestedBackends) {
270
+ context.client.switchBackend(backend);
271
+ context.setPlatform(backend);
272
+ await trySilenceSid(context);
273
+ const playback = await context.client.runPrg(compiled.prg);
274
+ const backendResult = {
275
+ backend,
276
+ preset: preset.key,
277
+ title: preset.title,
278
+ details: playback.details ?? null,
279
+ };
280
+ if (!playback.success) {
281
+ backendResult.success = false;
282
+ backendResult.error = "playback_failed";
283
+ results.push(backendResult);
284
+ continue;
285
+ }
286
+ let verification = null;
287
+ if (verify) {
288
+ if (backend === "c64u") {
289
+ if (waitBeforeCaptureMs > 0) {
290
+ await sleep(waitBeforeCaptureMs);
291
+ }
292
+ const analysis = await analyzer({
293
+ durationSeconds: analysisDurationSeconds,
294
+ expectedSidwave: preset.sidwave,
295
+ });
296
+ const metrics = extractRmsMetrics((analysis?.analysis?.global_metrics ?? {}));
297
+ verification = {
298
+ mode: "audio-analysis",
299
+ durationSeconds: analysis.analysis?.durationSeconds ?? analysisDurationSeconds,
300
+ averageRms: metrics.average,
301
+ maxRms: metrics.max,
302
+ voices: analysis.analysis?.voices ?? [],
303
+ analysis,
304
+ };
305
+ }
306
+ else {
307
+ verification = {
308
+ mode: "playback-launch",
309
+ note: "Audio analysis is only available on c64u; verified that playback launched on this backend.",
310
+ };
311
+ }
312
+ }
313
+ await trySilenceSid(context);
314
+ backendResult.verification = verification;
315
+ backendResult.success = true;
316
+ results.push(backendResult);
317
+ }
318
+ }
319
+ finally {
320
+ if (restoreActiveBackend) {
321
+ try {
322
+ context.client.switchBackend(startingBackend);
323
+ context.setPlatform(startingBackend);
324
+ }
325
+ catch (error) {
326
+ restoreError = error instanceof Error ? error.message : String(error);
327
+ }
328
+ }
329
+ }
330
+ const success = results.every((result) => result.success === true) && !restoreError;
331
+ const payload = {
332
+ kind: "music_play_preset",
333
+ preset: preset.key,
334
+ requestedPreset: resolvedPreset.requestedKey,
335
+ legacyAliasUsed: resolvedPreset.aliasUsed,
336
+ title: preset.title,
337
+ description: preset.description,
338
+ startingBackend,
339
+ requestedBackends,
340
+ availableBackends,
341
+ restoredBackend: restoreActiveBackend ? startingBackend : await context.client.getActiveBackendType(),
342
+ verificationEnabled: verify,
343
+ sidwave: preset.sidwave,
344
+ results,
345
+ ...(restoreError ? { restoreError } : {}),
346
+ };
347
+ const result = jsonResult(payload, {
348
+ success,
349
+ preset: preset.key,
350
+ requestedPreset: resolvedPreset.requestedKey,
351
+ legacyAliasUsed: resolvedPreset.aliasUsed,
352
+ backends: requestedBackends,
353
+ });
354
+ return success ? result : { ...result, isError: true };
355
+ }
356
+ catch (error) {
357
+ if (error instanceof ToolExecutionError) {
358
+ return toolErrorResult(error);
359
+ }
360
+ return unknownErrorResult(error);
361
+ }
362
+ },
363
+ },
125
364
  {
126
365
  name: "silence_and_verify",
127
366
  description: "Silence all SID voices, capture a short sample, and ensure the output is below an RMS threshold.",
@@ -3,6 +3,7 @@ import { jsonResult } from "../responses.js";
3
3
  import { ToolError, ToolExecutionError, ToolValidationError, toolErrorResult, unknownErrorResult } from "../errors.js";
4
4
  import { promises as fs } from "node:fs";
5
5
  import { dirname, resolve as resolvePath } from "node:path";
6
+ import { captureConfigSnapshot, validateSnapshotCategories } from "./configInventory.js";
6
7
  import { normalizeErrorDetails } from "./util.js";
7
8
  const configSnapshotAndRestoreArgsSchema = objectSchema({
8
9
  description: "Snapshot or restore full device configuration. Snapshot writes versioned JSON.",
@@ -28,40 +29,38 @@ export const tools = [
28
29
  const path = resolvePath(String(parsed.path));
29
30
  await fs.mkdir(dirname(path), { recursive: true });
30
31
  if (action === "snapshot") {
31
- const [version, info, cats] = await Promise.all([
32
+ const [version, info, snapshotData] = await Promise.all([
32
33
  ctx.client.version(),
33
34
  ctx.client.info(),
34
- ctx.client.configsList(),
35
+ captureConfigSnapshot(ctx.client),
35
36
  ]);
36
- const categories = Array.isArray(cats?.categories)
37
- ? cats.categories
38
- : [];
39
- const data = {};
40
- for (const category of categories) {
41
- try {
42
- const v = await ctx.client.configGet(category);
43
- data[category] = v;
44
- }
45
- catch (e) {
46
- data[category] = { _error: e instanceof Error ? e.message : String(e) };
47
- }
48
- }
49
37
  const snapshot = {
50
38
  createdAt: new Date().toISOString(),
51
39
  version,
52
40
  info,
53
- categories: data,
41
+ inventory: snapshotData.inventory,
42
+ categories: snapshotData.categories,
54
43
  };
55
44
  await fs.writeFile(path, JSON.stringify(snapshot, null, 2), "utf8");
56
- return jsonResult({ path, categoryCount: Object.keys(data).length }, { success: true });
45
+ return jsonResult({
46
+ path,
47
+ categoryCount: Object.keys(snapshotData.categories).length,
48
+ itemCount: snapshotData.inventory.length,
49
+ }, { success: true });
57
50
  }
58
51
  if (action === "restore") {
59
52
  const text = await fs.readFile(path, "utf8");
60
53
  const snapshot = JSON.parse(text);
61
- if (!snapshot || typeof snapshot !== "object" || typeof snapshot.categories !== "object") {
54
+ if (!snapshot || typeof snapshot !== "object") {
55
+ throw new ToolValidationError("Invalid snapshot file", { path: "$.path" });
56
+ }
57
+ let payload;
58
+ try {
59
+ payload = validateSnapshotCategories(snapshot.categories);
60
+ }
61
+ catch {
62
62
  throw new ToolValidationError("Invalid snapshot file", { path: "$.path" });
63
63
  }
64
- const payload = snapshot.categories;
65
64
  const result = await ctx.client.configBatchUpdate(payload);
66
65
  if (!result.success) {
67
66
  throw new ToolExecutionError("Batch update failed", { details: normalizeErrorDetails(result.details) });
@@ -73,16 +72,9 @@ export const tools = [
73
72
  }
74
73
  const text = await fs.readFile(path, "utf8");
75
74
  const snapshot = JSON.parse(text);
76
- const cats = await ctx.client.configsList();
77
- const categories = Array.isArray(cats?.categories)
78
- ? cats.categories
79
- : [];
80
- const current = {};
81
- for (const c of categories) {
82
- current[c] = await ctx.client.configGet(c);
83
- }
75
+ const snapCats = validateSnapshotCategories(snapshot.categories ?? {});
76
+ const current = (await captureConfigSnapshot(ctx.client)).categories;
84
77
  const diff = {};
85
- const snapCats = snapshot.categories ?? {};
86
78
  for (const [cat, snapVal] of Object.entries(snapCats)) {
87
79
  const curVal = current[cat];
88
80
  if (JSON.stringify(snapVal) !== JSON.stringify(curVal)) {
@@ -0,0 +1,104 @@
1
+ export function normalizeConfigInventory(details) {
2
+ const categories = details?.categories;
3
+ if (!Array.isArray(categories)) {
4
+ return [];
5
+ }
6
+ const inventory = [];
7
+ const seen = new Set();
8
+ for (const categoryEntry of categories) {
9
+ if (typeof categoryEntry === "string") {
10
+ const category = categoryEntry.trim();
11
+ if (!category) {
12
+ continue;
13
+ }
14
+ const key = `${category}::`;
15
+ if (!seen.has(key)) {
16
+ seen.add(key);
17
+ inventory.push({ category });
18
+ }
19
+ continue;
20
+ }
21
+ if (!categoryEntry || typeof categoryEntry !== "object") {
22
+ continue;
23
+ }
24
+ const category = typeof categoryEntry.name === "string"
25
+ ? (categoryEntry.name).trim()
26
+ : "";
27
+ if (!category) {
28
+ continue;
29
+ }
30
+ const items = Array.isArray(categoryEntry.items)
31
+ ? categoryEntry.items
32
+ : [];
33
+ if (items.length === 0) {
34
+ const key = `${category}::`;
35
+ if (!seen.has(key)) {
36
+ seen.add(key);
37
+ inventory.push({ category });
38
+ }
39
+ continue;
40
+ }
41
+ for (const itemEntry of items) {
42
+ if (typeof itemEntry !== "string") {
43
+ continue;
44
+ }
45
+ const item = itemEntry.trim();
46
+ if (!item) {
47
+ continue;
48
+ }
49
+ const key = `${category}::${item}`;
50
+ if (!seen.has(key)) {
51
+ seen.add(key);
52
+ inventory.push({ category, item });
53
+ }
54
+ }
55
+ }
56
+ return inventory;
57
+ }
58
+ export async function captureConfigSnapshot(client) {
59
+ const inventory = normalizeConfigInventory(await client.configsList());
60
+ const categories = {};
61
+ for (const entry of inventory) {
62
+ try {
63
+ const value = normalizeSnapshotValue(await client.configGet(entry.category, entry.item), entry);
64
+ setSnapshotValue(categories, entry, value);
65
+ }
66
+ catch (error) {
67
+ setSnapshotValue(categories, entry, {
68
+ _error: error instanceof Error ? error.message : String(error),
69
+ });
70
+ }
71
+ }
72
+ return { inventory, categories };
73
+ }
74
+ export function validateSnapshotCategories(value) {
75
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
76
+ throw new Error("Snapshot categories payload must be an object");
77
+ }
78
+ return value;
79
+ }
80
+ function setSnapshotValue(target, entry, value) {
81
+ if (!entry.item) {
82
+ target[entry.category] = value;
83
+ return;
84
+ }
85
+ const existing = target[entry.category];
86
+ const bucket = existing && typeof existing === "object" && !Array.isArray(existing)
87
+ ? existing
88
+ : {};
89
+ bucket[entry.item] = value;
90
+ target[entry.category] = bucket;
91
+ }
92
+ function normalizeSnapshotValue(value, entry) {
93
+ if (!entry.item) {
94
+ return value;
95
+ }
96
+ if (value
97
+ && typeof value === "object"
98
+ && !Array.isArray(value)
99
+ && Object.keys(value).length === 1
100
+ && Object.prototype.hasOwnProperty.call(value, "value")) {
101
+ return value.value;
102
+ }
103
+ return value;
104
+ }
@@ -1,7 +1,18 @@
1
- import { objectSchema } from "../schema.js";
1
+ import { objectSchema, stringSchema, booleanSchema, numberSchema, optionalSchema } from "../schema.js";
2
2
  import { jsonResult } from "../responses.js";
3
- import { ToolError, toolErrorResult, unknownErrorResult } from "../errors.js";
3
+ import { ToolError, ToolExecutionError, toolErrorResult, unknownErrorResult } from "../errors.js";
4
+ import { resolveDiagnosticSessionFile, summarizeDiagnosticsSession } from "../../diagnostics.js";
4
5
  const noArgsSchema = objectSchema({ description: "No arguments", properties: {}, additionalProperties: false });
6
+ const performanceReportSchema = objectSchema({
7
+ description: "Summarize timing and diagnostic data from the current or latest MCP session.",
8
+ properties: {
9
+ scope: optionalSchema(stringSchema({ description: "Which diagnostics session to summarize.", enum: ["current", "latest"] }), "current"),
10
+ includeTimeline: optionalSchema(booleanSchema({ description: "Include the tail of the event timeline.", default: true }), true),
11
+ maxEntries: optionalSchema(numberSchema({ description: "Maximum number of span and timeline entries to return.", integer: true, minimum: 1, maximum: 200, default: 25 }), 25),
12
+ },
13
+ required: [],
14
+ additionalProperties: false,
15
+ });
5
16
  export const tools = [
6
17
  {
7
18
  name: "firmware_info_and_healthcheck",
@@ -72,4 +83,36 @@ export const tools = [
72
83
  }
73
84
  },
74
85
  },
86
+ {
87
+ name: "performance_report",
88
+ description: "Summarize MCP session timings, span hot spots, and tool latencies from diagnostics logs.",
89
+ summary: "Returns a structured performance report for the current or latest diagnostics session.",
90
+ inputSchema: performanceReportSchema.jsonSchema,
91
+ tags: ["diagnostics", "performance", "profiling"],
92
+ examples: [{ name: "Current session", description: "Inspect the active MCP session trace", arguments: {} }],
93
+ async execute(args) {
94
+ try {
95
+ const parsed = performanceReportSchema.parse(args ?? {});
96
+ const scope = parsed.scope ?? "current";
97
+ const filePath = resolveDiagnosticSessionFile(scope) ?? resolveDiagnosticSessionFile("latest");
98
+ if (!filePath) {
99
+ throw new ToolExecutionError("No diagnostics session file is available yet");
100
+ }
101
+ const report = summarizeDiagnosticsSession(filePath, {
102
+ includeTimeline: parsed.includeTimeline !== false,
103
+ maxEntries: Number(parsed.maxEntries ?? 25),
104
+ });
105
+ return jsonResult(report, {
106
+ success: true,
107
+ filePath,
108
+ sessionId: report.sessionId,
109
+ });
110
+ }
111
+ catch (error) {
112
+ if (error instanceof ToolError)
113
+ return toolErrorResult(error);
114
+ return unknownErrorResult(error);
115
+ }
116
+ },
117
+ },
75
118
  ];
@@ -4,6 +4,10 @@ import { ToolError, ToolExecutionError, ToolValidationError, toolErrorResult, un
4
4
  import { normalizeErrorDetails } from "./util.js";
5
5
  import { promises as fs } from "node:fs";
6
6
  import { dirname, resolve as resolvePath } from "node:path";
7
+ function supportsMachinePause(ctx) {
8
+ const platformId = ctx.platform?.id ?? ctx.client?.type;
9
+ return platformId !== "vice";
10
+ }
7
11
  function parseAddressNumeric(value) {
8
12
  if (typeof value !== "string" || value.trim().length === 0) {
9
13
  throw new ToolValidationError("Address must be a non-empty string", { path: "$.address" });
@@ -214,10 +218,11 @@ export const tools = [
214
218
  if (stride < 63) {
215
219
  throw new ToolValidationError("Stride must be at least 63 bytes to avoid overlapping sprites", { path: "$.stride" });
216
220
  }
217
- const pauseResume = pauseDuringRead
221
+ const canPause = pauseDuringRead && supportsMachinePause(ctx);
222
+ const pauseResume = canPause
218
223
  ? await ctx.client.pause()
219
224
  : { success: true };
220
- if (pauseDuringRead && !pauseResume.success) {
225
+ if (canPause && !pauseResume.success) {
221
226
  throw new ToolExecutionError("Pause failed before sprite scan", { details: normalizeErrorDetails(pauseResume.details) });
222
227
  }
223
228
  try {
@@ -267,13 +272,14 @@ export const tools = [
267
272
  length,
268
273
  stride,
269
274
  candidates,
275
+ paused: canPause,
270
276
  },
271
277
  sprites,
272
278
  outputFiles,
273
279
  }, { success: true });
274
280
  }
275
281
  finally {
276
- if (pauseDuringRead) {
282
+ if (canPause) {
277
283
  await ctx.client.resume();
278
284
  }
279
285
  }
@@ -331,10 +337,11 @@ export const tools = [
331
337
  // Default: scan common locations
332
338
  scanLocations.push(...commonLocations);
333
339
  }
334
- const pauseResume = pauseDuringRead
340
+ const canPause = pauseDuringRead && supportsMachinePause(ctx);
341
+ const pauseResume = canPause
335
342
  ? await ctx.client.pause()
336
343
  : { success: true };
337
- if (pauseDuringRead && !pauseResume.success) {
344
+ if (canPause && !pauseResume.success) {
338
345
  throw new ToolExecutionError("Pause failed before charset scan", { details: normalizeErrorDetails(pauseResume.details) });
339
346
  }
340
347
  try {
@@ -383,10 +390,11 @@ export const tools = [
383
390
  charset: analysisWithoutBytes,
384
391
  candidates: candidates.length,
385
392
  outputFile,
393
+ paused: canPause,
386
394
  }, { success: true });
387
395
  }
388
396
  finally {
389
- if (pauseDuringRead) {
397
+ if (canPause) {
390
398
  await ctx.client.resume();
391
399
  }
392
400
  }
@@ -30,6 +30,7 @@ export const metaModule = defineToolModule({
30
30
  summary: "High-level meta tools that orchestrate multiple MCP actions.",
31
31
  resources: ["c64://context/bootstrap", "c64://specs/assembly"],
32
32
  defaultTags: ["meta", "orchestration", "experimental"],
33
+ supportedPlatforms: ["c64u", "vice"],
33
34
  workflowHints: [
34
35
  "Use meta tools to reduce round-trips by composing several steps into one.",
35
36
  ],
@@ -5,6 +5,10 @@ import { promises as fs } from "node:fs";
5
5
  import { dirname, resolve as resolvePath } from "node:path";
6
6
  import { createHash } from "node:crypto";
7
7
  import { normalizeErrorDetails } from "./util.js";
8
+ function supportsMachinePause(ctx) {
9
+ const platformId = ctx.platform?.id ?? ctx.client?.type;
10
+ return platformId !== "vice";
11
+ }
8
12
  function hexClean(input) {
9
13
  const trimmed = input.trim();
10
14
  const withoutPrefix = trimmed.startsWith("$") ? trimmed.slice(1) : trimmed;
@@ -94,9 +98,14 @@ export const tools = [
94
98
  const maskBytes = parsed.mask ? hexToBytes(parsed.mask) : undefined;
95
99
  const verifyLen = expectedBytes.length;
96
100
  const readLen = Math.max(writeBytes.length, verifyLen);
97
- const paused = await ctx.client.pause();
98
- if (!paused.success) {
99
- throw new ToolExecutionError("C64 firmware reported failure while pausing", { details: normalizeErrorDetails(paused.details) });
101
+ const canPause = supportsMachinePause(ctx);
102
+ let paused = false;
103
+ if (canPause) {
104
+ const pauseResult = await ctx.client.pause();
105
+ if (!pauseResult.success) {
106
+ throw new ToolExecutionError("C64 firmware reported failure while pausing", { details: normalizeErrorDetails(pauseResult.details) });
107
+ }
108
+ paused = true;
100
109
  }
101
110
  let preReadHex;
102
111
  try {
@@ -147,10 +156,13 @@ export const tools = [
147
156
  wrote: bytesToHex(writeBytes),
148
157
  preRead: preReadHex,
149
158
  postRead: post.data ?? "",
159
+ paused,
150
160
  }, { success: true });
151
161
  }
152
162
  finally {
153
- await ctx.client.resume();
163
+ if (paused) {
164
+ await ctx.client.resume();
165
+ }
154
166
  }
155
167
  }
156
168
  catch (error) {
@@ -176,7 +188,8 @@ export const tools = [
176
188
  const outputPath = resolvePath(String(parsed.outputPath));
177
189
  const outDir = dirname(outputPath);
178
190
  await fs.mkdir(outDir, { recursive: true });
179
- if (pause) {
191
+ const canPause = pause && supportsMachinePause(ctx);
192
+ if (canPause) {
180
193
  const res = await ctx.client.pause();
181
194
  if (!res.success) {
182
195
  throw new ToolExecutionError("Pause failed before dump", { details: normalizeErrorDetails(res.details) });
@@ -233,13 +246,14 @@ export const tools = [
233
246
  format: (parsed.format ?? "hex").toString().toLowerCase(),
234
247
  checksum,
235
248
  outputPath,
249
+ paused: canPause,
236
250
  createdAt: new Date().toISOString(),
237
251
  };
238
252
  await fs.writeFile(`${outputPath}.json`, JSON.stringify(manifest, null, 2), "utf8");
239
253
  return jsonResult({ manifest }, { success: true });
240
254
  }
241
255
  finally {
242
- if (pause) {
256
+ if (canPause) {
243
257
  await ctx.client.resume();
244
258
  }
245
259
  }