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.
- 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 +299 -60
- package/dist/memoryGraph.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +15 -4
- package/dist/server.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,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
|
-
`
|
|
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
|
-
|
|
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,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] ??
|
|
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] ??
|
|
675
|
+
for (const otherMid of this._keyToMems[kid]?.keys() ?? []) {
|
|
485
676
|
if (otherMid === mid || skip(otherMid))
|
|
486
677
|
continue;
|
|
487
|
-
const
|
|
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
|
-
|
|
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] ??
|
|
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] ??
|
|
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 =
|
|
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 =
|
|
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 =
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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);
|