c64bridge 1.0.7 → 1.1.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.
Files changed (132) hide show
  1. package/.github/skills/assembly-program/SKILL.md +37 -0
  2. package/.github/skills/basic-program/SKILL.md +40 -0
  3. package/.github/skills/cross-platform-demo/SKILL.md +32 -0
  4. package/.github/skills/drive-manager/SKILL.md +33 -0
  5. package/.github/skills/graphics-demo/SKILL.md +30 -0
  6. package/.github/skills/hello-world/SKILL.md +36 -0
  7. package/.github/skills/memory-debug/SKILL.md +32 -0
  8. package/.github/skills/pr-converge/SKILL.md +33 -0
  9. package/.github/skills/printer-job/SKILL.md +30 -0
  10. package/.github/skills/sid-music/SKILL.md +35 -0
  11. package/.github/skills/software-launch/SKILL.md +31 -0
  12. package/.github/skills/stream-control/SKILL.md +29 -0
  13. package/.github/skills/system-control/SKILL.md +32 -0
  14. package/AGENTS.md +28 -7
  15. package/README.md +69 -14
  16. package/data/assembly/assembly-spec.md +2 -0
  17. package/dist/c64Client.js +238 -60
  18. package/dist/config.js +48 -26
  19. package/dist/device.js +92 -37
  20. package/dist/diagnostics.js +62 -2
  21. package/dist/hostPaths.js +128 -0
  22. package/dist/loggingHttpClient.js +59 -1
  23. package/dist/mcp-server.js +409 -286
  24. package/dist/numericArg.js +55 -0
  25. package/dist/petscii.js +13 -1
  26. package/dist/platform.js +12 -3
  27. package/dist/prompts/registry.js +6 -1
  28. package/dist/tools/access.js +66 -0
  29. package/dist/tools/debug.js +6 -0
  30. package/dist/tools/developer.js +67 -34
  31. package/dist/tools/errors.js +54 -3
  32. package/dist/tools/input.js +23 -6
  33. package/dist/tools/machineControl.js +112 -20
  34. package/dist/tools/memory.js +135 -30
  35. package/dist/tools/meta/artifacts.js +5 -5
  36. package/dist/tools/meta/background.js +216 -54
  37. package/dist/tools/meta/compilation.js +6 -5
  38. package/dist/tools/meta/config.js +15 -6
  39. package/dist/tools/meta/filesystem.js +16 -14
  40. package/dist/tools/meta/graphics.js +48 -20
  41. package/dist/tools/meta/memory.js +45 -19
  42. package/dist/tools/meta/program.js +10 -7
  43. package/dist/tools/meta/screen.js +116 -25
  44. package/dist/tools/pauseGuard.js +23 -0
  45. package/dist/tools/pollValidator.js +53 -8
  46. package/dist/tools/programRunners.js +62 -18
  47. package/dist/tools/registry/batch.js +16 -4
  48. package/dist/tools/registry/config.js +9 -2
  49. package/dist/tools/registry/disk.js +12 -2
  50. package/dist/tools/registry/drive.js +3 -0
  51. package/dist/tools/registry/extract.js +3 -0
  52. package/dist/tools/registry/graphics.js +59 -6
  53. package/dist/tools/registry/index.js +17 -1
  54. package/dist/tools/registry/memory.js +11 -1
  55. package/dist/tools/registry/platform.js +4 -1
  56. package/dist/tools/registry/printer.js +2 -1
  57. package/dist/tools/registry/program.js +5 -1
  58. package/dist/tools/registry/rag.js +1 -0
  59. package/dist/tools/registry/sound.js +12 -1
  60. package/dist/tools/registry/stream.js +2 -1
  61. package/dist/tools/registry/system.js +8 -1
  62. package/dist/tools/schema.js +7 -7
  63. package/dist/tools/streaming.js +34 -4
  64. package/dist/tools/translation/assembler.js +247 -24
  65. package/dist/tools/translation/basicDetokenizer.js +80 -0
  66. package/dist/tools/translation/basicTokenizer.js +8 -3
  67. package/dist/tools/translation/disassembler.js +14 -0
  68. package/dist/tools/types.js +258 -7
  69. package/dist/tools/vice.js +5 -16
  70. package/dist/vicBitmap.js +1 -1
  71. package/dist/vice/mockServer.js +3 -0
  72. package/dist/vice/process.js +19 -0
  73. package/dist/vice/resourcePolicy.js +36 -0
  74. package/dist/vice/viceClient.js +22 -5
  75. package/doc/vice/support-matrix.md +17 -11
  76. package/mcp/schemas/c64_batch.schema.json +2 -1
  77. package/mcp/schemas/c64_config.schema.json +25 -475
  78. package/mcp/schemas/c64_debug.schema.json +3 -602
  79. package/mcp/schemas/c64_disk.schema.json +17 -344
  80. package/mcp/schemas/c64_drive.schema.json +2 -157
  81. package/mcp/schemas/c64_extract.schema.json +10 -364
  82. package/mcp/schemas/c64_graphics.schema.json +19 -426
  83. package/mcp/schemas/c64_input.schema.json +5 -318
  84. package/mcp/schemas/c64_memory.schema.json +23 -491
  85. package/mcp/schemas/c64_printer.schema.json +3 -258
  86. package/mcp/schemas/c64_program.schema.json +16 -491
  87. package/mcp/schemas/c64_rag.schema.json +1 -81
  88. package/mcp/schemas/c64_select_backend.schema.json +2 -36
  89. package/mcp/schemas/c64_sound.schema.json +20 -822
  90. package/mcp/schemas/c64_stream.schema.json +4 -75
  91. package/mcp/schemas/c64_system.schema.json +28 -348
  92. package/mcp/schemas/c64_vice.schema.json +2 -71
  93. package/mcp/server.json +1 -1
  94. package/mcp/tools.json +1983 -6910
  95. package/mcp.json +21 -1
  96. package/package.json +16 -8
  97. package/scripts/mockC64Server.mjs +44 -0
  98. package/scripts/run-coverage.mjs +27 -1
  99. package/scripts/smoke-run.sh +3 -2
  100. package/scripts/start.mjs +23 -1
  101. package/server.json +35 -2
  102. package/dist/.tsbuildinfo +0 -1
  103. package/doc/agent-state-spec.md +0 -243
  104. package/doc/audit/secret-handling.md +0 -61
  105. package/doc/plans/bun/baseline-metrics.md +0 -16
  106. package/doc/plans/bun/migration-checklist.md +0 -55
  107. package/doc/plans/bun/migration.md +0 -83
  108. package/doc/plans/improve-knowledge/notes.md +0 -43
  109. package/doc/plans/improve-knowledge/plan.md +0 -121
  110. package/doc/plans/improve-knowledge/rollout.md +0 -110
  111. package/doc/plans/meta-tools/README.md +0 -110
  112. package/doc/plans/meta-tools/analysis.md +0 -272
  113. package/doc/plans/meta-tools/catalog.md +0 -435
  114. package/doc/plans/meta-tools/notes.md +0 -192
  115. package/doc/plans/meta-tools/rollout.md +0 -368
  116. package/doc/plans/prompts/README.md +0 -96
  117. package/doc/plans/prompts/user-prompt-ideas.md +0 -147
  118. package/doc/plans/tool-consolidation/notes.md +0 -28
  119. package/doc/plans/tool-consolidation/plan.md +0 -169
  120. package/doc/plans/tool-consolidation/rollout.md +0 -104
  121. package/doc/plans/vice-backend-switch/plan.md +0 -486
  122. package/doc/plans/vice-backend-switch-2/plan.md +0 -48
  123. package/doc/plans/vice-support/notes.md +0 -63
  124. package/doc/plans/vice-support/plan.md +0 -88
  125. package/doc/plans/vice-support/rollout.md +0 -99
  126. package/doc/reviews/audit.md +0 -143
  127. package/doc/reviews/hardening/01-fable/prompt-handover.md +0 -125
  128. package/doc/reviews/hardening/01-fable/prompt.md +0 -98
  129. package/doc/reviews/hardening/01-fable/review-prompt.md +0 -468
  130. package/doc/reviews/hardening/01-fable/review.md +0 -1234
  131. package/doc/reviews/vice-support-review.md +0 -185
  132. package/scripts/compare-rest/rest-compare-2025-11-01T00-25-21-830Z.json +0 -2513
