@henols/c64-re-tools 2.0.0-rc.11 → 2.0.0-rc.13

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 (54) hide show
  1. package/README.md +7 -5
  2. package/package.json +1 -1
  3. package/skills/c64-assembler/SKILL.md +11 -2
  4. package/skills/c64-assembler/scripts/assemble.ts +4 -4
  5. package/skills/c64-basic/SKILL.md +8 -1
  6. package/skills/c64-basic/scripts/basic.ts +3 -3
  7. package/skills/c64-disk/SKILL.md +10 -3
  8. package/skills/c64-disk/scripts/disk.ts +4 -4
  9. package/skills/c64-emulator/SKILL.md +4 -0
  10. package/skills/c64-knowledge/SKILL.md +62 -12
  11. package/skills/c64-knowledge/scripts/knowledge.ts +352 -102
  12. package/skills/c64-memory-map/SKILL.md +7 -0
  13. package/skills/c64-memory-map/scripts/memmap.ts +1 -1
  14. package/skills/c64-provenance/SKILL.md +56 -3
  15. package/skills/c64-provenance/scripts/library.ts +584 -0
  16. package/skills/c64-provenance/scripts/provenance.ts +201 -9
  17. package/skills/c64-reverse-engineering/SKILL.md +16 -2
  18. package/skills/c64-source/SKILL.md +38 -7
  19. package/skills/c64-source/scripts/generate.ts +213 -29
  20. package/skills/c64-source/scripts/loader.ts +3 -1
  21. package/skills/c64-source/scripts/opcodes.ts +19 -0
  22. package/skills/c64-source/scripts/source.ts +387 -35
  23. package/skills/c64-static-analysis/SKILL.md +11 -2
  24. package/skills/c64-static-analysis/scripts/analyze.ts +95 -8
  25. package/skills/c64-static-analysis/scripts/macros.ts +233 -0
  26. package/skills/c64-testing/SKILL.md +8 -1
  27. package/skills/c64-testing/references/scenario-format.md +1 -1
  28. package/skills/c64-testing/scripts/checklist.ts +2 -2
  29. package/skills/c64-testing/scripts/run.ts +4 -4
  30. package/skills/c64-testing/scripts/scenario.ts +1 -1
  31. package/skills/c64-testing/scripts/test.ts +4 -4
  32. package/skills/c64-unpacker/SKILL.md +11 -4
  33. package/skills/c64-unpacker/scripts/unpack.ts +4 -4
  34. package/src/cli/main.ts +5 -3
  35. package/src/host-client/image.ts +1 -1
  36. package/src/host-client/transfer.ts +34 -10
  37. package/src/host-client/vice-session.ts +9 -9
  38. package/src/knowledge/conflicts.ts +157 -0
  39. package/src/knowledge/database.ts +150 -38
  40. package/src/knowledge/history.ts +54 -20
  41. package/src/knowledge/import.ts +3 -2
  42. package/src/knowledge/read.ts +169 -20
  43. package/src/knowledge/schema.ts +115 -72
  44. package/src/knowledge/text.ts +391 -0
  45. package/src/knowledge/write.ts +508 -51
  46. package/src/mcp/output.ts +14 -7
  47. package/src/mcp/tools/debug.ts +8 -6
  48. package/src/mcp/tools/media.ts +17 -13
  49. package/src/mcp/tools/memory.ts +9 -5
  50. package/src/mcp/tools/video.ts +6 -5
  51. package/src/platform.ts +28 -5
  52. package/src/project.ts +166 -3
  53. package/src/protocol/tools.ts +1 -1
  54. package/src/script.ts +46 -7
package/src/mcp/output.ts CHANGED
@@ -1,14 +1,21 @@
1
1
  // Tool results written to a project file instead of the context (D39). The
2
- // MCP server runs on the agent's machine with the project as its working
3
- // directory, so it writes the file itself; the host never sees the path.
2
+ // MCP server runs on the agent's machine, so it writes the file itself; the
3
+ // host never sees the path. The project is the folder that `project` names,
4
+ // or the one that the D67 rules find from the folder the agent started in (D68).
4
5
 
5
6
  import { createHash } from "node:crypto";
6
7
 
7
8
  import { z } from "zod";
8
9
 
9
- import { writeProjectFile } from "../host-client/transfer.ts";
10
+ import { projectFolder, writeProjectFile } from "../host-client/transfer.ts";
10
11
 
11
- export const OutInput = z.string().min(1).describe("write the result to this file, relative to the project directory, in place of returning it. An existing file is overwritten");
12
+ export const OutInput = z.string().min(1).describe("write the result to this file, relative to the project folder, in place of returning it. An existing file is overwritten");
13
+
14
+ /** The project of a tool with a path (D67, D68). */
15
+ export const ProjectInput = z
16
+ .string()
17
+ .min(1)
18
+ .describe("the project folder, relative to the folder where the agent started. Give it when that folder is not in a project and has projects below it. The paths of this call are relative to the project folder");
12
19
 
13
20
  /** What a result says in place of the bytes it wrote. */
