@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/restore.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Restore — bringing the full text of a compaction stub back from an export.
3
+ *
4
+ * A stub written before compaction archived its originals is the ONLY copy left:
5
+ * the text is gone (`contentLost`). The text often still exists elsewhere — a
6
+ * memory export (`[{ key, content }]`), or the Evermind coordinator's `mem:` record
7
+ * dump (`[{ id, text }]`) — and this module decides which record restores which
8
+ * stub. Pure and dependency-free; the `memory_restore` tool does the I/O.
9
+ *
10
+ * Matching, in order:
11
+ * 1. by key — a record's `key`, or its `id` with any `mem:` prefix dropped;
12
+ * 2. by text — the record's first line starts with the stub's pointer line (the
13
+ * original first line the stub kept). Only a UNIQUE match restores; two stubs
14
+ * sharing a pointer are reported ambiguous rather than guessed.
15
+ */
16
+
17
+ import { STUB_PREFIX, firstLine, isStub, stubPointer, type ArchivedFact } from "./compaction.js";
18
+
19
+ /** One record from a restore source, normalised. */
20
+ export interface RestoreRecord {
21
+ /** The memory key the record names, when it names one. */
22
+ key?: string;
23
+ /** The full text. */
24
+ content: string;
25
+ }
26
+
27
+ /** A stub that may be restored. */
28
+ export interface RestoreTarget {
29
+ key: string;
30
+ /** The stored stub body. */
31
+ stub: string;
32
+ }
33
+
34
+ export interface RestoreMatch {
35
+ key: string;
36
+ content: string;
37
+ via: "key" | "text";
38
+ }
39
+
40
+ export interface RestorePlan {
41
+ matches: RestoreMatch[];
42
+ /** Records that matched no target. */
43
+ unmatched: number;
44
+ /** Targets more than one stub could claim by text, left alone. */
45
+ ambiguous: string[];
46
+ }
47
+
48
+ /** Array-valued properties a wrapped export may keep its rows under. */
49
+ const WRAPPER_KEYS = ["entries", "memories", "records", "items", "data", "facts"];
50
+
51
+ function rowsOf(parsed: unknown): unknown[] {
52
+ if (Array.isArray(parsed)) return parsed;
53
+ if (parsed && typeof parsed === "object") {
54
+ const obj = parsed as Record<string, unknown>;
55
+ for (const k of WRAPPER_KEYS) {
56
+ const v = obj[k];
57
+ if (Array.isArray(v)) return v;
58
+ }
59
+ // A lone record (a one-line JSON Lines file parses as plain JSON).
60
+ if (typeof obj["content"] === "string" || typeof obj["text"] === "string") return [obj];
61
+ }
62
+ return [];
63
+ }
64
+
65
+ function toRecord(raw: unknown): RestoreRecord | null {
66
+ if (!raw || typeof raw !== "object") return null;
67
+ const r = raw as Record<string, unknown>;
68
+ const content = typeof r["content"] === "string" ? r["content"] : typeof r["text"] === "string" ? r["text"] : null;
69
+ if (content === null || !content.trim() || isStub(content)) return null;
70
+ const named = typeof r["key"] === "string" ? r["key"] : typeof r["id"] === "string" ? r["id"] : undefined;
71
+ const key = named?.replace(/^mem:/, "").trim();
72
+ return key ? { key, content } : { content };
73
+ }
74
+
75
+ /**
76
+ * Parse a restore source: a JSON array, an object wrapping one (`{ entries: [...] }`
77
+ * and similar), or JSON Lines. Rows without text, or whose text is itself a stub,
78
+ * are dropped — restoring a stub with a stub restores nothing.
79
+ */
80
+ export function parseRestoreSource(text: string): RestoreRecord[] {
81
+ let rows: unknown[];
82
+ try {
83
+ rows = rowsOf(JSON.parse(text));
84
+ } catch {
85
+ rows = [];
86
+ for (const line of text.split(/\r?\n/)) {
87
+ if (!line.trim()) continue;
88
+ try {
89
+ rows.push(JSON.parse(line));
90
+ } catch {
91
+ /* not a record */
92
+ }
93
+ }
94
+ }
95
+ return rows.map(toRecord).filter((r): r is RestoreRecord => r !== null);
96
+ }
97
+
98
+ const norm = (s: string) => s.toLowerCase().replace(/\s+/g, " ").trim();
99
+
100
+ /** Shortest pointer line a text match may rest on — below it, a prefix proves nothing. */
101
+ export const MIN_POINTER_CHARS = 12;
102
+
103
+ /** Decide which record restores which stub. */
104
+ export function planRestore(records: RestoreRecord[], targets: RestoreTarget[]): RestorePlan {
105
+ const byKey = new Map(targets.map((t) => [t.key, t]));
106
+ const pointers = targets.map((t) => ({ key: t.key, pointer: norm(stubPointer(t.stub)) }));
107
+ const matched = new Map<string, RestoreMatch>();
108
+ const ambiguous = new Set<string>();
109
+ let unmatched = 0;
110
+
111
+ for (const r of records) {
112
+ if (r.key && byKey.has(r.key)) {
113
+ if (!matched.has(r.key)) matched.set(r.key, { key: r.key, content: r.content, via: "key" });
114
+ continue;
115
+ }
116
+ const head = norm(firstLine(r.content, 100_000));
117
+ const hits = pointers.filter((p) => p.pointer.length >= MIN_POINTER_CHARS && head.startsWith(p.pointer));
118
+ if (hits.length === 1) {
119
+ const key = hits[0]!.key;
120
+ if (!matched.has(key)) matched.set(key, { key, content: r.content, via: "text" });
121
+ continue;
122
+ }
123
+ if (hits.length > 1) for (const h of hits) ambiguous.add(h.key);
124
+ else unmatched++;
125
+ }
126
+ // A key claimed unambiguously by key-match stays restored even if a text match was ambiguous.
127
+ const matches = [...matched.values()];
128
+ for (const m of matches) ambiguous.delete(m.key);
129
+ return { matches, unmatched, ambiguous: [...ambiguous] };
130
+ }
131
+
132
+ const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
133
+ const STUB_VERSION = new RegExp(`^\\s*${escapeRegExp(STUB_PREFIX)} v(\\d+)\\]`);
134
+
135
+ /** The Evermind version a stub names (`[absorbed→Evermind v7] …` → 7), 0 when unreadable. */
136
+ export function stubVersion(stub: string): number {
137
+ const m = STUB_VERSION.exec(stub);
138
+ return m ? Number(m[1]) : 0;
139
+ }
140
+
141
+ /** The archive row that makes a restored stub lossless again. */
142
+ export function restoredArchive(match: RestoreMatch, stub: string, now = Date.now()): ArchivedFact {
143
+ return { key: match.key, content: match.content, stub, version: stubVersion(stub), archivedAt: now };
144
+ }
package/src/tools.ts CHANGED
@@ -15,8 +15,9 @@
15
15
  */
