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.
@@ -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
- // Restore users FIRST (before memories) so createMemory()->ensureUser reuses
108
- // the restored row (with original name/created_at) instead of auto-creating
109
- // a bare row. Idempotent via UNIQUE(external_id) + INSERT OR IGNORE.
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
- if (args.apply === true) {
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
- wouldImport++;
214
- if (args.apply === true) {
215
- const newId = createMemory({
216
- type: it.type,
217
- content: it.content,
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
- const sourceId = typeof link.sourceId === "number" ? importedIds.get(link.sourceId) : undefined;
246
- const targetId = typeof link.targetId === "number" ? importedIds.get(link.targetId) : undefined;
247
- if (!sourceId || !targetId || typeof link.relation !== "string" || !LINK_RELATIONS.includes(link.relation) ||
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 (args.apply === true) {
253
- db.prepare("INSERT OR IGNORE INTO memory_links (source_memory_id, relation, target_memory_id, confidence, created_at) VALUES (?, ?, ?, ?, ?)").run(sourceId, link.relation, targetId, link.confidence ?? 0.5, nowISO());
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
- if (args.apply === true) {
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
- const selEntityById = db.prepare("SELECT id FROM entities WHERE id = ?");
306
- const selMemById = db.prepare("SELECT id FROM memories WHERE id = ?");
307
- 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)");
308
- const insRelation = db.prepare("INSERT INTO relations (source_entity_id, relation, target_entity_id, confidence, valid_from, valid_until, source_memory_id, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
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
- // Remap entity ids: prefer import map, else keep original id if row still exists.
352
- let newSource = null;
353
- let newTarget = null;
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
- if (args.apply === true) {
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
- const graphSuffix = `users ${usersImported} ok ${usersInvalid} invalid; entities ${entitiesImported} ok ${entitiesInvalid} invalid; relations ${relationsImported} ok ${relationsInvalid} invalid`;
391
- if (exportPrefs != null || exportLessons != null || exportProfile != null) {
392
- const prefArr = Array.isArray(exportPrefs) ? exportPrefs : [];
393
- const lessonArr = Array.isArray(exportLessons) ? exportLessons : [];
394
- const profileArr = Array.isArray(exportProfile) ? exportProfile : [];
395
- const validCat = new Set(["work_style", "coding_pref", "language", "domain", "other"]);
396
- let prefOk = 0;
397
- let prefInvalid = 0;
398
- let lessonOk = 0;
399
- let lessonInvalid = 0;
400
- let profileOk = 0;
401
- const selectPref = db.prepare("SELECT id FROM preferences WHERE category = ? AND key = ?");
402
- const insertPref = db.prepare("INSERT INTO preferences (category, key, value, confidence, source, updated_at) VALUES (?, ?, ?, ?, ?, ?)");
403
- const updatePref = db.prepare("UPDATE preferences SET value = ?, confidence = ?, source = ?, updated_at = ? WHERE category = ? AND key = ?");
404
- const insertLesson = db.prepare("INSERT INTO lessons (situation, mistake, correction, created_at) VALUES (?, ?, ?, ?)");
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
- if (args.apply === true) {
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
- if (args.apply === true) {
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
- if (args.apply === true) {
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)`);
@@ -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
- block += `\n- (${m.type} c${m.confidence.toFixed(2)}) ${truncate(m.content, 200)}`;
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
- return truncate(parts.join("\n\n"), PROFILE_BUDGET);
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 {
@@ -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
- return ok(truncate(parts.join("\n\n"), RECALL_BUDGET));
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.2.9",
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",