mcp-super-memory 0.4.8 → 0.5.1

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.
@@ -1,20 +1,32 @@
1
- import { readFile, writeFile, mkdir, appendFile } from "fs/promises";
1
+ import { readFile, writeFile, mkdir, appendFile, rename, copyFile } from "fs/promises";
2
2
  import { randomBytes } from "crypto";
3
3
  import { join } from "path";
4
4
  import { homedir } from "os";
5
5
  import { Mutex } from "async-mutex";
6
- import { embedTextAsync, EMBEDDING_BACKEND } from "./embedding.js";
6
+ import MiniSearch from "minisearch";
7
+ import { embedTextAsync, EMBEDDING_BACKEND, getThresholdProfile } from "./embedding.js";
7
8
  const DATA_DIR = process.env.SUPER_MEMORY_DATA_DIR ?? join(homedir(), ".super-memory");
8
9
  const GRAPH_FILE = join(DATA_DIR, "graph.json");
9
10
  const CONVERSATIONS_DIR = join(DATA_DIR, "conversations");
10
- const KEY_MERGE_THRESHOLD = 0.85;
11
- const MEMORY_DEDUP_THRESHOLD = 0.9;
12
- const KEY_AUTO_LINK_THRESHOLD = 0.5;
13
- const KEY_RECALL_THRESHOLD = 0.28;
14
- const CONTENT_RECALL_THRESHOLD = 0.28;
11
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
12
+ // Thresholds are calibrated per embedding backend/model (see embedding.ts).
13
+ const _THRESHOLDS = getThresholdProfile();
14
+ const KEY_MERGE_THRESHOLD = _THRESHOLDS.keyMerge;
15
+ const MEMORY_DEDUP_THRESHOLD = _THRESHOLDS.memoryDedup;
16
+ const KEY_AUTO_LINK_THRESHOLD = _THRESHOLDS.keyAutoLink;
17
+ const KEY_RECALL_THRESHOLD = _THRESHOLDS.keyRecall;
18
+ const CONTENT_RECALL_THRESHOLD = _THRESHOLDS.contentRecall;
15
19
  const DEPTH_INCREMENT = 0.05;
16
20
  const DEPTH_MAX = 1.0;
17
21
  const DEPTH_DEEP_THRESHOLD = 0.7;
22
+ const RRF_K = 60;
23
+ const BM25_RESULT_DEPTH = 50;
24
+ const DENSE_RESULT_DEPTH = 50;
25
+ const LINK_WEIGHT_DEFAULT = 1.0;
26
+ const LINK_WEIGHT_MIN = 0.1;
27
+ const LINK_WEIGHT_MAX = 3.0;
28
+ const LINK_REINFORCE_AMOUNT = 0.1;
29
+ const LINK_DECAY_RATE = 0.005;
18
30
  // ── Vector math ──
