@seanhogg/builderforce-memory-mcp 2026.10.3 → 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 (72) 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/index.d.ts +13 -6
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +11 -7
  27. package/dist/index.js.map +1 -1
  28. package/dist/install/claude-hooks.d.ts +5 -0
  29. package/dist/install/claude-hooks.d.ts.map +1 -1
  30. package/dist/install/claude-hooks.js +69 -9
  31. package/dist/install/claude-hooks.js.map +1 -1
  32. package/dist/install/server-spec.d.ts +33 -7
  33. package/dist/install/server-spec.d.ts.map +1 -1
  34. package/dist/install/server-spec.js +59 -9
  35. package/dist/install/server-spec.js.map +1 -1
  36. package/dist/model/recall-probe.d.ts +29 -0
  37. package/dist/model/recall-probe.d.ts.map +1 -0
  38. package/dist/model/recall-probe.js +72 -0
  39. package/dist/model/recall-probe.js.map +1 -0
  40. package/dist/persistence/fact-archive.d.ts +47 -0
  41. package/dist/persistence/fact-archive.d.ts.map +1 -0
  42. package/dist/persistence/fact-archive.js +85 -0
  43. package/dist/persistence/fact-archive.js.map +1 -0
  44. package/dist/persistence/shared-json-file.d.ts +24 -1
  45. package/dist/persistence/shared-json-file.d.ts.map +1 -1
  46. package/dist/persistence/shared-json-file.js +46 -1
  47. package/dist/persistence/shared-json-file.js.map +1 -1
  48. package/dist/restore.d.ts +56 -0
  49. package/dist/restore.d.ts.map +1 -0
  50. package/dist/restore.js +118 -0
  51. package/dist/restore.js.map +1 -0
  52. package/dist/tools.d.ts +13 -0
  53. package/dist/tools.d.ts.map +1 -1
  54. package/dist/tools.js +11 -90
  55. package/dist/tools.js.map +1 -1
  56. package/package.json +6 -6
  57. package/server.json +2 -2
  58. package/src/backend.ts +29 -0
  59. package/src/backends/memory-store.ts +141 -11
  60. package/src/bin/http.ts +7 -1
  61. package/src/bin/install.ts +7 -3
  62. package/src/bin/stdio.ts +20 -5
  63. package/src/compaction-tools.ts +206 -0
  64. package/src/compaction.ts +228 -9
  65. package/src/index.ts +55 -10
  66. package/src/install/claude-hooks.ts +72 -9
  67. package/src/install/server-spec.ts +74 -8
  68. package/src/model/recall-probe.ts +81 -0
  69. package/src/persistence/fact-archive.ts +101 -0
  70. package/src/persistence/shared-json-file.ts +54 -1
  71. package/src/restore.ts +144 -0
  72. package/src/tools.ts +24 -93