14
21
  export interface Written {
@@ -24,9 +31,9 @@ export const WrittenOutput = {
24
31
  sha256: z.string().regex(/^[0-9a-f]{64}$/).optional(),
25
32
  };
26
33
 
27
- /** Writes `data` to the project file `path` and gives what the result shows in its place. */
28
- export function writeOut(path: string, data: Uint8Array | string, root?: string): Written {
34
+ /** Writes `data` to the file `path` of the project that `project` names (or the D67 lookup finds), and gives what the result shows in its place. */
35
+ export function writeOut(path: string, data: Uint8Array | string, project: string | undefined): Written {
29
36
  const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : data;
30
- writeProjectFile(path, bytes, { what: "out", ...(root === undefined ? {} : { root }) });
37
+ writeProjectFile(path, bytes, { what: "out", root: projectFolder(project) });
31
38
  return { out: path, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") };
32
39
  }
@@ -7,7 +7,7 @@ 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_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, writeOut, WrittenOutput } from "../output.ts";
10
+ import { OutInput, ProjectInput, writeOut, WrittenOutput } from "../output.ts";
11
11
  import { AddressInput, AddressOutput, Byte, ConditionInput, ConditionOutput, defineTool, HexData, requireFields, showCondition, SpaceInput, toCondition, type ViceSessionApi } from "../server.ts";
12
12
  import { spaced } from "./memory.ts";
13
13
 
@@ -135,7 +135,7 @@ export const c64CpuHistory = defineTool({
135
135
  "In space c64, it also gives the raster line and cycle when the instruction started. " +
136
136
  "With out, it writes the entries as text lines to that project file, and the result gives the file in place of the entries. " +
137
137
  "This does not change the run state of the machine.",
138
- inputSchema: z.object({ limit: z.number().int().min(1).max(MAX_HISTORY).default(50), space: SpaceInput, out: OutInput.optional() }).strict(),
138
+ inputSchema: z.object({ limit: z.number().int().min(1).max(MAX_HISTORY).default(50), space: SpaceInput, out: OutInput.optional(), project: ProjectInput.optional() }).strict(),
139
139
  outputSchema: z.object({
140
140
  entries: z.array(
141
141
  z.object({
@@ -155,7 +155,8 @@ export const c64CpuHistory = defineTool({
155
155
  }),
156
156
  readOnly: false,
157
157
  async run(input, session) {
158
- const { out, ...request } = input;
158
+ const { out, project, ...request } = input;
159
+ if (out === undefined && project !== undefined) throw new WireFailure("invalid-input", "project is used only with out.");
159
160
  const { entries } = await session.cpuHistory(request);
160
161
  if (out === undefined) return { entries: entries.map((entry) => ({ ...entry, address: formatC64Address(entry.address) })) };
161
162
  const byte = (value: number) => value.toString(16).padStart(2, "0");
@@ -163,7 +164,7 @@ export const c64CpuHistory = defineTool({
163
164
  const raster = entry.rasterLine === undefined ? "" : ` line ${entry.rasterLine} cycle ${entry.rasterCycle ?? 0}`;
164
165
  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}`;
165
166
  });
166
- return { count: entries.length, ...writeOut(out, `${lines.join("\n")}\n`) };
167
+ return { count: entries.length, ...writeOut(out, `${lines.join("\n")}\n`, project) };
167
168
  },
168
169
  });
169
170
 
@@ -261,6 +262,7 @@ export const c64Memmap = defineTool({
261
262
  end: AddressInput.optional().describe("read and summary. Default $ffff"),
262
263
  maxRanges: z.number().int().min(1).max(MAX_MEMMAP_RANGES).optional().describe("read without out only. Default 256"),
263
264
  out: OutInput.optional().describe("read only: write a coverage map to this project file, one byte for each address. An existing file is overwritten"),
265
+ project: ProjectInput.optional().describe("read with out only: the project folder, relative to the folder where the agent started. Give it when that folder is not in a project and has projects below it"),
264
266
  })
265
267
  .strict(),
266
268
  outputSchema: z.object({
@@ -307,8 +309,8 @@ export const c64Memmap = defineTool({
307
309
  };
308
310
  }
309
311
  if (fields.out !== undefined) {
310
- requireFields("read", fields, ["start", "end", "out"]);
311
- return writeOut(fields.out, await readCoverage(session, start, end));
312
+ requireFields("read", fields, ["start", "end", "out", "project"]);
313
+ return writeOut(fields.out, await readCoverage(session, start, end), fields.project);
312
314
  }
313
315
  requireFields("read", fields, ["start", "end", "maxRanges"]);
314
316
  const result = await session.memmap({ action: "read", start, end, maxRanges: fields.maxRanges ?? 256 });
@@ -1,16 +1,16 @@
1
1
  // Media tools: c64_autostart, c64_program_load, c64_disk_attach and
2
- // c64_snapshot. Paths are project-relative; the host-client reads the file and
3
- // the Host Runtime receives only its bytes.
2
+ // c64_snapshot. Paths are relative to the project folder (D68); the host-client
3
+ // reads the file and the Host Runtime receives only its bytes.
4
4
 
5
5
  import { z } from "zod";
6
6
 
7
7
  import { formatC64Address } from "../../c64.ts";
8
- import { readProjectFile } from "../../host-client/transfer.ts";
8
+ import { projectFolder, readProjectFile } from "../../host-client/transfer.ts";
9
9
  import { AUTOSTART_TYPES, DISK_TYPES, MAX_SNAPSHOTS, RUN_STATES } from "../../protocol/vice.ts";
10
- import { OutInput, writeOut, WrittenOutput } from "../output.ts";
10
+ import { OutInput, ProjectInput, writeOut, WrittenOutput } from "../output.ts";
11
11
  import { AddressInput, AddressOutput, defineTool, requireFields, TransientName } from "../server.ts";
12
12
 
13
- const ProjectPath = z.string().min(1).describe("path relative to the project directory, for example build/game.prg");
13
+ const ProjectPath = z.string().min(1).describe("path relative to the project folder, for example build/game.prg");
14
14
 
15
15
  const list = (types: readonly string[]) => types.map((type) => `.${type}`).join(", ");
16
16
 
@@ -27,6 +27,7 @@ export const c64Autostart = defineTool({
27
27
  inputSchema: z
28
28
  .object({
29
29
  path: ProjectPath,
30
+ project: ProjectInput.optional(),
30
31
  index: z.number().int().min(0).max(0xfffe).optional().describe("directory entry of a disk image, 0 is the first entry. Without it, the first file loads as with LOAD\"*\""),
31
32
  run: z.boolean().default(true).describe("true runs the program after it loads"),
32
33
  })
@@ -39,7 +40,7 @@ export const c64Autostart = defineTool({
39
40
  readOnly: false,
40
41
  async run(input, session) {
41
42
  // VICE numbers the directory entries from 1, and its 0 loads "*".
42
- return session.autostart({ path: input.path, index: input.index === undefined ? 0 : input.index + 1, run: input.run });
43
+ return session.autostart({ path: input.path, index: input.index === undefined ? 0 : input.index + 1, run: input.run, root: projectFolder(input.project) });
43
44
  },
44
45
  });
45
46
 
@@ -53,13 +54,15 @@ export const c64ProgramLoad = defineTool({
53
54
  inputSchema: z
54
55
  .object({
55
56
  path: ProjectPath,
57
+ project: ProjectInput.optional(),
56
58
  address: AddressInput.optional().describe("load here instead of at the file's own load address"),
57
59
  })
58
60
  .strict(),
59
61
  outputSchema: z.object({ state: z.enum(RUN_STATES), loadAddress: AddressOutput, size: z.number().int().min(1).max(0x10000) }),
60
62
  readOnly: false,
61
63
  async run(input, session) {
62
- const result = await session.programLoad(input.address === undefined ? { path: input.path } : { path: input.path, address: input.address });
64
+ const root = projectFolder(input.project);
65
+ const result = await session.programLoad(input.address === undefined ? { path: input.path, root } : { path: input.path, address: input.address, root });
63
66
  return { state: result.state, loadAddress: formatC64Address(result.loadAddress), size: result.size };
64
67
  },
65
68
  });
@@ -71,11 +74,11 @@ export const c64DiskAttach = defineTool({
71
74
  `Put a disk image from the project into drive 8 (${list(DISK_TYPES)}). ` +
72
75
  "Nothing loads or runs. To load from the disk, type LOAD on the C64 or use c64_autostart. " +
73
76
  "This does not change the run state of the machine.",
74
- inputSchema: z.object({ path: ProjectPath }).strict(),
77
+ inputSchema: z.object({ path: ProjectPath, project: ProjectInput.optional() }).strict(),
75
78
  outputSchema: z.object({ attached: z.boolean() }),
76
79
  readOnly: false,
77
80
  async run(input, session) {
78
- return session.diskAttach(input);
81
+ return session.diskAttach({ path: input.path, root: projectFolder(input.project) });
79
82
  },
80
83
  });
81
84
 
@@ -94,6 +97,7 @@ export const c64Snapshot = defineTool({
94
97
  name: TransientName.optional().describe("save and discard, and restore without file: 1 to 64 letters, digits, dots, underscores or hyphens"),
95
98
  out: OutInput.optional().describe("save only: also write the snapshot to this project file, for example snapshots/room0.vsf. An existing file is overwritten"),
96
99
  file: ProjectPath.optional().describe("restore only: a .vsf file in the project, in place of name"),
100
+ project: ProjectInput.optional().describe("save with out, and restore with file: the project folder, relative to the folder where the agent started. Give it when that folder is not in a project and has projects below it"),
97
101
  })
98
102
  .strict(),
99
103
  outputSchema: z.object({
@@ -113,13 +117,13 @@ export const c64Snapshot = defineTool({
113
117
  return session.snapshot({ action });
114
118
  }
115
119
  if (action === "restore" && fields.file !== undefined) {
116
- requireFields(action, fields, ["file"], ["file"]);
117
- return session.snapshotRestore(readProjectFile(fields.file).bytes);
120
+ requireFields(action, fields, ["file", "project"], ["file"]);
121
+ return session.snapshotRestore(readProjectFile(fields.file, { root: projectFolder(fields.project) }).bytes);
118
122
  }
119
123
  if (action === "save") {
120
- requireFields(action, fields, ["name", "out"], ["name"]);
124
+ requireFields(action, fields, fields.out === undefined ? ["name"] : ["name", "out", "project"], ["name"]);
121
125
  const saved = await session.snapshot({ action, name: fields.name! });
122
- return fields.out === undefined ? saved : { ...saved, ...writeOut(fields.out, await session.snapshotExport(fields.name!)) };
126
+ return fields.out === undefined ? saved : { ...saved, ...writeOut(fields.out, await session.snapshotExport(fields.name!), fields.project) };
123
127
  }
124
128
  requireFields(action, fields, ["name"], ["name"]);
125
129
  return session.snapshot({ action, name: fields.name! });
@@ -6,7 +6,7 @@ import { z } from "zod";
6
6
  import { formatC64Address } from "../../c64.ts";
7
7
  import { WireFailure } from "../../protocol/messages.ts";
8
8
  import { MAX_COMPARE_DIFFERENCES, MAX_COMPARE_SIZE, MAX_DISASSEMBLE, MAX_MEMORY_READ, MAX_MEMORY_WRITE, MAX_SEARCH_PATTERN, MAX_SEARCH_RESULTS, type RegisterValues } from "../../protocol/vice.ts";
9
- import { OutInput, writeOut, WrittenOutput } from "../output.ts";
9
+ import { OutInput, ProjectInput, writeOut, WrittenOutput } from "../output.ts";
10
10
  import { AddressInput, AddressOutput, Byte, defineTool, HexData, HexDataInput, MemoryViewInput, requireFields, SpaceInput } from "../server.ts";
11
11
 
12
12
  /** With out, a read or a disassembly can be larger than one answer of the session: it is read in parts (D39). */
@@ -28,12 +28,14 @@ export const c64MemoryRead = defineTool({
28
28
  space: SpaceInput,
29
29
  view: MemoryViewInput,
30
30
  out: OutInput.optional(),
31
+ project: ProjectInput.optional(),
31
32
  })
32
33
  .strict(),
33
34
  outputSchema: z.object({ address: AddressOutput, size: z.number().int().min(1).max(MAX_MEMORY_READ_OUT), data: HexData.optional(), ...WrittenOutput }),
34
35
  readOnly: false,
35
36
  async run(input, session) {
36
- const { out, ...read } = input;
37
+ const { out, project, ...read } = input;
38
+ if (out === undefined && project !== undefined) throw new WireFailure("invalid-input", "project is used only with out.");
37
39
  if (out === undefined) {
38
40
  if (read.size > MAX_MEMORY_READ) throw new WireFailure("invalid-input", `size can be at most ${MAX_MEMORY_READ} without out.`);
39
41
  const result = await session.memoryRead(read);
@@ -45,7 +47,7 @@ export const c64MemoryRead = defineTool({
45
47
  const part = await session.memoryRead({ ...read, address: at, size: Math.min(MAX_MEMORY_READ, read.address + read.size - at) });
46
48
  parts.push(Buffer.from(part.data, "hex"));
47
49
  }
48
- return { address: formatC64Address(read.address), size: read.size, ...writeOut(out, Buffer.concat(parts)) };
50
+ return { address: formatC64Address(read.address), size: read.size, ...writeOut(out, Buffer.concat(parts), project) };
49
51
  },
50
52
  });
51
53
 
@@ -210,12 +212,14 @@ export const c64Disassemble = defineTool({
210
212
  space: SpaceInput,
211
213
  view: MemoryViewInput,
212
214
  out: OutInput.optional(),
215
+ project: ProjectInput.optional(),
213
216
  })
214
217
  .strict(),
215
218
  outputSchema: z.object({ instructions: z.array(z.object({ address: AddressOutput, bytes: HexData, text: z.string() })).optional(), count: z.number().int().min(0).optional(), ...WrittenOutput }),
216
219
  readOnly: false,
217
220
  async run(input, session) {
218
- const { out, ...request } = input;
221
+ const { out, project, ...request } = input;
222
+ if (out === undefined && project !== undefined) throw new WireFailure("invalid-input", "project is used only with out.");
219
223
  if (out === undefined) {
220
224
  if (request.count > MAX_DISASSEMBLE) throw new WireFailure("invalid-input", `count can be at most ${MAX_DISASSEMBLE} without out.`);
221
225
  const result = await session.disassemble(request);
@@ -238,7 +242,7 @@ export const c64Disassemble = defineTool({
238
242
  address = instruction.address + instruction.bytes.length / 2;
239
243
  }
240
244
  }
241
- return { count: lines.length, ...writeOut(out, `${lines.join("\n")}\n`) };
245
+ return { count: lines.length, ...writeOut(out, `${lines.join("\n")}\n`, project) };
242
246
  },
243
247
  });
244
248
 
@@ -6,7 +6,7 @@ import { z } from "zod";
6
6
  import { formatC64Address } from "../../c64.ts";
7
7
  import { WireFailure } from "../../protocol/messages.ts";
8
8
  import { CIA_SELECTIONS, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, MAX_OBSERVE_BYTES, MAX_OBSERVE_RANGES, SPACES, VICII_MODES, type ObserveParams } from "../../protocol/vice.ts";
9
- import { OutInput, writeOut, WrittenOutput } from "../output.ts";
9
+ import { OutInput, ProjectInput, writeOut, WrittenOutput } from "../output.ts";
10
10
  import { AddressInput, AddressOutput, Byte, defineTool, HexData, MemoryViewInput, requireFields, SpaceInput, ToolOutput, TransientName } from "../server.ts";
11
11
 
12
12
  const Color = z.number().int().min(0).max(15);
@@ -150,6 +150,7 @@ export const c64Screen = defineTool({
150
150
  mask: z.array(Rectangle).max(MAX_MASK_RECTANGLES).optional().describe("compare only: rectangles to ignore, in frame pixels"),
151
151
  includeDiff: z.boolean().optional().describe("compare only: also return a difference image"),
152
152
  out: OutInput.optional(),
153
+ project: ProjectInput.optional().describe("capture and compare with out only: the project folder, relative to the folder where the agent started. Give it when that folder is not in a project and has projects below it"),
153
154
  })
154
155
  .strict(),
155
156
  outputSchema: z.object({
@@ -169,21 +170,21 @@ export const c64Screen = defineTool({
169
170
  async run(input, session) {
170
171
  const { action, ...fields } = input;
171
172
  if (action === "capture") {
172
- requireFields(action, fields, ["baseline", "out"]);
173
+ requireFields(action, fields, fields.out === undefined ? ["baseline"] : ["baseline", "out", "project"]);
173
174
  const shot = await session.screenCapture(fields.baseline);
174
175
  const result = { width: shot.width, height: shot.height, ...(shot.baseline === undefined ? {} : { baseline: shot.baseline }) };
175
- if (fields.out !== undefined) return { ...result, ...writeOut(fields.out, Buffer.from(shot.png, "base64")) };
176
+ if (fields.out !== undefined) return { ...result, ...writeOut(fields.out, Buffer.from(shot.png, "base64"), fields.project) };
176
177
  return new ToolOutput(result, [{ type: "image", data: shot.png, mimeType: "image/png" }]);
177
178
  }
178
179
  if (action === "compare") {
179
- requireFields(action, fields, ["baseline", "maxMismatchRatio", "mask", "includeDiff", "out"], ["baseline"]);
180
+ requireFields(action, fields, ["baseline", "maxMismatchRatio", "mask", "includeDiff", ...(fields.out === undefined ? [] : ["out", "project"])], ["baseline"]);
180
181
  const { diffPng, ...result } = await session.screenCompare({
181
182
  baseline: fields.baseline!,
182
183
  maxMismatchRatio: fields.maxMismatchRatio ?? 0,
183
184
  mask: fields.mask ?? [],
184
185
  includeDiff: (fields.includeDiff ?? false) || fields.out !== undefined,
185
186
  });
186
- if (fields.out !== undefined && diffPng !== undefined) return { ...result, ...writeOut(fields.out, Buffer.from(diffPng, "base64")) };
187
+ if (fields.out !== undefined && diffPng !== undefined) return { ...result, ...writeOut(fields.out, Buffer.from(diffPng, "base64"), fields.project) };
187
188
  return diffPng === undefined ? result : new ToolOutput(result, [{ type: "image", data: diffPng, mimeType: "image/png" }]);
188
189
  }
189
190
  if (action === "list") {
package/src/platform.ts CHANGED
@@ -323,10 +323,33 @@ const AREAS: Array<{ start: number; end: number; area: string; kind: Lookup["kin
323
323
 
324
324
  const hex = (value: number) => `$${value.toString(16).padStart(4, "0")}`;
325
325
 
326
+ export interface Area {
327
+ name: string;
328
+ start: number;
329
+ end: number;
330
+ kind: Lookup["kind"];
331
+ /** What a memory area holds. A chip register block has none: its name says it. */
332
+ meaning?: string;
333
+ }
334
+
335
+ /** The memory area or the I/O register block of an address. */
336
+ export function areaOf(address: number): Area {
337
+ const block = IO.find((candidate) => address >= candidate.start && address <= candidate.end);
338
+ if (block !== undefined) return { name: block.area, start: block.start, end: block.end, kind: "platform" };
339
+ const area = AREAS.find((candidate) => address >= candidate.start && address <= candidate.end)!;
340
+ return { name: area.area, start: area.start, end: area.end, kind: area.kind, meaning: area.meaning };
341
+ }
342
+
343
+ /** The area of an address, and the register that an I/O mirror repeats: c64-source groups and describes its constants with them. */
344
+ export function describeAddress(address: number): { area: Area; mirrorOf?: number } {
345
+ const { mirrorOf } = lookup(address);
346
+ return { area: areaOf(address), ...(mirrorOf === undefined ? {} : { mirrorOf }) };
347
+ }
348
+
326
349
  /** The platform meaning of one address, or its application area. */
327
350
  export function lookup(address: number): Lookup {
328
- for (const block of IO) {
329
- if (address < block.start || address > block.end) continue;
351
+ const block = IO.find((candidate) => address >= candidate.start && address <= candidate.end);
352
+ if (block !== undefined) {
330
353
  const base = block.start + ((address - block.start) % block.period);
331
354
  const register = block.registers.find((entry) => entry.start === base);
332
355
  const mirror = base !== address ? { mirrorOf: base } : {};
@@ -334,11 +357,11 @@ export function lookup(address: number): Lookup {
334
357
  return { address, kind: "platform", area: block.area, name: register.name, meaning: register.meaning, ...mirror };
335
358
  }
336
359
  const entry = [...SYSTEM, ...ROM].find((candidate) => address >= candidate.start && address <= candidate.end);
337
- const area = AREAS.find((candidate) => address >= candidate.start && address <= candidate.end)!;
360
+ const area = areaOf(address);
338
361
  if (entry !== undefined) {
339
- return { address, kind: "platform", area: area.area, name: entry.name, meaning: entry.meaning, ...(entry.start !== address ? { entryStart: entry.start } : {}) };
362
+ return { address, kind: "platform", area: area.name, name: entry.name, meaning: entry.meaning, ...(entry.start !== address ? { entryStart: entry.start } : {}) };
340
363
  }
341
- return { address, kind: area.kind, area: area.area, meaning: area.meaning };
364
+ return { address, kind: area.kind, area: area.name, meaning: area.meaning! };
342
365
  }
343
366
 
344
367
  /**
package/src/project.ts CHANGED
@@ -1,8 +1,171 @@
1
- import { realpathSync } from "node:fs";
2
- import { isAbsolute, relative, resolve, sep } from "node:path";
1
+ // The project (D67): the nearest folder at or above the working directory that
2
+ // holds .c64-re-tools/, below the home folder, the way git finds .git. A skill
3
+ // script names it with --project <folder> and an MCP tool with `project`. Every
4
+ // project path is relative to that folder (D68). .c64-re-tools/local/ holds what
5
+ // the agent saves for itself and ignores itself in git (D70).
3
6
 
7
+ import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
+
11
+ /** The folder that marks a project and holds its knowledge. */
12
+ export const PROJECT_DIRECTORY = ".c64-re-tools";
13
+
14
+ /** The folder for machine-local scratch: saved results, batches and step files (D70). */
15
+ export const LOCAL_DIRECTORY = "local";
16
+
17
+ /** The .gitignore of LOCAL_DIRECTORY: git ignores everything in it, the .gitignore too. */
18
+ const LOCAL_GITIGNORE = "# Machine-local scratch of c64-re-tools (saved results, batches, step files). Never committed.\n*\n";
19
+
20
+ /** How deep a working directory without a project is searched for projects below it. */
21
+ const SEARCH_DEPTH = 3;
22
+
23
+ /** At most this many projects are named in a refusal. */
24
+ const LISTED_PROJECTS = 20;
25
+
26
+ /** A refusal of the project rules, with a stable code, shown to the LLM unchanged. */
27
+ export class ProjectError extends Error {
28
+ override name = "ProjectError";
29
+ readonly code: "invalid-input" | "not-found";
30
+
31
+ constructor(code: "invalid-input" | "not-found", message: string) {
32
+ super(message);
33
+ this.code = code;
34
+ }
35
+ }
36
+
37
+ /** How a refusal tells the caller to name a project: "--project <folder>" for a script, "project" for an MCP tool. */
38
+ export interface ProjectRemedy {
39
+ remedy?: string;
40
+ }
41
+
42
+ const SCRIPT_REMEDY = "Give --project <folder>";
43
+
44
+ /** The folder that --project chose for this process, or undefined. */
45
+ let chosen: string | undefined;
46
+
47
+ function isProject(folder: string): boolean {
48
+ return statSync(join(folder, PROJECT_DIRECTORY), { throwIfNoEntry: false })?.isDirectory() === true;
49
+ }
50
+
51
+ /** `start` and each folder above it, nearest first, below the home folder when `start` is under it. */
52
+ function foldersUpFrom(start: string): string[] {
53
+ const home = homedir();
54
+ const underHome = start !== home && isInside(home, start);
55
+ if (start === home) return [];
56
+ const folders: string[] = [];
57
+ for (let folder = start; ; folder = dirname(folder)) {
58
+ if (underHome && folder === home) break;
59
+ folders.push(folder);
60
+ if (dirname(folder) === folder) break;
61
+ }
62
+ return folders;
63
+ }
64
+
65
+ /** The projects below `start` to SEARCH_DEPTH, relative to it and sorted. Dot folders and node_modules are skipped. */
66
+ function projectsBelow(start: string): string[] {
67
+ const found: string[] = [];
68
+ const visit = (folder: string, depth: number) => {
69
+ let entries;
70
+ try {
71
+ entries = readdirSync(folder, { withFileTypes: true });
72
+ } catch {
73
+ return;
74
+ }
75
+ for (const entry of entries) {
76
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
77
+ const path = join(folder, entry.name);
78
+ if (isProject(path)) found.push(relative(start, path));
79
+ else if (depth < SEARCH_DEPTH) visit(path, depth + 1);
80
+ }
81
+ };
82
+ visit(start, 1);
83
+ return found.sort();
84
+ }
85
+
86
+ function listed(projects: readonly string[]): string {
87
+ const shown = projects.slice(0, LISTED_PROJECTS).join(", ");
88
+ return projects.length > LISTED_PROJECTS ? `${shown} and ${projects.length - LISTED_PROJECTS} more` : shown;
89
+ }
90
+
91
+ /**
92
+ * The project of the working directory `cwd` by the D67 rules: the nearest
93
+ * folder at or above it with .c64-re-tools/, below the home folder. Without
94
+ * one, `cwd` itself, unless projects are below it or it is the home folder:
95
+ * then a ProjectError names them and the remedy, so no write lands outside a
96
+ * project.
97
+ */
98
+ export function findProject(cwd: string = process.cwd(), options: ProjectRemedy = {}): string {
99
+ const start = resolve(cwd);
100
+ const remedy = options.remedy ?? SCRIPT_REMEDY;
101
+ for (const folder of foldersUpFrom(start)) {
102
+ if (isProject(folder)) return folder;
103
+ }
104
+ if (start === homedir()) {
105
+ throw new ProjectError("invalid-input", `The home folder is not a project folder. Start in a project folder, or ${remedy.charAt(0).toLowerCase()}${remedy.slice(1)}.`);
106
+ }
107
+ const below = projectsBelow(start);
108
+ if (below.length > 0) {
109
+ throw new ProjectError("invalid-input", `${start} is not a project folder. These folders below it are projects: ${listed(below)}. ${remedy} with one of them.`);
110
+ }
111
+ return start;
112
+ }
113
+
114
+ /**
115
+ * The project folder that `folder` names, relative to `cwd` or absolute. It
116
+ * must be a folder. It is the project as given, also without .c64-re-tools/
117
+ * (the first write makes it), but not a folder inside another project or the
118
+ * home folder.
119
+ */
120
+ export function namedProject(folder: string, cwd: string = process.cwd()): string {
121
+ if (folder.length === 0) throw new ProjectError("invalid-input", "The project folder must not be empty.");
122
+ const path = resolve(cwd, folder);
123
+ const stat = statSync(path, { throwIfNoEntry: false });
124
+ if (stat === undefined) throw new ProjectError("not-found", `There is no folder ${folder}.`);
125
+ if (!stat.isDirectory()) throw new ProjectError("invalid-input", `${folder} is not a folder.`);
126
+ if (path === homedir()) throw new ProjectError("invalid-input", "The home folder is not a project folder.");
127
+ const outer = foldersUpFrom(path).slice(1).find(isProject);
128
+ if (outer !== undefined) {
129
+ throw new ProjectError("invalid-input", `${folder} is inside the project ${relative(cwd, outer) || "."}. Give that project folder.`);
130
+ }
131
+ return path;
132
+ }
133
+
134
+ /**
135
+ * Makes .c64-re-tools/local/ with its .gitignore when the project has
136
+ * .c64-re-tools/ and local/ has no .gitignore yet (D70). A folder that is not
137
+ * yet a project gets nothing. A failure (for example a read-only project) is
138
+ * left to the write into local/ that needs the folder.
139
+ */
140
+ export function ensureLocal(root: string): void {
141
+ const local = join(root, PROJECT_DIRECTORY, LOCAL_DIRECTORY);
142
+ const gitignore = join(local, ".gitignore");
143
+ if (existsSync(gitignore) || !isProject(root)) return;
144
+ try {
145
+ mkdirSync(local, { recursive: true });
146
+ writeFileSync(gitignore, LOCAL_GITIGNORE, { flag: "wx" });
147
+ } catch {
148
+ // Another process made it, or the project is read-only.
149
+ }
150
+ }
151
+
152
+ /** The project of an MCP call or another caller without process state: `folder` when given, else the D67 lookup from `cwd`. */
153
+ export function resolveProject(folder: string | undefined, cwd: string = process.cwd(), options: ProjectRemedy = {}): string {
154
+ const root = folder === undefined ? findProject(cwd, options) : namedProject(folder, cwd);
155
+ ensureLocal(root);
156
+ return root;
157
+ }
158
+
159
+ /** Sets the project of this process from --project. Throws ProjectError for a folder that the rules refuse. */
160
+ export function useProject(folder: string): void {
161
+ chosen = namedProject(folder);
162
+ }
163
+
164
+ /** The project folder of this process: the --project choice, else the D67 lookup from the working directory. */
4
165
  export function projectRoot(): string {
5
- return process.cwd();
166
+ const root = chosen ?? findProject();
167
+ ensureLocal(root);
168
+ return root;
6
169
  }
7
170
 
8
171
  export function resolveProjectPath(path: string, root = projectRoot()): string {
@@ -5,7 +5,7 @@ import { type Fields, invalid, isInteger, isObject, isOneOf, onlyFields, Protoco
5
5
 
6
6
  // ---------------------------------------------------------------------------
7
7
  // Native-tool operations. Short-lived requests on "tool" connections; they
8
- // never touch an emulator or knowledge.db.
8
+ // never touch an emulator or the knowledge.
9
9
 
10
10
  /** A transferred source tree: relative POSIX paths, one attachment per file in this order. */
11
11
  export type SourceTree = Array<{ path: string; size: number }>;
package/src/script.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // The one error envelope for the skill scripts and the CLI: a script prints one JSON line,
2
2
  // a refusal prints {"error":{code,message}} with exit status 1, and a usage error prints the
3
- // usage text as an invalid-input error with exit status 2.
3
+ // usage text as an invalid-input error with exit status 2. Every script takes --project <folder> (D67).
4
4
 
5
+ import { ProjectError, useProject } from "./project.ts";
5
6
  import { shownMessage, WireFailure } from "./protocol/messages.ts";
6
7
  import { elapsedMs, startTrace, trace, type TraceFields } from "./trace.ts";
7
8
 
@@ -17,27 +18,65 @@ function isParseArgsError(error: unknown): error is Error {
17
18
  return error instanceof Error && typeof code === "string" && code.startsWith("ERR_PARSE_ARGS_");
18
19
  }
19
20
 
21
+ /** The line that every usage text ends with. */
22
+ export const PROJECT_USAGE = "Every script takes --project <folder>: the project folder, relative to the working directory. Without it, the script uses the nearest folder at or above the working directory that holds .c64-re-tools/.";
23
+
24
+ /**
25
+ * Takes --project <folder> and --project=<folder> out of a command line, up to "--".
26
+ * Returns the folder and the other arguments. Throws UsageError for a --project without a folder or a second --project.
27
+ */
28
+ export function takeProject(argv: readonly string[]): { project?: string; argv: string[] } {
29
+ const rest: string[] = [];
30
+ let project: string | undefined;
31
+ for (let index = 0; index < argv.length; index++) {
32
+ const argument = argv[index]!;
33
+ if (argument === "--") {
34
+ rest.push(...argv.slice(index));
35
+ break;
36
+ }
37
+ let value: string | undefined;
38
+ if (argument === "--project") {
39
+ value = argv[index + 1];
40
+ if (value === undefined || value.startsWith("-")) throw new UsageError("--project needs a folder");
41
+ index++;
42
+ } else if (argument.startsWith("--project=")) {
43
+ value = argument.slice("--project=".length);
44
+ if (value === "") throw new UsageError("--project needs a folder");
45
+ } else {
46
+ rest.push(argument);
47
+ continue;
48
+ }
49
+ if (project !== undefined) throw new UsageError("give --project only once");
50
+ project = value;
51
+ }
52
+ return project === undefined ? { argv: rest } : { project, argv: rest };
53
+ }
54
+
20
55
  function printLine(value: unknown): void {
21
56
  process.stdout.write(`${JSON.stringify(value)}\n`);
22
57
  }
23
58
 
24
59
  /**
25
- * Runs a script body. Prints its result as one JSON line when it returns a value (undefined prints nothing).
26
- * A refusal (WireFailure always, plus `refusals`) prints {"error":{code,message}} with exit status 1.
60
+ * Runs a script body with the command line of the process, without --project: that sets the project first (D67).
61
+ * Prints its result as one JSON line when it returns a value (undefined prints nothing).
62
+ * A refusal (WireFailure and ProjectError always, plus `refusals`) prints {"error":{code,message}} with exit status 1.
27
63
  * A UsageError or a node:util parseArgs error (code ERR_PARSE_ARGS_*) prints {"error":{"code":"invalid-input",
28
64
  * "message": message === "" ? usage : message + "\n\n" + usage}} with exit status 2. Anything else is rethrown.
29
65
  * The body may set process.exitCode itself for an extra exit status; a value it returns is printed as usual.
30
66
  */
31
- export async function runScript(body: () => unknown, options: { usage: string; refusals?: readonly RefusalClass[] }): Promise<void> {
67
+ export async function runScript(body: (argv: string[]) => unknown, options: { usage: string; refusals?: readonly RefusalClass[] }): Promise<void> {
68
+ const usage = `${options.usage}\n\n${PROJECT_USAGE}`;
32
69
  // The trace of the script, when C64RT_TRACE is set; its start event carries the script and its arguments.
33
70
  startTrace("script", { warn: (line) => process.stderr.write(`${line}\n`) });
34
71
  const started = performance.now();
35
72
  const end = (fields: TraceFields) => trace().event("script.end", { ms: elapsedMs(started), status: process.exitCode ?? 0, ...fields });
36
73
  let result: unknown;
37
74
  try {
38
- result = await body();
75
+ const { project, argv } = takeProject(process.argv.slice(2));
76
+ if (project !== undefined) useProject(project);
77
+ result = await body(argv);
39
78
  } catch (error) {
40
- if ([WireFailure, ...(options.refusals ?? [])].some((refusal) => error instanceof refusal)) {
79
+ if ([WireFailure, ProjectError, ...(options.refusals ?? [])].some((refusal) => error instanceof refusal)) {
41
80
  const { code } = error as Error & { code: string };
42
81
  const message = shownMessage(error as Error & { code: string });
43
82
  printLine({ error: { code, message } });
@@ -47,7 +86,7 @@ export async function runScript(body: () => unknown, options: { usage: string; r
47
86
  }
48
87
  if (error instanceof UsageError || isParseArgsError(error)) {
49
88
  const { message } = error;
50
- printLine({ error: { code: "invalid-input", message: message === "" ? options.usage : `${message}\n\n${options.usage}` } });
89
+ printLine({ error: { code: "invalid-input", message: message === "" ? usage : `${message}\n\n${usage}` } });
51
90
  process.exitCode = 2;
52
91
  end({ error: { code: "invalid-input", message } });
53
92
  return;