16
16
 
17
17
  import { z } from "zod";
18
- import type { MemoryBackend, RankedRecall, RecallHit, RecallMethod, RememberInput } from "./backend.js";
19
- import { DEFAULT_STUB_CHARS, planCompaction, type CompactionSkip } from "./compaction.js";
18
+ import type { MemoryBackend, RankedRecall, RecallHit, RecallMethod } from "./backend.js";
19
+ import { CONTENT_LOST_LABEL, type RecallProbe } from "./compaction.js";
20
+ import { appendCompactionTools } from "./compaction-tools.js";
20
21
  import { buildExperienceTools } from "./experience/tools.js";
21
22
  import type { ExperienceHost } from "./experience/host.js";
22
23
  import { appendGatewayCostTools } from "./gateway-tools.js";
@@ -44,6 +45,18 @@ export interface MemoryToolsOptions {
44
45
  * beside the memory tools. See {@link createExperienceHost}.
45
46
  */
46
47
  experience?: ExperienceHost | null;
48
+ /**
49
+ * The local model `memory_compact` proves absorption with before it stubs a
50
+ * fact (see `planLosslessCompaction`). Null/absent → compaction stubs nothing
51
+ * and says why. The bins pass `lazyRecallProbe()` (the configured Evermind).
52
+ */
53
+ recallProbe?: RecallProbe | null;
54
+ /**
55
+ * Reads a restore source by path for `memory_restore`. Only hosts that may read
56
+ * their own disk pass it (the stdio bin); without it the tool takes inline JSON
57
+ * only, so a networked server never reads a caller-named file.
58
+ */
59
+ readRestoreFile?: (path: string) => Promise<string>;
47
60
  }
