@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.
- package/README.md +7 -5
- package/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +11 -2
- package/skills/c64-assembler/scripts/assemble.ts +4 -4
- package/skills/c64-basic/SKILL.md +8 -1
- package/skills/c64-basic/scripts/basic.ts +3 -3
- package/skills/c64-disk/SKILL.md +10 -3
- package/skills/c64-disk/scripts/disk.ts +4 -4
- package/skills/c64-emulator/SKILL.md +4 -0
- package/skills/c64-knowledge/SKILL.md +62 -12
- package/skills/c64-knowledge/scripts/knowledge.ts +352 -102
- package/skills/c64-memory-map/SKILL.md +7 -0
- package/skills/c64-memory-map/scripts/memmap.ts +1 -1
- package/skills/c64-provenance/SKILL.md +56 -3
- package/skills/c64-provenance/scripts/library.ts +584 -0
- package/skills/c64-provenance/scripts/provenance.ts +201 -9
- package/skills/c64-reverse-engineering/SKILL.md +16 -2
- package/skills/c64-source/SKILL.md +38 -7
- package/skills/c64-source/scripts/generate.ts +213 -29
- package/skills/c64-source/scripts/loader.ts +3 -1
- package/skills/c64-source/scripts/opcodes.ts +19 -0
- package/skills/c64-source/scripts/source.ts +387 -35
- package/skills/c64-static-analysis/SKILL.md +11 -2
- package/skills/c64-static-analysis/scripts/analyze.ts +95 -8
- package/skills/c64-static-analysis/scripts/macros.ts +233 -0
- package/skills/c64-testing/SKILL.md +8 -1
- package/skills/c64-testing/references/scenario-format.md +1 -1
- package/skills/c64-testing/scripts/checklist.ts +2 -2
- package/skills/c64-testing/scripts/run.ts +4 -4
- package/skills/c64-testing/scripts/scenario.ts +1 -1
- package/skills/c64-testing/scripts/test.ts +4 -4
- package/skills/c64-unpacker/SKILL.md +11 -4
- package/skills/c64-unpacker/scripts/unpack.ts +4 -4
- package/src/cli/main.ts +5 -3
- package/src/host-client/image.ts +1 -1
- package/src/host-client/transfer.ts +34 -10
- package/src/host-client/vice-session.ts +9 -9
- package/src/knowledge/conflicts.ts +157 -0
- package/src/knowledge/database.ts +150 -38
- package/src/knowledge/history.ts +54 -20
- package/src/knowledge/import.ts +3 -2
- package/src/knowledge/read.ts +169 -20
- package/src/knowledge/schema.ts +115 -72
- package/src/knowledge/text.ts +391 -0
- package/src/knowledge/write.ts +508 -51
- package/src/mcp/output.ts +14 -7
- package/src/mcp/tools/debug.ts +8 -6
- package/src/mcp/tools/media.ts +17 -13
- package/src/mcp/tools/memory.ts +9 -5
- package/src/mcp/tools/video.ts +6 -5
- package/src/platform.ts +28 -5
- package/src/project.ts +166 -3
- package/src/protocol/tools.ts +1 -1
- 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
|
|
3
|
-
//
|
|
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
|
|
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
|
|
28
|
-
export function writeOut(path: string, data: Uint8Array | string,
|
|
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",
|
|
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
|
}
|
package/src/mcp/tools/debug.ts
CHANGED
|
@@ -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 });
|
package/src/mcp/tools/media.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
// Media tools: c64_autostart, c64_program_load, c64_disk_attach and
|
|
2
|
-
// c64_snapshot. Paths are project
|
|
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
|
|
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
|
|
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! });
|
package/src/mcp/tools/memory.ts
CHANGED
|
@@ -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
|
|
package/src/mcp/tools/video.ts
CHANGED
|
@@ -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
|
-
|
|
329
|
-
|
|
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 =
|
|
360
|
+
const area = areaOf(address);
|
|
338
361
|
if (entry !== undefined) {
|
|
339
|
-
return { address, kind: "platform", area: area.
|
|
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.
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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 {
|
package/src/protocol/tools.ts
CHANGED
|
@@ -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.
|
|
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
|
|
26
|
-
*
|
|
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
|
-
|
|
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 === "" ?
|
|
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;
|