@henols/c64-re-tools 1.1.0 → 2.0.0-rc.2

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 (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -37
  3. package/distribution/package.ts +36 -0
  4. package/distribution/plugin.ts +120 -0
  5. package/package.json +34 -40
  6. package/skills/c64-assembler/SKILL.md +73 -0
  7. package/skills/c64-assembler/scripts/assemble.ts +103 -0
  8. package/skills/c64-basic/SKILL.md +67 -0
  9. package/skills/c64-basic/scripts/basic.ts +46 -0
  10. package/skills/c64-disk/SKILL.md +79 -0
  11. package/skills/c64-disk/scripts/disk.ts +65 -0
  12. package/skills/c64-emulator/SKILL.md +124 -0
  13. package/skills/c64-knowledge/SKILL.md +102 -0
  14. package/skills/c64-knowledge/scripts/knowledge.ts +257 -0
  15. package/skills/c64-memory-map/SKILL.md +76 -0
  16. package/skills/c64-memory-map/scripts/memmap.ts +81 -0
  17. package/skills/c64-memory-map/scripts/platform.ts +424 -0
  18. package/skills/c64-provenance/SKILL.md +76 -0
  19. package/skills/c64-provenance/scripts/compare.ts +92 -0
  20. package/skills/c64-provenance/scripts/provenance.ts +83 -0
  21. package/skills/c64-reverse-engineering/SKILL.md +90 -0
  22. package/skills/c64-static-analysis/SKILL.md +96 -0
  23. package/skills/c64-static-analysis/scripts/analyze.ts +175 -0
  24. package/skills/c64-static-analysis/scripts/findings.ts +73 -0
  25. package/skills/c64-testing/SKILL.md +79 -0
  26. package/skills/c64-testing/references/scenario-format.md +76 -0
  27. package/skills/c64-testing/scripts/checklist.ts +51 -0
  28. package/skills/c64-testing/scripts/scenario.ts +473 -0
  29. package/skills/c64-testing/scripts/test.ts +55 -0
  30. package/skills/c64-unpacker/SKILL.md +82 -0
  31. package/skills/c64-unpacker/scripts/evidence.ts +135 -0
  32. package/skills/c64-unpacker/scripts/unpack.ts +216 -0
  33. package/src/c64.ts +281 -0
  34. package/src/cli/ap-sdk.ts +44 -0
  35. package/src/cli/cleanup.ts +194 -0
  36. package/src/cli/main.ts +122 -0
  37. package/src/cli/status.ts +34 -0
  38. package/src/host/main.ts +121 -0
  39. package/src/host/server.ts +409 -0
  40. package/src/host/tools/c1541.ts +255 -0
  41. package/src/host/tools/index.ts +31 -0
  42. package/src/host/tools/petcat.ts +70 -0
  43. package/src/host/tools/status.ts +30 -0
  44. package/src/host/tools/version.ts +41 -0
  45. package/src/host/vice/adapter.ts +761 -0
  46. package/src/host/vice/binary-monitor.ts +389 -0
  47. package/src/host/vice/chips.ts +108 -0
  48. package/src/host/vice/connect.ts +34 -0
  49. package/src/host/vice/process.ts +402 -0
  50. package/src/host/vice/screen.ts +183 -0
  51. package/src/host/vice/session.ts +1316 -0
  52. package/src/host/vice/text-monitor.ts +223 -0
  53. package/src/host-client/connect.ts +241 -0
  54. package/src/host-client/tools.ts +89 -0
  55. package/src/host-client/transfer.ts +100 -0
  56. package/src/host-client/vice-session.ts +254 -0
  57. package/src/knowledge/database.ts +132 -0
  58. package/src/knowledge/history.ts +128 -0
  59. package/src/knowledge/import.ts +344 -0
  60. package/src/knowledge/read.ts +191 -0
  61. package/src/knowledge/schema.ts +140 -0
  62. package/src/knowledge/write.ts +348 -0
  63. package/src/mcp/main.ts +109 -0
  64. package/src/mcp/server.ts +307 -0
  65. package/src/mcp/tools/debug.ts +237 -0
  66. package/src/mcp/tools/execution.ts +116 -0
  67. package/src/mcp/tools/input.ts +69 -0
  68. package/src/mcp/tools/machine.ts +97 -0
  69. package/src/mcp/tools/media.ts +106 -0
  70. package/src/mcp/tools/memory.ts +203 -0
  71. package/src/mcp/tools/video.ts +260 -0
  72. package/src/native/acme.ts +116 -0
  73. package/src/native/discover.ts +141 -0
  74. package/src/native/dxa.ts +166 -0
  75. package/src/native/ghidra/analyze.ts +86 -0
  76. package/src/native/ghidra/index.ts +157 -0
  77. package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
  78. package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
  79. package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
  80. package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
  81. package/src/native/ghidra/scripts/C64Export.java +242 -0
  82. package/src/native/ghidra/scripts/C64Prepare.java +60 -0
  83. package/src/native/local.ts +34 -0
  84. package/src/native/processes.ts +350 -0
  85. package/src/native/run.ts +172 -0
  86. package/src/native/staging.ts +83 -0
  87. package/src/native/status.ts +101 -0
  88. package/src/native/types.ts +228 -0
  89. package/src/native/watchdog.ts +78 -0
  90. package/src/project.ts +39 -0
  91. package/src/protocol/framing.ts +132 -0
  92. package/src/protocol/messages.ts +252 -0
  93. package/src/protocol/tools.ts +247 -0
  94. package/src/protocol/vice.ts +1235 -0
  95. package/src/script.ts +59 -0
  96. package/src/time.ts +15 -0
  97. package/src/trace.ts +162 -0
  98. package/THIRD-PARTY-NOTICES.md +0 -26
  99. package/bin/cli.mjs +0 -283
  100. package/skills/acme-build/SKILL.md +0 -260
  101. package/skills/acme-build/scripts/acme.mjs +0 -341
  102. package/skills/acme-build/template.a +0 -53
  103. package/skills/c64-disk-access/SKILL.md +0 -159
  104. package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
  105. package/skills/c64-memory-mapping/SKILL.md +0 -631
  106. package/skills/c64-memory-mapping/memmap.json +0 -8800
  107. package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
  108. package/skills/c64-petcat/SKILL.md +0 -87
  109. package/skills/c64-petcat/scripts/petcat.mjs +0 -221
  110. package/skills/c64-program-recon/SKILL.md +0 -706
  111. package/skills/c64-program-recon/references/control-flow.md +0 -180
  112. package/skills/c64-program-recon/references/graphics.md +0 -79
  113. package/skills/c64-program-recon/references/observation-hazards.md +0 -143
  114. package/skills/c64-program-recon/references/reconstruction.md +0 -133
  115. package/skills/c64-program-recon/references/sound-and-input.md +0 -74
  116. package/skills/c64-program-recon/references/tool-selection.md +0 -78
  117. package/skills/c64-program-recon/scripts/derive.mjs +0 -364
  118. package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
  119. package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
  120. package/skills/c64-provenance-diff/SKILL.md +0 -292
  121. package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
  122. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
  123. package/skills/c64-ram-capture/RELEASES.json.example +0 -17
  124. package/skills/c64-ram-capture/SKILL.md +0 -474
  125. package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
  126. package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
  127. package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
  128. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
  129. package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
  130. package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
  131. package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
  132. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
  133. package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
  134. package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
  135. package/skills/c64-ram-capture/transients/README.md +0 -137
  136. package/skills/routine-queue-walker/SKILL.md +0 -394
  137. package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
  138. package/skills/vice-wedge-triage/SKILL.md +0 -269
@@ -0,0 +1,348 @@
1
+ // Semantic knowledge writes. Every accepted change is one
2
+ // revision in one transaction: superseded rows are closed, never deleted.
3
+
4
+ import type { DatabaseSync } from "node:sqlite";
5
+
6
+ import { SYMBOL_NAME } from "../c64.ts";
7
+ import { isoCet } from "../time.ts";
8
+ import { KnowledgeError, transaction } from "./database.ts";
9
+ import {
10
+ COMMENT_COLUMNS,
11
+ commentsAt,
12
+ currentRevision,
13
+ REFERENCE_COLUMNS,
14
+ referencesFrom,
15
+ REGION_COLUMNS,
16
+ regionsOverlapping,
17
+ SYMBOL_COLUMNS,
18
+ symbolAt,
19
+ symbolNamed,
20
+ toComment,
21
+ toReference,
22
+ toRegion,
23
+ toSymbol,
24
+ type CommentRow,
25
+ type ReferenceRow,
26
+ type RegionRow,
27
+ type SymbolRow,
28
+ } from "./read.ts";
29
+ import {
30
+ COMMENT_PLACEMENTS,
31
+ REFERENCE_KINDS,
32
+ REGION_TYPES,
33
+ SYMBOL_KINDS,
34
+ type CommentPlacement,
35
+ type Origin,
36
+ type ReferenceKind,
37
+ type RegionType,
38
+ type SymbolKind,
39
+ } from "./schema.ts";
40
+
41
+ /** Who writes and why. Semantic writes come from the user or the LLM, never as an analyzer. */
42
+ export interface WriteContext {
43
+ origin: Extract<Origin, "user" | "llm">;
44
+ /** A short reason, kept with the revision for later review. */
45
+ description?: string;
46
+ /** The revision the caller read; the write is refused if knowledge changed since. */
47
+ expectedRevision?: number;
48
+ }
49
+
50
+ /** The revision a write created, or null when nothing needed to change. */
51
+ export interface WriteResult<T, P = never> {
52
+ revision: number | null;
53
+ current: T;
54
+ /** The current rows of another origin that this write replaced, as they were before it. */
55
+ previous?: P;
56
+ }
57
+
58
+ export { SYMBOL_NAME };
59
+ const MAX_COMMENT = 4000;
60
+
61
+ export function checkAddress(value: number, what = "address"): void {
62
+ if (!Number.isInteger(value) || value < 0 || value > 0xffff) throw new KnowledgeError("invalid-input", `${what} must be from $0000 to $ffff.`);
63
+ }
64
+
65
+ function checkOneOf<T extends string>(values: readonly T[], value: string, what: string): asserts value is T {
66
+ if (!(values as readonly string[]).includes(value)) throw new KnowledgeError("invalid-input", `${what} must be one of: ${values.join(", ")}.`);
67
+ }
68
+
69
+ export function hex(value: number): string {
70
+ return `$${value.toString(16).padStart(4, "0")}`;
71
+ }
72
+
73
+ /** What a revision records about its writer. Analyzer imports may add tool details. */
74
+ export interface ChangeContext {
75
+ origin: Origin;
76
+ description?: string;
77
+ expectedRevision?: number;
78
+ inputHash?: string;
79
+ toolVersion?: string;
80
+ }
81
+
82
+ /**
83
+ * One write: refuses a stale expected revision, then creates the revision row
84
+ * only when the first actual change happens, so a no-op leaves no revision.
85
+ */
86
+ export class Change {
87
+ revision: number | null = null;
88
+ readonly #db: DatabaseSync;
89
+ readonly #context: ChangeContext;
90
+ readonly #operation: string;
91
+
92
+ constructor(db: DatabaseSync, context: ChangeContext, operation: string) {
93
+ this.#db = db;
94
+ this.#context = context;
95
+ this.#operation = operation;
96
+ const current = currentRevision(db);
97
+ if (context.expectedRevision !== undefined && context.expectedRevision !== current) {
98
+ throw new KnowledgeError(
99
+ "stale-revision",
100
+ `Knowledge changed since revision ${context.expectedRevision}; it is now at revision ${current}. Read it again and decide again.`,
101
+ );
102
+ }
103
+ }
104
+
105
+ /** The id of this write's revision, created on first use. */
106
+ id(): number {
107
+ if (this.revision !== null) return this.revision;
108
+ const created = this.#db
109
+ .prepare("INSERT INTO revisions (created_at, origin, operation, input_hash, tool_version, description) VALUES (?, ?, ?, ?, ?, ?) RETURNING id")
110
+ .get(
111
+ isoCet(new Date()),
112
+ this.#context.origin,
113
+ this.#operation,
114
+ this.#context.inputHash ?? null,
115
+ this.#context.toolVersion ?? null,
116
+ this.#context.description ?? null,
117
+ ) as { id: number };
118
+ this.revision = created.id;
119
+ this.#db.prepare("UPDATE meta SET value = ? WHERE key = 'current_revision'").run(String(created.id));
120
+ return created.id;
121
+ }
122
+
123
+ /** Closes the current rows of `table` that `where` selects. */
124
+ close(table: string, where: string, ...values: Array<number | string>): void {
125
+ this.#db.prepare(`UPDATE ${table} SET valid_to_revision = ? WHERE valid_to_revision IS NULL AND ${where}`).run(this.id(), ...values);
126
+ }
127
+
128
+ insertSymbol(row: Omit<SymbolRow, "revision">): void {
129
+ this.#db.prepare("INSERT INTO symbols (address, name, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?)").run(row.address, row.name, row.kind, row.origin, this.id());
130
+ }
131
+
132
+ insertRegion(row: Omit<RegionRow, "revision">): void {
133
+ this.#db
134
+ .prepare("INSERT INTO regions (start_address, end_address, type, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?)")
135
+ .run(row.start, row.end, row.type, row.origin, this.id());
136
+ }
137
+
138
+ insertComment(row: Omit<CommentRow, "revision">): void {
139
+ this.#db.prepare("INSERT INTO comments (address, placement, text, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?)").run(row.address, row.placement, row.text, row.origin, this.id());
140
+ }
141
+
142
+ insertReference(row: Omit<ReferenceRow, "revision">): void {
143
+ this.#db
144
+ .prepare('INSERT INTO "references" (from_address, to_address, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?)')
145
+ .run(row.from, row.to, row.kind, row.origin, this.id());
146
+ }
147
+ }
148
+
149
+ /** Runs one semantic write in a transaction. */
150
+ function write<T>(db: DatabaseSync, context: WriteContext, operation: string, work: (change: Change) => T): WriteResult<T> {
151
+ if (context.origin !== "user" && context.origin !== "llm") {
152
+ throw new KnowledgeError("invalid-input", "A semantic write comes from the user or the LLM.");
153
+ }
154
+ return transaction(db, () => {
155
+ const change = new Change(db, context, operation);
156
+ const current = work(change);
157
+ return { revision: change.revision, current };
158
+ });
159
+ }
160
+
161
+ /** Names the symbol at an address (one current symbol per address, unique names). */
162
+ export function renameSymbol(
163
+ db: DatabaseSync,
164
+ context: WriteContext,
165
+ request: { address: number; name: string; kind?: SymbolKind },
166
+ ): WriteResult<SymbolRow, SymbolRow> {
167
+ checkAddress(request.address);
168
+ if (!SYMBOL_NAME.test(request.name)) {
169
+ throw new KnowledgeError("invalid-input", "A symbol name is a letter or underscore, then letters, digits or underscores (at most 64), with an optional leading dot.");
170
+ }
171
+ if (request.kind !== undefined) checkOneOf(SYMBOL_KINDS, request.kind, "kind");
172
+ let previous: SymbolRow | undefined;
173
+ const result = write(db, context, "rename-symbol", (change) => {
174
+ const existing = symbolAt(db, request.address);
175
+ const kind = request.kind ?? existing?.kind ?? "label";
176
+ if (existing !== undefined && existing.name === request.name && existing.kind === kind) return existing;
177
+ const owner = symbolNamed(db, request.name);
178
+ if (owner !== undefined && owner.address !== request.address) {
179
+ throw new KnowledgeError("conflict", `The name ${request.name} already belongs to ${hex(owner.address)}. Rename or remove that symbol first.`);
180
+ }
181
+ if (existing !== undefined) {
182
+ change.close("symbols", "address = ?", request.address);
183
+ if (existing.origin !== context.origin) previous = existing;
184
+ }
185
+ change.insertSymbol({ address: request.address, name: request.name, kind, origin: context.origin });
186
+ return symbolAt(db, request.address)!;
187
+ });
188
+ return previous === undefined ? result : { ...result, previous };
189
+ }
190
+
191
+ export function removeSymbol(db: DatabaseSync, context: WriteContext, request: { address: number }): WriteResult<null> {
192
+ checkAddress(request.address);
193
+ return write(db, context, "remove-symbol", (change) => {
194
+ if (symbolAt(db, request.address) === undefined) throw new KnowledgeError("not-found", `There is no symbol at ${hex(request.address)}.`);
195
+ change.close("symbols", "address = ?", request.address);
196
+ return null;
197
+ });
198
+ }
199
+
200
+ /** Closes the current regions over [start, end] and keeps their parts outside it. */
201
+ export function clearRange(db: DatabaseSync, change: Change, start: number, end: number): void {
202
+ const overlapping = regionsOverlapping(db, start, end);
203
+ if (overlapping.length === 0) return;
204
+ change.close("regions", "start_address <= ? AND end_address >= ?", end, start);
205
+ for (const region of overlapping) {
206
+ if (region.start < start) change.insertRegion({ start: region.start, end: start - 1, type: region.type, origin: region.origin });
207
+ if (region.end > end) change.insertRegion({ start: end + 1, end: region.end, type: region.type, origin: region.origin });
208
+ }
209
+ }
210
+
211
+ /** Classifies [start, end]; overlapping regions are split so current regions never overlap. */
212
+ export function classifyRegion(
213
+ db: DatabaseSync,
214
+ context: WriteContext,
215
+ request: { start: number; end: number; type: RegionType },
216
+ ): WriteResult<RegionRow[], RegionRow[]> {
217
+ checkAddress(request.start, "start");
218
+ checkAddress(request.end, "end");
219
+ if (request.end < request.start) throw new KnowledgeError("invalid-input", "end must not be before start.");
220
+ checkOneOf(REGION_TYPES, request.type, "type");
221
+ let previous: RegionRow[] = [];
222
+ const result = write(db, context, "classify-region", (change) => {
223
+ const overlapping = regionsOverlapping(db, request.start, request.end);
224
+ const same = overlapping.length === 1 && overlapping[0]!.start === request.start && overlapping[0]!.end === request.end && overlapping[0]!.type === request.type;
225
+ if (!same) {
226
+ clearRange(db, change, request.start, request.end);
227
+ change.insertRegion({ start: request.start, end: request.end, type: request.type, origin: context.origin });
228
+ previous = overlapping.filter((row) => row.origin !== context.origin);
229
+ }
230
+ return regionsOverlapping(db, request.start, request.end);
231
+ });
232
+ return previous.length === 0 ? result : { ...result, previous };
233
+ }
234
+
235
+ /** Makes [start, end] unclassified again; parts of regions outside it stay. */
236
+ export function unclassifyRegion(db: DatabaseSync, context: WriteContext, request: { start: number; end: number }): WriteResult<null> {
237
+ checkAddress(request.start, "start");
238
+ checkAddress(request.end, "end");
239
+ if (request.end < request.start) throw new KnowledgeError("invalid-input", "end must not be before start.");
240
+ return write(db, context, "unclassify-region", (change) => {
241
+ if (regionsOverlapping(db, request.start, request.end).length === 0) {
242
+ throw new KnowledgeError("not-found", `No region covers ${hex(request.start)}-${hex(request.end)}.`);
243
+ }
244
+ clearRange(db, change, request.start, request.end);
245
+ return null;
246
+ });
247
+ }
248
+
249
+ export function setComment(
250
+ db: DatabaseSync,
251
+ context: WriteContext,
252
+ request: { address: number; placement: CommentPlacement; text: string },
253
+ ): WriteResult<CommentRow> {
254
+ checkAddress(request.address);
255
+ checkOneOf(COMMENT_PLACEMENTS, request.placement, "placement");
256
+ const text = request.text.trim();
257
+ if (text.length === 0 || text.length > MAX_COMMENT) throw new KnowledgeError("invalid-input", `A comment has 1 to ${MAX_COMMENT} characters.`);
258
+ return write(db, context, "set-comment", (change) => {
259
+ const existing = commentsAt(db, request.address).find((comment) => comment.placement === request.placement);
260
+ if (existing?.text === text) return existing;
261
+ if (existing !== undefined) change.close("comments", "address = ? AND placement = ?", request.address, request.placement);
262
+ change.insertComment({ address: request.address, placement: request.placement, text, origin: context.origin });
263
+ return commentsAt(db, request.address).find((comment) => comment.placement === request.placement)!;
264
+ });
265
+ }
266
+
267
+ export function removeComment(db: DatabaseSync, context: WriteContext, request: { address: number; placement: CommentPlacement }): WriteResult<null> {
268
+ checkAddress(request.address);
269
+ checkOneOf(COMMENT_PLACEMENTS, request.placement, "placement");
270
+ return write(db, context, "remove-comment", (change) => {
271
+ if (!commentsAt(db, request.address).some((comment) => comment.placement === request.placement)) {
272
+ throw new KnowledgeError("not-found", `There is no ${request.placement} comment at ${hex(request.address)}.`);
273
+ }
274
+ change.close("comments", "address = ? AND placement = ?", request.address, request.placement);
275
+ return null;
276
+ });
277
+ }
278
+
279
+ export function addReference(
280
+ db: DatabaseSync,
281
+ context: WriteContext,
282
+ request: { from: number; to: number; kind: ReferenceKind },
283
+ ): WriteResult<ReferenceRow> {
284
+ checkAddress(request.from, "from");
285
+ checkAddress(request.to, "to");
286
+ checkOneOf(REFERENCE_KINDS, request.kind, "kind");
287
+ return write(db, context, "add-reference", (change) => {
288
+ const find = () => referencesFrom(db, request.from).find((reference) => reference.to === request.to && reference.kind === request.kind);
289
+ const existing = find();
290
+ if (existing !== undefined) return existing;
291
+ change.insertReference({ ...request, origin: context.origin });
292
+ return find()!;
293
+ });
294
+ }
295
+
296
+ export function removeReference(db: DatabaseSync, context: WriteContext, request: { from: number; to: number; kind: ReferenceKind }): WriteResult<null> {
297
+ checkAddress(request.from, "from");
298
+ checkAddress(request.to, "to");
299
+ checkOneOf(REFERENCE_KINDS, request.kind, "kind");
300
+ return write(db, context, "remove-reference", (change) => {
301
+ if (!referencesFrom(db, request.from).some((reference) => reference.to === request.to && reference.kind === request.kind)) {
302
+ throw new KnowledgeError("not-found", `There is no ${request.kind} reference from ${hex(request.from)} to ${hex(request.to)}.`);
303
+ }
304
+ change.close('"references"', "from_address = ? AND to_address = ? AND kind = ?", request.from, request.to, request.kind);
305
+ return null;
306
+ });
307
+ }
308
+
309
+ /** Row content without its revision, as a comparable key. */
310
+ const KEYS = {
311
+ symbols: { columns: SYMBOL_COLUMNS, map: toSymbol, key: (row: SymbolRow) => `${row.address}|${row.name}|${row.kind}|${row.origin}` },
312
+ regions: { columns: REGION_COLUMNS, map: toRegion, key: (row: RegionRow) => `${row.start}|${row.end}|${row.type}|${row.origin}` },
313
+ comments: { columns: COMMENT_COLUMNS, map: toComment, key: (row: CommentRow) => `${row.address}|${row.placement}|${row.text}|${row.origin}` },
314
+ references: { columns: REFERENCE_COLUMNS, map: toReference, key: (row: ReferenceRow) => `${row.from}|${row.to}|${row.kind}|${row.origin}` },
315
+ } as const;
316
+
317
+ /**
318
+ * Makes the current knowledge equal to what it was at `revision`, as a new
319
+ * revision. History is never erased; restored rows keep their origin.
320
+ */
321
+ export function revert(db: DatabaseSync, context: WriteContext, request: { revision: number }): WriteResult<{ restoredRevision: number }> {
322
+ return write(db, context, "revert", (change) => {
323
+ const current = currentRevision(db);
324
+ if (!Number.isInteger(request.revision) || request.revision < 0 || request.revision > current) {
325
+ throw new KnowledgeError("not-found", `There is no revision ${request.revision}; knowledge is at revision ${current}.`);
326
+ }
327
+ const at = "valid_from_revision <= ? AND (valid_to_revision IS NULL OR valid_to_revision > ?)";
328
+ for (const [table, spec] of Object.entries(KEYS)) {
329
+ const name = table === "references" ? '"references"' : table;
330
+ const then = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE ${at}`).all(request.revision, request.revision) as Array<Record<string, unknown>>;
331
+ const now = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE valid_to_revision IS NULL`).all() as Array<Record<string, unknown>>;
332
+ const key = (row: Record<string, unknown>) => (spec.key as (row: unknown) => string)(spec.map(row as never));
333
+ const thenKeys = new Set(then.map(key));
334
+ const nowKeys = new Set(now.map(key));
335
+ // Close first, so restored rows never collide with the current-row rules.
336
+ for (const row of now) if (!thenKeys.has(key(row))) change.close(name, "id = ?", row.id as number);
337
+ for (const row of then) {
338
+ if (nowKeys.has(key(row))) continue;
339
+ const mapped = spec.map(row as never) as SymbolRow & RegionRow & CommentRow & ReferenceRow;
340
+ if (table === "symbols") change.insertSymbol(mapped);
341
+ else if (table === "regions") change.insertRegion(mapped);
342
+ else if (table === "comments") change.insertComment(mapped);
343
+ else change.insertReference(mapped);
344
+ }
345
+ }
346
+ return { restoredRevision: request.revision };
347
+ });
348
+ }
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+
5
+ import { ViceSessionClient } from "../host-client/vice-session.ts";
6
+ import { elapsedMs, startTrace, trace } from "../trace.ts";
7
+ import { VIDEO_STANDARDS, WireFailure, type VideoStandard } from "../protocol/messages.ts";
8
+ import { createMcpServer, type SessionSource } from "./server.ts";
9
+ import { debugTools } from "./tools/debug.ts";
10
+ import { executionTools } from "./tools/execution.ts";
11
+ import { inputTools } from "./tools/input.ts";
12
+ import { machineTools } from "./tools/machine.ts";
13
+ import { mediaTools } from "./tools/media.ts";
14
+ import { memoryTools } from "./tools/memory.ts";
15
+ import { videoTools } from "./tools/video.ts";
16
+
17
+ const HELP = `c64-re-tools-mcp
18
+
19
+ Usage:
20
+ npx -y --package=@henols/c64-re-tools@latest c64-re-tools-mcp
21
+ c64-re-tools-mcp --help
22
+
23
+ Runs the stateful C64 MCP server on stdio. Each c64-re-tools-mcp process owns
24
+ one C64 emulator, started by the Host Runtime (c64-re-tools-host) when this
25
+ server starts and stopped when it exits. c64-re-tools install declares it for
26
+ the agent, through npx with the latest version.
27
+
28
+ Environment:
29
+ C64RT_VIDEO pal (default) or ntsc; fixed for the life of the process.
30
+ C64RT_HOST host:port of the Host Runtime when it is not on 127.0.0.1:6464,
31
+ host.docker.internal:6464 or host.containers.internal:6464.
32
+ C64RT_HOST_TOKEN
33
+ the shared secret of a Host Runtime that listens on an address
34
+ that is not loopback. Use the same value as the Host Runtime.`;
35
+
36
+ function log(line: string): void {
37
+ process.stderr.write(`c64-re-tools-mcp: ${line}\n`);
38
+ trace().log(line);
39
+ }
40
+
41
+ function videoStandard(env: NodeJS.ProcessEnv): VideoStandard {
42
+ const value = env.C64RT_VIDEO ?? "pal";
43
+ if ((VIDEO_STANDARDS as readonly string[]).includes(value)) return value as VideoStandard;
44
+ throw new WireFailure("installation-incomplete", "C64RT_VIDEO must be pal or ntsc. Fix the MCP server configuration and restart it.");
45
+ }
46
+
47
+ /**
48
+ * One session for the life of the process (1 MCP = 1 session = 1 VICE).
49
+ * It opens at start; if that fails before a session existed (for example the
50
+ * host was not running yet), the next tool call tries again. Once a session
51
+ * existed, its loss is final and reported by the session itself. Close stops
52
+ * an open that is still waiting for the host at once.
53
+ */
54
+ function sessionSource(): { get: SessionSource; close(): Promise<void> } {
55
+ let opening: Promise<ViceSessionClient> | undefined;
56
+ let closed = false;
57
+ const stop = new AbortController();
58
+ const open = () => {
59
+ const started = performance.now();
60
+ opening = (async () => ViceSessionClient.open({ videoStandard: videoStandard(process.env), signal: stop.signal }))();
61
+ void opening.then(() => trace().event("session.opened", { ms: elapsedMs(started) }), () => {});
62
+ opening.catch((error: unknown) => {
63
+ log(`session not opened: ${error instanceof Error ? error.message : String(error)}`);
64
+ opening = undefined;
65
+ });
66
+ return opening;
67
+ };
68
+ open();
69
+ return {
70
+ get: () => {
71
+ if (closed) return Promise.reject(new WireFailure("machine-unavailable", "The C64 session is shutting down."));
72
+ return opening ?? open();
73
+ },
74
+ async close() {
75
+ closed = true;
76
+ stop.abort();
77
+ const session = await opening?.catch(() => undefined);
78
+ await session?.close();
79
+ },
80
+ };
81
+ }
82
+
83
+ async function main(): Promise<void> {
84
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
85
+ process.stdout.write(`${HELP}\n`);
86
+ return;
87
+ }
88
+ startTrace("mcp", { warn: log });
89
+ const session = sessionSource();
90
+ const server = createMcpServer({ tools: [...machineTools, ...executionTools, ...debugTools, ...memoryTools, ...videoTools, ...inputTools, ...mediaTools], session: session.get, log });
91
+
92
+ let stopping = false;
93
+ const stop = async () => {
94
+ if (stopping) return;
95
+ stopping = true;
96
+ trace().event("mcp.stop");
97
+ await session.close();
98
+ process.exit(0);
99
+ };
100
+ // The harness ends the server by closing stdin or by a signal; both end the session and its emulator.
101
+ server.onclose = () => void stop();
102
+ process.stdin.once("end", () => void stop());
103
+ process.once("SIGINT", () => void stop());
104
+ process.once("SIGTERM", () => void stop());
105
+
106
+ await server.connect(new StdioServerTransport());
107
+ }
108
+
109
+ await main();