@seanhogg/builderforce-memory-mcp 2026.10.2 → 2026.10.22

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 (82) hide show
  1. package/README.md +22 -5
  2. package/dist/backend.d.ts +26 -0
  3. package/dist/backend.d.ts.map +1 -1
  4. package/dist/backends/memory-store.d.ts +50 -2
  5. package/dist/backends/memory-store.d.ts.map +1 -1
  6. package/dist/backends/memory-store.js +88 -10
  7. package/dist/backends/memory-store.js.map +1 -1
  8. package/dist/bin/http.js +7 -1
  9. package/dist/bin/http.js.map +1 -1
  10. package/dist/bin/install.js +6 -3
  11. package/dist/bin/install.js.map +1 -1
  12. package/dist/bin/stdio.d.ts +9 -4
  13. package/dist/bin/stdio.d.ts.map +1 -1
  14. package/dist/bin/stdio.js +20 -5
  15. package/dist/bin/stdio.js.map +1 -1
  16. package/dist/compaction-tools.d.ts +15 -0
  17. package/dist/compaction-tools.d.ts.map +1 -0
  18. package/dist/compaction-tools.js +192 -0
  19. package/dist/compaction-tools.js.map +1 -0
  20. package/dist/compaction.d.ts +114 -8
  21. package/dist/compaction.d.ts.map +1 -1
  22. package/dist/compaction.js +161 -7
  23. package/dist/compaction.js.map +1 -1
  24. package/dist/experience/file-store.d.ts +2 -1
  25. package/dist/experience/file-store.d.ts.map +1 -1
  26. package/dist/experience/file-store.js +4 -0
  27. package/dist/experience/file-store.js.map +1 -1
  28. package/dist/experience/train.d.ts +13 -4
  29. package/dist/experience/train.d.ts.map +1 -1
  30. package/dist/experience/train.js +14 -5
  31. package/dist/experience/train.js.map +1 -1
  32. package/dist/index.d.ts +13 -6
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +11 -7
  35. package/dist/index.js.map +1 -1
  36. package/dist/install/claude-hooks.d.ts +5 -0
  37. package/dist/install/claude-hooks.d.ts.map +1 -1
  38. package/dist/install/claude-hooks.js +69 -9
  39. package/dist/install/claude-hooks.js.map +1 -1
  40. package/dist/install/server-spec.d.ts +33 -7
  41. package/dist/install/server-spec.d.ts.map +1 -1
  42. package/dist/install/server-spec.js +59 -9
  43. package/dist/install/server-spec.js.map +1 -1
  44. package/dist/model/recall-probe.d.ts +29 -0
  45. package/dist/model/recall-probe.d.ts.map +1 -0
  46. package/dist/model/recall-probe.js +72 -0
  47. package/dist/model/recall-probe.js.map +1 -0
  48. package/dist/persistence/fact-archive.d.ts +47 -0
  49. package/dist/persistence/fact-archive.d.ts.map +1 -0
  50. package/dist/persistence/fact-archive.js +85 -0
  51. package/dist/persistence/fact-archive.js.map +1 -0
  52. package/dist/persistence/shared-json-file.d.ts +24 -1
  53. package/dist/persistence/shared-json-file.d.ts.map +1 -1
  54. package/dist/persistence/shared-json-file.js +46 -1
  55. package/dist/persistence/shared-json-file.js.map +1 -1
  56. package/dist/restore.d.ts +56 -0
  57. package/dist/restore.d.ts.map +1 -0
  58. package/dist/restore.js +118 -0
  59. package/dist/restore.js.map +1 -0
  60. package/dist/tools.d.ts +13 -0
  61. package/dist/tools.d.ts.map +1 -1
  62. package/dist/tools.js +11 -90
  63. package/dist/tools.js.map +1 -1
  64. package/package.json +6 -6
  65. package/server.json +2 -2
  66. package/src/backend.ts +29 -0
  67. package/src/backends/memory-store.ts +141 -11
  68. package/src/bin/http.ts +7 -1
  69. package/src/bin/install.ts +7 -3
  70. package/src/bin/stdio.ts +20 -5
  71. package/src/compaction-tools.ts +206 -0
  72. package/src/compaction.ts +228 -9
  73. package/src/experience/file-store.ts +5 -0
  74. package/src/experience/train.ts +26 -9
  75. package/src/index.ts +55 -10
  76. package/src/install/claude-hooks.ts +72 -9
  77. package/src/install/server-spec.ts +74 -8
  78. package/src/model/recall-probe.ts +81 -0
  79. package/src/persistence/fact-archive.ts +101 -0
  80. package/src/persistence/shared-json-file.ts +54 -1
  81. package/src/restore.ts +144 -0
  82. package/src/tools.ts +24 -93
package/src/bin/stdio.ts CHANGED
@@ -6,15 +6,20 @@
6
6
  * BUILDERFORCE_MEMORY_DB IndexedDB database name (default: MemoryStore default).
