@henols/c64-re-tools 2.1.0-rc.2 → 2.1.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 (58) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +1 -0
  3. package/skills/c64-assembler/scripts/assembler.ts +10 -0
  4. package/skills/c64-disk/SKILL.md +2 -2
  5. package/skills/c64-disk/scripts/disk.ts +24 -10
  6. package/skills/c64-disk/scripts/lib/containers.ts +9 -9
  7. package/skills/c64-docs/scripts/lib/links.ts +3 -3
  8. package/skills/c64-docs/scripts/lib/reference.ts +2 -2
  9. package/skills/c64-emulator/SKILL.md +8 -2
  10. package/skills/c64-knowledge/SKILL.md +3 -1
  11. package/skills/c64-knowledge/scripts/knowledge.ts +59 -43
  12. package/skills/c64-memory-map/SKILL.md +1 -1
  13. package/skills/c64-memory-map/scripts/memory-map.ts +3 -2
  14. package/skills/c64-provenance/SKILL.md +5 -4
  15. package/skills/c64-provenance/scripts/lib/library.ts +18 -6
  16. package/skills/c64-provenance/scripts/lib/separate.ts +45 -6
  17. package/skills/c64-provenance/scripts/provenance.ts +5 -4
  18. package/skills/c64-source/SKILL.md +2 -0
  19. package/skills/c64-source/scripts/lib/source.ts +2 -2
  20. package/skills/c64-source/scripts/source.ts +10 -6
  21. package/skills/c64-static-analysis/scripts/lib/idioms.ts +6 -2
  22. package/skills/c64-static-analysis/scripts/static-analysis.ts +4 -3
  23. package/skills/c64-testing/SKILL.md +11 -1
  24. package/skills/c64-testing/references/recording-format.md +2 -0
  25. package/skills/c64-testing/references/scenario-format.md +6 -0
  26. package/skills/c64-testing/scripts/lib/diff-files.ts +63 -13
  27. package/skills/c64-testing/scripts/lib/diff.ts +3 -3
  28. package/skills/c64-testing/scripts/lib/recording.ts +47 -12
  29. package/skills/c64-testing/scripts/lib/replay.ts +11 -0
  30. package/skills/c64-testing/scripts/lib/runs.ts +64 -4
  31. package/skills/c64-testing/scripts/lib/scenario.ts +43 -6
  32. package/skills/c64-testing/scripts/testing.ts +15 -2
  33. package/skills/c64-unpacker/SKILL.md +9 -5
  34. package/skills/c64-unpacker/scripts/lib/evidence.ts +4 -5
  35. package/skills/c64-unpacker/scripts/lib/loads.ts +3 -2
  36. package/skills/c64-unpacker/scripts/unpacker.ts +75 -25
  37. package/src/host/vice/session.ts +99 -10
  38. package/src/host-client/image.ts +32 -0
  39. package/src/host-client/steps.ts +106 -6
  40. package/src/host-client/tools.ts +1 -1
  41. package/src/host-client/transfer.ts +35 -8
  42. package/src/knowledge/expression.ts +1 -1
  43. package/src/knowledge/history.ts +19 -2
  44. package/src/knowledge/names.ts +2 -1
  45. package/src/knowledge/program.ts +1 -16
  46. package/src/knowledge/read.ts +43 -0
  47. package/src/knowledge/schema.ts +3 -2
  48. package/src/knowledge/text.ts +1 -0
  49. package/src/knowledge/write.ts +40 -6
  50. package/src/mcp/output.ts +5 -0
  51. package/src/mcp/server.ts +19 -4
  52. package/src/mcp/tools/debug.ts +7 -6
  53. package/src/mcp/tools/execution.ts +18 -5
  54. package/src/mcp/tools/media.ts +14 -2
  55. package/src/mcp/tools/memory.ts +7 -3
  56. package/src/mcp/tools/video.ts +5 -3
  57. package/src/project.ts +1 -1
  58. package/src/protocol/vice.ts +4 -1
@@ -63,6 +63,8 @@ export type ActionStep =
63
63
 
64
64
  export const ACTION_STEP_KINDS = ["write", "registers", "joystick", "type", "press", "frames", "runUntil"] as const;
65
65
  export type ActionStepKind = (typeof ACTION_STEP_KINDS)[number];
66
+ /** The step kinds that VICE runs: all but press, which only the replay core has. */
67
+ export const VICE_STEP_KINDS = ACTION_STEP_KINDS.filter((kind) => kind !== "press");
66
68
 
67
69
  /** The fields of each object of the steps: the same as the inputs of the MCP tools. */