@@ -0,0 +1,55 @@
1
+ /*
2
+ C64 Bridge - An MCP Server for the Commodore 64 Ultimate
3
+ Copyright (C) 2025 Christian Gleissner
4
+
5
+ Licensed under the GNU General Public License v2.0 or later.
6
+ See <https://www.gnu.org/licenses/> for details.
7
+ */
8
+ import { ToolValidationError } from "./tools/errors.js";
9
+ /**
10
+ * Shared parser for memory address and length arguments.
11
+ *
12
+ * Accepted forms: `$C000`, `0xC000`, `%1010`, and decimal. Returns undefined
13
+ * when the text is not a numeric literal at all, so the caller can try a symbol
14
+ * name next. A decimal with a leading zero (`0400`) is rejected as ambiguous,
15
+ * because it was previously read as decimal 400 although it looks like hex.
16
+ */
17
+ export function parseNumericLiteral(raw, path) {
18
+ const text = raw.trim();
19
+ if (/^\$[0-9a-f]+$/i.test(text))
20
+ return Number.parseInt(text.slice(1), 16);
21
+ if (/^0x[0-9a-f]+$/i.test(text))
22
+ return Number.parseInt(text.slice(2), 16);
23
+ if (/^%[01]+$/.test(text))
24
+ return Number.parseInt(text.slice(1), 2);
25
+ if (/^[0-9]+$/.test(text)) {
26
+ if (text.length > 1 && text.startsWith("0")) {
27
+ throw new ToolValidationError(`Ambiguous number "${text}": a decimal with a leading zero could also be hexadecimal. Write "$${text}" for hex or "${Number.parseInt(text, 10)}" for decimal.`, { path });
28
+ }
29
+ return Number.parseInt(text, 10);
30
+ }
31
+ return undefined;
32
+ }
33
+ /**
34
+ * Throws for a bare string of hex digits that contains A-F (`C000`), which
35
+ * would otherwise be reported as an unknown symbol or an unparsable number.
36
+ * Call it after symbol lookup so names that happen to be hex digits still work.
37
+ */
38
+ export function assertNotBareHex(raw, path) {
39
+ const text = raw.trim();
40
+ if (/^[0-9a-f]*[a-f][0-9a-f]*$/i.test(text)) {
41
+ throw new ToolValidationError(`Ambiguous value "${text}": hexadecimal needs a prefix. Write "$${text.toUpperCase()}" or "0x${text.toUpperCase()}".`, { path });
42
+ }
43
+ }
44
+ export function formatAddressForMessage(value) {
45
+ return `$${value.toString(16).toUpperCase().padStart(4, "0")}`;
46
+ }
47
+ /** Validates that an address (and optional length) lies within $0000-$FFFF. */
48
+ export function assertAddressRange(address, length, path) {
49
+ if (!Number.isInteger(address) || address < 0 || address > 0xffff) {
50
+ throw new ToolValidationError("Address must be in range $0000-$FFFF", { path });
51
+ }
52
+ if (length !== undefined && address + length - 1 > 0xffff) {
53
+ throw new ToolValidationError(`Memory range ${formatAddressForMessage(address)} + ${length} bytes passes $FFFF`, { path });
54
+ }
55
+ }
package/dist/petscii.js CHANGED
@@ -63,12 +63,22 @@ function ensureScreenCodeMap() {
63
63
  }