7
7
  * BUILDERFORCE_MEMORY_READONLY '1' to disable remember/forget tools.
8
8
  * BUILDERFORCE_MEMORY_FILE Absolute path to a JSON snapshot. Defaults to the
9
- * shared per-machine store (~/.builderforce-memory/
10
- * memory.json) so memory survives the respawn every
11
- * MCP client does between sessions — without a
12
- * snapshot fake-indexeddb loses everything on exit.
9
+ * shared per-machine store — resolveMemoryFile():
10
+ * ~/.builderforce-memory/memory.json, or the legacy
11
+ * ~/.claude/builderforce-memory/memory.json when that
12
+ * is where the live memory.json is — so memory
13
+ * survives the respawn every MCP client does between
14
+ * sessions. Compacted originals are archived beside it
15
+ * (memory.archive.json); each overwrite keeps .bak.1-3.
13
16
  * BUILDERFORCE_MEMORY_MODEL Absolute path to a `.evermind` package (an
14
17
  * `evermind-lm`). When set, recall is ranked by SSM
15
18
  * embedding cosine FUSED with the lexical ranking
16
19
  * instead of lexically alone. Pure CPU — no GPU is
17
20
  * involved; see src/embedding/evermind-embedder.ts.
21
+ * Also the model memory_compact's recall probe asks
22
+ * before stubbing a fact; unset = nothing is compacted.
18
23
  * BUILDERFORCE_MEMORY_TOKENIZER Tokenizer JSON matching that checkpoint. Defaults
19
24
  * to `<model>.tokenizer.json`.
20
25
  * BUILDERFORCE_MEMORY_VECTORS Persistent vector-cache path. Defaults beside the
@@ -34,11 +39,16 @@
34
39
 
35
40
  import { createLocalMemoryStoreBackend } from "../backends/memory-store.js";
36
41
  import { createRecallEmbedder } from "../embedding/index.js";
42
+ import { readFile } from "node:fs/promises";
37
43
  import { createExperienceHost } from "../experience/host.js";
38
44
  import { resolveMemoryFile } from "../install/server-spec.js";
45
+ import { lazyRecallProbe } from "../model/recall-probe.js";
39
46
  import { runStdio } from "../transports/stdio.js";
40
47
 
41
- const memoryFile = resolveMemoryFile();
48
+ const log = (line: string) => void process.stderr.write(`${line}\n`);
49
+ // ONE resolver for installer, servers and hook: env, else whichever location holds
50
+ // the live memory.json (logged when there is a choice), else the canonical default.
51
+ const memoryFile = resolveMemoryFile(process.env, undefined, { log });
42
52
 
