mcp-super-memory 0.4.7 → 0.5.0

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,8 +188,57 @@ 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.`);
193
+ }
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");
115
238
  }
239
+ this._storedDim = newDim;
240
+ await this.save();
241
+ console.error(`[graph] migration complete: now ${newDim}-dim (backup saved as graph.json.bak).`);
116
242
  }
117
243
  _isExpired(mem) {
118
244
  return mem.ttl != null && Date.now() / 1000 > mem.ttl;
@@ -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,49 @@ 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
+ // ── Apply depth/time modulation to fused scores ──
659
+ for (const mid of Object.keys(memScores)) {
660
+ const mem = this.memories[mid];
661
+ if (!mem)
662
+ continue;
663
+ const depthFactor = 0.9 + mem.depth * 0.1;
664
+ const tf = this._timeFactor(mem);
665
+ memScores[mid] *= depthFactor * tf;
666
+ }
476
667
  // ── 2-hop: via shared keys ──
477
668
  for (const mid of Object.keys(memScores)) {
478
669
  const hop1Score = memScores[mid];
479
- for (const kid of this._memToKeys[mid] ?? new Set()) {
670
+ for (const kid of this._memToKeys[mid]?.keys() ?? []) {
480
671
  if (!(kid in this.keys))
481
672
  continue;
482
673
  const concept = this.keys[kid].concept;
483
674
  const idf = this._keyIdf(kid);
484
- for (const otherMid of this._keyToMems[kid] ?? new Set()) {
675
+ for (const otherMid of this._keyToMems[kid]?.keys() ?? []) {
485
676
  if (otherMid === mid || skip(otherMid))
486
677
  continue;
487
- const hop2Score = hop1Score * MemoryGraph.HOP_DECAY * idf;
678
+ const lw = this._getLinkWeight(kid, otherMid);
679
+ const hop2Score = hop1Score * MemoryGraph.HOP_DECAY * idf * lw;
488
680
  memScores[otherMid] = (memScores[otherMid] ?? 0) + hop2Score;
489
681
  if (!memMatchedKeys[otherMid])
490
682
  memMatchedKeys[otherMid] = [];
@@ -500,7 +692,12 @@ export class MemoryGraph {
500
692
  const memObj = this.memories[mid];
501
693
  if (!memObj)
502
694
  continue;
503
- for (const linkedId of memObj.links) {
695
+ const linkedIds = new Set(memObj.links);
696
+ for (const [otherMid, otherMem] of Object.entries(this.memories)) {
697
+ if (otherMem.links.includes(mid))
698
+ linkedIds.add(otherMid);
699
+ }
700
+ for (const linkedId of linkedIds) {
504
701
  if (linkedId === mid || skip(linkedId))
505
702
  continue;
506
703
  const linkScore = hop1Score * MemoryGraph.HOP_DECAY;
@@ -544,6 +741,31 @@ export class MemoryGraph {
544
741
  links: mem.links,
545
742
  });
546
743
  }
744
+ // ── Hebbian link reinforcement / decay ──
745
+ const returnedSet = new Set(ranked.map(([mid]) => mid));
746
+ const matchedKeyIds = new Set(keyScores.slice(0, 10).map(([, kid]) => kid));
747
+ // Strengthen ONLY the links that actually fired for this query (matched
748
+ // key → returned memory). Reinforcing a returned memory's *other* keys
749
+ // would let an unrelated association grow every time that memory surfaces
750
+ // for a different key, slowly polluting the graph. This mirrors the decay
751
+ // side, which is already scoped to matched keys.
752
+ for (const [mid] of ranked) {
753
+ for (const kid of this._memToKeys[mid]?.keys() ?? []) {
754
+ if (!matchedKeyIds.has(kid))
755
+ continue;
756
+ this._setLinkWeight(kid, mid, this._getLinkWeight(kid, mid) + LINK_REINFORCE_AMOUNT);
757
+ }
758
+ }
759
+ // Weaken: explored but not returned
760
+ for (const [, kid] of keyScores.slice(0, 10)) {
761
+ for (const [memId, cw] of this._keyToMems[kid] ?? new Map()) {
762
+ if (skip(memId))
763
+ continue;
764
+ if (!returnedSet.has(memId)) {
765
+ this._setLinkWeight(kid, memId, cw - LINK_DECAY_RATE);
766
+ }
767
+ }
768
+ }
547
769
  this.markDirty();
548
770
  });
549
771
  await this.flush(); // outside lock
@@ -555,9 +777,9 @@ export class MemoryGraph {
555
777
  return [];
556
778
  const related = {};
557
779
  // Key-sharing
558
- for (const kid of this._memToKeys[memoryId] ?? new Set()) {
780
+ for (const kid of this._memToKeys[memoryId]?.keys() ?? []) {
559
781
  const concept = this.keys[kid]?.concept ?? "?";
560
- for (const mid of this._keyToMems[kid] ?? new Set()) {
782
+ for (const mid of this._keyToMems[kid]?.keys() ?? []) {
561
783
  if (mid === memoryId || !(mid in this.memories))
562
784
  continue;
563
785
  const mem = this.memories[mid];
@@ -583,7 +805,7 @@ export class MemoryGraph {
583
805
  if (!(linkedId in this.memories) || linkedId === memoryId)
584
806
  continue;
585
807
  const mem = this.memories[linkedId];
586
- if (this._isExpired(mem))
808
+ if (this._isExpired(mem) || linkedId in this._supersededBy)
587
809
  continue;
588
810
  if (!related[linkedId]) {
589
811
  related[linkedId] = {
@@ -603,7 +825,7 @@ export class MemoryGraph {
603
825
  }
604
826
  // Reverse links (←)
605
827
  for (const [mid, mem] of Object.entries(this.memories)) {
606
- if (mid === memoryId || this._isExpired(mem))
828
+ if (mid === memoryId || this._isExpired(mem) || mid in this._supersededBy)
607
829
  continue;
608
830
  if (mem.links.includes(memoryId)) {
609
831
  if (!related[mid]) {
@@ -629,7 +851,12 @@ export class MemoryGraph {
629
851
  return false;
630
852
  delete this.memories[memoryId];
631
853
  this._unlinkMemory(memoryId);
854
+ this._removeMemoryReferences([memoryId]);
632
855
  this._pruneOrphanKeys();
856
+ try {
857
+ this._bm25.discard(memoryId);
858
+ }
859
+ catch { /* already removed */ }
633
860
  delete this._supersededBy[memoryId];
634
861
  for (const [oldId, newId] of Object.entries(this._supersededBy)) {
635
862
  if (newId === memoryId)
@@ -673,12 +900,17 @@ export class MemoryGraph {
673
900
  for (const mid of expired) {
674
901
  delete this.memories[mid];
675
902
  this._unlinkMemory(mid);
903
+ try {
904
+ this._bm25.discard(mid);
905
+ }
906
+ catch { /* already removed */ }
676
907
  delete this._supersededBy[mid];
677
908
  for (const [oldId, newId] of Object.entries(this._supersededBy)) {
678
909
  if (newId === mid)
679
910
  delete this._supersededBy[oldId];
680
911
  }
681
912
  }
913
+ this._removeMemoryReferences(expired);
682
914
  this._pruneOrphanKeys();
683
915
  if (expired.length > 0)
684
916
  await this.save();
@@ -689,7 +921,7 @@ export class MemoryGraph {
689
921
  // ── Conversation store ──
690
922
  export async function saveTurn(sessionId, role, content) {
691
923
  await mkdir(CONVERSATIONS_DIR, { recursive: true });
692
- const path = join(CONVERSATIONS_DIR, `${sessionId}.jsonl`);
924
+ const path = conversationPath(sessionId);
693
925
  let turn = 0;
694
926
  try {
695
927
  const text = await readFile(path, "utf-8");
@@ -708,7 +940,7 @@ export async function saveTurn(sessionId, role, content) {
708
940
  return turn;
709
941
  }
710
942
  export async function loadConversation(sessionId, turn) {
711
- const path = join(CONVERSATIONS_DIR, `${sessionId}.jsonl`);
943
+ const path = conversationPath(sessionId);
712
944
  let text;
713
945
  try {
714
946
  text = await readFile(path, "utf-8");
@@ -716,10 +948,17 @@ export async function loadConversation(sessionId, turn) {
716
948
  catch {
717
949
  return [];
718
950
  }
719
- const lines = text
720
- .split("\n")
721
- .filter((l) => l.trim())
722
- .map((l) => JSON.parse(l));
951
+ const lines = [];
952
+ text.split("\n").forEach((line, idx) => {
953
+ if (!line.trim())
954
+ return;
955
+ try {
956
+ lines.push(JSON.parse(line));
957
+ }
958
+ catch (err) {
959
+ throw new Error(`Invalid conversation log ${sessionId} at line ${idx + 1}: ${errorMessage(err)}`);
960
+ }
961
+ });
723
962
  if (turn != null) {
724
963
  const start = Math.max(0, turn - 2);
725
964
  const end = Math.min(lines.length, turn + 3);