@seanhogg/builderforce-memory-mcp 2026.7.14 → 2026.9.28

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 (58) hide show
  1. package/README.md +47 -5
  2. package/dist/backend.d.ts +32 -0
  3. package/dist/backend.d.ts.map +1 -1
  4. package/dist/backends/memory-store.d.ts +32 -9
  5. package/dist/backends/memory-store.d.ts.map +1 -1
  6. package/dist/backends/memory-store.js +155 -33
  7. package/dist/backends/memory-store.js.map +1 -1
  8. package/dist/bin/http.d.ts +5 -0
  9. package/dist/bin/http.d.ts.map +1 -1
  10. package/dist/bin/http.js +11 -0
  11. package/dist/bin/http.js.map +1 -1
  12. package/dist/bin/install.js +3 -2
  13. package/dist/bin/install.js.map +1 -1
  14. package/dist/bin/stdio.d.ts +17 -7
  15. package/dist/bin/stdio.d.ts.map +1 -1
  16. package/dist/bin/stdio.js +24 -8
  17. package/dist/bin/stdio.js.map +1 -1
  18. package/dist/compaction.d.ts +76 -0
  19. package/dist/compaction.d.ts.map +1 -0
  20. package/dist/compaction.js +77 -0
  21. package/dist/compaction.js.map +1 -0
  22. package/dist/embedding/evermind-embedder.d.ts +119 -0
  23. package/dist/embedding/evermind-embedder.d.ts.map +1 -0
  24. package/dist/embedding/evermind-embedder.js +201 -0
  25. package/dist/embedding/evermind-embedder.js.map +1 -0
  26. package/dist/embedding/index.d.ts +38 -0
  27. package/dist/embedding/index.d.ts.map +1 -0
  28. package/dist/embedding/index.js +44 -0
  29. package/dist/embedding/index.js.map +1 -0
  30. package/dist/index.d.ts +8 -4
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +12 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/install/claude-hooks.d.ts +42 -2
  35. package/dist/install/claude-hooks.d.ts.map +1 -1
  36. package/dist/install/claude-hooks.js +35 -3
  37. package/dist/install/claude-hooks.js.map +1 -1
  38. package/dist/install/server-spec.d.ts +12 -0
  39. package/dist/install/server-spec.d.ts.map +1 -1
  40. package/dist/install/server-spec.js +18 -0
  41. package/dist/install/server-spec.js.map +1 -1
  42. package/dist/tools.d.ts.map +1 -1
  43. package/dist/tools.js +117 -6
  44. package/dist/tools.js.map +1 -1
  45. package/package.json +15 -4
  46. package/server.json +47 -0
  47. package/src/backend.ts +36 -0
  48. package/src/backends/memory-store.ts +184 -35
  49. package/src/bin/http.ts +12 -0
  50. package/src/bin/install.ts +3 -2
  51. package/src/bin/stdio.ts +25 -8
  52. package/src/compaction.ts +126 -0
  53. package/src/embedding/evermind-embedder.ts +276 -0
  54. package/src/embedding/index.ts +54 -0
  55. package/src/index.ts +18 -4
  56. package/src/install/claude-hooks.ts +40 -4
  57. package/src/install/server-spec.ts +25 -0
  58. package/src/tools.ts +133 -12
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Compaction — turning an ABSORBED memory into a terse pointer stub.
3
+ *
4
+ * Once a fact has been folded into an Evermind (or any downstream learner) the
5
+ * full body has done its job: keeping it verbatim in the store means every
6
+ * subsequent recall pays for text the model already carries in its weights.
7
+ * Compaction replaces the body with a one-line `[absorbed→Evermind vN] <first
8
+ * line>` pointer, so the fact stops filling context while it stays findable.
9
+ *
10
+ * The rules live here — not in the tool handler and not in a transport — because
11
+ * 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.
14
+ *
15
+ * Dependency-free and published as its own subpath
16
+ * (`@seanhogg/builderforce-memory-mcp/compaction`), so an external compactor imports
17
+ * these rules instead of re-implementing them — the marker below is the shared
18
+ * idempotency contract between the two processes.
19
+ */
20
+
21
+ /**
22
+ * Marker that opens every compacted stub — also the idempotency guard. An entry
23
+ * whose content already starts with this is skipped, so compacting twice (or
24
+ * from two processes) never double-stubs.
25
+ */
26
+ export const STUB_PREFIX = "[absorbed→Evermind";
27
+
28
+ /** Default cap on the pointer line carried inside a stub. */
29
+ export const DEFAULT_STUB_CHARS = 140;
30
+
31
+ /** True when a body is already a compaction stub. */
32
+ export function isStub(content: string): boolean {
33
+ return content.trimStart().startsWith(STUB_PREFIX);
34
+ }
35
+
36
+ /** The first non-empty line of a body, trimmed to `max` chars — the stub's pointer text. */
37
+ export function firstLine(content: string, max = DEFAULT_STUB_CHARS): string {
38
+ const line =
39
+ content
40
+ .split(/\r?\n/)
41
+ .map((l) => l.trim())
42
+ .find((l) => l.length > 0) ?? "";
43
+ return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
44
+ }
45
+
46
+ /** Build the terse stub that replaces an absorbed entry's body. */
47
+ export function memoryStub(content: string, version: number, maxChars = DEFAULT_STUB_CHARS): string {
48
+ return `${STUB_PREFIX} v${version}] ${firstLine(content, maxChars)}`;
49
+ }
50
+
51
+ /** One entry considered for compaction. */
52
+ export interface CompactionCandidate {
53
+ key: string;
54
+ content: string;
55
+ }
56
+
57
+ /** A body rewrite the caller should apply. */
58
+ export interface CompactionWrite {
59
+ key: string;
60
+ /** The stub that replaces the entry's body. */
61
+ content: string;
62
+ /** Characters recovered by this rewrite. */
63
+ bytesSaved: number;
64
+ }
65
+
66
+ /** Why a candidate was left alone. */
67
+ export interface CompactionSkip {
68
+ key: string;
69
+ reason: "not_found" | "already_compacted" | "not_smaller";
70
+ }
71
+
72
+ /** The decided plan: what to write, what to leave, and the total recovered. */
73
+ export interface CompactionPlan {
74
+ writes: CompactionWrite[];
75
+ skipped: CompactionSkip[];
76
+ bytesSaved: number;
77
+ }
78
+
79
+ /** Options controlling how a stub body is produced. */
80
+ export interface CompactionOptions {
81
+ /** Explicit replacement body. Omit to generate a pointer from each entry's own first line. */
82
+ stub?: string;
83
+ /** Evermind version that absorbed the facts; appears in a generated stub. Default 0. */
84
+ version?: number;
85
+ /** Max characters of a generated pointer line. Default {@link DEFAULT_STUB_CHARS}. */
86
+ maxChars?: number;
87
+ }
88
+
89
+ /**
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.
93
+ */
94
+ export function planCompaction(
95
+ candidates: Array<CompactionCandidate | { key: string; content: undefined }>,
96
+ opts: CompactionOptions = {},
97
+ ): CompactionPlan {
98
+ const version = opts.version ?? 0;
99
+ const maxChars = Math.max(20, opts.maxChars ?? DEFAULT_STUB_CHARS);
100
+ const writes: CompactionWrite[] = [];
101
+ const skipped: CompactionSkip[] = [];
102
+ let bytesSaved = 0;
103
+
104
+ for (const c of candidates) {
105
+ const content = c.content;
106
+ if (typeof content !== "string" || !content.trim()) {
107
+ skipped.push({ key: c.key, reason: "not_found" });
108
+ continue;
109
+ }
110
+ if (isStub(content)) {
111
+ skipped.push({ key: c.key, reason: "already_compacted" });
112
+ continue;
113
+ }
114
+ const stub = opts.stub ?? memoryStub(content, version, maxChars);
115
+ // Never grow an entry — a "compaction" that costs tokens is a regression.
116
+ if (stub.length >= content.length) {
117
+ skipped.push({ key: c.key, reason: "not_smaller" });
118
+ continue;
119
+ }
120
+ const saved = content.length - stub.length;
121
+ writes.push({ key: c.key, content: stub, bytesSaved: saved });
122
+ bytesSaved += saved;
123
+ }
124
+
125
+ return { writes, skipped, bytesSaved };
126
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * evermind-embedder — SSM-embedding recall for a HEADLESS host, with no GPU.
3
+ *
4
+ * The stdio/HTTP servers used to rank recall by word overlap because standing up
5
+ * the embedding model was assumed to mean standing up WebGPU. It does not:
6
+ * `EvermindLM` is a pure-CPU model and `EvermindLM.embed()` mean-pools + L2-
7
+ * normalises the same hidden state the GPU `HybridMambaModel.embed()` does, to the
8
+ * same contract. So a headless Node process can embed — it just needs the
9
+ * checkpoint and the tokenizer that produced its token ids.
10
+ *
11
+ * This module is the loader for that pair, plus the ONE thing a per-session
12
+ * subprocess needs to make embedding affordable: a vector cache that OUTLIVES the
13
+ * process. Recall embeds the query plus every stored memory; recomputing 200
14
+ * memory embeddings on the first recall of every session would cost more than the
15
+ * lexical ranking it replaces. The cache is stamped with the model's fingerprint,
16
+ * so an adapted checkpoint invalidates it wholesale rather than silently mixing
17
+ * vectors from two different models.
18
+ *
19
+ * It mirrors how this package already persists memories: a JSON file alongside the
20
+ * memory snapshot, written by the process that owns it, tolerant of a corrupt or
21
+ * absent file (drop it and recompute — a cache is never the source of truth).
22
+ */
23
+
24
+ /** The seam `MemoryStore.recallRanked(query, k, runtime)` consumes. */
25
+ export interface TextEmbedderLike {
26
+ embed(text: string): Promise<Float32Array>;
27
+ /** Vector width. */
28
+ readonly dimensions: number;
29
+ /** Model identity — vectors are only comparable within one fingerprint. */
30
+ readonly fingerprint: string;
31
+ }
32
+
33
+ /** A text embedder that also owns a persistent cache. */
34
+ export interface PersistentTextEmbedder extends TextEmbedderLike {
35
+ /** Write any pending vectors to disk now. Safe to call repeatedly. */
36
+ flush(): void;
37
+ }
38
+
39
+ export interface EvermindEmbedderOptions {
40
+ /**
41
+ * Absolute path to a `.evermind` package holding an `evermind-lm` checkpoint.
42
+ * This is the model whose hidden state becomes the embedding.
43
+ */
44
+ modelFile: string;
45
+ /**
46
+ * Absolute path to the tokenizer JSON that produced the checkpoint's token ids
47
+ * (`BPETokenizer.toObject()`, or a Hugging Face `tokenizer.json`).
48
+ *
49
+ * Only needed for a package that does NOT embed its own tokenizer. Token ids are
50
+ * meaningless without the exact vocabulary behind them, so an `.evermind` built
51
+ * with `PackageMeta.tokenizer` carries one and is self-contained — that is
52
+ * preferred over any file, because a file can be the WRONG vocabulary while the
53
+ * embedded one is checksummed and vocab-size-checked against the checkpoint.
54
+ * Resolution order: the package's own tokenizer, then this path, then a sibling
55
+ * `<modelFile>.tokenizer.json`.
56
+ */
57
+ tokenizerFile?: string;
58
+ /**
59
+ * Absolute path for the persistent vector cache. Omit to disable persistence
60
+ * (vectors are still cached in memory for the life of the process).
61
+ */
62
+ cacheFile?: string;
63
+ /** Max vectors retained in the cache (LRU past this). Default 4000. */
64
+ maxEntries?: number;
65
+ /** Debounce before a dirty cache is written back, ms. Default 500. */
66
+ flushDelayMs?: number;
67
+ }
68
+
69
+ /** Minimal structural view of the optional engine package. */
70
+ interface EmbedEngine {
71
+ EvermindModelPackage: {
72
+ fromBlob(blob: ArrayBuffer): {
73
+ manifest: { modelType?: string; tokenizerFormat?: string };
74
+ loadLM(): unknown;
75
+ /** Present on engines that support the embedded-tokenizer section. */
76
+ loadTokenizer?(): { encode(t: string): number[] } | null;
77
+ };
78
+ };
79
+ BPETokenizer: {
80
+ new (): { loadFromSpec(spec: unknown): void; loadHuggingFace(spec: unknown): void; encode(t: string): number[] };
81
+ };
82
+ EvermindTextEmbedder: new (model: unknown, codec: unknown) => TextEmbedderLike;
83
+ }
84
+
85
+ type FsLike = {
86
+ readFileSync(path: string, enc: "utf8"): string;
87
+ readFileSync(path: string): Uint8Array;
88
+ writeFileSync(path: string, data: string): void;
89
+ existsSync(path: string): boolean;
90
+ };
91
+
92
+ const DEFAULT_MAX_ENTRIES = 4000;
93
+ const DEFAULT_FLUSH_DELAY_MS = 500;
94
+
95
+ /** The on-disk cache shape: a fingerprint stamp plus text→vector rows. */
96
+ interface VectorCacheFile {
97
+ fingerprint: string;
98
+ dimensions: number;
99
+ /** [text, vector] pairs, least-recently-used first (insertion order is the LRU). */
100
+ vectors: Array<[string, number[]]>;
101
+ }
102
+
103
+ /** Indirect import so the optional engine peer is never statically resolved. */
104
+ function dynamicImport(m: string): Promise<unknown> {
105
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
106
+ return new Function("m", "return import(m)")(m) as Promise<unknown>;
107
+ }
108
+
109
+ /**
110
+ * Load the tokenizer spec from `file`, accepting either `BPETokenizer.toObject()`
111
+ * output or a Hugging Face `tokenizer.json`.
112
+ */
113
+ function loadCodec(engine: EmbedEngine, fs: FsLike, file: string): { encode(t: string): number[] } {
114
+ const spec = JSON.parse(fs.readFileSync(file, "utf8")) as { vocab?: unknown; merges?: unknown; model?: unknown };
115
+ const tok = new engine.BPETokenizer();
116
+ if (spec.vocab && spec.merges) tok.loadFromSpec(spec);
117
+ else tok.loadHuggingFace(spec);
118
+ return tok;
119
+ }
120
+
121
+ /**
122
+ * Build a CPU text embedder from a `.evermind` package + its tokenizer, wrapped in
123
+ * a persistent vector cache.
124
+ *
125
+ * Returns `null` — never throws — when the engine peer is absent, a file is
126
+ * missing, or the package is not an `evermind-lm`. Recall then stays on the
127
+ * lexical ranking and says so, which is the correct degrade: a memory server that
128
+ * refuses to start because an OPTIONAL model is unavailable is worse than one that
129
+ * ranks by word overlap.
130
+ */
131
+ export async function createEvermindEmbedder(
132
+ opts: EvermindEmbedderOptions,
133
+ ): Promise<PersistentTextEmbedder | null> {
134
+ try {
135
+ const engine = (await dynamicImport("@seanhogg/builderforce-memory-engine")) as Partial<EmbedEngine>;
136
+ if (!engine?.EvermindModelPackage || !engine.BPETokenizer || !engine.EvermindTextEmbedder) return null;
137
+
138
+ const fs = (await dynamicImport("node:fs")) as FsLike;
139
+ if (!fs.existsSync(opts.modelFile)) return null;
140
+
141
+ const bytes = fs.readFileSync(opts.modelFile);
142
+ const blob = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
143
+ const pkg = (engine as EmbedEngine).EvermindModelPackage.fromBlob(blob);
144
+ if (pkg.manifest.modelType !== "evermind-lm") return null;
145
+
146
+ // Prefer the package's OWN tokenizer. It is checksummed and cross-checked
147
+ // against the checkpoint's vocab size at package time, so it cannot be the
148
+ // wrong vocabulary — which a separate file silently can be. Falling back to a
149
+ // file keeps every package written before the section existed working.
150
+ const embedded = pkg.loadTokenizer?.() ?? null;
151
+ let codec: { encode(t: string): number[] };
152
+ if (embedded) {
153
+ codec = embedded;
154
+ } else {
155
+ const tokenizerFile = opts.tokenizerFile ?? `${opts.modelFile}.tokenizer.json`;
156
+ if (!fs.existsSync(tokenizerFile)) return null;
157
+ codec = loadCodec(engine as EmbedEngine, fs, tokenizerFile);
158
+ }
159
+ const inner = new (engine as EmbedEngine).EvermindTextEmbedder(pkg.loadLM(), codec);
160
+ return new CachedTextEmbedder(inner, fs, opts);
161
+ } catch {
162
+ // Any failure to stand the model up is a degrade, not a fault.
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Wraps a text embedder with an LRU vector cache mirrored to a JSON file.
169
+ *
170
+ * Exported for hosts that already hold an embedder (a GPU runtime, say) and want
171
+ * the same cross-process cache — there is one implementation of "cache these
172
+ * vectors on disk", not one per embedder.
173
+ */
174
+ export class CachedTextEmbedder implements PersistentTextEmbedder {
175
+ readonly dimensions: number;
176
+ readonly fingerprint: string;
177
+
178
+ /** text → vector. Map insertion order is the LRU order. */
179
+ private readonly cache = new Map<string, Float32Array>();
180
+ private readonly file: string | undefined;
181
+ private readonly maxEntries: number;
182
+ private readonly flushDelayMs: number;
183
+ private dirty = false;
184
+ private timer: ReturnType<typeof setTimeout> | null = null;
185
+
186
+ constructor(
187
+ private readonly inner: TextEmbedderLike,
188
+ private readonly fs: FsLike,
189
+ opts: Pick<EvermindEmbedderOptions, "cacheFile" | "maxEntries" | "flushDelayMs">,
190
+ ) {
191
+ this.dimensions = inner.dimensions;
192
+ this.fingerprint = inner.fingerprint;
193
+ this.file = opts.cacheFile;
194
+ this.maxEntries = Math.max(1, opts.maxEntries ?? DEFAULT_MAX_ENTRIES);
195
+ this.flushDelayMs = Math.max(0, opts.flushDelayMs ?? DEFAULT_FLUSH_DELAY_MS);
196
+ this.hydrate();
197
+ }
198
+
199
+ async embed(text: string): Promise<Float32Array> {
200
+ const hit = this.cache.get(text);
201
+ if (hit) {
202
+ // LRU touch — re-insert so this entry becomes most-recently-used.
203
+ this.cache.delete(text);
204
+ this.cache.set(text, hit);
205
+ return hit;
206
+ }
207
+ const vec = await this.inner.embed(text);
208
+ this.put(text, vec);
209
+ return vec;
210
+ }
211
+
212
+ /** Write pending vectors now, cancelling any scheduled write. */
213
+ flush(): void {
214
+ if (this.timer) {
215
+ clearTimeout(this.timer);
216
+ this.timer = null;
217
+ }
218
+ if (!this.dirty || !this.file) return;
219
+ const payload: VectorCacheFile = {
220
+ fingerprint: this.fingerprint,
221
+ dimensions: this.dimensions,
222
+ vectors: [...this.cache.entries()].map(([text, v]) => [text, Array.from(v)]),
223
+ };
224
+ try {
225
+ this.fs.writeFileSync(this.file, JSON.stringify(payload));
226
+ this.dirty = false;
227
+ } catch {
228
+ // A cache that cannot be written is still a working in-memory cache.
229
+ }
230
+ }
231
+
232
+ private put(text: string, vec: Float32Array): void {
233
+ if (this.cache.size >= this.maxEntries) {
234
+ const lru = this.cache.keys().next().value;
235
+ if (lru !== undefined) this.cache.delete(lru);
236
+ }
237
+ this.cache.set(text, vec);
238
+ this.dirty = true;
239
+ this.schedule();
240
+ }
241
+
242
+ /**
243
+ * Debounce the write-back: one recall embeds the query plus every new memory,
244
+ * and that burst should cost ONE file write, not one per vector.
245
+ */
246
+ private schedule(): void {
247
+ if (!this.file || this.timer) return;
248
+ this.timer = setTimeout(() => {
249
+ this.timer = null;
250
+ this.flush();
251
+ }, this.flushDelayMs);
252
+ // Never hold the process open for a cache write.
253
+ (this.timer as unknown as { unref?: () => void }).unref?.();
254
+ }
255
+
256
+ /**
257
+ * Read the cache back. A cache stamped with a DIFFERENT fingerprint is from a
258
+ * different (or adapted) model, so its vectors are not comparable with this
259
+ * model's — it is dropped whole rather than mixed in.
260
+ */
261
+ private hydrate(): void {
262
+ if (!this.file || !this.fs.existsSync(this.file)) return;
263
+ let parsed: VectorCacheFile;
264
+ try {
265
+ parsed = JSON.parse(this.fs.readFileSync(this.file, "utf8")) as VectorCacheFile;
266
+ } catch {
267
+ return;
268
+ }
269
+ if (!parsed || parsed.fingerprint !== this.fingerprint || !Array.isArray(parsed.vectors)) return;
270
+ for (const row of parsed.vectors) {
271
+ if (!Array.isArray(row) || typeof row[0] !== "string" || !Array.isArray(row[1])) continue;
272
+ if (row[1].length !== this.dimensions) continue;
273
+ this.cache.set(row[0], Float32Array.from(row[1]));
274
+ }
275
+ }
276
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * embedding — stand up the recall embedder for a headless server.
3
+ *
4
+ * Both out-of-process bins (stdio and HTTP) need the SAME decision: is an
5
+ * embedding model configured, and if so where do its vectors get cached? That
6
+ * decision lives here once, so the two servers cannot drift into ranking recall
7
+ * differently from one another.
8
+ */
9
+
10
+ import { createEvermindEmbedder, type PersistentTextEmbedder } from "./evermind-embedder.js";
11
+
12
+ export { createEvermindEmbedder, CachedTextEmbedder } from "./evermind-embedder.js";
13
+ export type { EvermindEmbedderOptions, PersistentTextEmbedder, TextEmbedderLike } from "./evermind-embedder.js";
14
+
15
+ /** Absolute path to a `.evermind` package (an `evermind-lm`) to embed recall with. */
16
+ export const MODEL_FILE_ENV = "BUILDERFORCE_MEMORY_MODEL";
17
+ /** Absolute path to the tokenizer JSON matching that checkpoint. */
18
+ export const TOKENIZER_FILE_ENV = "BUILDERFORCE_MEMORY_TOKENIZER";
19
+ /** Absolute path for the persistent vector cache; defaults beside the snapshot. */
20
+ export const VECTOR_CACHE_ENV = "BUILDERFORCE_MEMORY_VECTORS";
21
+
22
+ /**
23
+ * Where vectors are cached by default: beside the memory snapshot, under the same
24
+ * name. They belong together — the cache is only meaningful for the memories in
25
+ * that snapshot, and pointing a second store at the same vectors would be a cache
26
+ * for the wrong corpus.
27
+ */
28
+ export function defaultVectorCacheFile(memoryFile: string): string {
29
+ return memoryFile.replace(/\.json$/i, "") + ".vectors.json";
30
+ }
31
+
32
+ export interface RecallEmbedderEnv {
33
+ env?: Record<string, string | undefined>;
34
+ /** The snapshot path in effect — used to site the default vector cache. */
35
+ memoryFile: string;
36
+ }
37
+
38
+ /**
39
+ * The embedder this server should rank recall with, or `null` when none is
40
+ * configured (recall then ranks lexically and says so).
41
+ *
42
+ * Opt-in by design: no model path, no model. A memory server must start and serve
43
+ * recall on a host that has never downloaded a checkpoint.
44
+ */
45
+ export async function createRecallEmbedder(opts: RecallEmbedderEnv): Promise<PersistentTextEmbedder | null> {
46
+ const env = opts.env ?? process.env;
47
+ const modelFile = env[MODEL_FILE_ENV];
48
+ if (!modelFile) return null;
49
+ return createEvermindEmbedder({
50
+ modelFile,
51
+ tokenizerFile: env[TOKENIZER_FILE_ENV],
52
+ cacheFile: env[VECTOR_CACHE_ENV] || defaultVectorCacheFile(opts.memoryFile),
53
+ });
54
+ }
package/src/index.ts CHANGED
@@ -8,7 +8,14 @@
8
8
  */
9
9
 
10
10
  // ── Seam ────────────────────────────────────────────────────────────────────
11
- export type { MemoryBackend, RecallHit, RememberInput } from "./backend.js";
11
+ export type { MemoryBackend, RankedRecall, RecallHit, RecallMethod, RememberInput } from "./backend.js";
12
+
13
+ // -- CPU SSM-embedding recall (no GPU) --------------------------------------
14
+ // The headless servers rank recall by EvermindLM embedding cosine fused with the
15
+ // lexical ranking whenever a checkpoint is configured; exported so an embedding
16
+ // host can build the same embedder (and share its persistent vector cache).
17
+ export { createRecallEmbedder, createEvermindEmbedder, CachedTextEmbedder, defaultVectorCacheFile, MODEL_FILE_ENV, TOKENIZER_FILE_ENV, VECTOR_CACHE_ENV } from "./embedding/index.js";
18
+ export type { EvermindEmbedderOptions, PersistentTextEmbedder, TextEmbedderLike, RecallEmbedderEnv } from "./embedding/index.js";
12
19
 
13
20
  // ── Local backend (IndexedDB via @seanhogg/builderforce-memory) ────────────────────────
14
21
  export { MemoryStoreBackend, createLocalMemoryStoreBackend } from "./backends/memory-store.js";
@@ -18,6 +25,13 @@ export type { LocalBackendOptions } from "./backends/memory-store.js";
18
25
  export { buildMemoryTools } from "./tools.js";
19
26
  export type { MemoryTool, MemoryToolsOptions, ToolResult } from "./tools.js";
20
27
 
28
+ // ── Compaction (absorbed fact → one-line pointer stub) ───────────────────────
29
+ // Exported so an EXTERNAL compactor (the BuilderForce VS Code extension rewrites
30
+ // the snapshot file directly) can produce byte-identical stubs, keeping the two
31
+ // paths idempotent with respect to each other.
32
+ export { STUB_PREFIX, DEFAULT_STUB_CHARS, isStub, firstLine, memoryStub, planCompaction } from "./compaction.js";
33
+ export type { CompactionCandidate, CompactionWrite, CompactionSkip, CompactionPlan, CompactionOptions } from "./compaction.js";
34
+
21
35
  // ── Transports ──────────────────────────────────────────────────────────────
22
36
  export { createMemoryMcpServer } from "./transports/sdk.js";
23
37
  export type { SdkServerOptions, SdkMcpServerConfig } from "./transports/sdk.js";
@@ -32,7 +46,7 @@ export type { HttpHandlerOptions } from "./transports/http.js";
32
46
  export { hashToken, timingSafeEqualStr, buildTenantIndex, bearerToken, RateLimiter } from "./transports/auth.js";
33
47
 
34
48
  // ── Multi-host installer (wire the stdio server into any MCP-capable agent) ───
35
- export { buildServerSpec, MCP_PACKAGE, MCP_BIN, RUNTIME_PEERS } from "./install/server-spec.js";
49
+ export { buildServerSpec, defaultMemoryFile, resolveMemoryFile, MCP_PACKAGE, MCP_BIN, MEMORY_FILE_ENV, RUNTIME_PEERS } from "./install/server-spec.js";
36
50
  export type { StdioServerSpec, ServerSpecOptions } from "./install/server-spec.js";
37
51
  export { installMemoryServer } from "./install/install.js";
38
52
  export type { InstallOptions, InstallResult, InstallStatus, HostSelector, FsLike } from "./install/install.js";
@@ -40,5 +54,5 @@ export { HOSTS, SERVER_KEY, findHost } from "./install/hosts.js";
40
54
  export type { HostAdapter, HostEnv, ConfigFormat } from "./install/hosts.js";
41
55
  // Claude Code "memory combo" — hooks (SessionStart/PreCompact/UserPromptSubmit/Stop)
42
56
  // + companion skill that make memory self-driving (contextual recall + autonomous capture).
43
- export { installClaudeCombo, bfmemHookSource, companionSkillMd, claudeComboPaths } from "./install/claude-hooks.js";
44
- export type { ClaudeComboPaths } from "./install/claude-hooks.js";
57
+ export { installClaudeCombo, bfmemHookSource, companionSkillMd, claudeComboPaths, pluginHooksConfig, HOOK_EVENTS } from "./install/claude-hooks.js";
58
+ export type { ClaudeComboPaths, HookConfig } from "./install/claude-hooks.js";
@@ -22,9 +22,21 @@
22
22
 
23
23
  import nodePath from "node:path";
24
24
  import type { FsLike } from "./install.js";
25
+ import { MEMORY_FILE_ENV } from "./server-spec.js";
25
26
 
26
- /** The single hook script, four modes. `memoryFile` is the JSON snapshot path. */
27
- export function bfmemHookSource(memoryFile: string): string {
27
+ /**
28
+ * The single hook script, four modes.
29
+ *
30
+ * `memoryFile` is the JSON snapshot path to bake in. Pass `null` to emit a
31
+ * script that resolves the path at RUN time instead — required for the Claude
32
+ * Code plugin, whose files are static and machine-independent. The runtime
33
+ * branch must stay in lockstep with `resolveMemoryFile`; `install.test.mjs`
34
+ * asserts it does.
35
+ */
36
+ export function bfmemHookSource(memoryFile: string | null): string {
37
+ const fileExpr = memoryFile === null
38
+ ? `process.env[${JSON.stringify(MEMORY_FILE_ENV)}] || path.join(os.homedir(), ".builderforce-memory", "memory.json")`
39
+ : JSON.stringify(memoryFile);
28
40
  return `#!/usr/bin/env node
29
41
  /**
30
42
  * builderforce-memory hook — generated by builderforce-memory-install. Dependency-free:
@@ -39,7 +51,7 @@ import fs from "node:fs";
39
51
  import os from "node:os";
40
52
  import path from "node:path";
41
53
 
42
- const FILE = ${JSON.stringify(memoryFile)};
54
+ const FILE = ${fileExpr};
43
55
  const mode = process.argv[2] || "--session";
44
56
 
45
57
  function readStdin() {
@@ -262,13 +274,37 @@ export function claudeComboPaths(claudeDir: string): ClaudeComboPaths {
262
274
  };
263
275
  }
264
276
 
265
- const HOOK_EVENTS = [
277
+ /**
278
+ * The four events that make memory self-driving, and the hook-script mode each
279
+ * one runs. Single source of truth for BOTH delivery shapes: the settings.json
280
+ * merge performed by {@link installClaudeCombo}, and the static `hooks.json` a
281
+ * Claude Code plugin ships ({@link pluginHooksConfig}).
282
+ */
283
+ export const HOOK_EVENTS = [
266
284
  { event: "SessionStart", mode: "--session" },
267
285
  { event: "PreCompact", mode: "--precompact" },
268
286
  { event: "UserPromptSubmit", mode: "--recall" },
269
287
  { event: "Stop", mode: "--capture" },
270
288
  ] as const;
271
289
 
290
+ /** Hook-config shape shared by `settings.json` and a plugin's `hooks/hooks.json`. */
291
+ export type HookConfig = Record<string, Array<{ hooks: Array<{ type: "command"; command: string }> }>>;
292
+
293
+ /**
294
+ * The plugin form of the combo: every event pointed at the plugin-local hook
295
+ * script. `${CLAUDE_PLUGIN_ROOT}` is expanded by Claude Code to the installed
296
+ * plugin directory, so the script needs no install step and no baked-in paths.
297
+ */
298
+ export function pluginHooksConfig(scriptRelPath = "hooks/bfmem-hook.mjs"): HookConfig {
299
+ const config: HookConfig = {};
300
+ for (const { event, mode } of HOOK_EVENTS) {
301
+ config[event] = [{
302
+ hooks: [{ type: "command", command: `node "\${CLAUDE_PLUGIN_ROOT}/${scriptRelPath}" ${mode}` }],
303
+ }];
304
+ }
305
+ return config;
306
+ }
307
+
272
308
  /**
273
309
  * Write the hook script + companion skill and register all four hooks in
274
310
  * settings.json. Idempotent (skips a hook command already present) and
@@ -7,12 +7,37 @@
7
7
  * server is launched, so the per-host adapters never re-derive the command.
8
8
  */
9
9
 
10
+ import nodeOs from "node:os";
11
+ import nodePath from "node:path";
12
+
10
13
  export interface StdioServerSpec {
11
14
  command: string;
12
15
  args: string[];
13
16
  env?: Record<string, string>;
14
17
  }
15
18
 
19
+ /**
20
+ * The default JSON snapshot path: ONE store per machine, so every MCP host
21
+ * (Claude Code, Cursor, Claude Desktop, …) shares the same memory.
22
+ *
23
+ * Single source of truth for the installer, the stdio bin's fallback, and the
24
+ * Claude Code plugin's hook script — a memory file the writer and the reader
25
+ * disagree about is memory that silently disappears.
26
+ */
27
+ export const MEMORY_FILE_ENV = "BUILDERFORCE_MEMORY_FILE";
28
+
29
+ export function defaultMemoryFile(homedir: string = nodeOs.homedir()): string {
30
+ return nodePath.join(homedir, ".builderforce-memory", "memory.json");
31
+ }
32
+
33
+ /** The snapshot path in effect: explicit env wins, else the shared default. */
34
+ export function resolveMemoryFile(
35
+ env: Record<string, string | undefined> = process.env,
36
+ homedir?: string,
37
+ ): string {
38
+ return env[MEMORY_FILE_ENV] || defaultMemoryFile(homedir);
39
+ }
40
+
16
41
  export interface ServerSpecOptions {
17
42
  /** Absolute path to the JSON snapshot that makes memory survive restarts. */
18
43
  memoryFile?: string;