43
53
  const backend = await createLocalMemoryStoreBackend({
44
54
  dbName: process.env["BUILDERFORCE_MEMORY_DB"],
@@ -56,4 +66,9 @@ await runStdio(backend, {
56
66
  gatewayApiKey: process.env["BUILDERFORCE_API_KEY"],
57
67
  // Null unless BUILDERFORCE_MEMORY_EXPERIENCE=1 — Synapse's opt-in.
58
68
  experience: await createExperienceHost({ memoryFile }),
69
+ // memory_compact proves absorption with the configured Evermind before stubbing;
70
+ // no model → nothing is compacted (and the tool says so).
71
+ recallProbe: lazyRecallProbe(),
72
+ // A local subprocess may read the user's own export file for memory_restore.
73
+ readRestoreFile: (path) => readFile(path, "utf8"),
59
74
  });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The lossless compaction pair — `memory_compact` and `memory_restore` — split out
3
+ * of tools.ts the same way the gateway and experience families are.
4
+ *
5
+ * `memory_compact` stubs a fact only after archiving its original and proving the
6
+ * local Evermind absorbed it; `memory_restore` brings back the text of stubs that
7
+ * predate archiving (`contentLost`). The rules are compaction.ts / restore.ts; this
8
+ * module is only their I/O against a MemoryBackend.
9
+ */
10
+
11
+ import { z } from "zod";
12
+ import type { MemoryBackend, RecallHit, RememberInput } from "./backend.js";
13
+ import { DEFAULT_STUB_CHARS, planLosslessCompaction, type CompactionSkip } from "./compaction.js";
14
+ import { parseRestoreSource, planRestore, restoredArchive, type RestoreTarget } from "./restore.js";
15
+ import { fail, ok, type MemoryTool } from "./tool-core.js";
16
+ import type { MemoryToolsOptions } from "./tools.js";
17
+
18
+ const SKIP_REASONS: Record<CompactionSkip["reason"], string> = {
19
+ not_found: "not found",
20
+ already_compacted: "already compacted",
21
+ not_smaller: "already shorter than its stub",
22
+ no_probe: "no local Evermind model to prove it was absorbed (set BUILDERFORCE_MEMORY_MODEL)",
23
+ probe_failed: "the Evermind model could not answer a recall question about it — not absorbed yet",
24
+ no_archive: "this backend cannot archive the original, so stubbing would lose it",
25
+ };
26
+
27
+ /** One-line compaction receipt: what shrank, what it saved, and what was left alone. */
28
+ function renderCompaction(compacted: number, bytesSaved: number, skipped: CompactionSkip[]): string {
29
+ const lines = [`Compacted ${compacted} memory(ies), ${bytesSaved} character(s) reclaimed.`];
30
+ for (const s of skipped) lines.push(`• skipped ${s.key} — ${SKIP_REASONS[s.reason]}`);
31
+ return lines.join("\n");
32
+ }
33
+
34
+ /** `memory_restore` input: inline JSON always; a file path only where the host may read its disk. */
35
+ function restoreSchema(allowFile: boolean): z.ZodRawShape {
36
+ const shape: z.ZodRawShape = {
37
+ json: z.string().optional().describe("The export itself, as JSON text (array, or JSON Lines)."),
38
+ };
39
+ if (allowFile) shape["file"] = z.string().optional().describe("Path to a JSON export file to restore from.");
40
+ return shape;
41
+ }
42
+
43
+ /** Write a batch through the backend's batch path when it has one. */
44
+ async function writeAll(backend: MemoryBackend, writes: RememberInput[]): Promise<void> {
45
+ if (writes.length === 0) return;
46
+ if (backend.rememberMany) await backend.rememberMany(writes);
47
+ else for (const w of writes) await backend.remember!(w);
48
+ }
49
+
50
+ /** Registers `memory_compact` and `memory_restore`. Caller checks the backend is writable. */
51
+ export function appendCompactionTools(
52
+ tools: MemoryTool[],
53
+ backend: MemoryBackend,
54
+ opts: Pick<MemoryToolsOptions, "recallProbe" | "readRestoreFile">,
55
+ ): void {
56
+ // memory_compact — the FIRST-CLASS compaction path. It writes through the live
57
+ // store (get → plan → archive → remember), so a durable backend's in-memory state
58
+ // and its on-disk snapshot agree afterwards. It is LOSSLESS: each original is
59
+ // archived before its body is stubbed, and only facts the local Evermind answers
60
+ // a recall probe about are stubbed at all (see planLosslessCompaction).
61
+ tools.push({
62
+ name: "memory_compact",
63
+ description:
64
+ "Shrink memories that have already been absorbed into the local Evermind model down to " +
65
+ "a one-line pointer stub, reclaiming the context they were eating. Each fact is first " +
66
+ "checked with a recall question put to the model; facts it cannot answer stay as they " +
67
+ "are. The full original is archived and memory_recall / memory_get still return it, so " +
68
+ "nothing is lost. Already-compacted keys are skipped, so repeating the call is safe.",
69
+ inputSchema: {
70
+ keys: z.array(z.string()).min(1).describe("Exact keys of the memories to compact."),
71
+ stub: z
72
+ .string()
73
+ .optional()
74
+ .describe(
75
+ "Explicit replacement body applied to every listed key. Omit to generate " +
76
+ "'[absorbed→Evermind vN] <first line>' from each entry's own first line.",
77
+ ),
78
+ version: z
79
+ .number()
80
+ .int()
81
+ .min(0)
82
+ .optional()
83
+ .describe("Version of the model that absorbed these facts; appears in the generated stub."),
84
+ maxChars: z
85
+ .number()
86
+ .int()
87
+ .min(20)
88
+ .max(500)
89
+ .optional()
90
+ .describe(`Max characters of the generated pointer line (default ${DEFAULT_STUB_CHARS}).`),
91
+ },
92
+ handler: async (args) => {
93
+ try {
94
+ const raw = args["keys"];
95
+ const keys = Array.isArray(raw) ? raw.map((k) => String(k)).filter(Boolean) : [];
96
+ if (keys.length === 0) return fail("keys is required.");
97
+
98
+ // ONE read pass: the hit carries both the body the plan reasons about
99
+ // and the tags/importance the rewrite must preserve. A stub resolved to
100
+ // its archive reports the STORED stub in `hit.stub` — that is what the
101
+ // plan must see, or an archived fact would be compacted again.
102
+ const hits = new Map<string, RecallHit>();
103
+ for (const key of keys) {
104
+ const hit = await backend.get(key);
105
+ if (hit) hits.set(key, hit);
106
+ }
107
+
108
+ const plan = await planLosslessCompaction(
109
+ keys.map((key) => {
110
+ const hit = hits.get(key);
111
+ return { key, content: hit ? (hit.stub ?? hit.content) : undefined };
112
+ }),
113
+ {
114
+ stub: typeof args["stub"] === "string" ? (args["stub"] as string) : undefined,
115
+ version: args["version"] == null ? undefined : Number(args["version"]),
116
+ maxChars: args["maxChars"] == null ? undefined : Number(args["maxChars"]),
117
+ probe: opts.recallProbe ?? null,
118
+ canArchive: typeof backend.archive === "function",
119
+ },
120
+ );
121
+
122
+ if (plan.writes.length > 0) {
123
+ // Originals FIRST, durably — only then may the bodies become stubs.
124
+ await backend.archive!(plan.writes.map((w) => w.archive));
125
+ // Preserve tags/importance — compaction shortens a body, it does not
126
+ // reclassify the fact.
127
+ await writeAll(
128
+ backend,
129
+ plan.writes.map((w) => {
130
+ const prev = hits.get(w.key);
131
+ return { key: w.key, content: w.content, tags: prev?.tags, importance: prev?.importance };
132
+ }),
133
+ );
134
+ }
135
+
136
+ return ok(renderCompaction(plan.writes.length, plan.bytesSaved, plan.skipped));
137
+ } catch (err) {
138
+ return fail(`compact failed: ${String(err)}`);
139
+ }
140
+ },
141
+ });
142
+
143
+ // memory_restore — the way back for stubs whose original was never archived
144
+ // (`contentLost`). The text is matched to a stub by key, or by its first line
145
+ // against the stub's pointer, then archived beside the stub (the stub stays, so
146
+ // the token saving stays) — or written back as the body on a backend that
147
+ // cannot archive.
148
+ tools.push({
149
+ name: "memory_restore",
150
+ description:
151
+ "Restore the full text of memories marked '(content lost)' — compaction stubs whose " +
152
+ "original was never kept — from an export. Accepts [{key, content}] (a memory export) " +
153
+ "or the Evermind coordinator's [{id, text}] record dump; records are matched by key, " +
154
+ "else by their first line against the stub. Restored memories recall in full again.",
155
+ inputSchema: restoreSchema(!!opts.readRestoreFile),
156
+ handler: async (args) => {
157
+ try {
158
+ const file = typeof args["file"] === "string" ? (args["file"] as string) : "";
159
+ let text = typeof args["json"] === "string" ? (args["json"] as string) : "";
160
+ if (file && opts.readRestoreFile) text = await opts.readRestoreFile(file);
161
+ if (!text.trim()) return fail(opts.readRestoreFile ? "file or json is required." : "json is required.");
162
+
163
+ const records = parseRestoreSource(text);
164
+ if (records.length === 0) return fail("no restorable records (need {key, content} or {id, text} rows).");
165
+
166
+ // Targets: the lost stubs (all of them when the backend can list them,
167
+ // else just the keys the records name).
168
+ const keys = new Set<string>(backend.lostKeys ? await backend.lostKeys() : []);
169
+ for (const r of records) if (r.key) keys.add(r.key);
170
+ const targets: RestoreTarget[] = [];
171
+ const hits = new Map<string, RecallHit>();
172
+ for (const key of keys) {
173
+ const hit = await backend.get(key);
174
+ if (!hit?.contentLost) continue;
175
+ hits.set(key, hit);
176
+ targets.push({ key, stub: hit.content });
177
+ }
178
+
179
+ const plan = planRestore(records, targets);
180
+ if (plan.matches.length > 0) {
181
+ if (backend.archive) {
182
+ await backend.archive(plan.matches.map((m) => restoredArchive(m, hits.get(m.key)!.content)));
183
+ } else {
184
+ await writeAll(
185
+ backend,
186
+ plan.matches.map((m) => {
187
+ const prev = hits.get(m.key);
188
+ return { key: m.key, content: m.content, tags: prev?.tags, importance: prev?.importance };
189
+ }),
190
+ );
191
+ }
192
+ }
193
+
194
+ const lines = [
195
+ `Restored ${plan.matches.length} memory(ies) of ${targets.length} with lost content.`,
196
+ ...plan.matches.map((m) => `• ${m.key} (matched by ${m.via})`),
197
+ ];
198
+ if (plan.ambiguous.length > 0) lines.push(`Ambiguous (left alone): ${plan.ambiguous.join(", ")}`);
199
+ if (plan.unmatched > 0) lines.push(`${plan.unmatched} record(s) matched no lost memory.`);
200
+ return ok(lines.join("\n"));
201
+ } catch (err) {
202
+ return fail(`restore failed: ${String(err)}`);
203
+ }
204
+ },
205
+ });
206
+ }
package/src/compaction.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Compaction — turning an ABSORBED memory into a terse pointer stub.
2
+ * Compaction — turning an ABSORBED memory into a terse pointer stub, LOSSLESSLY.
3
3
  *
4
4
  * Once a fact has been folded into an Evermind (or any downstream learner) the
5
5
  * full body has done its job: keeping it verbatim in the store means every
@@ -7,10 +7,24 @@
7
7
  * Compaction replaces the body with a one-line `[absorbed→Evermind vN] <first
8
8
  * line>` pointer, so the fact stops filling context while it stays findable.
9
9
  *
10
+ * Two rules make that safe:
11
+ * 1. The full original is ARCHIVED before the body is stubbed — in a sidecar
12
+ * `<memory>.archive.json` beside the snapshot, never inside it, so it survives
13
+ * the snapshot round-trip, an external rewrite of the snapshot, and the
14
+ * store's entry cap / eviction. Recall resolves a stub back to its archive.
15
+ * 2. A fact is stubbed only after a RECALL PROBE passes: a question generated
16
+ * from the fact is put to the local Evermind model, and an answer that does
17
+ * not carry the fact (or no model at all) leaves the fact uncompacted —
18
+ * "absorbed" is proven, not asserted.
19
+ *
20
+ * A stub with no archive is a fact whose text is gone (compacted before rule 1
21
+ * existed): {@link isContentLost} names it, and every reader labels it with
22
+ * {@link CONTENT_LOST_LABEL} until `memory_restore` brings the text back.
23
+ *
10
24
  * The rules live here — not in the tool handler and not in a transport — because
11
25
  * BOTH the in-process `memory_compact` tool and any external compactor (the
12
- * BuilderForce VS Code extension rewrites the snapshot directly) must produce
13
- * byte-identical stubs; otherwise re-running either one re-stubs the other's work.
26
+ * BuilderForce VS Code extension) must produce byte-identical stubs and the same
27
+ * archive; otherwise re-running either one re-stubs the other's work.
14
28
  *
15
29
  * Dependency-free and published as its own subpath
16
30
  * (`@seanhogg/builderforce-memory-mcp/compaction`), so an external compactor imports
@@ -48,25 +62,60 @@ export function memoryStub(content: string, version: number, maxChars = DEFAULT_
48
62
  return `${STUB_PREFIX} v${version}] ${firstLine(content, maxChars)}`;
49
63
  }
50
64
 
65
+ /** The pointer text inside a stub (after the `] `), without a trailing ellipsis. */
66
+ export function stubPointer(stub: string): string {
67
+ const s = stub.trim();
68
+ const close = s.indexOf("] ");
69
+ const pointer = isStub(s) && close >= 0 ? s.slice(close + 2) : s;
70
+ return pointer.replace(/…$/, "").trim();
71
+ }
72
+
51
73
  /** One entry considered for compaction. */
52
74
  export interface CompactionCandidate {
53
75
  key: string;
54
76
  content: string;
55
77
  }
56
78
 
57
- /** A body rewrite the caller should apply. */
79
+ /**
80
+ * The full original of a compacted fact — what the archive keeps so a stub is
81
+ * never the only copy. Written BEFORE the stub replaces the body.
82
+ */
83
+ export interface ArchivedFact {
84
+ key: string;
85
+ /** The original, full body. */
86
+ content: string;
87
+ /** The stub that replaced it. */
88
+ stub: string;
89
+ /** Evermind version that absorbed it. */
90
+ version: number;
91
+ /** Unix ms the archive row was written. */
92
+ archivedAt: number;
93
+ }
94
+
95
+ /** A body rewrite the caller should apply — archive `archive` FIRST, then write `content`. */
58
96
  export interface CompactionWrite {
59
97
  key: string;
60
98
  /** The stub that replaces the entry's body. */
61
99
  content: string;
62
100
  /** Characters recovered by this rewrite. */
63
101
  bytesSaved: number;
102
+ /** The original body, to be archived before the stub is written. */
103
+ archive: ArchivedFact;
64
104
  }
65
105
 
66
106
  /** Why a candidate was left alone. */
67
107
  export interface CompactionSkip {
68
108
  key: string;
69
- reason: "not_found" | "already_compacted" | "not_smaller";
109
+ reason:
110
+ | "not_found"
111
+ | "already_compacted"
112
+ | "not_smaller"
113
+ /** No local model to probe with — the fact cannot be proven absorbed. */
114
+ | "no_probe"
115
+ /** The model could not answer the probe question generated from the fact. */
116
+ | "probe_failed"
117
+ /** The caller cannot archive the original, so stubbing would lose it. */
118
+ | "no_archive";
70
119
  }
71
120
 
72
121
  /** The decided plan: what to write, what to leave, and the total recovered. */
@@ -84,18 +133,23 @@ export interface CompactionOptions {
84
133
  version?: number;
85
134
  /** Max characters of a generated pointer line. Default {@link DEFAULT_STUB_CHARS}. */
86
135
  maxChars?: number;
136
+ /** Clock for {@link ArchivedFact.archivedAt}. Default `Date.now()`. */
137
+ now?: number;
87
138
  }
88
139
 
89
140
  /**
90
- * Decide the compaction for a set of candidates. Pure — no I/O — so the same
91
- * rules are unit-testable and reusable by any caller that can read and write
92
- * memories. `undefined` content means the key was not found.
141
+ * Decide the STRUCTURAL compaction for a set of candidates: what is already a
142
+ * stub, what would not shrink. Pure — no I/O — and NOT the whole contract: a
143
+ * caller must still archive each write's `archive` before applying it, and prove
144
+ * absorption — {@link planLosslessCompaction} runs this and then the probe.
145
+ * `undefined` content means the key was not found.
93
146
  */
94
147
  export function planCompaction(
95
148
  candidates: Array<CompactionCandidate | { key: string; content: undefined }>,
96
149
  opts: CompactionOptions = {},
97
150
  ): CompactionPlan {
98
151
  const version = opts.version ?? 0;
152
+ const now = opts.now ?? Date.now();
99
153
  const maxChars = Math.max(20, opts.maxChars ?? DEFAULT_STUB_CHARS);
100
154
  const writes: CompactionWrite[] = [];
101
155
  const skipped: CompactionSkip[] = [];
@@ -118,9 +172,174 @@ export function planCompaction(
118
172
  continue;
119
173
  }
120
174
  const saved = content.length - stub.length;
121
- writes.push({ key: c.key, content: stub, bytesSaved: saved });
175
+ writes.push({
176
+ key: c.key,
177
+ content: stub,
178
+ bytesSaved: saved,
179
+ archive: { key: c.key, content, stub, version, archivedAt: now },
180
+ });
122
181
  bytesSaved += saved;
123
182
  }
124
183
 
125
184
  return { writes, skipped, bytesSaved };
126
185
  }
