th-memory-mcp 2.2.9 → 2.3.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/ARCHITECTURE_v2.md +1 -1
- package/README.md +52 -1
- package/README.th.md +51 -1
- package/SECURITY.md +9 -0
- package/design.md +1 -1
- package/dist/cli/commands.js +507 -0
- package/dist/cli.js +4 -0
- package/dist/core/consolidation-engine.js +119 -2
- package/dist/core/graph-engine.js +11 -0
- package/dist/db/index.js +4 -0
- package/dist/db/migrations.js +30 -0
- package/dist/lib/config.js +1 -1
- package/dist/lib/highlight.js +139 -0
- package/dist/lib/memory-format.js +53 -0
- package/dist/memory/deduplicator.js +24 -9
- package/dist/retrieval/fts.js +17 -21
- package/dist/tools/consolidate.js +47 -21
- package/dist/tools/context.js +6 -2
- package/dist/tools/forget.js +6 -0
- package/dist/tools/import_memory.js +255 -138
- package/dist/tools/merge_memory.js +14 -0
- package/dist/tools/profile.js +16 -3
- package/dist/tools/recall.js +3 -1
- package/package.json +4 -2
|
@@ -30,6 +30,27 @@ export const importMemoryInput = {
|
|
|
30
30
|
function isValidSource(s) {
|
|
31
31
|
return SOURCE_TYPES.includes(s);
|
|
32
32
|
}
|
|
33
|
+
// Batch A-2: normalize the per-item trust marker. Accepts boolean true,
|
|
34
|
+
// "trusted"/"trust"/"yes"/"1" strings as trusted; everything else (including
|
|
35
|
+
// missing) defaults to untrusted (false). No schema change — the flag is
|
|
36
|
+
// carried inside memory metadata as { trusted: boolean } for team B delimiters.
|
|
37
|
+
function parseTrustedFlag(v) {
|
|
38
|
+
// Strict: only a boolean true marks a source trusted. String aliases
|
|
39
|
+
// ("yes"/"true"/"1") are NOT accepted — otherwise a caller could pass
|
|
40
|
+
// trusted:"yes" and bypass the untrusted-import label.
|
|
41
|
+
return v === true;
|
|
42
|
+
}
|
|
43
|
+
function withTrustedFlag(meta, trusted) {
|
|
44
|
+
const flag = parseTrustedFlag(trusted);
|
|
45
|
+
if (meta == null)
|
|
46
|
+
return { trusted: flag };
|
|
47
|
+
if (typeof meta === "object" && !Array.isArray(meta)) {
|
|
48
|
+
return { ...meta, trusted: flag };
|
|
49
|
+
}
|
|
50
|
+
// Non-object legacy metadata cannot carry a field — wrap it so the
|
|
51
|
+
// contract metadata.trusted stays readable without altering the DB schema.
|
|
52
|
+
return { value: meta, trusted: flag };
|
|
53
|
+
}
|
|
33
54
|
export function importMemoryHandler(args) {
|
|
34
55
|
try {
|
|
35
56
|
let raw;
|
|
@@ -104,13 +125,15 @@ export function importMemoryHandler(args) {
|
|
|
104
125
|
else {
|
|
105
126
|
memoryItems = [];
|
|
106
127
|
}
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// a
|
|
128
|
+
// Batch A-2 phase 1: parse + validate everything first (no DB writes).
|
|
129
|
+
// Phase 2 (below) applies all validated rows inside ONE db.transaction,
|
|
130
|
+
// so a mid-way error rolls back everything. Users are validated here and
|
|
131
|
+
// restored first inside the transaction so createMemory()->ensureUser
|
|
132
|
+
// reuses the restored row. Idempotent via UNIQUE(external_id).
|
|
110
133
|
let usersImported = 0;
|
|
111
134
|
let usersInvalid = 0;
|
|
135
|
+
const validUsers = [];
|
|
112
136
|
if (exportUsers != null) {
|
|
113
|
-
const insertUser = db.prepare("INSERT OR IGNORE INTO users (external_id, name, created_at) VALUES (?, ?, ?)");
|
|
114
137
|
for (const u of exportUsers) {
|
|
115
138
|
const external = typeof u.externalId === "string"
|
|
116
139
|
? u.externalId
|
|
@@ -132,9 +155,7 @@ export function importMemoryHandler(args) {
|
|
|
132
155
|
: null) ?? nowISO();
|
|
133
156
|
const name = typeof u.name === "string" ? u.name.slice(0, 500) : null;
|
|
134
157
|
usersImported++;
|
|
135
|
-
|
|
136
|
-
insertUser.run(external, name, created);
|
|
137
|
-
}
|
|
158
|
+
validUsers.push({ external, name, created });
|
|
138
159
|
}
|
|
139
160
|
}
|
|
140
161
|
let wouldImport = 0;
|
|
@@ -142,6 +163,11 @@ export function importMemoryHandler(args) {
|
|
|
142
163
|
let invalid = 0;
|
|
143
164
|
const log = [];
|
|
144
165
|
const importedIds = new Map();
|
|
166
|
+
// Phase-1 validated memory payloads (applied in the single transaction).
|
|
167
|
+
const validMemories = [];
|
|
168
|
+
// Intra-batch exact-dupe guard: deduplicate() only sees committed rows,
|
|
169
|
+
// so track normalized type+content accepted in THIS batch as well.
|
|
170
|
+
const batchSeen = new Set();
|
|
145
171
|
for (const [idx, it] of memoryItems.entries()) {
|
|
146
172
|
if (!it ||
|
|
147
173
|
typeof it.content !== "string" ||
|
|
@@ -210,49 +236,46 @@ export function importMemoryHandler(args) {
|
|
|
210
236
|
log.push(`skip duplicate -> existing ${dup.existingId}`);
|
|
211
237
|
continue;
|
|
212
238
|
}
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
summary: typeof it.summary === "string" ? it.summary : null,
|
|
219
|
-
source: it.source ?? "imported",
|
|
220
|
-
confidence: typeof it.confidence === "number" ? it.confidence : 0.7,
|
|
221
|
-
importance: typeof it.importance === "number" ? it.importance : 0.5,
|
|
222
|
-
salience: typeof it.salience === "number" ? it.salience : 0.5,
|
|
223
|
-
projectId: typeof it.projectId === "string" ? it.projectId : null,
|
|
224
|
-
sessionId: typeof it.sessionId === "string" ? it.sessionId : null,
|
|
225
|
-
userId: typeof it.userId === "string"
|
|
226
|
-
? it.userId
|
|
227
|
-
: typeof args.userId === "string"
|
|
228
|
-
? args.userId
|
|
229
|
-
: null,
|
|
230
|
-
validFrom: typeof it.validFrom === "string" ? it.validFrom : null,
|
|
231
|
-
validUntil: typeof it.validUntil === "string" ? it.validUntil : null,
|
|
232
|
-
metadata: it.metadata ?? null,
|
|
233
|
-
});
|
|
234
|
-
if (typeof it.id === "number" && Number.isInteger(it.id))
|
|
235
|
-
importedIds.set(it.id, newId);
|
|
236
|
-
if (typeof it.status === "string" && it.status !== "active") {
|
|
237
|
-
db.prepare("UPDATE memories SET status = ? WHERE id = ?").run(it.status, newId);
|
|
238
|
-
}
|
|
239
|
+
const batchKey = `${it.type}::${it.content.toLowerCase().trim()}`;
|
|
240
|
+
if (batchSeen.has(batchKey)) {
|
|
241
|
+
skipped++;
|
|
242
|
+
log.push(`skip duplicate -> duplicate within import batch (row ${idx})`);
|
|
243
|
+
continue;
|
|
239
244
|
}
|
|
245
|
+
batchSeen.add(batchKey);
|
|
246
|
+
wouldImport++;
|
|
247
|
+
validMemories.push({ idx, it });
|
|
240
248
|
}
|
|
241
249
|
let linksImported = 0;
|
|
242
250
|
let linksInvalid = 0;
|
|
251
|
+
const validLinks = [];
|
|
243
252
|
if (exportLinks != null) {
|
|
253
|
+
// Phase-1 remap precheck (no writes): a link is resolvable in phase 2
|
|
254
|
+
// iff both old ids belong to memories validated in THIS batch
|
|
255
|
+
// (mirrors the original importedIds-only rule, but works for dry-run too —
|
|
256
|
+
// the old code always reported links invalid on dry-run because the map
|
|
257
|
+
// was still empty at validate time).
|
|
258
|
+
const batchMemIds = new Set();
|
|
259
|
+
for (const { it } of validMemories) {
|
|
260
|
+
if (typeof it.id === "number" && Number.isInteger(it.id))
|
|
261
|
+
batchMemIds.add(it.id);
|
|
262
|
+
}
|
|
244
263
|
for (const link of exportLinks) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
264
|
+
if (typeof link.sourceId !== "number" ||
|
|
265
|
+
typeof link.targetId !== "number" ||
|
|
266
|
+
typeof link.relation !== "string" ||
|
|
267
|
+
!LINK_RELATIONS.includes(link.relation) ||
|
|
248
268
|
(link.confidence != null && (typeof link.confidence !== "number" || link.confidence < 0 || link.confidence > 1))) {
|
|
249
269
|
linksInvalid++;
|
|
250
270
|
continue;
|
|
251
271
|
}
|
|
252
|
-
if (
|
|
253
|
-
|
|
272
|
+
if (!batchMemIds.has(link.sourceId) || !batchMemIds.has(link.targetId)) {
|
|
273
|
+
linksInvalid++;
|
|
274
|
+
log.push(`invalid link ${link.sourceId}->${link.targetId}: unresolvable memory id`);
|
|
275
|
+
continue;
|
|
254
276
|
}
|
|
255
277
|
linksImported++;
|
|
278
|
+
validLinks.push({ sourceId: link.sourceId, targetId: link.targetId, relation: link.relation, confidence: link.confidence });
|
|
256
279
|
}
|
|
257
280
|
}
|
|
258
281
|
// Batch A: restore M003 entities/relations idempotently.
|
|
@@ -263,9 +286,8 @@ export function importMemoryHandler(args) {
|
|
|
263
286
|
let entitiesImported = 0;
|
|
264
287
|
let entitiesInvalid = 0;
|
|
265
288
|
const importedEntityIds = new Map();
|
|
289
|
+
const validEntities = [];
|
|
266
290
|
if (exportEntities != null) {
|
|
267
|
-
const selEntity = db.prepare("SELECT id FROM entities WHERE canonical_name = ?");
|
|
268
|
-
const insEntity = db.prepare("INSERT INTO entities (name, canonical_name, type, metadata) VALUES (?, ?, ?, ?)");
|
|
269
291
|
for (const e of exportEntities) {
|
|
270
292
|
const name = typeof e.name === "string" ? e.name : "";
|
|
271
293
|
const canonical = typeof e.canonicalName === "string"
|
|
@@ -278,34 +300,22 @@ export function importMemoryHandler(args) {
|
|
|
278
300
|
continue;
|
|
279
301
|
}
|
|
280
302
|
entitiesImported++;
|
|
281
|
-
|
|
282
|
-
const existing = selEntity.get(canonical);
|
|
283
|
-
let newId;
|
|
284
|
-
if (existing) {
|
|
285
|
-
newId = existing.id;
|
|
286
|
-
}
|
|
287
|
-
else {
|
|
288
|
-
const type = typeof e.type === "string" ? e.type.slice(0, 200) : "concept";
|
|
289
|
-
const meta = e.metadata == null
|
|
290
|
-
? JSON.stringify({})
|
|
291
|
-
: typeof e.metadata === "string"
|
|
292
|
-
? e.metadata
|
|
293
|
-
: JSON.stringify(e.metadata);
|
|
294
|
-
const res = insEntity.run(name.slice(0, 500), canonical, type, meta);
|
|
295
|
-
newId = Number(res.lastInsertRowid);
|
|
296
|
-
}
|
|
297
|
-
if (typeof e.id === "number" && Number.isInteger(e.id))
|
|
298
|
-
importedEntityIds.set(e.id, newId);
|
|
299
|
-
}
|
|
303
|
+
validEntities.push({ e, name, canonical });
|
|
300
304
|
}
|
|
301
305
|
}
|
|
302
306
|
let relationsImported = 0;
|
|
303
307
|
let relationsInvalid = 0;
|
|
308
|
+
const validRelations = [];
|
|
304
309
|
if (exportRelations != null) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
310
|
+
// Phase-1 entity-existence precheck (read-only): an old entity id is
|
|
311
|
+
// resolvable in phase 2 iff it is shipped in THIS batch (validEntities)
|
|
312
|
+
// or still exists in the DB. oldMem stays orphan-safe (NULL fallback).
|
|
313
|
+
const batchEntIds = new Set();
|
|
314
|
+
for (const { e } of validEntities) {
|
|
315
|
+
if (typeof e.id === "number" && Number.isInteger(e.id))
|
|
316
|
+
batchEntIds.add(e.id);
|
|
317
|
+
}
|
|
318
|
+
const selEntExists = db.prepare("SELECT id FROM entities WHERE id = ?");
|
|
309
319
|
for (const r of exportRelations) {
|
|
310
320
|
const oldSource = typeof r.sourceEntityId === "number"
|
|
311
321
|
? r.sourceEntityId
|
|
@@ -348,61 +358,31 @@ export function importMemoryHandler(args) {
|
|
|
348
358
|
relationsInvalid++;
|
|
349
359
|
continue;
|
|
350
360
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if (importedEntityIds.has(oldSource))
|
|
355
|
-
newSource = importedEntityIds.get(oldSource);
|
|
356
|
-
else if (selEntityById.get(oldSource))
|
|
357
|
-
newSource = oldSource;
|
|
358
|
-
if (importedEntityIds.has(oldTarget))
|
|
359
|
-
newTarget = importedEntityIds.get(oldTarget);
|
|
360
|
-
else if (selEntityById.get(oldTarget))
|
|
361
|
-
newTarget = oldTarget;
|
|
362
|
-
if (newSource == null || newTarget == null) {
|
|
361
|
+
const srcOk = batchEntIds.has(oldSource) || !!selEntExists.get(oldSource);
|
|
362
|
+
const tgtOk = batchEntIds.has(oldTarget) || !!selEntExists.get(oldTarget);
|
|
363
|
+
if (!srcOk || !tgtOk) {
|
|
363
364
|
relationsInvalid++;
|
|
364
365
|
continue;
|
|
365
366
|
}
|
|
366
|
-
// Remap memory id: prefer import map, else keep original if still present, else NULL (orphan-safe).
|
|
367
|
-
let newMem = null;
|
|
368
|
-
if (oldMem == null)
|
|
369
|
-
newMem = null;
|
|
370
|
-
else if (importedIds.has(oldMem))
|
|
371
|
-
newMem = importedIds.get(oldMem);
|
|
372
|
-
else if (selMemById.get(oldMem))
|
|
373
|
-
newMem = oldMem;
|
|
374
|
-
else
|
|
375
|
-
newMem = null;
|
|
376
367
|
relationsImported++;
|
|
377
|
-
|
|
378
|
-
const dup = selRelation.get(newSource, predicate, newTarget, newMem);
|
|
379
|
-
if (dup)
|
|
380
|
-
continue;
|
|
381
|
-
const meta = r.metadata == null
|
|
382
|
-
? JSON.stringify({})
|
|
383
|
-
: typeof r.metadata === "string"
|
|
384
|
-
? r.metadata
|
|
385
|
-
: JSON.stringify(r.metadata);
|
|
386
|
-
insRelation.run(newSource, predicate, newTarget, conf, vf, vu, newMem, meta);
|
|
387
|
-
}
|
|
368
|
+
validRelations.push({ r, oldSource, oldTarget, predicate, conf, vf, vu, oldMem });
|
|
388
369
|
}
|
|
389
370
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const upsertProfile = db.prepare("INSERT INTO profile (section, content, updated_at) VALUES (?, ?, ?) ON CONFLICT(section) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at");
|
|
371
|
+
// Phase-1 validation for prefs/lessons/profile (no writes yet).
|
|
372
|
+
const prefArr = Array.isArray(exportPrefs) ? exportPrefs : [];
|
|
373
|
+
const lessonArr = Array.isArray(exportLessons) ? exportLessons : [];
|
|
374
|
+
const profileArr = Array.isArray(exportProfile) ? exportProfile : [];
|
|
375
|
+
const hasAux = exportPrefs != null || exportLessons != null || exportProfile != null;
|
|
376
|
+
const validCat = new Set(["work_style", "coding_pref", "language", "domain", "other"]);
|
|
377
|
+
let prefOk = 0;
|
|
378
|
+
let prefInvalid = 0;
|
|
379
|
+
let lessonOk = 0;
|
|
380
|
+
let lessonInvalid = 0;
|
|
381
|
+
let profileOk = 0;
|
|
382
|
+
const validPrefs = [];
|
|
383
|
+
const validLessons = [];
|
|
384
|
+
const validProfiles = [];
|
|
385
|
+
if (hasAux) {
|
|
406
386
|
for (const p of prefArr) {
|
|
407
387
|
if (!p ||
|
|
408
388
|
typeof p.category !== "string" ||
|
|
@@ -418,21 +398,7 @@ export function importMemoryHandler(args) {
|
|
|
418
398
|
continue;
|
|
419
399
|
}
|
|
420
400
|
prefOk++;
|
|
421
|
-
|
|
422
|
-
const existing = selectPref.get(p.category, p.key);
|
|
423
|
-
const conf = typeof p.confidence === "number" ? p.confidence : 0.5;
|
|
424
|
-
const src = typeof p.source === "string" ? p.source : "imported";
|
|
425
|
-
const ts = typeof p.updated_at === "string" && isIsoDateString(p.updated_at) ? p.updated_at : nowISO();
|
|
426
|
-
if (existing)
|
|
427
|
-
updatePref.run(p.value, conf, src, ts, p.category, p.key);
|
|
428
|
-
else
|
|
429
|
-
insertPref.run(p.category, p.key, p.value, conf, src, ts);
|
|
430
|
-
const row = db.prepare("SELECT id FROM preferences WHERE category = ? AND key = ?").get(p.category, p.key);
|
|
431
|
-
if (row) {
|
|
432
|
-
syncSearchIndex("preferences", row.id, `${p.category}/${p.key}`, `${p.key}: ${p.value}`);
|
|
433
|
-
upsertEmbedding("preferences", row.id, embed(`${p.category} ${p.key} ${p.value}`));
|
|
434
|
-
}
|
|
435
|
-
}
|
|
401
|
+
validPrefs.push(p);
|
|
436
402
|
}
|
|
437
403
|
for (const l of lessonArr) {
|
|
438
404
|
if (!l ||
|
|
@@ -449,23 +415,174 @@ export function importMemoryHandler(args) {
|
|
|
449
415
|
continue;
|
|
450
416
|
}
|
|
451
417
|
lessonOk++;
|
|
452
|
-
|
|
453
|
-
const ts = typeof l.created_at === "string" && isIsoDateString(l.created_at) ? l.created_at : nowISO();
|
|
454
|
-
const res = insertLesson.run(l.situation, l.mistake, l.correction, ts);
|
|
455
|
-
const id = Number(res.lastInsertRowid);
|
|
456
|
-
syncSearchIndex("lessons", id, l.situation.slice(0, 80), `${l.situation} | mistake: ${l.mistake} -> correction: ${l.correction}`);
|
|
457
|
-
upsertEmbedding("lessons", id, embed(`${l.situation} ${l.mistake} ${l.correction}`));
|
|
458
|
-
}
|
|
418
|
+
validLessons.push(l);
|
|
459
419
|
}
|
|
460
420
|
for (const pr of profileArr) {
|
|
461
421
|
if (!pr || typeof pr.section !== "string" || pr.section.length === 0 || typeof pr.content !== "string" || pr.content.length === 0)
|
|
462
422
|
continue;
|
|
463
423
|
profileOk++;
|
|
464
|
-
|
|
465
|
-
const ts = typeof pr.updated_at === "string" && isIsoDateString(pr.updated_at) ? pr.updated_at : nowISO();
|
|
466
|
-
upsertProfile.run(pr.section, pr.content, ts);
|
|
467
|
-
}
|
|
424
|
+
validProfiles.push(pr);
|
|
468
425
|
}
|
|
426
|
+
}
|
|
427
|
+
// Batch A-2 phase 2: single-transaction apply. All validated rows go in
|
|
428
|
+
// together; any mid-way throw rolls back EVERYTHING (atomic import).
|
|
429
|
+
if (args.apply === true) {
|
|
430
|
+
try {
|
|
431
|
+
const applyAll = db.transaction(() => {
|
|
432
|
+
const insertUser = db.prepare("INSERT OR IGNORE INTO users (external_id, name, created_at) VALUES (?, ?, ?)");
|
|
433
|
+
for (const u of validUsers)
|
|
434
|
+
insertUser.run(u.external, u.name, u.created);
|
|
435
|
+
for (const { it } of validMemories) {
|
|
436
|
+
const newId = createMemory({
|
|
437
|
+
type: it.type,
|
|
438
|
+
content: it.content,
|
|
439
|
+
summary: typeof it.summary === "string" ? it.summary : null,
|
|
440
|
+
source: it.source ?? "imported",
|
|
441
|
+
confidence: typeof it.confidence === "number" ? it.confidence : 0.7,
|
|
442
|
+
importance: typeof it.importance === "number" ? it.importance : 0.5,
|
|
443
|
+
salience: typeof it.salience === "number" ? it.salience : 0.5,
|
|
444
|
+
projectId: typeof it.projectId === "string" ? it.projectId : null,
|
|
445
|
+
sessionId: typeof it.sessionId === "string" ? it.sessionId : null,
|
|
446
|
+
userId: typeof it.userId === "string"
|
|
447
|
+
? it.userId
|
|
448
|
+
: typeof args.userId === "string"
|
|
449
|
+
? args.userId
|
|
450
|
+
: null,
|
|
451
|
+
validFrom: typeof it.validFrom === "string" ? it.validFrom : null,
|
|
452
|
+
validUntil: typeof it.validUntil === "string" ? it.validUntil : null,
|
|
453
|
+
metadata: withTrustedFlag(it.metadata, it.trusted),
|
|
454
|
+
});
|
|
455
|
+
if (typeof it.id === "number" && Number.isInteger(it.id))
|
|
456
|
+
importedIds.set(it.id, newId);
|
|
457
|
+
if (typeof it.status === "string" && it.status !== "active") {
|
|
458
|
+
db.prepare("UPDATE memories SET status = ? WHERE id = ?").run(it.status, newId);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Entities first (relations remap needs importedEntityIds).
|
|
462
|
+
const selEntity = db.prepare("SELECT id FROM entities WHERE canonical_name = ?");
|
|
463
|
+
const insEntity = db.prepare("INSERT INTO entities (name, canonical_name, type, metadata) VALUES (?, ?, ?, ?)");
|
|
464
|
+
const batchCanonical = new Map();
|
|
465
|
+
for (const { e, name, canonical } of validEntities) {
|
|
466
|
+
if (batchCanonical.has(canonical)) {
|
|
467
|
+
if (typeof e.id === "number" && Number.isInteger(e.id))
|
|
468
|
+
importedEntityIds.set(e.id, batchCanonical.get(canonical));
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const existing = selEntity.get(canonical);
|
|
472
|
+
let newId;
|
|
473
|
+
if (existing) {
|
|
474
|
+
newId = existing.id;
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
const type = typeof e.type === "string" ? e.type.slice(0, 200) : "concept";
|
|
478
|
+
const meta = e.metadata == null
|
|
479
|
+
? JSON.stringify({})
|
|
480
|
+
: typeof e.metadata === "string"
|
|
481
|
+
? e.metadata
|
|
482
|
+
: JSON.stringify(e.metadata);
|
|
483
|
+
const res = insEntity.run(name.slice(0, 500), canonical, type, meta);
|
|
484
|
+
newId = Number(res.lastInsertRowid);
|
|
485
|
+
}
|
|
486
|
+
batchCanonical.set(canonical, newId);
|
|
487
|
+
if (typeof e.id === "number" && Number.isInteger(e.id))
|
|
488
|
+
importedEntityIds.set(e.id, newId);
|
|
489
|
+
}
|
|
490
|
+
const insLink = db.prepare("INSERT OR IGNORE INTO memory_links (source_memory_id, relation, target_memory_id, confidence, created_at) VALUES (?, ?, ?, ?, ?)");
|
|
491
|
+
for (const link of validLinks) {
|
|
492
|
+
const sourceId = importedIds.get(link.sourceId);
|
|
493
|
+
const targetId = importedIds.get(link.targetId);
|
|
494
|
+
if (!sourceId || !targetId) {
|
|
495
|
+
// Old id has no remapped row (points outside this import and
|
|
496
|
+
// the row is gone): reclassify as invalid, keep counts honest.
|
|
497
|
+
linksImported--;
|
|
498
|
+
linksInvalid++;
|
|
499
|
+
log.push(`invalid link ${link.sourceId}->${link.targetId}: unresolvable memory id`);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
insLink.run(sourceId, link.relation, targetId, link.confidence ?? 0.5, nowISO());
|
|
503
|
+
}
|
|
504
|
+
const selEntityById = db.prepare("SELECT id FROM entities WHERE id = ?");
|
|
505
|
+
const selMemById = db.prepare("SELECT id FROM memories WHERE id = ?");
|
|
506
|
+
const selRelation = db.prepare("SELECT id FROM relations WHERE source_entity_id = ? AND relation = ? AND target_entity_id = ? AND COALESCE(source_memory_id, -1) = COALESCE(?, -1)");
|
|
507
|
+
const insRelation = db.prepare("INSERT INTO relations (source_entity_id, relation, target_entity_id, confidence, valid_from, valid_until, source_memory_id, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
508
|
+
for (const v of validRelations) {
|
|
509
|
+
let newSource = null;
|
|
510
|
+
let newTarget = null;
|
|
511
|
+
if (importedEntityIds.has(v.oldSource))
|
|
512
|
+
newSource = importedEntityIds.get(v.oldSource);
|
|
513
|
+
else if (selEntityById.get(v.oldSource))
|
|
514
|
+
newSource = v.oldSource;
|
|
515
|
+
if (importedEntityIds.has(v.oldTarget))
|
|
516
|
+
newTarget = importedEntityIds.get(v.oldTarget);
|
|
517
|
+
else if (selEntityById.get(v.oldTarget))
|
|
518
|
+
newTarget = v.oldTarget;
|
|
519
|
+
if (newSource == null || newTarget == null) {
|
|
520
|
+
relationsImported--;
|
|
521
|
+
relationsInvalid++;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
let newMem = null;
|
|
525
|
+
if (v.oldMem == null)
|
|
526
|
+
newMem = null;
|
|
527
|
+
else if (importedIds.has(v.oldMem))
|
|
528
|
+
newMem = importedIds.get(v.oldMem);
|
|
529
|
+
else if (selMemById.get(v.oldMem))
|
|
530
|
+
newMem = v.oldMem;
|
|
531
|
+
else
|
|
532
|
+
newMem = null;
|
|
533
|
+
const dup = selRelation.get(newSource, v.predicate, newTarget, newMem);
|
|
534
|
+
if (dup)
|
|
535
|
+
continue;
|
|
536
|
+
const meta = v.r.metadata == null
|
|
537
|
+
? JSON.stringify({})
|
|
538
|
+
: typeof v.r.metadata === "string"
|
|
539
|
+
? v.r.metadata
|
|
540
|
+
: JSON.stringify(v.r.metadata);
|
|
541
|
+
insRelation.run(newSource, v.predicate, newTarget, v.conf, v.vf, v.vu, newMem, meta);
|
|
542
|
+
}
|
|
543
|
+
if (hasAux) {
|
|
544
|
+
const selectPref = db.prepare("SELECT id FROM preferences WHERE category = ? AND key = ?");
|
|
545
|
+
const insertPref = db.prepare("INSERT INTO preferences (category, key, value, confidence, source, updated_at) VALUES (?, ?, ?, ?, ?, ?)");
|
|
546
|
+
const updatePref = db.prepare("UPDATE preferences SET value = ?, confidence = ?, source = ?, updated_at = ? WHERE category = ? AND key = ?");
|
|
547
|
+
const insertLesson = db.prepare("INSERT INTO lessons (situation, mistake, correction, created_at) VALUES (?, ?, ?, ?)");
|
|
548
|
+
const upsertProfile = db.prepare("INSERT INTO profile (section, content, updated_at) VALUES (?, ?, ?) ON CONFLICT(section) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at");
|
|
549
|
+
for (const p of validPrefs) {
|
|
550
|
+
const existing = selectPref.get(p.category, p.key);
|
|
551
|
+
const conf = typeof p.confidence === "number" ? p.confidence : 0.5;
|
|
552
|
+
const src = typeof p.source === "string" ? p.source : "imported";
|
|
553
|
+
const ts = typeof p.updated_at === "string" && isIsoDateString(p.updated_at) ? p.updated_at : nowISO();
|
|
554
|
+
if (existing)
|
|
555
|
+
updatePref.run(p.value, conf, src, ts, p.category, p.key);
|
|
556
|
+
else
|
|
557
|
+
insertPref.run(p.category, p.key, p.value, conf, src, ts);
|
|
558
|
+
const row = db.prepare("SELECT id FROM preferences WHERE category = ? AND key = ?").get(p.category, p.key);
|
|
559
|
+
if (row) {
|
|
560
|
+
syncSearchIndex("preferences", row.id, `${p.category}/${p.key}`, `${p.key}: ${p.value}`);
|
|
561
|
+
upsertEmbedding("preferences", row.id, embed(`${p.category} ${p.key} ${p.value}`));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
for (const l of validLessons) {
|
|
565
|
+
const ts = typeof l.created_at === "string" && isIsoDateString(l.created_at) ? l.created_at : nowISO();
|
|
566
|
+
const res = insertLesson.run(l.situation, l.mistake, l.correction, ts);
|
|
567
|
+
const id = Number(res.lastInsertRowid);
|
|
568
|
+
syncSearchIndex("lessons", id, (l.situation ?? "").slice(0, 80), `${l.situation} | mistake: ${l.mistake} -> correction: ${l.correction}`);
|
|
569
|
+
upsertEmbedding("lessons", id, embed(`${l.situation} ${l.mistake} ${l.correction}`));
|
|
570
|
+
}
|
|
571
|
+
for (const pr of validProfiles) {
|
|
572
|
+
const ts = typeof pr.updated_at === "string" && isIsoDateString(pr.updated_at) ? pr.updated_at : nowISO();
|
|
573
|
+
upsertProfile.run(pr.section, pr.content, ts);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
applyAll();
|
|
578
|
+
}
|
|
579
|
+
catch (e) {
|
|
580
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
581
|
+
return err(`import failed and rolled back: ${reason} (validated ${wouldImport} memories, ${linksImported} links, ${entitiesImported} entities, ${relationsImported} relations; nothing was written)`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const graphSuffix = `users ${usersImported} ok ${usersInvalid} invalid; entities ${entitiesImported} ok ${entitiesInvalid} invalid; relations ${relationsImported} ok ${relationsInvalid} invalid`;
|
|
585
|
+
if (hasAux) {
|
|
469
586
|
const mode = args.apply === true ? "applied" : "dry-run";
|
|
470
587
|
const summary = `import ${mode}: ${wouldImport} memories to import, ${skipped} duplicate(s) skipped, ${invalid} invalid memories; ${linksImported} link(s) imported, ${linksInvalid} invalid link(s); preferences ${prefOk} ok ${prefInvalid} invalid; lessons ${lessonOk} ok ${lessonInvalid} invalid; profile ${profileOk} ok; ${graphSuffix}`;
|
|
471
588
|
return ok(summary);
|
|
@@ -39,6 +39,20 @@ export function mergeMemoryHandler(args) {
|
|
|
39
39
|
return err("cannot merge a memory into itself");
|
|
40
40
|
if (src.status === "deleted" || tgt.status === "deleted")
|
|
41
41
|
return err("cannot merge deleted memories");
|
|
42
|
+
// Batch B-3 (defense-in-depth, mirrors link_memory): refuse to merge
|
|
43
|
+
// across scope boundaries. Note: userId/sessionId/projectId are
|
|
44
|
+
// caller-supplied with no auth layer (single-user local process) — these
|
|
45
|
+
// checks are only as trustworthy as the caller (see README/SECURITY).
|
|
46
|
+
if ((src.scope === "USER" || tgt.scope === "USER") &&
|
|
47
|
+
src.user_id !== tgt.user_id)
|
|
48
|
+
return err("cannot merge memories across different users");
|
|
49
|
+
if ((src.scope === "SESSION" || tgt.scope === "SESSION") &&
|
|
50
|
+
src.session_id !== tgt.session_id)
|
|
51
|
+
return err("cannot merge memories across different sessions");
|
|
52
|
+
if (src.scope === "PROJECT" &&
|
|
53
|
+
tgt.scope === "PROJECT" &&
|
|
54
|
+
src.project_id !== tgt.project_id)
|
|
55
|
+
return err("cannot merge memories across different projects");
|
|
42
56
|
db.prepare("UPDATE memories SET metadata = ?, updated_at = ? WHERE id = ?").run(mergeMetadata(tgt.metadata, src.id), nowISO(), tgt.id);
|
|
43
57
|
supersede(src.id, tgt.id);
|
|
44
58
|
return ok(`merged memory ${src.id} into ${tgt.id} (source superseded, provenance recorded in metadata.merged_from)`);
|
package/dist/tools/profile.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { db, truncate, ok, err, } from "../db/index.js";
|
|
2
|
+
import { MEMORY_REF_CLOSE, MEMORY_REF_GUIDANCE, MEMORY_REF_OPEN, UNTRUSTED_NOTE, UNTRUSTED_TAG, isUntrustedMetadata, } from "../lib/memory-format.js";
|
|
2
3
|
export const PROFILE_BUDGET = 3000;
|
|
3
4
|
const PROFILE_SECTION_MAX = 400;
|
|
4
5
|
const PREF_CATEGORY_MAX = 30;
|
|
@@ -9,7 +10,7 @@ const LESSON_CORRECTION_MAX = 150;
|
|
|
9
10
|
const profileRows = db.prepare("SELECT section, content FROM profile");
|
|
10
11
|
const topPrefs = db.prepare("SELECT category, key, value, confidence FROM preferences ORDER BY confidence DESC, updated_at DESC LIMIT 15");
|
|
11
12
|
const recentLessons = db.prepare("SELECT situation, mistake, correction FROM lessons ORDER BY created_at DESC, id DESC LIMIT 5");
|
|
12
|
-
const topMemories = db.prepare("SELECT type, content, importance, confidence FROM memories WHERE status = 'active' ORDER BY importance * confidence DESC, updated_at DESC LIMIT 15");
|
|
13
|
+
const topMemories = db.prepare("SELECT type, content, importance, confidence, metadata FROM memories WHERE status = 'active' ORDER BY importance * confidence DESC, updated_at DESC LIMIT 15");
|
|
13
14
|
export function buildProfileText() {
|
|
14
15
|
const parts = [];
|
|
15
16
|
const prof = profileRows.all();
|
|
@@ -40,11 +41,23 @@ export function buildProfileText() {
|
|
|
40
41
|
if (mems.length > 0) {
|
|
41
42
|
let block = "[memories]";
|
|
42
43
|
for (const m of mems) {
|
|
43
|
-
|
|
44
|
+
// Batch B-2: label untrusted imports (metadata.trusted=false) inline.
|
|
45
|
+
const tag = isUntrustedMetadata(m.metadata)
|
|
46
|
+
? ` ${UNTRUSTED_TAG} (${UNTRUSTED_NOTE})`
|
|
47
|
+
: "";
|
|
48
|
+
block += `\n- (${m.type} c${m.confidence.toFixed(2)})${tag} ${truncate(m.content, 200)}`;
|
|
44
49
|
}
|
|
45
50
|
parts.push(block);
|
|
46
51
|
}
|
|
47
|
-
|
|
52
|
+
// Batch B-2: profile/memory text is reference data, not instructions —
|
|
53
|
+
// wrap with delimiters. Reserve wrapper overhead inside PROFILE_BUDGET so
|
|
54
|
+
// the wrapped output still fits the 3000-char contract.
|
|
55
|
+
const overhead = MEMORY_REF_GUIDANCE.length +
|
|
56
|
+
MEMORY_REF_OPEN.length +
|
|
57
|
+
MEMORY_REF_CLOSE.length +
|
|
58
|
+
8;
|
|
59
|
+
const inner = truncate(parts.join("\n\n"), Math.max(0, PROFILE_BUDGET - overhead));
|
|
60
|
+
return `${MEMORY_REF_GUIDANCE}\n${MEMORY_REF_OPEN}\n${inner}\n${MEMORY_REF_CLOSE}`;
|
|
48
61
|
}
|
|
49
62
|
export async function getProfileHandler() {
|
|
50
63
|
try {
|
package/dist/tools/recall.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { db, buildFtsMatch, escapeLike, truncate, getAllEmbeddings, ok, err, } from "../db/index.js";
|
|
3
3
|
import { embed, cosine, deserialize } from "../lib/embed.js";
|
|
4
|
+
import { wrapMemoryReference } from "../lib/memory-format.js";
|
|
4
5
|
export const recallInput = {
|
|
5
6
|
topic: z.string().min(1).max(500).describe("Topic to recall from memory"),
|
|
6
7
|
limit: z
|
|
@@ -121,7 +122,8 @@ export async function recallHandler(args) {
|
|
|
121
122
|
if (parts.length === 0) {
|
|
122
123
|
return ok(`no memory found for "${truncate(args.topic, 100)}"`);
|
|
123
124
|
}
|
|
124
|
-
|
|
125
|
+
// Batch B-2: recalled memory is reference data, not instructions.
|
|
126
|
+
return ok(wrapMemoryReference(truncate(parts.join("\n\n"), RECALL_BUDGET)));
|
|
125
127
|
}
|
|
126
128
|
catch (e) {
|
|
127
129
|
return err(e instanceof Error ? e.message : String(e));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "th-memory-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"mcpName": "io.github.worakorn-prince/th-memory-mcp",
|
|
5
5
|
"description": "Adaptive Memory MCP server - SQLite-backed memory for OpenCode",
|
|
6
6
|
"author": "worakorn-prince",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"type": "module",
|
|
13
13
|
"main": "dist/index.js",
|
|
14
14
|
"bin": {
|
|
15
|
-
"th-memory-mcp": "dist/index.js"
|
|
15
|
+
"th-memory-mcp": "dist/index.js",
|
|
16
|
+
"th-memory": "dist/cli.js"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
18
19
|
"dist",
|
|
@@ -29,6 +30,7 @@
|
|
|
29
30
|
"prepublishOnly": "npm run version:sync && npm run build",
|
|
30
31
|
"version:sync": "node scripts/sync-version.mjs",
|
|
31
32
|
"version:check": "node scripts/sync-version.mjs --check",
|
|
33
|
+
"check:capture-sync": "node scripts/check-capture-sync.mjs",
|
|
32
34
|
"start": "node dist/index.js",
|
|
33
35
|
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
|
|
34
36
|
"test": "npm run build && node --test test/*.test.mjs test/smoke.mjs",
|