@@ -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
+ }
package/src/index.ts CHANGED
@@ -18,8 +18,12 @@ export { createRecallEmbedder, createEvermindEmbedder, CachedTextEmbedder, defau
18
18
  export type { EvermindEmbedderOptions, PersistentTextEmbedder, TextEmbedderLike, RecallEmbedderEnv } from "./embedding/index.js";
19
19
 
20
20
  // ── Local backend (IndexedDB via @seanhogg/builderforce-memory) ────────────────────────
21
- export { MemoryStoreBackend, createLocalMemoryStoreBackend } from "./backends/memory-store.js";
22
- export type { LocalBackendOptions } from "./backends/memory-store.js";
21
+ export { MemoryStoreBackend, createLocalMemoryStoreBackend, DEFAULT_RECALL_RANKING } from "./backends/memory-store.js";
22
+ export type { LocalBackendOptions, MemoryStoreBackendOptions, RecallRanking } from "./backends/memory-store.js";
23
+ export { FileFactArchive, MemoryFactArchive } from "./persistence/fact-archive.js";
24
+ export type { FactArchive } from "./persistence/fact-archive.js";
25
+ export { SharedJsonFile, DEFAULT_BACKUPS, backupFile } from "./persistence/shared-json-file.js";
26
+ export type { SharedJsonFileOptions } from "./persistence/shared-json-file.js";
23
27
 
24
28
  // ── Tool core ─────────────────────────────────────────────────────────────────
25
29
  export { buildMemoryTools } from "./tools.js";
@@ -32,12 +36,42 @@ export { createExperienceHost, EXPERIENCE_ENV } from "./experience/host.js";
32
36
  export type { ExperienceHost, ExperienceHostOptions, ExperienceEngine } from "./experience/host.js";
33
37
  export { nextExperienceVersion } from "./experience/train.js";
34
38
 
35
- // ── Compaction (absorbed fact → one-line pointer stub) ───────────────────────
36
- // Exported so an EXTERNAL compactor (the BuilderForce VS Code extension rewrites
37
- // the snapshot file directly) can produce byte-identical stubs, keeping the two
38
- // paths idempotent with respect to each other.
39
- export { STUB_PREFIX, DEFAULT_STUB_CHARS, isStub, firstLine, memoryStub, planCompaction } from "./compaction.js";
40
- export type { CompactionCandidate, CompactionWrite, CompactionSkip, CompactionPlan, CompactionOptions } from "./compaction.js";
39
+ // ── Compaction (absorbed fact → one-line pointer stub, LOSSLESS) ─────────────
40
+ // Exported so an EXTERNAL compactor (the BuilderForce VS Code extension) produces
41
+ // byte-identical stubs AND follows the same archive + recall-probe rules, keeping
42
+ // the two paths idempotent with respect to each other.
43
+ export {
44
+ STUB_PREFIX,
45
+ DEFAULT_STUB_CHARS,
46
+ CONTENT_LOST_LABEL,
47
+ isStub,
48
+ isContentLost,
49
+ firstLine,
50
+ memoryStub,
51
+ stubPointer,
52
+ planCompaction,
53
+ planLosslessCompaction,
54
+ probeQuestionFor,
55
+ probePasses,
56
+ defaultArchiveFile,
57
+ parseArchive,
58
+ mergeArchive,
59
+ } from "./compaction.js";
60
+ export type {
61
+ ArchivedFact,
62
+ ArchiveSnapshot,
63
+ CompactionCandidate,
64
+ CompactionWrite,
65
+ CompactionSkip,
66
+ CompactionPlan,
67
+ CompactionOptions,
68
+ LosslessCompactionOptions,
69
+ ProbeQuestion,
70
+ RecallProbe,
71
+ } from "./compaction.js";
72
+ export { createRecallProbe, lazyRecallProbe, generatorProbe, PROBE_MAX_NEW_TOKENS } from "./model/recall-probe.js";
73
+ export { parseRestoreSource, planRestore, restoredArchive, stubVersion, MIN_POINTER_CHARS } from "./restore.js";
74
+ export type { RestoreRecord, RestoreTarget, RestoreMatch, RestorePlan } from "./restore.js";
41
75
 
42
76
  // ── Transports ──────────────────────────────────────────────────────────────
43
77
  export { createMemoryMcpServer } from "./transports/sdk.js";
@@ -53,8 +87,19 @@ export type { HttpHandlerOptions } from "./transports/http.js";
53
87
  export { hashToken, timingSafeEqualStr, buildTenantIndex, bearerToken, RateLimiter } from "./transports/auth.js";
54
88
 
55
89
  // ── Multi-host installer (wire the stdio server into any MCP-capable agent) ───
56
- export { buildServerSpec, defaultMemoryFile, resolveMemoryFile, MCP_PACKAGE, MCP_BIN, MEMORY_FILE_ENV, RUNTIME_PEERS } from "./install/server-spec.js";
57
- export type { StdioServerSpec, ServerSpecOptions } from "./install/server-spec.js";
90
+ export {
91
+ buildServerSpec,
92
+ defaultMemoryFile,
93
+ memoryFileCandidates,
94
+ resolveMemoryFile,
95
+ CANONICAL_MEMORY_SEGMENTS,
96
+ LEGACY_MEMORY_SEGMENTS,
97
+ MCP_PACKAGE,
98
+ MCP_BIN,
99
+ MEMORY_FILE_ENV,
100
+ RUNTIME_PEERS,
101
+ } from "./install/server-spec.js";
102
+ export type { StdioServerSpec, ServerSpecOptions, MemoryFileProbe, ResolveMemoryFileOptions } from "./install/server-spec.js";
58
103
  export { installMemoryServer } from "./install/install.js";
59
104
  export type { InstallOptions, InstallResult, InstallStatus, HostSelector, FsLike } from "./install/install.js";
60
105
  export { HOSTS, SERVER_KEY, findHost } from "./install/hosts.js";