186
+
187
+ // ── Recall probe ─────────────────────────────────────────────────────────────
188
+
189
+ /**
190
+ * The local model compaction asks before it stubs a fact. `answer` returns the
191
+ * model's text for a question, or null when it cannot answer at all.
192
+ */
193
+ export interface RecallProbe {
194
+ answer(question: string): Promise<string | null>;
195
+ }
196
+
197
+ /** A question generated from one fact, and the token a correct answer must carry. */
198
+ export interface ProbeQuestion {
199
+ question: string;
200
+ expected: string;
201
+ }
202
+
203
+ const PROBE_BLANK = "____";
204
+
205
+ /** Tokens of a line that a cloze question can blank: alphanumeric, 4+ chars. */
206
+ function probeTokens(line: string): string[] {
207
+ return (line.match(/[A-Za-z0-9][A-Za-z0-9_.-]*[A-Za-z0-9]/g) ?? []).filter((t) => t.length >= 4);
208
+ }
209
+
210
+ /** How identifying a token is: digits and capitals beat length (a name/version IS the fact). */
211
+ function salience(token: string): number {
212
+ return (/[0-9]/.test(token) ? 100 : 0) + (/[A-Z]/.test(token) ? 50 : 0) + token.length;
213
+ }
214
+
215
+ /**
216
+ * A cloze question over the fact's pointer line: its most identifying token is
217
+ * blanked and the model must supply it. Deterministic, so the same fact always
218
+ * gets the same question. Null when the line has nothing worth blanking.
219
+ */
220
+ export function probeQuestionFor(key: string, content: string): ProbeQuestion | null {
221
+ const line = firstLine(content, 400);
222
+ const tokens = probeTokens(line);
223
+ if (tokens.length === 0) return null;
224
+ let expected = tokens[0]!;
225
+ for (const t of tokens) if (salience(t) > salience(expected)) expected = t;
226
+ const at = line.indexOf(expected);
227
+ const cloze = `${line.slice(0, at)}${PROBE_BLANK}${line.slice(at + expected.length)}`;
228
+ return {
229
+ question: `Stored memory "${key}": ${cloze}\nThe missing word (${PROBE_BLANK}) is:`,
230
+ expected,
231
+ };
232
+ }
233
+
234
+ /** True when the model's answer carries the blanked token (case-insensitive). */
235
+ export function probePasses(answer: string | null | undefined, expected: string): boolean {
236
+ if (typeof answer !== "string" || !expected) return false;
237
+ return answer.toLowerCase().includes(expected.toLowerCase());
238
+ }
239
+
240
+ /** Options for {@link planLosslessCompaction}. */
241
+ export interface LosslessCompactionOptions extends CompactionOptions {
242
+ /** The local model to prove absorption with. Null/absent → nothing is stubbed. */
243
+ probe?: RecallProbe | null;
244
+ /** False when the caller cannot archive originals → nothing is stubbed. Default true. */
245
+ canArchive?: boolean;
246
+ }
247
+
248
+ /**
249
+ * The full compaction contract: {@link planCompaction}'s structural rules, then a
250
+ * recall probe per surviving write. A write is kept only when the model answers
251
+ * the question generated from its fact; otherwise the fact stays uncompacted and
252
+ * the reason is recorded. The caller then archives every write's `archive` BEFORE
253
+ * applying its stub.
254
+ */
255
+ export async function planLosslessCompaction(
256
+ candidates: Array<CompactionCandidate | { key: string; content: undefined }>,
257
+ opts: LosslessCompactionOptions = {},
258
+ ): Promise<CompactionPlan> {
259
+ const structural = planCompaction(candidates, opts);
260
+ const writes: CompactionWrite[] = [];
261
+ const skipped: CompactionSkip[] = [...structural.skipped];
262
+ for (const w of structural.writes) {
263
+ if (opts.canArchive === false) {
264
+ skipped.push({ key: w.key, reason: "no_archive" });
265
+ continue;
266
+ }
267
+ if (!opts.probe) {
268
+ skipped.push({ key: w.key, reason: "no_probe" });
269
+ continue;
270
+ }
271
+ const q = probeQuestionFor(w.key, w.archive.content);
272
+ let answer: string | null = null;
273
+ if (q) {
274
+ try {
275
+ answer = await opts.probe.answer(q.question);
276
+ } catch {
277
+ answer = null;
278
+ }
279
+ }
280
+ if (!q || !probePasses(answer, q.expected)) {
281
+ skipped.push({ key: w.key, reason: "probe_failed" });
282
+ continue;
283
+ }
284
+ writes.push(w);
285
+ }
286
+ return { writes, skipped, bytesSaved: writes.reduce((n, w) => n + w.bytesSaved, 0) };
287
+ }
288
+
289
+ // ── Archive + lost content ───────────────────────────────────────────────────
290
+
291
+ /**
292
+ * Label every reader attaches to a stub whose original text no longer exists
293
+ * anywhere (compacted before archiving existed).
294
+ */
295
+ export const CONTENT_LOST_LABEL = "(content lost — re-verify before relying on it)";
296
+
297
+ /**
298
+ * Where the archive lives: beside the snapshot, under the same name — the same
299
+ * convention as the vector cache (`memory.json` → `memory.archive.json`).
300
+ */
301
+ export function defaultArchiveFile(memoryFile: string): string {
302
+ return memoryFile.replace(/\.json$/i, "") + ".archive.json";
303
+ }
304
+
305
+ /** True when `content` is a stub AND no archived original exists for it. */
306
+ export function isContentLost(content: string, archived: ArchivedFact | undefined): boolean {
307
+ return isStub(content) && !archived;
308
+ }
309
+
310
+ /** On-disk archive: key → the latest archived original for that key. Never pruned. */
311
+ export interface ArchiveSnapshot {
312
+ version: 1;
313
+ facts: Record<string, ArchivedFact>;
314
+ }
315
+
316
+ /** Parse archive text; an absent/corrupt file is an empty archive. */
317
+ export function parseArchive(text: string | null | undefined): Record<string, ArchivedFact> {
318
+ if (!text) return {};
319
+ let parsed: Partial<ArchiveSnapshot> | null;
320
+ try {
321
+ parsed = JSON.parse(text) as Partial<ArchiveSnapshot> | null;
322
+ } catch {
323
+ return {};
324
+ }
325
+ const facts = parsed && typeof parsed === "object" ? parsed.facts : undefined;
326
+ if (!facts || typeof facts !== "object") return {};
327
+ // Null prototype: a memory key is arbitrary text, "__proto__" included.
328
+ const out: Record<string, ArchivedFact> = Object.create(null) as Record<string, ArchivedFact>;
329
+ for (const [key, f] of Object.entries(facts)) {
330
+ if (f && typeof f.content === "string") out[key] = { ...f, key };
331
+ }
332
+ return out;
333
+ }
334
+
335
+ /**
336
+ * Merge `facts` into archive text, returning the next text. Rows are only ever
337
+ * added or replaced by a newer archive of the same key — never removed — so a
338
+ * forgotten or evicted stub keeps its original.
339
+ */
340
+ export function mergeArchive(text: string | null | undefined, facts: ArchivedFact[]): string {
341
+ const all = parseArchive(text);
342
+ for (const f of facts) all[f.key] = f;
343
+ const snap: ArchiveSnapshot = { version: 1, facts: all };
344
+ return `${JSON.stringify(snap, null, 2)}\n`;
345
+ }
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type {
13
13
  Adaptation,
14
+ CurationRecord,
14
15
  Episode,
15
16
  EpisodeSummary,
16
17
  ExperienceSnapshot,
@@ -129,6 +130,10 @@ export class FileExperienceStore implements ExperienceStore {
129
130
  this.fresh();
130
131
  await this.inner.recordAdaptation(adaptation);
131
132
  }
133
+ async recordCuration(curation: CurationRecord): Promise<void> {
134
+ this.fresh();
135
+ await this.inner.recordCuration(curation);
136
+ }
132
137
 
133
138
  async snapshot(): Promise<ExperienceSnapshot> {
134
139
  this.fresh();
@@ -17,6 +17,7 @@
17
17
  import type {
18
18
  adaptPackage,
19
19
  curatePendingExperience,
20
+ curationRecords,
20
21
  experienceDocuments,
21
22
  packDocuments,
22
23
  CurationResult,
@@ -36,10 +37,16 @@ export interface TrainEngine {
36
37
  /** The opt-in curation step and the CPU embedder it scores with (engine ≥ 2026.10.2). */
37
38
  curatePendingExperience?: typeof curatePendingExperience;
38
39
  EvermindTextEmbedder?: typeof EvermindTextEmbedder;
40
+ /** The `curated_out` records to persist for a curation (engine ≥ 2026.10.3). */
41
+ curationRecords?: typeof curationRecords;
39
42
  }
40
43
 
41
- /** What curation left out of a pass, when it ran. */
42
- export type CurationReport = { dropped: CurationResult["dropped"] };
44
+ /**
45
+ * What curation left out of a pass, when it ran: `dropped` this call, and
46
+ * `alreadyDropped` — left out by an earlier call, verdict still standing, so skipped
47
+ * without being embedded again.
48
+ */
49
+ export type CurationReport = { dropped: CurationResult["dropped"]; alreadyDropped?: CurationResult["dropped"] };
43
50
 
44
51
  export type TrainOutcome =
45
52
  | { status: "nothing_new"; pending: 0; version: string }
@@ -103,8 +110,10 @@ export interface TrainExperienceOptions {
103
110
  * store's run outcomes, + near-duplicate removal against the batch AND the
104
111
  * recently learned procedures — docs/ml-task-catalog.md §4.2), scoring with the
105
112
  * model's own CPU embedder. Default false ⇒ every pending procedure is learned,
106
- * exactly as before. A procedure curation drops is NOT recorded as learned, so a
107
- * later pass reconsiders it.
113
+ * exactly as before. A procedure curation drops is NOT recorded as learned; it is
114
+ * recorded as `curated_out` (with the id it duplicates, or its quality score), and
115
+ * later curations skip it while that verdict stands — a duplicate until what it
116
+ * repeats is no longer learned, a low-quality one until it has new runs.
108
117
  */
109
118
  curate?: boolean;
110
119
  }
@@ -126,15 +135,20 @@ export async function trainExperience(
126
135
 
127
136
  let docs = pending;
128
137
  let curation: CurationReport | undefined;
138
+ /** The LM curation embedded with — untrained, loaded from `model.pkg` — reused by the first pass. */
139
+ let curationLm: ConstructorParameters<typeof EvermindTextEmbedder>[0] | undefined;
129
140
  if (opts.curate) {
130
141
  if (!engine.curatePendingExperience || !engine.EvermindTextEmbedder) throw new Error(CURATION_UNSUPPORTED);
131
142
  // The model being trained embeds for curation: its own notion of "the same procedure".
132
- const lm = model.pkg.loadLM() as ConstructorParameters<typeof EvermindTextEmbedder>[0];
143
+ // Embedding never changes its weights, so the same LM is the first pass's copy.
144
+ curationLm = model.pkg.loadLM() as ConstructorParameters<typeof EvermindTextEmbedder>[0];
133
145
  const codec = model.codec as ConstructorParameters<typeof EvermindTextEmbedder>[1];
134
- const embedder = new engine.EvermindTextEmbedder(lm, codec);
146
+ const embedder = new engine.EvermindTextEmbedder(curationLm, codec);
135
147
  const r = await engine.curatePendingExperience(pending, snap, { embedder, known });
136
148
  docs = r.kept;
137
- curation = { dropped: r.dropped };
149
+ curation = { dropped: r.dropped, ...(r.alreadyDropped?.length ? { alreadyDropped: r.alreadyDropped } : {}) };
150
+ // Persist the verdicts, so the next call skips these instead of re-embedding them.
151
+ if (engine.curationRecords) await store.recordCuration(engine.curationRecords(r, snap, Date.now()));
138
152
  }
139
153
  if (docs.length === 0) return { status: "curated_out", pending: pending.length, version, curation: curation ?? { dropped: [] } };
140
154
 
@@ -145,8 +159,11 @@ export async function trainExperience(
145
159
  let lossSum = 0;
146
160
  let passes = 0;
147
161
  for (const pass of engine.packDocuments(docs, engine.EVERMIND_ADAPT_MAX_CHARS)) {
148
- const r = engine.adaptPackage(pkg, model.codec, pass.map((d) => d.text).join("\n\n"), next);
149
- if (!r) continue;
162
+ // Until a pass succeeds `pkg` is still `model.pkg`, so curation's untrained LM is its copy.
163
+ const lm = pkg === (model.pkg as unknown) ? curationLm : undefined;
164
+ const r = engine.adaptPackage(pkg, model.codec, pass.map((d) => d.text).join("\n\n"), next, lm ? { lm } : {});
165
+ if (!r) continue; // no trainable window: the LM was never touched
166
+ curationLm = undefined; // trained in place — spent
150
167
  pkg = r.pkg;
151
168
  lossSum += r.loss;
152
169
  passes++;