48
61
 
49
62
  const DEFAULT_MAX_RESULTS = 5;
@@ -62,6 +75,9 @@ const RANKER_LINE: Record<RecallMethod, string> = {
62
75
  lexical: "Ranked by: lexical recall (fallback - no embedding model loaded).",
63
76
  };
64
77
 
78
+ /** A stub whose original is gone is surfaced WITH the label, never as a plain fact. */
79
+ const lostLabel = (h: RecallHit): string => (h.contentLost ? ` ${CONTENT_LOST_LABEL}` : "");
80
+
65
81
  function renderHits(hits: RecallHit[], maxChars: number, method?: RecallMethod): string {
66
82
  const ranker = method ? `\n${RANKER_LINE[method]}` : "";
67
83
  if (hits.length === 0) return `No matching memories.${ranker}`;
@@ -70,7 +86,7 @@ function renderHits(hits: RecallHit[], maxChars: number, method?: RecallMethod):
70
86
  .map((h) => {
71
87
  const tags = h.tags?.length ? ` tags=[${h.tags.join(", ")}]` : "";
72
88
  const score = h.score != null ? ` score=${h.score.toFixed(3)}` : "";
73
- return `• ${h.key}${score}${tags}\n ${clip(h.content, maxChars)}`;
89
+ return `• ${h.key}${score}${tags}${lostLabel(h)}\n ${clip(h.content, maxChars)}`;
74
90
  })
75
91
  .join("\n") + ranker
76
92
  );
@@ -90,19 +106,6 @@ async function rankedRecall(
90
106
  return { hits: await backend.recall(query, topK) };
91
107
  }
92
108
 