64
64
  screenCodeMapInitialised = true;
65
65
  }
66
+ /** Letters of the lower-case set; reverse-video codes (+128) show the same letter. */
67
+ function lowerCaseSetChar(code) {
68
+ const base = code & 0x7f;
69
+ if (base >= 1 && base <= 26)
70
+ return String.fromCharCode(0x60 + base);
71
+ if (base >= 0x41 && base <= 0x5a)
72
+ return String.fromCharCode(base);
73
+ return undefined;
74
+ }
66
75
  export function screenCodesToAscii(bytes, options) {
67
76
  ensureScreenCodeMap();
68
77
  const columns = options?.columns && options.columns > 0 ? options.columns : 40;
69
78
  const maxRows = options?.rows && options.rows > 0 ? options.rows : undefined;
70
79
  const trimSpaces = options?.trimTrailingSpaces !== false;
71
80
  const trimEmptyRows = options?.trimTrailingEmptyRows !== false;
81
+ const lowerCase = options?.charset === "lower";
72
82
  const lines = [];
73
83
  const totalCells = maxRows ? Math.min(bytes.length, columns * maxRows) : bytes.length;
74
84
  for (let index = 0; index < totalCells; index += columns) {
@@ -79,7 +89,9 @@ export function screenCodesToAscii(bytes, options) {
79
89
  let line = "";
80
90
  for (let idx = index; idx < end; idx += 1) {
81
91
  const code = bytes[idx] ?? 0;
82
- const char = SCREEN_CODE_TO_ASCII.get(code & 0xff) ?? petsciiByteToChar(code & 0xff);
92
+ const char = lowerCase
93
+ ? lowerCaseSetChar(code & 0xff) ?? SCREEN_CODE_TO_ASCII.get(code & 0xff) ?? petsciiByteToChar(code & 0xff)
94
+ : SCREEN_CODE_TO_ASCII.get(code & 0xff) ?? petsciiByteToChar(code & 0xff);
83
95
  line += char;
84
96
  }
85
97
  if (trimSpaces) {
package/dist/platform.js CHANGED
@@ -51,15 +51,21 @@ export function isPlatformSupported(platform, supported) {
51
51
  }
52
52
  export function describePlatformCapabilities(tools) {
53
53
  const map = {
54
- c64u: { available: [], unsupported: [] },
55
- u2: { available: [], unsupported: [] },
56
- vice: { available: [], unsupported: [] },
54
+ c64u: { available: [], unsupported: [], unsupportedOperations: [] },
55
+ u2: { available: [], unsupported: [], unsupportedOperations: [] },
56
+ vice: { available: [], unsupported: [], unsupportedOperations: [] },
57
57
  };
58
58
  for (const tool of tools) {
59
59
  const supported = tool.metadata.platforms ?? ["c64u"];
60
60
  for (const id of ["c64u", "u2", "vice"]) {
61
61
  if (supported.includes(id)) {
62
62
  map[id].available.push(tool.name);
63
+ // Operations of an available tool that this backend does not provide.
64
+ for (const [op, platforms] of Object.entries(tool.metadata.operationPlatforms ?? {})) {
65
+ if (platforms.length > 0 && !platforms.includes(id)) {
66
+ map[id].unsupportedOperations.push(`${tool.name}.${op}`);
67
+ }
68
+ }
63
69
  }
64
70
  else {
65
71
  map[id].unsupported.push(tool.name);
@@ -73,18 +79,21 @@ export function describePlatformCapabilities(tools) {
73
79
  limited_features: PLATFORM_FEATURES.c64u.limited,
74
80
  tools: map.c64u.available.sort(),
75
81
  unsupported_tools: map.c64u.unsupported.sort(),
82
+ unsupported_operations: map.c64u.unsupportedOperations.sort(),
76
83
  },
77
84
  u2: {
78
85
  features: PLATFORM_FEATURES.u2.features,
79
86
  limited_features: PLATFORM_FEATURES.u2.limited,
80
87
  tools: map.u2.available.sort(),
81
88
  unsupported_tools: map.u2.unsupported.sort(),
89
+ unsupported_operations: map.u2.unsupportedOperations.sort(),
82
90
  },
83
91
  vice: {
84
92
  features: PLATFORM_FEATURES.vice.features,
85
93
  limited_features: PLATFORM_FEATURES.vice.limited,
86
94
  tools: map.vice.available.sort(),
87
95
  unsupported_tools: map.vice.unsupported.sort(),
96
+ unsupported_operations: map.vice.unsupportedOperations.sort(),
88
97
  },
89
98
  },
90
99
  };
@@ -1,6 +1,7 @@
1
1
  import { listKnowledgeResources } from "../rag/knowledgeIndex.js";
2
2
  import { RESOURCE_URIS } from "../rag/resourceUris.js";
3
3
  import { toolRegistry } from "../tools/registry/index.js";
4
+ import { ToolValidationError } from "../tools/errors.js";
4
5
  const ASSEMBLY_HARDWARE_NOTES = {
5
6
  sid: [
6
7
  "When routing this request, keep SID register usage, voice allocation, and timing side effects in scope.",
@@ -354,7 +355,11 @@ export function createPromptRegistry() {
354
355
  resolve(name, args) {
355
356
  const definition = definitionByName.get(name);
356
357
  if (!definition) {
357
- throw new Error(`Unknown prompt: ${name}`);
358
+ const allowed = Array.from(definitionByName.keys()).sort();
359
+ throw new ToolValidationError(`Unknown prompt: ${name}. Valid prompts: ${allowed.join(", ")}`, {
360
+ path: "$.name",
361
+ details: { allowed, received: name },
362
+ });
358
363
  }
359
364
  const rawArgs = args ?? {};
360
365
  const prepared = definition.prepareArgs ? definition.prepareArgs(rawArgs) : {};
@@ -0,0 +1,66 @@
1
+ import { ToolValidationError } from "./errors.js";
2
+ export const ACCESS_ENV_VAR = "C64BRIDGE_ACCESS";
3
+ const ACCESS_MODES = ["full", "no-destructive", "read-only"];
4
+ const warnedValues = new Set();
5
+ /**
6
+ * Reads the access mode from the environment at call time. An unset or empty variable means
7
+ * "full". An unknown value fails closed to "read-only" and logs a warning once per value.
8
+ */
9
+ export function getAccessMode(env = process.env) {
10
+ const raw = env[ACCESS_ENV_VAR];
11
+ if (raw === undefined || raw.trim() === "") {
12
+ return "full";
13
+ }
14
+ const value = raw.trim().toLowerCase();
15
+ if (ACCESS_MODES.includes(value)) {
16
+ return value;
17
+ }
18
+ if (!warnedValues.has(value)) {
19
+ warnedValues.add(value);
20
+ process.stderr.write(`[c64bridge] Unknown ${ACCESS_ENV_VAR} value "${raw}"; using "read-only". Valid values: ${ACCESS_MODES.join(", ")}.\n`);
21
+ }
22
+ return "read-only";
23
+ }
24
+ export function isEffectAllowed(mode, effect) {
25
+ switch (mode) {
26
+ case "full":
27
+ return true;
28
+ case "no-destructive":
29
+ return effect !== "destructive";
30
+ default:
31
+ return effect === "read";
32
+ }
33
+ }
34
+ /** Throws a validation error when the active access mode does not allow the effect. */
35
+ export function assertAccess(toolName, op, effect) {
36
+ const mode = getAccessMode();
37
+ if (isEffectAllowed(mode, effect)) {
38
+ return;
39
+ }
40
+ const subject = op ? `${toolName} operation "${op}"` : toolName;
41
+ throw new ToolValidationError(`${subject} is blocked: it has effect "${effect}" and the access mode is "${mode}" (${ACCESS_ENV_VAR}=${mode}). `
42
+ + `${mode === "read-only" ? "Only read operations are allowed." : "Destructive operations are not allowed."}`, {
43
+ path: "$.op",
44
+ details: { tool: toolName, op: op ?? null, effect, accessMode: mode, variable: ACCESS_ENV_VAR },
45
+ });
46
+ }
47
+ /** Effect of a background task operation (read, write, read_screen, menu_button and the *_memory aliases). */
48
+ export function taskOperationEffect(operation) {
49
+ switch (operation) {
50
+ case "read":
51
+ case "read_memory":
52
+ case "read_screen":
53
+ return "read";
54
+ default:
55
+ // write, write_memory, menu_button and unknown names (rejected later by validation).
56
+ return "write";
57
+ }
58
+ }
59
+ export function annotationsForEffects(effects) {
60
+ return {
61
+ readOnlyHint: effects.length > 0 && effects.every((effect) => effect === "read"),
62
+ destructiveHint: effects.includes("destructive"),
63
+ idempotentHint: false,
64
+ openWorldHint: false,
65
+ };
66
+ }
@@ -494,6 +494,12 @@ export const debugModuleGroup = defineToolModule({
494
494
  description: "Available debugger operations.",
495
495
  variants: debugOperationSchemas.map((schema) => schema.jsonSchema),
496
496
  }),
497
+ operationEffects: {
498
+ list_checkpoints: "read", get_checkpoint: "read", create_checkpoint: "write", delete_checkpoint: "write",
499
+ toggle_checkpoint: "write", set_condition: "write", list_registers: "read", get_registers: "read",
500
+ set_registers: "write", step: "write", step_return: "write", get_monitor_state: "read",
501
+ wait_for_state: "read", nuclear_reset: "write", continue_execution: "write",
502
+ },
497
503
  tags: ["debug", "breakpoint", "registers", "grouped"],
498
504
  examples: [
499
505
  {
@@ -20,6 +20,16 @@ const noArgsSchema = objectSchema({
20
20
  properties: {},
21
21
  additionalProperties: false,
22
22
  });
23
+ const flashCategoryArgsSchema = objectSchema({
24
+ description: "Optional configuration category; omit to apply to the whole device configuration.",
25
+ properties: {
26
+ category: optionalSchema(stringSchema({
27
+ description: "Configuration category name (e.g. 'Audio Mixer'). Omit to act on every category.",
28
+ minLength: 1,
29
+ })),
30
+ },
31
+ additionalProperties: false,
32
+ });
23
33
  const categorySchema = stringSchema({
24
34
  description: "Configuration category reported by the Ultimate firmware (e.g. 'Audio').",
25
35
  minLength: 1,
@@ -70,16 +80,44 @@ const configGetArgsSchema = objectSchema({
70
80
  required: ["category"],
71
81
  additionalProperties: false,
72
82
  });
83
+ const BATCH_CATEGORY_SCHEMA = {
84
+ type: "object",
85
+ description: "Item/value pairs of one category (primitive values only).",
86
+ additionalProperties: {
87
+ type: ["string", "number", "boolean"],
88
+ },
89
+ };
90
+ function isCategoryMap(value) {
91
+ return typeof value === "object" && value !== null && !Array.isArray(value)
92
+ && Object.values(value).every((entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry));
93
+ }
94
+ function parseCategoryMap(input, resolvedPath, into) {
95
+ for (const [category, items] of Object.entries(input)) {
96
+ if (typeof items !== "object" || items === null || Array.isArray(items)) {
97
+ throw new ToolValidationError("Each category must map to an object of item values", {
98
+ path: `${resolvedPath}.${category}`,
99
+ });
100
+ }
101
+ const parsedItems = into[category] ?? {};
102
+ for (const [item, rawValue] of Object.entries(items)) {
103
+ parsedItems[item] = primitiveValueSchema.parse(rawValue, `${resolvedPath}.${category}.${item}`);
104
+ }
105
+ into[category] = parsedItems;
106
+ }
107
+ }
73
108
  const configBatchSchema = {
74
109
  jsonSchema: {
75
- description: "Nested object mapping categories to item/value pairs (primitive values only).",
110
+ description: "Apply configuration changes. Documented form: `updates`, an object mapping categories to item/value pairs. "
111
+ + "For backward compatibility the runtime also accepts the categories as top-level keys next to `op`.",
76
112
  type: "object",
77
- additionalProperties: {
78
- type: "object",
79
- additionalProperties: {
80
- type: ["string", "number", "boolean"],
113
+ properties: {
114
+ updates: {
115
+ type: "object",
116
+ description: "Categories mapped to item/value pairs (primitive values only), for example {\"Audio\": {\"Volume\": 80}}.",
117
+ additionalProperties: BATCH_CATEGORY_SCHEMA,
81
118
  },
82
119
  },
120
+ additionalProperties: BATCH_CATEGORY_SCHEMA,
83
121
  },
84
122
  parse(value, path) {
85
123
  const resolvedPath = path ?? "$";
@@ -88,21 +126,16 @@ const configBatchSchema = {
88
126
  path: resolvedPath,
89
127
  });
90
128
  }
91
- const input = value;
129
+ const input = { ...value };
92
130
  const categories = {};
93
- for (const [category, items] of Object.entries(input)) {
94
- if (typeof items !== "object" || items === null || Array.isArray(items)) {
95
- throw new ToolValidationError("Each category must map to an object of item values", {
96
- path: `${resolvedPath}.${category}`,
97
- });
98
- }
99
- const parsedItems = {};
100
- for (const [item, rawValue] of Object.entries(items)) {
101
- const parsedValue = primitiveValueSchema.parse(rawValue, `${resolvedPath}.${category}.${item}`);
102
- parsedItems[item] = parsedValue;
103
- }
104
- categories[category] = parsedItems;
131
+ // `updates` is the documented form. A legacy category literally named "updates"
132
+ // holds primitive values, so it is not a category map and stays a legacy category.
133
+ if (Object.prototype.hasOwnProperty.call(input, "updates") && isCategoryMap(input.updates)) {
134
+ const updates = input.updates;
135
+ delete input.updates;
136
+ parseCategoryMap(updates, `${resolvedPath}.updates`, categories);
105
137
  }
138
+ parseCategoryMap(input, resolvedPath, categories);
106
139
  if (Object.keys(categories).length === 0) {
107
140
  throw new ToolValidationError("Batch payload must contain at least one category", {
108
141
  path: resolvedPath,
@@ -265,7 +298,7 @@ export const developerModule = defineToolModule({
265
298
  relatedResources: ["c64://guide/bootstrap"],
266
299
  tags: ["config", "write"],
267
300
  examples: [
268
- { name: "Batch update", description: "Update multiple settings", arguments: { Audio: { Volume: 80, DACMode: "stereo" } } },
301
+ { name: "Batch update", description: "Update multiple settings", arguments: { updates: { Audio: { Volume: 80, DACMode: "stereo" } } } },
269
302
  ],
270
303
  workflowHints: [
271
304
  "Use when multiple categories must be updated together; remind the user to review validation errors closely.",
@@ -305,7 +338,7 @@ export const developerModule = defineToolModule({
305
338
  name: "config_load_from_flash",
306
339
  description: "Load configuration settings from flash storage.",
307
340
  summary: "Restores the firmware configuration by reading the persisted flash snapshot.",
308
- inputSchema: noArgsSchema.jsonSchema,
341
+ inputSchema: flashCategoryArgsSchema.jsonSchema,
309
342
  relatedResources: ["c64://guide/bootstrap"],
310
343
  tags: ["config", "flash"],
311
344
  examples: [
@@ -316,15 +349,15 @@ export const developerModule = defineToolModule({
316
349
  ],
317
350
  async execute(args, ctx) {
318
351
  try {
319
- noArgsSchema.parse(args ?? {});
320
- ctx.logger.info("Loading configuration from flash");
321
- const result = await ctx.client.configLoadFromFlash();
352
+ const { category } = flashCategoryArgsSchema.parse(args ?? {});
353
+ ctx.logger.info("Loading configuration from flash", { category });
354
+ const result = await ctx.client.configLoadFromFlash(category);
322
355
  if (!result.success) {
323
356
  throw new ToolExecutionError("C64 backend reported failure while loading from flash", {
324
357
  details: toRecord(result.details),
325
358
  });
326
359
  }
327
- return textResult("Configuration loaded from flash.", {
360
+ return textResult(`Configuration${category ? ` category ${category}` : ""} loaded from flash.`, {
328
361
  success: true,
329
362
  details: toRecord(result.details) ?? null,
330
363
  });
@@ -341,7 +374,7 @@ export const developerModule = defineToolModule({
341
374
  name: "config_save_to_flash",
342
375
  description: "Persist current configuration settings to flash storage.",
343
376
  summary: "Saves the active configuration snapshot so it survives power cycles.",
344
- inputSchema: noArgsSchema.jsonSchema,
377
+ inputSchema: flashCategoryArgsSchema.jsonSchema,
345
378
  relatedResources: ["c64://guide/bootstrap"],
346
379
  tags: ["config", "flash"],
347
380
  examples: [
@@ -352,15 +385,15 @@ export const developerModule = defineToolModule({
352
385
  ],
353
386
  async execute(args, ctx) {
354
387
  try {
355
- noArgsSchema.parse(args ?? {});
356
- ctx.logger.info("Saving configuration to flash");
357
- const result = await ctx.client.configSaveToFlash();
388
+ const { category } = flashCategoryArgsSchema.parse(args ?? {});
389
+ ctx.logger.info("Saving configuration to flash", { category });
390
+ const result = await ctx.client.configSaveToFlash(category);
358
391
  if (!result.success) {
359
392
  throw new ToolExecutionError("C64 backend reported failure while saving to flash", {
360
393
  details: toRecord(result.details),
361
394
  });
362
395
  }
363
- return textResult("Configuration saved to flash.", {
396
+ return textResult(`Configuration${category ? ` category ${category}` : ""} saved to flash.`, {
364
397
  success: true,
365
398
  details: toRecord(result.details) ?? null,
366
399
  });
@@ -377,7 +410,7 @@ export const developerModule = defineToolModule({
377
410
  name: "config_reset_to_default",
378
411
  description: "Reset configuration categories to their factory defaults.",
379
412
  summary: "Instructs the firmware to discard overrides and restore default configuration values.",
380
- inputSchema: noArgsSchema.jsonSchema,
413
+ inputSchema: flashCategoryArgsSchema.jsonSchema,
381
414
  relatedResources: ["c64://guide/bootstrap"],
382
415
  tags: ["config", "reset"],
383
416
  examples: [
@@ -388,15 +421,15 @@ export const developerModule = defineToolModule({
388
421
  ],
389
422
  async execute(args, ctx) {
390
423
  try {
391
- noArgsSchema.parse(args ?? {});
392
- ctx.logger.info("Resetting configuration to defaults");
393
- const result = await ctx.client.configResetToDefault();
424
+ const { category } = flashCategoryArgsSchema.parse(args ?? {});
425
+ ctx.logger.info("Resetting configuration to defaults", { category });
426
+ const result = await ctx.client.configResetToDefault(category);
394
427
  if (!result.success) {
395
428
  throw new ToolExecutionError("C64 backend reported failure while resetting configuration", {
396
429
  details: toRecord(result.details),
397
430
  });
398
431
  }
399
- return textResult("Configuration reset to defaults.", {
432
+ return textResult(`Configuration${category ? ` category ${category}` : ""} reset to defaults.`, {
400
433
  success: true,
401
434
  details: toRecord(result.details) ?? null,
402
435
  });
@@ -22,14 +22,39 @@ export class ToolExecutionError extends ToolError {
22
22
  super(message, "execution", options);
23
23
  }
24
24
  }
25
+ function joinBackends(backends) {
26
+ return backends.join(" or ");
27
+ }
28
+ export function describeUnsupportedPlatform(subject, platform, supported, configured) {
29
+ const base = supported.length > 0
30
+ ? `${subject} requires backend ${joinBackends(supported)} (active: ${platform}).`
31
+ : `${subject} is not available on backend ${platform}.`;
32
+ if (supported.length === 0) {
33
+ return base;
34
+ }
35
+ if (configured === undefined) {
36
+ return base;
37
+ }
38
+ const switchable = supported.filter((id) => id !== platform && configured.includes(id));
39
+ if (switchable.length > 0) {
40
+ return `${base} Use \`c64_select_backend\` to switch to ${joinBackends(switchable)}.`;
41
+ }
42
+ return `${base} None of these backends is configured on this server.`;
43
+ }
25
44
  export class ToolUnsupportedPlatformError extends ToolExecutionError {
26
- constructor(tool, platform, supported) {
27
- super(`Tool ${tool} is not available on platform ${platform}`, {
45
+ constructor(tool, platform, supported, options) {
46
+ const subject = options?.toolName
47
+ ? options.op
48
+ ? `\`${options.toolName}\` op \`${options.op}\``
49
+ : `\`${options.toolName}\``
50
+ : tool;
51
+ super(describeUnsupportedPlatform(subject, platform, supported, options?.configured), {
28
52
  code: "unsupported_platform",
29
53
  details: {
30
54
  tool,
31
55
  platform,
32
56
  supported,
57
+ ...(options?.configured ? { configured: options.configured } : {}),
33
58
  },
34
59
  });
35
60
  this.tool = tool;
@@ -48,7 +73,9 @@ export function toolErrorResult(error) {
48
73
  // Clients often show only the text content, so surface the underlying cause
49
74
  // (for example a range check or firmware error string) next to the summary.
50
75
  const detailMessage = describeDetailMessage(error.details);
51
- const message = detailMessage && !located.includes(detailMessage) ? `${located}: ${detailMessage}` : located;
76
+ const withDetail = detailMessage && !located.includes(detailMessage) ? `${located}: ${detailMessage}` : located;
77
+ const limits = describeLimits(error.details);
78
+ const message = limits ? `${withDetail} [${limits}]` : withDetail;
52
79
  const base = textResult(message, { error: metadata });
53
80
  return { ...base, isError: true };
54
81
  }
@@ -59,6 +86,30 @@ function describeDetailMessage(details) {
59
86
  const message = details.message;
60
87
  return typeof message === "string" && message.trim().length > 0 ? message.trim() : undefined;
61
88
  }
89
+ const MAX_LISTED_VALUES = 40;
90
+ // Valid values and bounds recorded by validators, so a client sees them without reading the schema.
91
+ function describeLimits(details) {
92
+ if (!details || typeof details !== "object") {
93
+ return undefined;
94
+ }
95
+ const record = details;
96
+ const parts = [];
97
+ if (Array.isArray(record.allowed) && record.allowed.length > 0) {
98
+ const values = record.allowed.map((value) => String(value));
99
+ const shown = values.slice(0, MAX_LISTED_VALUES).join(", ");
100
+ parts.push(values.length > MAX_LISTED_VALUES ? `allowed: ${shown}, ... (${values.length} total)` : `allowed: ${shown}`);
101
+ }
102
+ for (const key of ["minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"]) {
103
+ const value = record[key];
104
+ if (typeof value === "number" || typeof value === "string") {
105
+ parts.push(`${key}: ${value}`);
106
+ }
107
+ }
108
+ if (parts.length > 0 && record.received !== undefined && typeof record.received !== "object") {
109
+ parts.push(`received: ${JSON.stringify(record.received)}`);
110
+ }
111
+ return parts.length > 0 ? parts.join("; ") : undefined;
112
+ }
62
113
  export function unknownErrorResult(error) {
63
114
  if (error instanceof ToolError) {
64
115
  return toolErrorResult(error);
@@ -69,10 +69,26 @@ const PETSCII_TOKENS = {
69
69
  // ASCII a-z are shifted graphics characters in PETSCII, so typed "print" would
70
70
  // reach BASIC as graphics and give ?SYNTAX ERROR. Literal letters are therefore
71
71
  // sent as the unshifted PETSCII letters $41-$5A; {tokens} keep their codes.
72
+ // Newline is RETURN. Any other character without a PETSCII equivalent, and any
73
+ // unknown {TOKEN}, is rejected instead of being typed as something else.
74
+ function describeChar(char) {
75
+ const codePoint = char.codePointAt(0) ?? 0;
76
+ return `'${codePoint < 0x20 ? "" : char}' (U+${codePoint.toString(16).toUpperCase().padStart(4, "0")})`;
77
+ }
72
78
  function expandPetsciiTokens(text) {
73
- return text.replace(/\{([^}]+)\}|[a-z]/g, (_match, token) => {
79
+ return text.replace(/\{([^}]+)\}|\r\n|[\s\S]/gu, (match, token, offset) => {
74
80
  if (token === undefined) {
75
- return _match.toUpperCase();
81
+ if (match === "\n" || match === "\r" || match === "\r\n") {
82
+ return String.fromCharCode(13);
83
+ }
84
+ const code = match.codePointAt(0) ?? 0;
85
+ if (code >= 0x61 && code <= 0x7a) {
86
+ return match.toUpperCase();
87
+ }
88
+ if (code >= 0x20 && code <= 0x7e) {
89
+ return match;
90
+ }
91
+ throw new ToolValidationError(`Character ${describeChar(match)} at position ${offset + 1} of the text has no PETSCII mapping. Use printable ASCII, \\n for RETURN, or a {TOKEN}.`, { path: "$.text" });
76
92
  }
77
93
  const upper = token.trim().toUpperCase();
78
94
  const code = PETSCII_TOKENS[upper];
@@ -90,7 +106,7 @@ function expandPetsciiTokens(text) {
90
106
  if (!isNaN(num) && num >= 0 && num <= 255) {
91
107
  return String.fromCharCode(num);
92
108
  }
93
- return _match.toUpperCase(); // type unrecognised tokens literally
109
+ throw new ToolValidationError(`Unknown token {${token}} at position ${offset + 1} of the text. Valid tokens: ${Object.keys(PETSCII_TOKENS).map((name) => `{${name}}`).join(", ")}, or a numeric code such as {$1D} or {29} (0-255).`, { path: "$.text" });
94
110
  });
95
111
  }
96
112
  // ---------------------------------------------------------------------------
@@ -131,7 +147,7 @@ export const writeTextArgsSchema = objectSchema({
131
147
  properties: {
132
148
  op: literalSchema("write_text"),
133
149
  text: stringSchema({
134
- description: "Text to type. Use {RETURN}, {CLR}, {F1}…{F8}, {UP/DOWN/LEFT/RIGHT}, colour names, etc.",
150
+ description: "Text to type: printable ASCII (letters are sent as PETSCII upper case), \\n for RETURN, and {TOKEN}s such as {RETURN}, {CLR}, {F1}…{F8}, {UP}, {DOWN}, {LEFT}, {RIGHT}, colour names, or {$1D}/{29} codes. Characters with no PETSCII mapping and unknown tokens are rejected.",
135
151
  minLength: 1,
136
152
  maxLength: 512,
137
153
  }),
@@ -147,7 +163,7 @@ export const writeTextArgsSchema = objectSchema({
147
163
  additionalProperties: false,
148
164
  });
149
165
  export const keyArgsSchema = objectSchema({
150
- description: "Tap a single key or hold it for a duration.",
166
+ description: "Press a single key one or more times, optionally with a delay between presses.",
151
167
  properties: {
152
168
  op: literalSchema("key"),
153
169
  key: stringSchema({
@@ -270,7 +286,7 @@ const inputOperationHandlers = {
270
286
  const keyByte = tokenCode !== undefined
271
287
  ? tokenCode
272
288
  : parsed.key.length === 1
273
- ? parsed.key.toUpperCase().charCodeAt(0) & 0xff
289
+ ? expandPetsciiTokens(parsed.key).charCodeAt(0)
274
290
  : (() => {
275
291
  throw new ToolValidationError(`Unrecognised key: ${parsed.key}. Use a single character or a PETSCII token name.`, { path: "$.key" });
276
292
  })();
@@ -417,6 +433,7 @@ export const inputModule = defineToolModule({
417
433
  ],
418
434
  }),
419
435
  operationPlatforms: { joystick: ["c64u", "vice"], keyboard: ["c64u"], release_all: ["c64u"], state: ["c64u"] },
436
+ operationEffects: { write_text: "write", key: "write", keyboard: "write", joystick: "write", release_all: "write", state: "read" },
420
437
  tags: ["input", "keyboard", "joystick"],
421
438
  examples: [
422
439
  {