19
31
  function cosineSim(a, b) {
20
32
  let dot = 0, normA = 0, normB = 0;
@@ -35,6 +47,18 @@ function batchCosineSim(query, matrix) {
35
47
  function uid() {
36
48
  return randomBytes(6).toString("hex");
37
49
  }
50
+ function errorMessage(err) {
51
+ return err instanceof Error ? err.message : String(err);
52
+ }
53
+ function isNodeError(err) {
54
+ return err instanceof Error && "code" in err;
55
+ }
56
+ function conversationPath(sessionId) {
57
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
58
+ throw new Error("Invalid session_id. Use 1-128 characters: letters, numbers, dot, underscore, or hyphen.");
59
+ }
60
+ return join(CONVERSATIONS_DIR, `${sessionId}.jsonl`);
61
+ }
38
62
  export function sanitizeKeys(keys) {
39
63
  let arr;
40
64
  if (typeof keys === "string") {
@@ -65,26 +89,51 @@ export class MemoryGraph {
65
89
  _storedDim = null;
66
90
  _lock = new Mutex();
67
91
  _dirty = false;
68
- static HOP_DECAY = 0.5;
92
+ _bm25;
93
+ constructor() {
94
+ this._bm25 = new MiniSearch({
95
+ fields: ["content"],
96
+ storeFields: [],
97
+ idField: "id",
98
+ tokenize: (text) => text
99
+ .toLowerCase()
100
+ .split(/[\s\p{P}]+/u)
101
+ .filter((t) => t.length >= 1),
102
+ processTerm: (term) => (term.length < 1 ? false : term.toLowerCase()),
103
+ });
104
+ }
105
+ static HOP_DECAY = 0.3;
69
106
  static TIME_HALF_LIFE = 30 * 24 * 3600;
70
107
  get linkCount() {
71
108
  return Object.values(this._keyToMems).reduce((sum, mids) => sum + mids.size, 0);
72
109
  }
73
- _link(keyId, memId) {
110
+ _link(keyId, memId, weight = LINK_WEIGHT_DEFAULT) {
74
111
  if (!this._keyToMems[keyId])
75
- this._keyToMems[keyId] = new Set();
76
- this._keyToMems[keyId].add(memId);
112
+ this._keyToMems[keyId] = new Map();
113
+ if (!this._keyToMems[keyId].has(memId)) {
114
+ this._keyToMems[keyId].set(memId, weight);
115
+ }
77
116
  if (!this._memToKeys[memId])
78
- this._memToKeys[memId] = new Set();
79
- this._memToKeys[memId].add(keyId);
117
+ this._memToKeys[memId] = new Map();
118
+ if (!this._memToKeys[memId].has(keyId)) {
119
+ this._memToKeys[memId].set(keyId, weight);
120
+ }
80
121
  }
81
122
  _hasLink(keyId, memId) {
82
123
  return this._keyToMems[keyId]?.has(memId) ?? false;
83
124
  }
125
+ _getLinkWeight(keyId, memId) {
126
+ return this._keyToMems[keyId]?.get(memId) ?? LINK_WEIGHT_DEFAULT;
127
+ }
128
+ _setLinkWeight(keyId, memId, weight) {
129
+ const clamped = Math.max(LINK_WEIGHT_MIN, Math.min(LINK_WEIGHT_MAX, weight));
130
+ this._keyToMems[keyId]?.set(memId, clamped);
131
+ this._memToKeys[memId]?.set(keyId, clamped);
132
+ }
84
133
  _unlinkMemory(memId) {
85
134
  const kids = this._memToKeys[memId];
86
135
  if (kids) {
87
- for (const kid of kids) {
136
+ for (const kid of kids.keys()) {
88
137
  const mems = this._keyToMems[kid];
89
138
  if (mems) {
90
139
  mems.delete(memId);
@@ -95,6 +144,34 @@ export class MemoryGraph {
95
144
  delete this._memToKeys[memId];
96
145
  }
97
146
  }
147
+ _validMemoryLinks(links, selfId) {
148
+ if (!Array.isArray(links))
149
+ return [];
150
+ const seen = new Set();
151
+ const valid = [];
152
+ for (const linkedId of links) {
153
+ if (typeof linkedId !== "string")
154
+ continue;
155
+ if (linkedId === selfId || seen.has(linkedId))
156
+ continue;
157
+ if (!(linkedId in this.memories))
158
+ continue;
159
+ seen.add(linkedId);
160
+ valid.push(linkedId);
161
+ }
162
+ return valid;
163
+ }
164
+ _removeMemoryReferences(memoryIds) {
165
+ const deleted = new Set(memoryIds);
166
+ for (const [mid, mem] of Object.entries(this.memories)) {
167
+ mem.links = this._validMemoryLinks(mem.links, mid).filter((linkedId) => !deleted.has(linkedId));
168
+ }
169
+ }
170
+ _pruneDanglingExplicitLinks() {
171
+ for (const [mid, mem] of Object.entries(this.memories)) {
172
+ mem.links = this._validMemoryLinks(mem.links, mid);
173
+ }
174
+ }
98
175
  _pruneOrphanKeys() {
99
176
  for (const kid of Object.keys(this.keys)) {
100
177
  const mems = this._keyToMems[kid];
@@ -111,9 +188,58 @@ export class MemoryGraph {
111
188
  if (dim !== this._storedDim) {
112
189
  throw new Error(`Embedding dimension mismatch: existing data uses ${this._storedDim}-dim, ` +
113
190
  `current backend (${EMBEDDING_BACKEND}) produces ${dim}-dim.\n` +
114
- `To switch backends, delete ~/.super-memory/graph.json first.`);
191
+ `Restart the server to auto-migrate (re-embeds all data with the current ` +
192
+ `backend, preserving content), or set SUPER_MEMORY_AUTO_MIGRATE=false to opt out.`);
115
193
  }
116
194
  }
195
+ // Recover from an embedding-backend/dimension change instead of bricking.
196
+ // Switching backends (e.g. OpenAI 1536-dim → local 768/1024-dim) used to make
197
+ // every recall/remember throw forever. Here we detect the mismatch on load and
198
+ // re-embed all keys and memories with the current backend — content, links,
199
+ // depth, and access history are preserved. Disable with SUPER_MEMORY_AUTO_MIGRATE=false.
200
+ async _ensureEmbeddingDim() {
201
+ if (this._storedDim === null)
202
+ return;
203
+ let probeDim;
204
+ try {
205
+ probeDim = (await embedTextAsync("dimension probe")).length;
206
+ }
207
+ catch (err) {
208
+ console.error(`[graph] could not probe embedding dimension: ${errorMessage(err)}`);
209
+ return;
210
+ }
211
+ if (probeDim === this._storedDim)
212
+ return;
213
+ if (process.env.SUPER_MEMORY_AUTO_MIGRATE === "false") {
214
+ console.error(`[graph] WARNING: stored embeddings are ${this._storedDim}-dim but the current ` +
215
+ `backend (${EMBEDDING_BACKEND}) produces ${probeDim}-dim. Auto-migration is ` +
216
+ `disabled — recall/remember will fail until the original backend is restored.`);
217
+ return;
218
+ }
219
+ await this._migrateEmbeddings(probeDim);
220
+ }
221
+ async _migrateEmbeddings(newDim) {
222
+ const nKeys = Object.keys(this.keys).length;
223
+ const nMems = Object.keys(this.memories).length;
224
+ console.error(`[graph] embedding dimension changed ${this._storedDim} -> ${newDim}. Re-embedding ` +
225
+ `${nKeys} keys + ${nMems} memories with the current backend (${EMBEDDING_BACKEND}); ` +
226
+ `content and links are preserved. This is a one-time migration.`);
227
+ try {
228
+ await copyFile(GRAPH_FILE, `${GRAPH_FILE}.bak.${this._storedDim}d`);
229
+ }
230
+ catch (err) {
231
+ console.error(`[graph] pre-migration backup failed (continuing): ${errorMessage(err)}`);
232
+ }
233
+ for (const mem of Object.values(this.memories)) {
234
+ mem.embedding = await embedTextAsync(mem.content, "passage");
235
+ }
236
+ for (const key of Object.values(this.keys)) {
237
+ key.embedding = await embedTextAsync(key.concept, "passage");
238
+ }
239
+ this._storedDim = newDim;
240
+ await this.save();
241
+ console.error(`[graph] migration complete: now ${newDim}-dim (backup saved as graph.json.bak).`);
242
+ }
117
243
  _isExpired(mem) {
118
244
  return mem.ttl != null && Date.now() / 1000 > mem.ttl;
119
245
  }
@@ -156,15 +282,24 @@ export class MemoryGraph {
156
282
  const sims = batchCosineSim(embedding, matrix);
157
283
  for (let i = 0; i < keyIds.length; i++) {
158
284
  if (sims[i] >= KEY_AUTO_LINK_THRESHOLD && !this._hasLink(keyIds[i], memId)) {
159
- this._link(keyIds[i], memId);
285
+ this._link(keyIds[i], memId, sims[i]);
160
286
  }
161
287
  }
162
288
  }
289
+ _rebuildBm25Index() {
290
+ this._bm25.removeAll();
291
+ const docs = Object.entries(this.memories)
292
+ .filter(([mid]) => !(mid in this._supersededBy))
293
+ .filter(([, mem]) => !this._isExpired(mem))
294
+ .map(([mid, mem]) => ({ id: mid, content: mem.content }));
295
+ if (docs.length > 0)
296
+ this._bm25.addAll(docs);
297
+ }
163
298
  getKeysForMemory(memId) {
164
299
  const kids = this._memToKeys[memId];
165
300
  if (!kids)
166
301
  return [];
167
- return [...kids]
302
+ return [...kids.keys()]
168
303
  .filter((kid) => kid in this.keys)
169
304
  .map((kid) => this.keys[kid].concept);
170
305
  }
@@ -175,8 +310,10 @@ export class MemoryGraph {
175
310
  const text = await readFile(GRAPH_FILE, "utf-8");
176
311
  raw = JSON.parse(text);
177
312
  }
178
- catch {
179
- return;
313
+ catch (err) {
314
+ if (isNodeError(err) && err.code === "ENOENT")
315
+ return;
316
+ throw new Error(`Failed to load memory graph at ${GRAPH_FILE}: ${errorMessage(err)}`);
180
317
  }
181
318
  for (const [kid, k] of Object.entries(raw.keys ?? {})) {
182
319
  this.keys[kid] = k;
@@ -193,6 +330,9 @@ export class MemoryGraph {
193
330
  supersedes: null,
194
331
  };
195
332
  const mem = { ...defaults, ...m };
333
+ mem.links = Array.isArray(mem.links)
334
+ ? mem.links.filter((linkedId) => typeof linkedId === "string")
335
+ : [];
196
336
  if (!mem.embedding || mem.embedding.length === 0) {
197
337
  mem.embedding = await embedTextAsync(mem.content);
198
338
  }
@@ -203,13 +343,18 @@ export class MemoryGraph {
203
343
  this._storedDim = firstMem.embedding.length;
204
344
  }
205
345
  for (const lnk of raw.links ?? []) {
206
- this._link(lnk.key_id, lnk.memory_id);
346
+ if (lnk.key_id in this.keys && lnk.memory_id in this.memories) {
347
+ this._link(lnk.key_id, lnk.memory_id, lnk.weight ?? LINK_WEIGHT_DEFAULT);
348
+ }
207
349
  }
350
+ this._pruneDanglingExplicitLinks();
208
351
  for (const [mid, mem] of Object.entries(this.memories)) {
209
352
  if (mem.supersedes) {
210
353
  this._supersededBy[mem.supersedes] = mid;
211
354
  }
212
355
  }
356
+ await this._ensureEmbeddingDim();
357
+ this._rebuildBm25Index();
213
358
  console.error(`[graph] loaded ${Object.keys(this.keys).length} keys, ` +
214
359
  `${Object.keys(this.memories).length} memories, ${this.linkCount} links`);
215
360
  }
@@ -217,8 +362,8 @@ export class MemoryGraph {
217
362
  await mkdir(DATA_DIR, { recursive: true });
218
363
  const links = [];
219
364
  for (const [kid, mids] of Object.entries(this._keyToMems)) {
220
- for (const mid of mids) {
221
- links.push({ key_id: kid, memory_id: mid });
365
+ for (const [mid, weight] of mids) {
366
+ links.push({ key_id: kid, memory_id: mid, weight });
222
367
  }
223
368
  }
224
369
  const data = {
@@ -226,7 +371,9 @@ export class MemoryGraph {
226
371
  memories: this.memories,
227
372
  links,
228
373
  };
229
- await writeFile(GRAPH_FILE, JSON.stringify(data, null, 2), "utf-8");
374
+ const tmp = GRAPH_FILE + ".tmp";
375
+ await writeFile(tmp, JSON.stringify(data, null, 2), "utf-8");
376
+ await rename(tmp, GRAPH_FILE);
230
377
  this._dirty = false;
231
378
  }
232
379
  markDirty() {
@@ -295,7 +442,7 @@ export class MemoryGraph {
295
442
  resultMid = mid;
296
443
  const now = Date.now() / 1000;
297
444
  const expiresAt = options.ttlSeconds != null ? now + options.ttlSeconds : null;
298
- const validLinks = (options.relatedTo ?? []).filter((lid) => lid in this.memories);
445
+ const validLinks = this._validMemoryLinks(options.relatedTo ?? [], mid);
299
446
  this.memories[mid] = {
300
447
  id: mid,
301
448
  content,
@@ -319,6 +466,7 @@ export class MemoryGraph {
319
466
  this._link(kid, mid);
320
467
  }
321
468
  this._autoLinkKeys(mid, embedding);
469
+ this._bm25.add({ id: mid, content });
322
470
  await this.save();
323
471
  });
324
472
  return [resultMid, false];
@@ -332,19 +480,25 @@ export class MemoryGraph {
332
480
  throw new Error(`Memory ${oldId} not found`);
333
481
  }
334
482
  const old = this.memories[oldId];
335
- // Chain cleanup: keep depth max 1 (new old; grandparent deleted)
483
+ // Chain cleanup: keep depth max 1 (new -> old; grandparent deleted)
336
484
  const grandparentId = old.supersedes;
337
485
  if (grandparentId && grandparentId in this.memories) {
338
486
  delete this.memories[grandparentId];
339
487
  this._unlinkMemory(grandparentId);
488
+ this._removeMemoryReferences([grandparentId]);
340
489
  delete this._supersededBy[grandparentId];
341
490
  this._pruneOrphanKeys();
491
+ try {
492
+ this._bm25.discard(grandparentId);
493
+ }
494
+ catch { /* already removed */ }
342
495
  }
343
496
  const mid = uid();
344
497
  resultMid = mid;
345
498
  const now = Date.now() / 1000;
346
499
  const ns = options.namespace ?? old.namespace;
347
- const validLinks = (options.relatedTo ?? []).filter((lid) => lid in this.memories);
500
+ const nextLinks = options.relatedTo === undefined ? old.links : options.relatedTo;
501
+ const validLinks = this._validMemoryLinks(nextLinks ?? [], mid);
348
502
  this.memories[mid] = {
349
503
  id: mid,
350
504
  content: newContent,
@@ -359,12 +513,17 @@ export class MemoryGraph {
359
513
  ttl: old.ttl,
360
514
  links: validLinks,
361
515
  };
516
+ this._bm25.add({ id: mid, content: newContent });
362
517
  // Weaken old memory depth
363
518
  old.depth =
364
519
  old.depth >= DEPTH_DEEP_THRESHOLD
365
520
  ? old.depth * 0.8
366
521
  : old.depth * 0.3;
367
522
  this._supersededBy[oldId] = mid;
523
+ try {
524
+ this._bm25.discard(oldId);
525
+ }
526
+ catch { /* already removed */ }
368
527
  const keyConcepts = options.keyConcepts;
369
528
  if (keyConcepts && keyConcepts.length > 0) {
370
529
  const sanitized = sanitizeKeys(keyConcepts);
@@ -376,9 +535,9 @@ export class MemoryGraph {
376
535
  }
377
536
  }
378
537
  else {
379
- // Copy old links (snapshot to avoid mutation during iteration)
380
- for (const kid of [...(this._memToKeys[oldId] ?? new Set())]) {
381
- this._link(kid, mid);
538
+ // Copy old links with weights (snapshot to avoid mutation during iteration)
539
+ for (const [kid, w] of [...(this._memToKeys[oldId] ?? new Map())]) {
540
+ this._link(kid, mid, w);
382
541
  }
383
542
  }
384
543
  this._autoLinkKeys(mid, newEmbedding);
@@ -390,12 +549,11 @@ export class MemoryGraph {
390
549
  async recall(query, topK = 5, namespace, expand = false) {
391
550
  if (Object.keys(this.memories).length === 0)
392
551
  return [];
393
- const qEmb = await embedTextAsync(query); // outside lock
552
+ const qEmb = await embedTextAsync(query, "query"); // outside lock
394
553
  this._checkDim(qEmb);
395
554
  const results = [];
396
555
  await this._lock.runExclusive(async () => {
397
556
  const queryLower = query.toLowerCase().trim();
398
- const memScores = {};
399
557
  const memMatchedKeys = {};
400
558
  const memHop = {};
401
559
  const skip = (mid) => {
@@ -410,7 +568,15 @@ export class MemoryGraph {
410
568
  return true;
411
569
  return false;
412
570
  };
413
- // ── Path A: Key batch matching → links → memories ──
571
+ // ── BM25 sparse search ──
572
+ const bm25Ranked = [];
573
+ const bm25Results = this._bm25.search(query, { fuzzy: 0.2, prefix: true });
574
+ for (const r of bm25Results.slice(0, BM25_RESULT_DEPTH)) {
575
+ if (!skip(r.id))
576
+ bm25Ranked.push({ id: r.id, score: r.score });
577
+ }
578
+ // ── Dense Path A: Key batch matching ──
579
+ const denseScores = {};
414
580
  const keyIds = Object.keys(this.keys);
415
581
  const keySims = keyIds.length > 0
416
582
  ? batchCosineSim(qEmb, keyIds.map((kid) => this.keys[kid].embedding))
@@ -431,21 +597,19 @@ export class MemoryGraph {
431
597
  keyScores.sort((a, b) => b[0] - a[0]);
432
598
  for (const [keySim, kid] of keyScores.slice(0, 10)) {
433
599
  const idf = this._keyIdf(kid);
434
- for (const memId of this._keyToMems[kid] ?? new Set()) {
600
+ for (const memId of this._keyToMems[kid]?.keys() ?? []) {
435
601
  if (skip(memId))
436
602
  continue;
437
- const mem = this.memories[memId];
438
- const depthFactor = 0.9 + mem.depth * 0.1;
439
- const tf = this._timeFactor(mem);
440
- const score = keySim * idf * depthFactor * tf;
441
- memScores[memId] = (memScores[memId] ?? 0) + score;
603
+ const lw = this._getLinkWeight(kid, memId);
604
+ const score = keySim * idf * lw;
605
+ denseScores[memId] = (denseScores[memId] ?? 0) + score;
442
606
  if (!memMatchedKeys[memId])
443
607
  memMatchedKeys[memId] = [];
444
608
  memMatchedKeys[memId].push(this.keys[kid].concept);
445
609
  memHop[memId] = 1;
446
610
  }
447
611
  }
448
- // ── Path B: Content batch direct matching ──
612
+ // ── Dense Path B: Content batch direct matching ──
449
613
  const memIds = Object.keys(this.memories);
450
614
  if (memIds.length > 0) {
451
615
  const contentSims = batchCosineSim(qEmb, memIds.map((mid) => this.memories[mid].embedding));
@@ -455,15 +619,12 @@ export class MemoryGraph {
455
619
  continue;
456
620
  const cSim = contentSims[i];
457
621
  if (cSim >= CONTENT_RECALL_THRESHOLD) {
458
- const mem = this.memories[mid];
459
- const depthFactor = 0.9 + mem.depth * 0.1;
460
- const tf = this._timeFactor(mem);
461
- const contentScore = cSim * depthFactor * tf * 0.8;
462
- if (mid in memScores) {
463
- memScores[mid] += contentScore * 0.2;
622
+ const contentScore = cSim * 0.8;
623
+ if (mid in denseScores) {
624
+ denseScores[mid] += contentScore * 0.2;
464
625
  }
465
626
  else {
466
- memScores[mid] = contentScore;
627
+ denseScores[mid] = contentScore;
467
628
  }
468
629
  if (!memMatchedKeys[mid])
469
630
  memMatchedKeys[mid] = [];
@@ -473,18 +634,70 @@ export class MemoryGraph {
473
634
  }
474
635
  }
475
636
  }
637
+ // ── Build dense ranked list ──
638
+ const denseRanked = Object.entries(denseScores)
639
+ .sort(([, a], [, b]) => b - a)
640
+ .slice(0, DENSE_RESULT_DEPTH)
641
+ .map(([id, score]) => ({ id, score }));
642
+ // ── RRF fusion ──
643
+ const memScores = {};
644
+ for (let rank = 0; rank < bm25Ranked.length; rank++) {
645
+ const mid = bm25Ranked[rank].id;
646
+ memScores[mid] = (memScores[mid] ?? 0) + 1 / (RRF_K + rank + 1);
647
+ if (!memMatchedKeys[mid])
648
+ memMatchedKeys[mid] = [];
649
+ if (!memMatchedKeys[mid].includes("(bm25)"))
650
+ memMatchedKeys[mid].push("(bm25)");
651
+ if (!(mid in memHop))
652
+ memHop[mid] = 1;
653
+ }
654
+ for (let rank = 0; rank < denseRanked.length; rank++) {
655
+ const mid = denseRanked[rank].id;
656
+ memScores[mid] = (memScores[mid] ?? 0) + 1 / (RRF_K + rank + 1);
657
+ }
658
+ // ── Lexical exact-key boost ──
659
+ // RRF flattens score magnitude, so a memory whose key the query names
660
+ // *literally* ranks no higher than one that merely shares fuzzy content
661
+ // similarity — and with compressed embeddings (e.g. e5) the dense key
662
+ // signal can't break that tie on its own. Give memories an additive bonus
663
+ // when the query literally contains one of their key concepts, so an exact
664
+ // concept hit outranks same-language content noise. IDF-weighted so hub
665
+ // keys don't dominate; on the same RRF scale (~one top-rank contribution).
666
+ for (const [, kid] of keyScores) {
667
+ const concept = this.keys[kid]?.concept;
668
+ if (!concept || concept.length < 2)
669
+ continue;
670
+ if (!queryLower.includes(concept.toLowerCase()))
671
+ continue;
672
+ const bonus = (1 / (RRF_K + 1)) * this._keyIdf(kid);
673
+ for (const memId of this._keyToMems[kid]?.keys() ?? []) {
674
+ if (skip(memId))
675
+ continue;
676
+ memScores[memId] = (memScores[memId] ?? 0) + bonus;
677
+ }
678
+ }
679
+ // ── Apply depth/time modulation to fused scores ──
680
+ for (const mid of Object.keys(memScores)) {
681
+ const mem = this.memories[mid];
682
+ if (!mem)
683
+ continue;
684
+ const depthFactor = 0.9 + mem.depth * 0.1;
685
+ const tf = this._timeFactor(mem);
686
+ memScores[mid] *= depthFactor * tf;
687
+ }
476
688
  // ── 2-hop: via shared keys ──
477
689
  for (const mid of Object.keys(memScores)) {
478
690
  const hop1Score = memScores[mid];
479
- for (const kid of this._memToKeys[mid] ?? new Set()) {
691
+ for (const kid of this._memToKeys[mid]?.keys() ?? []) {
480
692
  if (!(kid in this.keys))
481
693
  continue;
482
694
  const concept = this.keys[kid].concept;
483
695
  const idf = this._keyIdf(kid);
484
- for (const otherMid of this._keyToMems[kid] ?? new Set()) {
696
+ for (const otherMid of this._keyToMems[kid]?.keys() ?? []) {
485
697
  if (otherMid === mid || skip(otherMid))
486
698
  continue;
487
- const hop2Score = hop1Score * MemoryGraph.HOP_DECAY * idf;
699
+ const lw = this._getLinkWeight(kid, otherMid);
700
+ const hop2Score = hop1Score * MemoryGraph.HOP_DECAY * idf * lw;
488
701
  memScores[otherMid] = (memScores[otherMid] ?? 0) + hop2Score;
489
702
  if (!memMatchedKeys[otherMid])
490
703
  memMatchedKeys[otherMid] = [];
@@ -500,7 +713,12 @@ export class MemoryGraph {
500
713
  const memObj = this.memories[mid];
501
714
  if (!memObj)
502
715
  continue;
503
- for (const linkedId of memObj.links) {
716
+ const linkedIds = new Set(memObj.links);
717
+ for (const [otherMid, otherMem] of Object.entries(this.memories)) {
718
+ if (otherMem.links.includes(mid))
719
+ linkedIds.add(otherMid);
720
+ }
721
+ for (const linkedId of linkedIds) {
504
722
  if (linkedId === mid || skip(linkedId))
505
723
  continue;
506
724
  const linkScore = hop1Score * MemoryGraph.HOP_DECAY;
@@ -544,6 +762,31 @@ export class MemoryGraph {
544
762
  links: mem.links,
545
763
  });
546
764
  }
765
+ // ── Hebbian link reinforcement / decay ──
766
+ const returnedSet = new Set(ranked.map(([mid]) => mid));
767
+ const matchedKeyIds = new Set(keyScores.slice(0, 10).map(([, kid]) => kid));
768
+ // Strengthen ONLY the links that actually fired for this query (matched
769
+ // key → returned memory). Reinforcing a returned memory's *other* keys
770
+ // would let an unrelated association grow every time that memory surfaces
771
+ // for a different key, slowly polluting the graph. This mirrors the decay
772
+ // side, which is already scoped to matched keys.
773
+ for (const [mid] of ranked) {
774
+ for (const kid of this._memToKeys[mid]?.keys() ?? []) {
775
+ if (!matchedKeyIds.has(kid))
776
+ continue;
777
+ this._setLinkWeight(kid, mid, this._getLinkWeight(kid, mid) + LINK_REINFORCE_AMOUNT);
778
+ }
779
+ }
780
+ // Weaken: explored but not returned
781
+ for (const [, kid] of keyScores.slice(0, 10)) {
782
+ for (const [memId, cw] of this._keyToMems[kid] ?? new Map()) {
783
+ if (skip(memId))
784
+ continue;
785
+ if (!returnedSet.has(memId)) {
786
+ this._setLinkWeight(kid, memId, cw - LINK_DECAY_RATE);
787
+ }
788
+ }
789
+ }
547
790
  this.markDirty();
548
791
  });
549
792
  await this.flush(); // outside lock
@@ -555,9 +798,9 @@ export class MemoryGraph {
555
798
  return [];
556
799
  const related = {};
557
800
  // Key-sharing
558
- for (const kid of this._memToKeys[memoryId] ?? new Set()) {
801
+ for (const kid of this._memToKeys[memoryId]?.keys() ?? []) {
559
802
  const concept = this.keys[kid]?.concept ?? "?";
560
- for (const mid of this._keyToMems[kid] ?? new Set()) {
803
+ for (const mid of this._keyToMems[kid]?.keys() ?? []) {
561
804
  if (mid === memoryId || !(mid in this.memories))
562
805
  continue;
563
806
  const mem = this.memories[mid];
@@ -583,7 +826,7 @@ export class MemoryGraph {
583
826
  if (!(linkedId in this.memories) || linkedId === memoryId)
584
827
  continue;
585
828
  const mem = this.memories[linkedId];
586
- if (this._isExpired(mem))
829
+ if (this._isExpired(mem) || linkedId in this._supersededBy)
587
830
  continue;
588
831
  if (!related[linkedId]) {
589
832
  related[linkedId] = {
@@ -603,7 +846,7 @@ export class MemoryGraph {
603
846
  }
604
847
  // Reverse links (←)
605
848
  for (const [mid, mem] of Object.entries(this.memories)) {
606
- if (mid === memoryId || this._isExpired(mem))
849
+ if (mid === memoryId || this._isExpired(mem) || mid in this._supersededBy)
607
850
  continue;
608
851
  if (mem.links.includes(memoryId)) {
609
852
  if (!related[mid]) {
@@ -629,7 +872,12 @@ export class MemoryGraph {
629
872
  return false;
630
873
  delete this.memories[memoryId];
631
874
  this._unlinkMemory(memoryId);
875
+ this._removeMemoryReferences([memoryId]);
632
876
  this._pruneOrphanKeys();
877
+ try {
878
+ this._bm25.discard(memoryId);
879
+ }
880
+ catch { /* already removed */ }
633
881
  delete this._supersededBy[memoryId];
634
882
  for (const [oldId, newId] of Object.entries(this._supersededBy)) {
635
883
  if (newId === memoryId)
@@ -673,12 +921,17 @@ export class MemoryGraph {
673
921
  for (const mid of expired) {
674
922
  delete this.memories[mid];
675
923
  this._unlinkMemory(mid);
924
+ try {
925
+ this._bm25.discard(mid);
926
+ }
927
+ catch { /* already removed */ }
676
928
  delete this._supersededBy[mid];
677
929
  for (const [oldId, newId] of Object.entries(this._supersededBy)) {
678
930
  if (newId === mid)
679
931
  delete this._supersededBy[oldId];
680
932
  }
681
933
  }
934
+ this._removeMemoryReferences(expired);
682
935
  this._pruneOrphanKeys();
683
936
  if (expired.length > 0)
684
937
  await this.save();
@@ -689,7 +942,7 @@ export class MemoryGraph {
689
942
  // ── Conversation store ──
690
943
  export async function saveTurn(sessionId, role, content) {
691
944
  await mkdir(CONVERSATIONS_DIR, { recursive: true });
692
- const path = join(CONVERSATIONS_DIR, `${sessionId}.jsonl`);
945
+ const path = conversationPath(sessionId);
693
946
  let turn = 0;
694
947
  try {
695
948
  const text = await readFile(path, "utf-8");
@@ -708,7 +961,7 @@ export async function saveTurn(sessionId, role, content) {
708
961
  return turn;
709
962
  }
710
963
  export async function loadConversation(sessionId, turn) {
711
- const path = join(CONVERSATIONS_DIR, `${sessionId}.jsonl`);
964
+ const path = conversationPath(sessionId);
712
965
  let text;
713
966
  try {
714
967
  text = await readFile(path, "utf-8");
@@ -716,10 +969,17 @@ export async function loadConversation(sessionId, turn) {
716
969
  catch {
717
970
  return [];
718
971
  }
719
- const lines = text
720
- .split("\n")
721
- .filter((l) => l.trim())
722
- .map((l) => JSON.parse(l));
972
+ const lines = [];
973
+ text.split("\n").forEach((line, idx) => {
974
+ if (!line.trim())
975
+ return;
976
+ try {
977
+ lines.push(JSON.parse(line));
978
+ }
979
+ catch (err) {
980
+ throw new Error(`Invalid conversation log ${sessionId} at line ${idx + 1}: ${errorMessage(err)}`);
981
+ }
982
+ });
723
983
  if (turn != null) {
724
984
  const start = Math.max(0, turn - 2);
725
985
  const end = Math.min(lines.length, turn + 3);