93
- const SKIP_REASONS: Record<CompactionSkip["reason"], string> = {
94
- not_found: "not found",
95
- already_compacted: "already compacted",
96
- not_smaller: "already shorter than its stub",
97
- };
98
-
99
- /** One-line compaction receipt: what shrank, what it saved, and what was left alone. */
100
- function renderCompaction(compacted: number, bytesSaved: number, skipped: CompactionSkip[]): string {
101
- const lines = [`Compacted ${compacted} memory(ies), ${bytesSaved} character(s) reclaimed.`];
102
- for (const s of skipped) lines.push(`• skipped ${s.key} — ${SKIP_REASONS[s.reason]}`);
103
- return lines.join("\n");
104
- }
105
-
106
109
  /**
107
110
  * Builds the memory tool set bound to `backend`. Write tools are included only
108
111
  * when `writable` is not false AND the backend actually implements them.
@@ -156,7 +159,9 @@ export function buildMemoryTools(backend: MemoryBackend, opts: MemoryToolsOption
156
159
  const key = String(args["key"] ?? "");
157
160
  if (!key) return fail("key is required.");
158
161
  const hit = await backend.get(key);
159
- return hit ? ok(`• ${hit.key}\n ${hit.content}`) : ok(`No memory found for key "${key}".`);
162
+ return hit
163
+ ? ok(`• ${hit.key}${lostLabel(hit)}\n ${hit.content}`)
164
+ : ok(`No memory found for key "${key}".`);
160
165
  } catch (err) {
161
166
  return fail(`get failed: ${String(err)}`);
162
167
  }
@@ -226,82 +231,8 @@ export function buildMemoryTools(backend: MemoryBackend, opts: MemoryToolsOption
226
231
  });
227
232
  }
228
233
 
229
- // memory_compact — the FIRST-CLASS compaction path. It writes through the live
230
- // store (get → plan → remember), so a durable backend's in-memory state and its
231
- // on-disk snapshot agree afterwards. Compacting the snapshot file behind the
232
- // server's back instead is what races: the server still holds the full bodies and
233
- // re-snapshots over them on its next write. Requires read + write, so it is
234
- // registered alongside the other write tools.
235
- if (writable && backend.remember) {
236
- tools.push({
237
- name: "memory_compact",
238
- description:
239
- "Shrink memories that have already been absorbed elsewhere (folded into a model, " +
240
- "summarised into a longer-lived note) down to a one-line pointer stub, reclaiming the " +
241
- "context they were eating. Call this AFTER something durable has learned the facts — " +
242
- "never as a way to tidy up, because the full body is gone afterwards. Already-compacted " +
243
- "keys are skipped, so repeating the call is safe.",
244
- inputSchema: {
245
- keys: z.array(z.string()).min(1).describe("Exact keys of the memories to compact."),
246
- stub: z
247
- .string()
248
- .optional()
249
- .describe(
250
- "Explicit replacement body applied to every listed key. Omit to generate " +
251
- "'[absorbed→Evermind vN] <first line>' from each entry's own first line.",
252
- ),
253
- version: z
254
- .number()
255
- .int()
256
- .min(0)
257
- .optional()
258
- .describe("Version of the model that absorbed these facts; appears in the generated stub."),
259
- maxChars: z
260
- .number()
261
- .int()
262
- .min(20)
263
- .max(500)
264
- .optional()
265
- .describe(`Max characters of the generated pointer line (default ${DEFAULT_STUB_CHARS}).`),
266
- },
267
- handler: async (args) => {
268
- try {
269
- const raw = args["keys"];
270
- const keys = Array.isArray(raw) ? raw.map((k) => String(k)).filter(Boolean) : [];
271
- if (keys.length === 0) return fail("keys is required.");
272
-
273
- // ONE read pass: the hit carries both the body the plan reasons about
274
- // and the tags/importance the rewrite must preserve.
275
- const hits = new Map<string, RecallHit>();
276
- for (const key of keys) {
277
- const hit = await backend.get(key);
278
- if (hit) hits.set(key, hit);
279
- }
280
-
281
- const plan = planCompaction(keys.map((key) => ({ key, content: hits.get(key)?.content })), {
282
- stub: typeof args["stub"] === "string" ? (args["stub"] as string) : undefined,
283
- version: args["version"] == null ? undefined : Number(args["version"]),
284
- maxChars: args["maxChars"] == null ? undefined : Number(args["maxChars"]),
285
- });
286
-
287
- // Preserve tags/importance — compaction shortens a body, it does not
288
- // reclassify the fact.
289
- const writes: RememberInput[] = plan.writes.map((w) => {
290
- const prev = hits.get(w.key);
291
- return { key: w.key, content: w.content, tags: prev?.tags, importance: prev?.importance };
292
- });
293
- if (writes.length > 0) {
294
- if (backend.rememberMany) await backend.rememberMany(writes);
295
- else for (const w of writes) await backend.remember!(w);
296
- }
297
-
298
- return ok(renderCompaction(plan.writes.length, plan.bytesSaved, plan.skipped));
299
- } catch (err) {
300
- return fail(`compact failed: ${String(err)}`);
301
- }
302
- },
303
- });
304
- }
234
+ // memory_compact + memory_restore — the lossless compaction pair (compaction-tools.ts).
235
+ if (writable && backend.remember) appendCompactionTools(tools, backend, opts);
305
236
 
306
237
  if (writable && backend.forget) {
307
238
  tools.push({