68
70
  export const STEP_FIELDS = {
@@ -110,6 +112,32 @@ export function keyboardBytes(input: { mode: "text" | "petscii"; text?: string |
110
112
  }
111
113
  }
112
114
 
115
+ /**
116
+ * One line of JSON with a space after each colon and comma: the form that a
117
+ * refusal tells the caller to write.
118
+ */
119
+ export function jsonLine(value: unknown): string {
120
+ if (Array.isArray(value)) return `[${value.map(jsonLine).join(", ")}]`;
121
+ if (isObject(value)) return `{${Object.entries(value).filter(([, item]) => item !== undefined).map(([key, item]) => `${JSON.stringify(key)}: ${jsonLine(item)}`).join(", ")}}`;
122
+ return JSON.stringify(value) ?? "null";
123
+ }
124
+
125
+ /** Field names of the 2.0 files and what a 2.1 object has in their place, when it has that field. */
126
+ const RENAMED: Record<string, Array<{ field: string; write: string }>> = {
127
+ at: [
128
+ { field: "address", write: "address" },
129
+ { field: "start", write: "start" },
130
+ ],
131
+ bytes: [{ field: "data", write: 'data, the bytes as hex text, for example "data": "a900",' }],
132
+ equals: [{ field: "operator", write: '"operator": "eq" and "value"' }],
133
+ };
134
+
135
+ /** What to write in place of a field of the 2.0 files, for an object with these fields; undefined when the field has no 2.1 name there. */
136
+ export function renamedField(key: string, fields: readonly string[]): string | undefined {
137
+ const renamed = RENAMED[key]?.find((item) => fields.includes(item.field));
138
+ return renamed === undefined ? undefined : `${key} is a 2.0 field. Write ${renamed.write} in place of ${key}`;
139
+ }
140
+
113
141
  /** The checks of one input: `fail` names the field of the problem. */
114
142
  class Fields {
115
143
  readonly #value: Record<string, unknown>;
@@ -121,7 +149,11 @@ class Fields {
121
149
  this.#fail = fail;
122
150
  if (!isObject(value)) fail(`${where} must be an object with ${fields.join(", ")}`);
123
151
  this.#value = value as Record<string, unknown>;
124
- for (const key of Object.keys(this.#value)) if (!fields.includes(key)) fail(`${where} has the field ${key}, which it does not take. Its fields are ${fields.join(", ")}`);
152
+ for (const key of Object.keys(this.#value)) {
153
+ if (fields.includes(key)) continue;
154
+ const renamed = renamedField(key, fields);
155
+ fail(`${where} has the field ${key}, which it does not take. ${renamed === undefined ? "" : `${renamed}. `}Its fields are ${fields.join(", ")}`);
156
+ }
125
157
  }
126
158
 
127
159
  fail(field: string, message: string): never {
@@ -256,22 +288,90 @@ function checkInput(kind: ActionStepKind, value: unknown, at: string, fail: (mes
256
288
  }
257
289
  }
258
290
 
291
+ /** A "$xxxx" text of a number, or the value as it is. */
292
+ const asAddress = (value: unknown) => (isInteger(value, 0, 0xffff) ? `$${value.toString(16).padStart(4, "0")}` : value);
293
+
294
+ /** The hex text of a list of bytes, or the value as it is. */
295
+ const asHex = (value: unknown) => (Array.isArray(value) && value.every((byte) => isInteger(byte, 0, 0xff)) ? Buffer.from(value as number[]).toString("hex") : value);
296
+
297
+ /** A runUntil target from the 2.0 input: at (with raster), raster, or memory with at and equals. */
298
+ function targetOf(input: Record<string, unknown>): unknown {
299
+ if ("target" in input) return input.target;
300
+ if (isObject(input.memory)) return { kind: "memory", address: input.memory.at ?? input.memory.address, operator: "eq", value: input.memory.equals };
301
+ if ("at" in input) return { kind: "address", address: input.at, ...(input.raster === undefined ? {} : { condition: { kind: "raster", line: input.raster } }) };
302
+ return { kind: "raster", line: input.raster };
303
+ }
304
+
305
+ /**
306
+ * The 2.1 form of a step that has a form of the 2.0 files: the 2.0 fields
307
+ * that it has and the step to write. Undefined when the step has no 2.0 form.
308
+ * Keys of the step other than its kind (for example side) stay.
309
+ */
310
+ export function newStepForm(kind: string, step: Record<string, unknown>): { fields: string[]; step: Record<string, unknown> } | undefined {
311
+ const input = step[kind];
312
+ const { [kind]: _input, ...rest } = step;
313
+ const form = (fields: string[], value: unknown, others = rest) => ({ fields, step: { [kind]: value, ...others } });
314
+ switch (kind) {
315
+ case "runUntil": {
316
+ const { timeoutFrames: beside, ...others } = rest;
317
+ const old = isObject(input) && !("target" in input) && ("at" in input || "raster" in input || "memory" in input);
318
+ if (!isObject(input) || (!old && beside === undefined)) return undefined;
319
+ const fields = [...(old ? ["at", "raster", "memory"].filter((key) => key in input).map((key) => `runUntil.${key}`) : []), ...(beside === undefined ? [] : ["timeoutFrames beside runUntil"])];
320
+ const timeoutFrames = beside ?? input.timeoutFrames;
321
+ return form(fields, { target: targetOf(input), ...(timeoutFrames === undefined ? {} : { timeoutFrames }) }, others);
322
+ }
323
+ case "type":
324
+ return typeof input === "string" ? form(["type as text"], { mode: "text", text: input }) : undefined;
325
+ case "write": {
326
+ if (!isObject(input) || !("at" in input || "bytes" in input || Array.isArray(input.data))) return undefined;
327
+ const { at: old, bytes, address, data, ...others } = input;
328
+ return form(
329
+ ["at", "bytes"].filter((key) => key in input).map((key) => `write.${key}`).concat(Array.isArray(data) ? ["write.data as a list"] : []),
330
+ { address: address ?? old, data: asHex(data ?? bytes), ...others },
331
+ );
332
+ }
333
+ case "registers": {
334
+ if (!isObject(input)) return undefined;
335
+ const fields = Object.entries(input).flatMap(([name, value]) => ((name === "pc" && typeof value === "number") || (name !== "pc" && name !== "flags" && typeof value === "string" && c64AddressOf(value) !== undefined) ? [`registers.${name} as ${typeof value === "number" ? "a number" : "text"}`] : []));
336
+ if (fields.length === 0) return undefined;
337
+ const values = Object.fromEntries(Object.entries(input).map(([name, value]) => [name, name === "pc" ? asAddress(value) : typeof value === "string" ? (c64AddressOf(value) ?? value) : value]));
338
+ return form(fields, values);
339
+ }
340
+ default:
341
+ return undefined;
342
+ }
343
+ }
344
+
345
+ /** Refuses a step in a form of the 2.0 files: the message names its 2.0 fields and gives the step to write. */
346
+ export function refuseOldStep(kind: string, step: Record<string, unknown>, at: string, fail: (message: string) => never): void {
347
+ const found = newStepForm(kind, step);
348
+ if (found === undefined) return;
349
+ const petscii = kind === "type" ? ', or {"type": {"mode": "petscii", "bytes": [...]}} for PETSCII codes' : "";
350
+ fail(`${at} has the 2.0 form (${found.fields.join(", ")}). Write ${jsonLine(found.step)}${petscii}`);
351
+ }
352
+
259
353
  /**
260
354
  * Checks one action step of the given kind and returns it with the defaults
261
355
  * filled in. The step has no other key. `press` is refused unless the caller
262
- * runs the steps on the replay core.
356
+ * runs the steps on the replay core. A step in a form of the 2.0 files is
357
+ * refused with the step to write.
263
358
  */
264
359
  export function checkActionStep(kind: ActionStepKind, step: Record<string, unknown>, at: string, fail: (message: string) => never, options: { press?: boolean } = {}): ActionStep {
360
+ refuseOldStep(kind, step, at, fail);
265
361
  for (const key of Object.keys(step)) if (key !== kind) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
266
362
  if (kind === "press" && options.press !== true) fail(`${at}: ${PRESS_REMEDY}`);
267
363
  return checkInput(kind, step[kind], at, fail);
268
364
  }
269
365
 
270
- /** The kind of a step: its one key from `kinds`. */
271
- export function stepKind<K extends string>(step: unknown, kinds: readonly K[], at: string, fail: (message: string) => never): K {
366
+ /**
367
+ * The kind of a step: its one key from `kinds`. A refusal lists `shown`: the
368
+ * kinds that the caller runs.
369
+ */
370
+ export function stepKind<K extends string>(step: unknown, kinds: readonly K[], at: string, fail: (message: string) => never, shown: readonly string[] = kinds): K {
272
371
  if (!isObject(step)) return fail(`${at} must be an object`);
273
372
  const found = Object.keys(step).filter((key) => (kinds as readonly string[]).includes(key));
274
- if (found.length !== 1) return fail(`${at} must have exactly one of ${kinds.join(", ")}`);
373
+ if (found.length > 1) return fail(`${at} has ${found.length} step kinds: ${found.join(", ")}. Give each one its own step`);
374
+ if (found.length !== 1) return fail(`${at} must have exactly one of ${shown.join(", ")}`);
275
375
  return found[0] as K;
276
376
  }
277
377
 
@@ -280,7 +380,7 @@ export function checkActionSteps(value: unknown, fail: (message: string) => neve
280
380
  if (!Array.isArray(value) || value.length === 0) return fail("the steps are a JSON list with at least one step");
281
381
  return value.map((step: unknown, index) => {
282
382
  const at = `step ${index + 1}`;
283
- return checkActionStep(stepKind(step, ACTION_STEP_KINDS, at, fail), step as Record<string, unknown>, at, fail, options);
383
+ return checkActionStep(stepKind(step, ACTION_STEP_KINDS, at, fail, options.press === true ? ACTION_STEP_KINDS : VICE_STEP_KINDS), step as Record<string, unknown>, at, fail, options);
284
384
  });
285
385
  }
286
386
 
@@ -85,6 +85,6 @@ export async function hostTools(options: ToolCallOptions = {}): Promise<{ host:
85
85
 
86
86
  /** Skill scripts see failures and wire limits through the host-client, never the private protocol module. */
87
87
  export { WireFailure } from "../protocol/messages.ts";
88
- export { DISK_ACTIONS } from "../protocol/tools.ts";
88
+ export { DISK_ACTIONS, DISK_IMAGE_TYPES } from "../protocol/tools.ts";
89
89
  export { JOYSTICK_DIRECTIONS, MAX_BASELINES, MAX_EXECUTION_COUNT, MAX_KEYBOARD_BYTES, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, MAX_MEMORY_WRITE, MAX_TIMEOUT_FRAMES, maskCoversFrame } from "../protocol/vice.ts";
90
90
  export { MAX_TRACE_FRAMES, MAX_TRACE_HITS, MAX_TRACE_POINTS, TRACE_KINDS, type TraceHit, type TraceKind } from "../protocol/vice.ts";
@@ -58,10 +58,33 @@ export function readProjectFile(path: string, options: { root?: string } = {}):
58
58
  return { bytes: readFileSync(real), type: extname(path).slice(1).toLowerCase() };
59
59
  }
60
60
 
61
+ /** Why a file cannot be written, by the error code of the file system; the codes that are a fault of the path. */
62
+ const WRITE_PROBLEMS: Record<string, string> = {
63
+ EISDIR: "is a folder. Give the path of a file",
64
+ EEXIST: "has a file where a folder must be. Give a path whose folders are folders",
65
+ ENOTDIR: "has a file where a folder must be. Give a path whose folders are folders",
66
+ ENOENT: "is in a folder that does not exist",
67
+ EACCES: "cannot be written: there is no permission. Give a path that you can write",
68
+ EPERM: "cannot be written: there is no permission. Give a path that you can write",
69
+ EROFS: "is on a file system that is read-only. Give another path",
70
+ ENAMETOOLONG: "is too long. Give a shorter path",
71
+ };
72
+
73
+ /**
74
+ * The refusal for a write that the file system refused because of the path
75
+ * (a folder, a file in place of a folder, no permission): invalid-input with
76
+ * the path. Undefined for another error, which is no fault of the path.
77
+ */
78
+ export function writeRefusal(error: unknown, path: string, what?: string): WireFailure | undefined {
79
+ const problem = WRITE_PROBLEMS[(error as NodeJS.ErrnoException | undefined)?.code ?? ""];
80
+ return problem === undefined ? undefined : new WireFailure("invalid-input", `${what === undefined ? "" : `${what}: `}${path} ${problem}.`);
81
+ }
82
+
61
83
  /**
62
84
  * Writes bytes to a project file on the client side, making its directories
63
85
  * and overwriting it. Throws WireFailure(invalid-input) for a path outside the
64
- * project, also one that leaves it through a symbolic link.
86
+ * project, also one that leaves it through a symbolic link, and for a path
87
+ * that the file system refuses: a folder, a file in place of a folder, no permission.
65
88
  */
66
89
  export function writeProjectFile(path: string, bytes: Uint8Array, options: { root?: string; what?: string } = {}): void {
67
90
  const root = options.root ?? currentProject();
@@ -76,16 +99,20 @@ export function writeProjectFile(path: string, bytes: Uint8Array, options: { roo
76
99
  let existing = dirname(file);
77
100
  while (!existsSync(existing)) existing = dirname(existing);
78
101
  if (!isInside(realpathSync(root), realpathSync(existing))) throw refuse();
79
- mkdirSync(dirname(file), { recursive: true });
80
- if (!isInside(realpathSync(root), realpathSync(dirname(file)))) throw refuse();
81
- let link = false;
82
102
  try {
83
- link = lstatSync(file).isSymbolicLink();
103
+ mkdirSync(dirname(file), { recursive: true });
104
+ if (!isInside(realpathSync(root), realpathSync(dirname(file)))) throw refuse();
105
+ let link = false;
106
+ try {
107
+ link = lstatSync(file).isSymbolicLink();
108
+ } catch (error) {
109
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
110
+ }
111
+ if (link) throw refuse();
112
+ writeFileSync(file, bytes);
84
113
  } catch (error) {
85
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
114
+ throw writeRefusal(error, path, options.what) ?? error;
86
115
  }
87
- if (link) throw refuse();
88
- writeFileSync(file, bytes);
89
116
  }
90
117
 
91
118
  /** Directories never staged: version control and the toolkit's own project state. */
@@ -119,7 +119,7 @@ function value(node: Node, resolve: (name: string) => number | undefined): numbe
119
119
  return node.value;
120
120
  case "name": {
121
121
  const found = resolve(node.name);
122
- if (found === undefined) throw new Refusal("invalid-input", `"${node.name}" is no symbol of the knowledge and no platform name.`);
122
+ if (found === undefined) throw new Refusal("invalid-input", `${node.name} is not a symbol of the knowledge or a platform name. Give a number, a symbol that knowledge.ts symbols lists, or a platform name that memory-map.ts at gives.`);
123
123
  return found;
124
124
  }
125
125
  case "unary": {
@@ -38,7 +38,7 @@ import {
38
38
  type SymbolRow,
39
39
  } from "./read.ts";
40
40
  import { DEFAULT_SPACE, type EvidenceKind, type Origin, type Space } from "./schema.ts";
41
- import { compareText } from "./text.ts";
41
+ import { compareText, NO_REVISION } from "./text.ts";
42
42
  import type { Evidence } from "./write.ts";
43
43
 
44
44
  export interface Revision {
@@ -49,6 +49,8 @@ export interface Revision {
49
49
  description?: string;
50
50
  inputHash?: string;
51
51
  toolVersion?: string;
52
+ /** A revert: the revision whose knowledge it restored. */
53
+ restores?: string;
52
54
  /** What the change rests on. */
53
55
  evidence?: Evidence[];
54
56
  }
@@ -79,6 +81,7 @@ function toRevision(row: Raw): Revision {
79
81
  if (row.description !== null) revision.description = row.description as string;
80
82
  if (row.input_hash !== null) revision.inputHash = row.input_hash as string;
81
83
  if (row.tool_version !== null) revision.toolVersion = row.tool_version as string;
84
+ if (row.restored_revision !== null) revision.restores = row.restored_revision as string;
82
85
  return revision;
83
86
  }
84
87
 
@@ -170,8 +173,21 @@ export function revisions(db: DatabaseSync | undefined, options: { limit?: numbe
170
173
  return (rows as Raw[]).map((row) => withEvidence(db, toRevision(row)));
171
174
  }
172
175
 
176
+ /** Revision 0: the knowledge before every revision. It is empty, and no write made it, so it has no time, origin or evidence. */
177
+ export interface InitialRevision {
178
+ id: typeof NO_REVISION;
179
+ operation: "none";
180
+ description: string;
181
+ createdAt?: undefined;
182
+ origin?: undefined;
183
+ inputHash?: undefined;
184
+ toolVersion?: undefined;
185
+ restores?: undefined;
186
+ evidence?: undefined;
187
+ }
188
+
173
189
  export interface RevisionChanges {
174
- revision: Revision;
190
+ revision: Revision | InitialRevision;
175
191
  /** Rows this revision made current. */
176
192
  added: HistoryEntry[];
177
193
  /** Rows this revision closed. */
@@ -181,6 +197,7 @@ export interface RevisionChanges {
181
197
  /** One revision and exactly what it changed. */
182
198
  export function revision(db: DatabaseSync | undefined, text: string): RevisionChanges {
183
199
  const id = resolveRevision(db, text);
200
+ if (id === NO_REVISION) return { revision: { id: NO_REVISION, operation: "none", description: "The knowledge before every revision. It is empty." }, added: [], closed: [] };
184
201
  const raw = db?.prepare("SELECT * FROM revisions WHERE id = ?").get(id) as Raw | undefined;
185
202
  if (db === undefined || raw === undefined) throw new Refusal("not-found", `There is no revision ${id}.`);
186
203
  const tables = [
@@ -20,7 +20,8 @@ export function addressOfName(db: DatabaseSync | undefined, name: string, space:
20
20
  if (addresses.length === 1) return addresses[0]!;
21
21
  const where = space === DEFAULT_SPACE ? "" : ` in ${space}`;
22
22
  if (addresses.length > 1) throw new Refusal("invalid-input", `the analyzers give ${name} to ${rows.map((row) => `${formatC64Address(row.address)} (${row.origin})`).join(" and ")}${where}. Give the address`);
23
- throw new Refusal("not-found", `no symbol or analyzer name${where} is ${name}`);
23
+ const option = space === DEFAULT_SPACE ? "" : ` --space ${space}`;
24
+ throw new Refusal("not-found", `${name} is not a name in the knowledge${where}. Give a $ address, or a name that knowledge.ts symbols${option} lists, with or without --analyzer.`);
24
25
  }
25
26
 
26
27
  /** Every name of a space with its address: the symbols, and each analyzer name that addressOfName accepts. With kinds, only names of those kinds. */
@@ -10,6 +10,7 @@ import { Refusal } from "../errors.ts";
10
10
  import { type AddressRange, COVERAGE, formatC64Address } from "../c64.ts";
11
11
 
12
12
  import { COVERAGE_KINDS, DEFAULT_SPACE, MEMORY_ROW, type CoverageKind, type Space } from "./schema.ts";
13
+ import { coverageRuns } from "./read.ts";
13
14
  import { checkSpace, write, type Evidence, type WriteContext } from "./write.ts";
14
15
 
15
16
  /** The 64 KiB of one space: offset n is address n. */
@@ -147,22 +148,6 @@ function currentCoverage(db: DatabaseSync, space: Space): Map<CoverageKind, Uint
147
148
  return maps;
148
149
  }
149
150
 
150
- /** One import of a coverage map: its project path, its SHA-256, the space it went into and the revision. */
151
- export interface CoverageRun {
152
- name: string;
153
- sha256: string;
154
- space: Space;
155
- revision: string;
156
- }
157
-
158
- /** The imports of coverage maps, by name and then oldest first: the last import of a name is the latest. */
159
- export function coverageRuns(db: DatabaseSync | undefined): CoverageRun[] {
160
- if (db === undefined) return [];
161
- return db
162
- .prepare(`SELECT DISTINCT e.ref AS name, e.sha256 AS sha256, coalesce(e.space, '${DEFAULT_SPACE}') AS space, e.revision AS revision FROM evidence e JOIN revisions r ON r.id = e.revision WHERE r.operation = 'coverage' AND e.kind = 'coverage' ORDER BY e.ref, e.revision`)
163
- .all() as unknown as CoverageRun[];
164
- }
165
-
166
151
  /**
167
152
  * Joins coverage maps into the coverage of a space: adds the ranges of
168
153
  * the flags that no imported run set before, and as wx the addresses that one
@@ -243,6 +243,49 @@ export function currentRevision(db: DatabaseSync | undefined): string {
243
243
  return (db.prepare("SELECT max(id) AS id FROM revisions").get() as { id: string | null }).id ?? "0";
244
244
  }
245
245
 
246
+ /** One import of a coverage map: its project path, its SHA-256, the space it went into and the revision. */
247
+ export interface CoverageRun {
248
+ name: string;
249
+ sha256: string;
250
+ space: Space;
251
+ revision: string;
252
+ }
253
+
254
+ /**
255
+ * The imports of coverage maps that the knowledge has, by name and then oldest
256
+ * first: the last import of a name is the latest. A revert gives back the maps
257
+ * of the revision that it restores, so a map that a revert removed is not known.
258
+ * With `at`, the maps of the knowledge at that revision.
259
+ */
260
+ export function coverageRuns(db: DatabaseSync | undefined, at?: string): CoverageRun[] {
261
+ if (db === undefined || at === NO_REVISION) return [];
262
+ const rows = db
263
+ .prepare(
264
+ `SELECT r.id AS id, r.operation AS operation, r.restored_revision AS restored, e.ref AS name, e.sha256 AS sha256, coalesce(e.space, '${DEFAULT_SPACE}') AS space
265
+ FROM revisions r LEFT JOIN evidence e ON e.revision = r.id AND e.kind = 'coverage'
266
+ WHERE (r.operation = 'coverage' AND e.id IS NOT NULL) OR (r.operation = 'revert' AND r.restored_revision IS NOT NULL)
267
+ ORDER BY r.id, e.id`,
268
+ )
269
+ .all() as Array<{ id: string; operation: string; restored: string | null; name: string | null; sha256: string | null; space: Space }>;
270
+ // The maps after each revision that changed them, oldest first.
271
+ const after: Array<{ id: string; runs: CoverageRun[] }> = [];
272
+ let runs: CoverageRun[] = [];
273
+ for (const row of rows) {
274
+ if (at !== undefined && row.id > at) break;
275
+ if (row.operation === "revert") {
276
+ const restored = row.restored!;
277
+ runs = [];
278
+ for (const item of after) if (item.id <= restored) runs = item.runs;
279
+ } else {
280
+ const run: CoverageRun = { name: row.name!, sha256: row.sha256!, space: row.space, revision: row.id };
281
+ if (!runs.some((item) => item.name === run.name && item.sha256 === run.sha256 && item.space === run.space && item.revision === run.revision)) runs = [...runs, run];
282
+ }
283
+ if (after.at(-1)?.id === row.id) after[after.length - 1]!.runs = runs;
284
+ else after.push({ id: row.id, runs });
285
+ }
286
+ return [...runs].sort((a, b) => (a.name === b.name ? (a.revision < b.revision ? -1 : a.revision > b.revision ? 1 : 0) : a.name < b.name ? -1 : 1));
287
+ }
288
+
246
289
  /** At most this many ids are named when a start of an id fits several revisions. */
247
290
  const NAMED_REVISIONS = 5;
248
291
 
@@ -113,7 +113,8 @@ function createSchema(db: DatabaseSync): void {
113
113
  operation TEXT NOT NULL,
114
114
  input_hash TEXT,
115
115
  tool_version TEXT,
116
- description TEXT
116
+ description TEXT,
117
+ restored_revision TEXT
117
118
  );
118
119
  CREATE TABLE symbols (
119
120
  id TEXT PRIMARY KEY,
@@ -316,7 +317,7 @@ function createSchema(db: DatabaseSync): void {
316
317
  }
317
318
 
318
319
  /** The version of the index this build makes. An index with another version is emptied and made again. */
319
- export const INDEX_VERSION = 6;
320
+ export const INDEX_VERSION = 7;
320
321
 
321
322
  function indexVersion(db: DatabaseSync): number | undefined {
322
323
  const hasMeta = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
@@ -187,6 +187,7 @@ const REVISION_FIELDS: readonly Field[] = [
187
187
  field("reason", "description", "text", true),
188
188
  field("inputHash", "input_hash", "text", true),
189
189
  field("toolVersion", "tool_version", "text", true),
190
+ field("restores", "restored_revision", "text", true),
190
191
  ];
191
192
 
192
193
  /** The SQL name of a table: "references" is a keyword. */
@@ -32,6 +32,7 @@ import {
32
32
  referencesFrom,
33
33
  REGION_COLUMNS,
34
34
  regionsOverlapping,
35
+ coverageRuns,
35
36
  resolveRevision,
36
37
  SUPPRESSION_COLUMNS,
37
38
  suppressionsOverlapping,
@@ -49,6 +50,7 @@ import {
49
50
  toSuppression,
50
51
  toSymbol,
51
52
  type CommentRow,
53
+ type CoverageRun,
52
54
  type MacroRow,
53
55
  type MacroUseRow,
54
56
  type ModuleRow,
@@ -59,7 +61,7 @@ import {
59
61
  type SuppressionRow,
60
62
  type SymbolRow,
61
63
  } from "./read.ts";
62
- import { COMMENT_PLACEMENTS, EVIDENCE_KINDS, MAX_ELEMENT, MODULE_FILE, DEFAULT_SPACE, GENERATED_FILES, isGeneratedFile, MACRO_NAME, MACRO_PARAM, SWITCH_NAME, NO_ELEMENT_TYPES, OPERAND_SELECTS, POINTER_KIND, REFERENCE_KINDS, REGION_TYPES, SYMBOL_KINDS, type CommentPlacement, type EvidenceKind, type OperandSelect, type Origin, type ReferenceKind, type RegionType, type Space, type SymbolKind } from "./schema.ts";
64
+ import { CODE_REGION_TYPES, COMMENT_PLACEMENTS, EVIDENCE_KINDS, MAX_ELEMENT, MODULE_FILE, DEFAULT_SPACE, GENERATED_FILES, isGeneratedFile, MACRO_NAME, MACRO_PARAM, SWITCH_NAME, NO_ELEMENT_TYPES, OPERAND_SELECTS, POINTER_KIND, REFERENCE_KINDS, REGION_TYPES, SYMBOL_KINDS, type CommentPlacement, type EvidenceKind, type OperandSelect, type Origin, type ReferenceKind, type RegionType, type Space, type SymbolKind } from "./schema.ts";
63
65
  import { newRevisionId, rowId, sqlName } from "./text.ts";
64
66
 
65
67
  /** What a change rests on: a coverage run, a test or another project file with its SHA-256, or a note. */
@@ -136,6 +138,8 @@ export interface ChangeContext {
136
138
  inputHash?: string;
137
139
  toolVersion?: string;
138
140
  evidence?: Evidence[];
141
+ /** A revert: the revision whose knowledge it restores. */
142
+ restores?: string;
139
143
  }
140
144
 
141
145
  /**
@@ -177,8 +181,8 @@ export class Change {
177
181
  const now = new Date();
178
182
  const id = newRevisionId(currentRevision(this.#db), now);
179
183
  this.#db
180
- .prepare("INSERT INTO revisions (id, created_at, origin, operation, input_hash, tool_version, description) VALUES (?, ?, ?, ?, ?, ?, ?)")
181
- .run(id, isoCet(now), this.#context.origin, this.#operation, this.#context.inputHash ?? null, this.#context.toolVersion ?? null, this.#context.description ?? null);
184
+ .prepare("INSERT INTO revisions (id, created_at, origin, operation, input_hash, tool_version, description, restored_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
185
+ .run(id, isoCet(now), this.#context.origin, this.#operation, this.#context.inputHash ?? null, this.#context.toolVersion ?? null, this.#context.description ?? null, this.#context.restores ?? null);
182
186
  this.revision = id;
183
187
  const evidence = this.#db.prepare("INSERT INTO evidence (revision, kind, ref, sha256, space) VALUES (?, ?, ?, ?, ?)");
184
188
  for (const item of this.#context.evidence ?? []) evidence.run(id, item.kind, item.ref, item.sha256 ?? null, item.space ?? null);
@@ -321,7 +325,7 @@ function checkEvidence(evidence: Evidence[] | undefined): void {
321
325
  }
322
326
 
323
327
  /** Runs semantic writes in one transaction, as one revision. */
324
- export function write<T>(db: DatabaseSync, context: WriteContext, operation: string, work: (change: Change) => T): { revision: string | null; current: T } {
328
+ export function write<T>(db: DatabaseSync, context: WriteContext & Pick<ChangeContext, "restores">, operation: string, work: (change: Change) => T): { revision: string | null; current: T } {
325
329
  if (context.origin !== "user" && context.origin !== "llm") {
326
330
  throw new Refusal("invalid-input", "A semantic write comes from the user or the LLM.");
327
331
  }
@@ -572,6 +576,32 @@ function checkOperand(request: OperandRequest): void {
572
576
 
573
577
  const shown = (value: number, size: number) => (value < 0 ? String(value) : `$${value.toString(16).padStart(size === 0x100 ? 2 : 4, "0")}`);
574
578
 
579
+ /**
580
+ * Refuses an address that is not the start of an instruction. The code
581
+ * regions of the knowledge give the instructions: they are decoded from the
582
+ * start of the region, as the listing and the source decode them.
583
+ */
584
+ function checkInstructionStart(db: DatabaseSync, space: Space, image: OperandImage, address: number): void {
585
+ const where = formatC64Address(address);
586
+ const code = regionsOverlapping(db, address, address, space).find((region) => CODE_REGION_TYPES.includes(region.type));
587
+ if (code === undefined) {
588
+ throw new Refusal("invalid-input", `${where} is in no code region, so the knowledge does not know an instruction that starts there. Classify the code that holds the instruction first (classify <range> code), then write the operand.`);
589
+ }
590
+ let start = code.start;
591
+ let next = code.start;
592
+ while (next < address) {
593
+ const opcode = image.byteAt(next);
594
+ if (opcode === undefined) {
595
+ throw new Refusal("invalid-input", `The code region ${formatC64Address(code.start)}-${formatC64Address(code.end)} has no program byte at ${formatC64Address(next)}, so its instructions up to ${where} are not known. Correct the region (classify), then write the operand.`);
596
+ }
597
+ start = next;
598
+ next += instructionLength(opcode);
599
+ }
600
+ if (next !== address) {
601
+ throw new Refusal("invalid-input", `${where} is not the start of an instruction. It is an operand byte of the instruction at ${formatC64Address(start)}, decoded from the start of the code region at ${formatC64Address(code.start)}. Give ${formatC64Address(start)}.`);
602
+ }
603
+ }
604
+
575
605
  /**
576
606
  * Refuses an expression whose value is not the operand in the image, and
577
607
  * returns the addresses of its names. Symbols of the knowledge come first, then
@@ -581,6 +611,7 @@ function checkOperandValue(db: DatabaseSync, space: Space, image: OperandImage,
581
611
  const where = formatC64Address(request.address);
582
612
  const opcode = image.byteAt(request.address);
583
613
  if (opcode === undefined) throw new Refusal("invalid-input", `${where} is outside the image.`);
614
+ checkInstructionStart(db, space, image, request.address);
584
615
  const length = instructionLength(opcode);
585
616
  if (length === 1) throw new Refusal("invalid-input", `The instruction at ${where} (opcode ${shown(opcode, 0x100)}) has no operand.`);
586
617
  if ((opcode & 0x1f) === 0x10) throw new Refusal("invalid-input", `The instruction at ${where} is a branch: its operand is a distance, not a value.`);
@@ -1171,8 +1202,11 @@ const KEYS = {
1171
1202
  * revision. History is never erased; restored rows keep their origin.
1172
1203
  */
1173
1204
  export function revert(db: DatabaseSync, context: WriteContext, request: { revision: string }): WriteResult<{ restoredRevision: string }> {
1174
- return write(db, context, "revert", (change) => {
1175
- const restored = resolveRevision(db, request.revision);
1205
+ const restored = resolveRevision(db, request.revision);
1206
+ return write(db, { ...context, restores: restored }, "revert", (change) => {
1207
+ // The coverage maps that the knowledge has follow the revert, also when no coverage range changes.
1208
+ const maps = (runs: CoverageRun[]) => JSON.stringify(runs.map((run) => [run.name, run.sha256, run.space, run.revision]));
1209
+ if (maps(coverageRuns(db, restored)) !== maps(coverageRuns(db))) change.id();
1176
1210
  const at = "valid_from_revision <= ? AND (valid_to_revision IS NULL OR valid_to_revision > ?)";
1177
1211
  for (const [table, spec] of Object.entries(KEYS)) {
1178
1212
  const name = table === "references" ? '"references"' : table;
package/src/mcp/output.ts CHANGED
@@ -22,6 +22,11 @@ export const WrittenOutput = {
22
22
  sha256: z.string().regex(/^[0-9a-f]{64}$/).optional(),
23
23
  };
24
24
 
25
+ /** Lines as file text: each line ends with a newline, and no lines give an empty file. */
26
+ export function linesText(lines: readonly string[]): string {
27
+ return lines.map((line) => `${line}\n`).join("");
28
+ }
29
+
25
30
  /** Writes `data` to the file `path` of the project that `project` names (or the project lookup finds), and gives what the result shows in its place. */
26
31
  export function writeOut(path: string, data: Uint8Array | string, project: string | undefined): Written {
27
32
  return writeReported(path, data, { what: "out", root: projectFolder(project) });
package/src/mcp/server.ts CHANGED
@@ -91,6 +91,17 @@ export function requireFields(action: string, given: Record<string, unknown>, al
91
91
  }
92
92
  }
93
93
 
94
+ /**
95
+ * Checks the fields that go with out: project only with out, and the
96
+ * fields of `notWithOut` only without it.
97
+ */
98
+ export function requireOut(given: { out?: unknown; project?: unknown } & Record<string, unknown>, notWithOut: readonly string[] = []): void {
99
+ if (given.out === undefined && given.project !== undefined) throw new WireFailure("invalid-input", "project is used only with out.");
100
+ for (const field of notWithOut) {
101
+ if (given.out !== undefined && given[field] !== undefined) throw new WireFailure("invalid-input", `${field} is not used with out.`);
102
+ }
103
+ }
104
+
94
105
  /** A tool result that carries content blocks (for example an image) beside its structured result. */
95
106
  export class ToolOutput<T> {
96
107
  readonly structured: T;
@@ -163,12 +174,16 @@ function errorResult(error: WireError): CallToolResult {
163
174
  return { isError: true, content: [{ type: "text", text: JSON.stringify({ error: { code: error.code, message: shownMessage(error) } }) }] };
164
175
  }
165
176
 
166
- /** "address: Invalid string: must match ..." style messages, one sentence per problem. */
167
- function describeIssues(error: z.ZodError): string {
177
+ /**
178
+ * "address: Invalid string: must match ..." style messages, one sentence per problem.
179
+ * An unknown field of the tool input also gets the fields that the tool takes.
180
+ */
181
+ function describeIssues(error: z.ZodError, input?: z.ZodObject): string {
168
182
  return error.issues
169
183
  .map((issue) => {
170
184
  const where = issue.path.length === 0 ? "input" : issue.path.join(".");
171
- return `${where}: ${issue.message}`;
185
+ const fields = issue.code === "unrecognized_keys" && issue.path.length === 0 && input !== undefined ? `. The fields are ${Object.keys(input.shape).join(", ")}` : "";
186
+ return `${where}: ${issue.message}${fields}`;
172
187
  })
173
188
  .join(". ");
174
189
  }
@@ -211,7 +226,7 @@ async function runCall(
211
226
  const tool = tools.get(name);
212
227
  if (tool === undefined) return errorResult({ code: "invalid-input", message: `There is no tool named ${name}.` });
213
228
  const parsed = tool.inputSchema.safeParse(args ?? {});
214
- if (!parsed.success) return errorResult({ code: "invalid-input", message: describeIssues(parsed.error) });
229
+ if (!parsed.success) return errorResult({ code: "invalid-input", message: describeIssues(parsed.error, tool.inputSchema) });
215
230
  try {
216
231
  let output: unknown;
217
232
  try {
@@ -7,9 +7,9 @@ import { COVERAGE, executedRamCode, EXECUTE, formatC64Address, READ, WRITE, type
7
7
  import { WireFailure } from "../../protocol/messages.ts";
8
8
  import { CHECKPOINT_ACTIONS, MAX_BACKTRACE, MAX_HISTORY, MAX_TRACE_FRAMES, MAX_TRACE_HITS, MAX_TRACE_POINTS, TRACE_KINDS, TRACE_ENDS, MAX_MEMMAP_RANGES, MAX_MEMORY_READ, MAX_PROFILE, MAX_WATCH_SIZE, SPACES, TIMING_ACTIONS, WATCH_ACCESS, type Breakpoint, type Watchpoint } from "../../protocol/vice.ts";
9
9
  import { readCoverage } from "../../host-client/coverage.ts";
10
- import { OutInput, ProjectInput, writeOut, WrittenOutput } from "../output.ts";
10
+ import { linesText, OutInput, ProjectInput, writeOut, WrittenOutput } from "../output.ts";
11
11
  import { AddressInput, Byte, SpaceInput, TOOL_INPUTS } from "../inputs.ts";
12
- import { AddressOutput, ConditionOutput, defineTool, HexData, requireFields, showCondition, toCondition } from "../server.ts";
12
+ import { AddressOutput, ConditionOutput, defineTool, HexData, requireFields, requireOut, showCondition, toCondition } from "../server.ts";
13
13
  import { spaced } from "./memory.ts";
14
14
 
15
15
  /** No default here: a filled-in default would count as a field the other actions refuse. */
@@ -169,7 +169,7 @@ export const c64CpuHistory = defineTool({
169
169
  const raster = entry.rasterLine === undefined ? "" : ` line ${entry.rasterLine} cycle ${entry.rasterCycle ?? 0}`;
170
170
  return `${formatC64Address(entry.address)} ${spaced(entry.bytes).padEnd(9)} ${entry.text.padEnd(16)} a=${byte(entry.a)} x=${byte(entry.x)} y=${byte(entry.y)} sp=${byte(entry.sp)}${raster}`;
171
171
  });
172
- return { count: entries.length, ...writeOut(out, `${lines.join("\n")}\n`, project) };
172
+ return { count: entries.length, ...writeOut(out, linesText(lines), project) };
173
173
  },
174
174
  });
175
175
 
@@ -184,6 +184,7 @@ export const c64Trace = defineTool({
184
184
  "A point is exec (an instruction at an address from start to end), read or write (an access from start to end). " +
185
185
  `Give 1 to ${MAX_TRACE_POINTS} points. ` +
186
186
  "Each hit gives the instruction (pc, bytes, text) and the registers a, x, y and sp before it. " +
187
+ "point in a hit is the number of its point in points, 0 for the first. " +
187
188
  "It also gives the frame from 0, and the raster line and cycle where the instruction started. " +
188
189
  "A read or write hit also gives the address that the instruction used, and the byte there after the access. " +
189
190
  `The trace ends after frames frames (1 to ${MAX_TRACE_FRAMES}), at maxHits hits (at most ${MAX_TRACE_HITS}), or at a breakpoint, a watchpoint or a JAM. ` +
@@ -233,7 +234,7 @@ export const c64Trace = defineTool({
233
234
  const shown = traced.hits.map(({ address, ...hit }) => ({ ...hit, pc: formatC64Address(hit.pc), ...(address === undefined ? {} : { address: formatC64Address(address) }) }));
234
235
  const summary = { end: traced.end, frames: traced.frames, pc: formatC64Address(traced.pc), count: traced.hits.length, hits: shown.slice(0, TRACE_LISTED) };
235
236
  if (out === undefined) return summary;
236
- return { ...summary, ...writeOut(out, `${shown.map((hit) => JSON.stringify(hit)).join("\n")}\n`, project) };
237
+ return { ...summary, ...writeOut(out, linesText(shown.map((hit) => JSON.stringify(hit))), project) };
237
238
  },
238
239
  });
239
240
 
@@ -392,11 +393,11 @@ export const c64Memmap = defineTool({
392
393
  ...(written.length > MAX_SUMMARY_RANGES ? { writtenAndExecutedTruncated: true } : {}),
393
394
  };
394
395
  }
396
+ requireOut(fields, ["maxRanges"]);
397
+ requireFields("read", fields, ["start", "end", "maxRanges", "out", "project"]);
395
398
  if (fields.out !== undefined) {
396
- requireFields("read", fields, ["start", "end", "out", "project"]);
397
399
  return writeOut(fields.out, await readCoverage(session, start, end), fields.project);
398
400
  }
399
- requireFields("read", fields, ["start", "end", "maxRanges"]);
400
401
  const result = await session.memmap({ action: "read", start, end, maxRanges: fields.maxRanges ?? 256 });
401
402
  const ranges = result.ranges.map((range) => ({
402
403
  start: formatC64Address(range.start),