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.
- package/README.md +107 -35
- package/dist/embedding.d.ts +13 -1
- package/dist/embedding.d.ts.map +1 -1
- package/dist/embedding.js +125 -12
- package/dist/embedding.js.map +1 -1
- package/dist/memoryGraph.d.ts +11 -1
- package/dist/memoryGraph.d.ts.map +1 -1
- package/dist/memoryGraph.js +320 -60
- package/dist/memoryGraph.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -8
package/dist/memoryGraph.js
CHANGED
|
@@ -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
|
|
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
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
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
|
-
|
|
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
|
|
76
|
-
this._keyToMems[keyId].
|
|
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
|
|
79
|
-
this._memToKeys[memId].
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? []
|
|
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
|
|
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
|
|
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
|
|
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
|
-
// ──
|
|
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] ??
|
|
600
|
+
for (const memId of this._keyToMems[kid]?.keys() ?? []) {
|
|
435
601
|
if (skip(memId))
|
|
436
602
|
continue;
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
|
|
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
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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] ??
|
|
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] ??
|
|
696
|
+
for (const otherMid of this._keyToMems[kid]?.keys() ?? []) {
|
|
485
697
|
if (otherMid === mid || skip(otherMid))
|
|
486
698
|
continue;
|
|
487
|
-
const
|
|
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
|
-
|
|
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] ??
|
|
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] ??
|
|
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 =
|
|
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 =
|
|
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 =
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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);
|