th-memory-mcp 2.2.8 → 2.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  # th-memory-mcp v2 — Architecture & Implementation Specification
2
2
 
3
3
  **Status:** ✅ Released — `th-memory-mcp v2.0.0` is published (npm + Official MCP Registry + Glama).
4
- **Baseline:** v1.2.2 → **Current:** v2.2.8
4
+ **Baseline:** v1.2.2 → **Current:** v2.2.9
5
5
  **Primary goal:** evolve th-memory-mcp from a structured local memory MCP into a durable, temporal, conflict-aware, hybrid-retrieval memory engine for AI agents.
6
6
 
7
7
  > **Audience guide:** End users should read [README.md](README.md) (install, tools, usage). This document is the **canonical architecture & agent-rules spec** for developers and AI coding agents — the single source of truth for structure and behavior. The former `design.md` build log has been folded into §40 Implementation Status.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![th-memory-mcp MCP server](https://glama.ai/mcp/servers/worakorn-prince/th-memory-mcp/badges/card.svg)](https://glama.ai/mcp/servers/worakorn-prince/th-memory-mcp)
12
12
 
13
13
 
14
- **Status:** v2.2.8 — a temporal, conflict-aware, hybrid-retrieval memory engine. 16 MCP tools, 25 passing test suites. Non-destructive schema migration from v1 (all v1 data preserved). New in v2.2: lifecycle states, temporal validity, conflict/dedup resolution with USER/SESSION/PROJECT/GLOBAL scope, hybrid FTS+vector retrieval (RRF), memory graph, `get_context` assembly, periodic consolidation, and `link_memory` / `merge_memory` / `update_memory` / `import_memory` / `extract_memories`. New in v2.2.3: scope-enforced retrieval, graph scope isolation, export/import round-trip, hardened import path (realpath), strict import validation, N+1 query elimination, cold/ablation benchmark, and `MEMORY_RETRIEVAL_MODE` switch. New in v2.2.7: synced secret filter between Claude hook and capture-core (6-pattern redact instead of line-drop), fixed `err()` to return `isError:true` per MCP spec, fixed backup rotation (backup only when migrations pending + prune to 5 files), and added hook error logging for SessionEnd distill. New in v2.2.8: fixed scope contamination 0.75→0 (critical) and conflict false 0→1 (GLOBAL leak), fixed graph hop1 0.52→1.0 via includeGraph, and rescaled benchmark profiles to 5K/20K/100K/500K/1M (pre-commit now quick 5K + normal 20K only).
14
+ **Status:** v2.2.9 — a temporal, conflict-aware, hybrid-retrieval memory engine. 16 MCP tools, 25 passing test suites. Non-destructive schema migration from v1 (all v1 data preserved). New in v2.2: lifecycle states, temporal validity, conflict/dedup resolution with USER/SESSION/PROJECT/GLOBAL scope, hybrid FTS+vector retrieval (RRF), memory graph, `get_context` assembly, periodic consolidation, and `link_memory` / `merge_memory` / `update_memory` / `import_memory` / `extract_memories`. New in v2.2.3: scope-enforced retrieval, graph scope isolation, export/import round-trip, hardened import path (realpath), strict import validation, N+1 query elimination, cold/ablation benchmark, and `MEMORY_RETRIEVAL_MODE` switch. New in v2.2.7: synced secret filter between Claude hook and capture-core (6-pattern redact instead of line-drop), fixed `err()` to return `isError:true` per MCP spec, fixed backup rotation (backup only when migrations pending + prune to 5 files), and added hook error logging for SessionEnd distill. New in v2.2.8: fixed scope contamination 0.75→0 (critical) and conflict false 0→1 (GLOBAL leak), fixed graph hop1 0.52→1.0 via includeGraph, and rescaled benchmark profiles to 5K/20K/100K/500K/1M (pre-commit now quick 5K + normal 20K only). New in v2.2.9: extended export to entities/users/relations, forget now removes associated links, fail-closed migration with shared ISO helper, eliminated FTS N+1, included smoke in test script, and synced viewer to rescaled profiles 5K/20K/100K/500K/1M.
15
15
 
16
16
  ## Requirements
17
17
 
@@ -255,9 +255,9 @@ npm run benchmark:viewer
255
255
  # open http://localhost:3000/benchmark/viewer/ or http://localhost:3000/result/viewer.html
256
256
  ```
257
257
 
258
- The viewer loads `benchmark/results/history.jsonl`, groups by version, takes the **latest run of the 3 most recent versions** (e.g. 2.2.2 / 2.2.3 / 2.2.4) and shows a highlighted table (1 row per version) + bar charts for `Recall@5 / MRR / NDCG@5` and `Latency p95`. Results are also saved per version in `result/v*_benchmark_result.md` and `benchmark/results/versions/<ver>/`.
258
+ The viewer loads `benchmark/results/history.jsonl`, groups by version, takes the **latest run of the 3 most recent versions** (e.g. 2.2.6 / 2.2.7 / 2.2.8) and shows a highlighted table (1 row per version) + bar charts for `Recall@5 / MRR / NDCG@5` and `Latency p95`. Results are also saved per version in `result/v*_benchmark_result.md` and `benchmark/results/versions/<ver>/`.
259
259
 
260
- Last internal run (v2.2.4, warm, same dataset — not third-party): Recall@5=0.92, Precision@5=0.92, MRR=1.00, NDCG@5=0.94 over 30 topics/180 records. See `result/v2.2.4_benchmark_result.md` and `repro/README.md` for details and caveats (internal small-N, single-machine self-run).
260
+ Last internal run (v2.2.8, warm, normal profile, a2dcbce — not third-party): Recall@5=0.9060, Precision@5=0.9060, MRR=1, NDCG@5=0.9347 over 100 topics/600 records. See `result/v2.2.8_benchmark_result.md` and `repro/README.md` for details and caveats (internal, single-machine self-run).
261
261
 
262
262
  ## Known Limitations
263
263
 
package/README.th.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  MCP server ความจำระยะยาวสำหรับ OpenCode — เก็บ preferences, lessons, ประวัติการใช้งาน ลง SQLite ไฟล์เดียว (local 100%, ไม่มี external API) เพื่อให้ AI "จำและปรับตัว" กับผู้ใช้ผ่าน context-based learning
9
9
 
10
- **สถานะ:** v2.2.8 — engine ความจำแบบ temporal, conflict-aware, hybrid-retrieval 16 MCP tools, 25 ชุดเทสผ่าน อัปเกรด schema แบบ non-destructive จาก v1 (ข้อมูล v1 ทั้งหมดถูกเก็บรักษา) ฟีเจอร์ใหม่ใน v2.2: lifecycle states, temporal validity, การแก้ conflict/dedup พร้อม scope USER/SESSION/PROJECT/GLOBAL, hybrid FTS+vector retrieval (RRF), memory graph, ประกอบ `get_context`, consolidation, และ `link_memory` / `merge_memory` / `update_memory` / `import_memory` / `extract_memories` ใหม่ใน v2.2.3: บังคับ scope ใน retrieval, แยก scope ใน graph, export/import แบบ round-trip, ป้องกัน symlink ด้วย realpath, ตรวจ import เข้มงวด, เลิก N+1 query, benchmark cold/ablation และสวิตช์ `MEMORY_RETRIEVAL_MODE` ใหม่ใน v2.2.7: ซิงค์ secret filter ระหว่าง Claude hook กับ capture-core (redact 6 patterns แทนทิ้งบรรทัด), แก้ `err()` ให้ส่ง `isError:true` ตาม MCP spec, แก้ backup rotation (backup เฉพาะเมื่อมี migration pending + prune เก็บ 5 ไฟล์ล่าสุด) และเพิ่ม hook error logging สำหรับ SessionEnd distill ใหม่ใน v2.2.8: แก้บัค scope contamination 0.75→0 (ร้ายแรง), แก้ conflict false 0→1, ปรับ benchmark rescale 5K/20K/100K/500K/1M และ viewer เทียบ profiles ภายใน version, workflow ก่อน commit เหลือ 2 profiles (quick 5K + normal 20K)
10
+ **สถานะ:** v2.2.9 — engine ความจำแบบ temporal, conflict-aware, hybrid-retrieval 16 MCP tools, 25 ชุดเทสผ่าน อัปเกรด schema แบบ non-destructive จาก v1 (ข้อมูล v1 ทั้งหมดถูกเก็บรักษา) ฟีเจอร์ใหม่ใน v2.2: lifecycle states, temporal validity, การแก้ conflict/dedup พร้อม scope USER/SESSION/PROJECT/GLOBAL, hybrid FTS+vector retrieval (RRF), memory graph, ประกอบ `get_context`, consolidation, และ `link_memory` / `merge_memory` / `update_memory` / `import_memory` / `extract_memories` ใหม่ใน v2.2.3: บังคับ scope ใน retrieval, แยก scope ใน graph, export/import แบบ round-trip, ป้องกัน symlink ด้วย realpath, ตรวจ import เข้มงวด, เลิก N+1 query, benchmark cold/ablation และสวิตช์ `MEMORY_RETRIEVAL_MODE` ใหม่ใน v2.2.7: ซิงค์ secret filter ระหว่าง Claude hook กับ capture-core (redact 6 patterns แทนทิ้งบรรทัด), แก้ `err()` ให้ส่ง `isError:true` ตาม MCP spec, แก้ backup rotation (backup เฉพาะเมื่อมี migration pending + prune เก็บ 5 ไฟล์ล่าสุด) และเพิ่ม hook error logging สำหรับ SessionEnd distill ใหม่ใน v2.2.8: แก้บัค scope contamination 0.75→0 (ร้ายแรง), แก้ conflict false 0→1, ปรับ benchmark rescale 5K/20K/100K/500K/1M และ viewer เทียบ profiles ภายใน version, workflow ก่อน commit เหลือ 2 profiles (quick 5K + normal 20K) ใหม่ใน v2.2.9: ขยาย export ให้ครอบคลุม entities/users/relations, forget ลบลิงก์ที่เกี่ยวข้อง, migration แบบ fail-closed พร้อม ISO helper กลาง, เลิก N+1 ใน FTS, รวม smoke ในสคริปต์เทส และซิงค์ viewer กับโปรไฟล์ rescale 5K/20K/100K/500K/1M
11
11
 
12
12
  > English: [README.md](README.md)
13
13
 
package/design.md CHANGED
@@ -3,7 +3,7 @@
3
3
  This document is up-to-date with the actual codebase state (after completing all future feature plans except AI-assisted extraction, which was removed). The full specification is at `ARCHITECTURE_v2.md` (canonical spec); this file summarizes the overview and current status for convenience.
4
4
 
5
5
  ## Current Status
6
- - **Version:** `package.json` = `2.2.8`
6
+ - **Version:** `package.json` = `2.2.9`
7
7
  - **MCP tools:** 16 tools (`remember`, `recall`, `get_context`, `link_memory`, `merge_memory`, `update_memory`, `import_memory`, `extract_memories`, `consolidate`, `forget`, `history`, `recent_interactions`, `profile`, `lesson`, `memory_stats`, `export_memory`)
8
8
  - **Test suites:** 25 suites passing (0 fail) — run via `npm test` (CI on GitHub Actions)
9
9
 
@@ -209,21 +209,26 @@ export function runMigrations(db) {
209
209
  const shouldBackup = process.env.MEMORY_BACKUP_ON_MIGRATE !== "0" &&
210
210
  process.env.MEMORY_BACKUP_ON_MIGRATE !== "false";
211
211
  if (shouldBackup) {
212
- try {
213
- const dbPath = db.name;
214
- if (dbPath && dbPath !== ":memory:") {
215
- const backupPath = `${dbPath}.backup-${Date.now()}`;
216
- try {
217
- copyFileSync(dbPath, backupPath);
218
- }
219
- catch {
220
- console.error("[migrations] failed to create backup, aborting");
221
- return;
222
- }
212
+ const dbPath = db.name;
213
+ if (dbPath && dbPath !== ":memory:") {
214
+ try {
215
+ // A plain copy of the main file is not a valid snapshot while recent
216
+ // commits are still in the WAL. Checkpoint first, then fail closed.
217
+ db.pragma("wal_checkpoint(TRUNCATE)");
218
+ // NOTE: better-sqlite3 exposes .backup(dest) but it is async
219
+ // (Promise-based) while runMigrations is sync and runs inside the
220
+ // DB singleton init path. Awaiting it would require making the whole
221
+ // migration/startup chain async and risks a half-applied state, so we
222
+ // keep the synchronous copyFileSync snapshot here on purpose.
223
+ // No new dependency added.
224
+ copyFileSync(dbPath, `${dbPath}.backup-${Date.now()}`);
223
225
  pruneOldBackups(dbPath);
224
226
  }
227
+ catch (e) {
228
+ const reason = e instanceof Error ? e.message : String(e);
229
+ throw new Error(`migration backup failed for ${dbPath}: ${reason}`);
230
+ }
225
231
  }
226
- catch { }
227
232
  }
228
233
  for (const m of pending) {
229
234
  const tx = db.transaction(() => {
package/dist/index.js CHANGED
@@ -89,7 +89,7 @@ server.registerTool("merge_memory", {
89
89
  }, (args) => mergeMemoryHandler(args));
90
90
  server.registerTool("update_memory", {
91
91
  title: "Update a memory",
92
- description: "Update mutable fields (summary/importance/confidence/valid_until/metadata) in place. If content changes, a superseding memory is created by default (set supersede=false to edit in place).",
92
+ description: "Update mutable fields (summary/importance/confidence/valid_from/valid_until/metadata) in place. If content changes, a superseding memory is created by default (set supersede=false to edit in place).",
93
93
  inputSchema: updateMemoryInput,
94
94
  }, (args) => updateMemoryHandler(args));
95
95
  server.registerTool("import_memory", {
@@ -1,6 +1,8 @@
1
1
  // config: shared constants. Pure module — no side effects, no I/O.
2
2
  import { fileURLToPath } from "node:url";
3
- export const VERSION = "1.1.0";
3
+ // Keep this in sync with package.json. It is surfaced in the MCP handshake
4
+ // and in export files, so a stale value makes backups harder to diagnose.
5
+ export const VERSION = "2.2.9";
4
6
  // dist/lib/config.js -> <project>/data/memory.db (independent of cwd).
5
7
  export const DEFAULT_DB_PATH = fileURLToPath(new URL("../../data/memory.db", import.meta.url));
6
8
  export const EXPORTS_DIRNAME = "exports";
@@ -0,0 +1,11 @@
1
+ import { z } from "zod";
2
+ // Single source of truth for strict ISO datetime validation.
3
+ // Requires a full datetime with timezone offset (e.g. 2024-01-01T00:00:00.000Z).
4
+ // Date-only strings like "2024-01-01" are intentionally rejected: they are
5
+ // ambiguous (no timezone) and break validFrom<=validUntil comparisons.
6
+ export const isoDateTimeSchema = z.string().datetime({ offset: true });
7
+ export function isIsoDateString(s) {
8
+ if (typeof s !== "string")
9
+ return false;
10
+ return isoDateTimeSchema.safeParse(s).success;
11
+ }
@@ -6,7 +6,7 @@ export function ftsSearch(query, opts = {}) {
6
6
  ? ""
7
7
  : "AND m.status NOT IN ('deleted','archived','superseded')";
8
8
  const rows = db
9
- .prepare(`SELECT m.id FROM memories m
9
+ .prepare(`SELECT m.id, m.scope, m.project_id, m.session_id, m.user_id FROM memories m
10
10
  JOIN search_index ON search_index.ref_table = 'memories' AND search_index.ref_id = m.id
11
11
  WHERE search_index MATCH @match ${statusClause}
12
12
  ORDER BY rank
@@ -15,16 +15,11 @@ export function ftsSearch(query, opts = {}) {
15
15
  match: buildFtsMatch(query),
16
16
  limit,
17
17
  });
18
- const filtered = rows.filter(({ id }) => {
19
- const m = db
20
- .prepare("SELECT scope, project_id, session_id, user_id FROM memories WHERE id = ?")
21
- .get(id);
22
- if (!m)
23
- return false;
18
+ const uid = opts.userId == null ? null : resolveUserId(opts.userId);
19
+ const filtered = rows.filter((m) => {
24
20
  if (m.scope === "USER") {
25
21
  if (opts.userId == null)
26
22
  return false;
27
- const uid = resolveUserId(opts.userId);
28
23
  return m.user_id === uid;
29
24
  }
30
25
  if (m.scope === "SESSION") {
@@ -21,6 +21,36 @@ const selectPrefs = db.prepare("SELECT id, category, key, value, confidence, sou
21
21
  const selectLessons = db.prepare("SELECT id, situation, mistake, correction, created_at FROM lessons ORDER BY id");
22
22
  const selectProfile = db.prepare("SELECT section, content, updated_at FROM profile ORDER BY section");
23
23
  const selectInteractions = db.prepare("SELECT id, ts, session_id, kind, content, meta FROM interactions ORDER BY id");
24
+ const selectMemories = db.prepare(`
25
+ SELECT m.id, m.type, m.content, m.summary, m.status, m.source,
26
+ m.confidence, m.importance, m.salience, m.project_id AS projectId,
27
+ m.session_id AS sessionId, u.external_id AS userId,
28
+ m.valid_from AS validFrom, m.valid_until AS validUntil, m.metadata
29
+ FROM memories m
30
+ LEFT JOIN users u ON u.id = m.user_id
31
+ ORDER BY m.id
32
+ `);
33
+ const selectMemoryLinks = db.prepare("SELECT source_memory_id AS sourceId, relation, target_memory_id AS targetId, confidence, created_at AS createdAt FROM memory_links ORDER BY source_memory_id, target_memory_id");
34
+ // Batch A gap-close: export users/entities/relations so backup/restore is complete.
35
+ // FORMAT DECISION: keep `th-memory-mcp/v2` (do NOT bump to v3) and add the three
36
+ // new top-level fields as additive/optional. Rationale: existing consumers and
37
+ // test/export_v2.test.mjs assert format === v2 with a strict equality check; a v3
38
+ // bump would break them and any downstream parser that allow-lists v2. Old v2
39
+ // files simply lack these keys and import treats missing as empty (backward compat).
40
+ const selectUsers = db.prepare("SELECT id, external_id AS externalId, name, created_at AS createdAt FROM users ORDER BY id");
41
+ const selectEntities = db.prepare("SELECT id, name, canonical_name AS canonicalName, type, metadata FROM entities ORDER BY id");
42
+ const selectRelations = db.prepare("SELECT id, source_entity_id AS sourceEntityId, relation, target_entity_id AS targetEntityId, confidence, valid_from AS validFrom, valid_until AS validUntil, source_memory_id AS sourceMemoryId, metadata FROM relations ORDER BY id");
43
+ function parseMetadata(raw) {
44
+ if (raw == null)
45
+ return null;
46
+ try {
47
+ return JSON.parse(raw);
48
+ }
49
+ catch {
50
+ // Preserve malformed legacy values rather than making an export fail.
51
+ return raw;
52
+ }
53
+ }
24
54
  function timestamp(d = new Date()) {
25
55
  const p = (n) => String(n).padStart(2, "0");
26
56
  return (`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
@@ -65,9 +95,22 @@ export async function exportMemoryHandler(args) {
65
95
  const interactionsIncluded = includeInteractions
66
96
  ? selectInteractions.all()
67
97
  : undefined;
98
+ const memories = selectMemories.all()
99
+ .map((m) => ({ ...m, metadata: parseMetadata(m.metadata) }));
100
+ const users = selectUsers.all();
101
+ const entities = selectEntities.all().map((e) => ({ ...e, metadata: parseMetadata(e.metadata) }));
102
+ const relations = selectRelations.all().map((r) => ({ ...r, metadata: parseMetadata(r.metadata) }));
68
103
  const payload = {
69
104
  exported_at: nowISO(),
70
105
  version: VERSION,
106
+ format: "th-memory-mcp/v2",
107
+ memories,
108
+ memoryLinks: selectMemoryLinks.all(),
109
+ // Additive v2 fields (optional for backward compat): full backup of
110
+ // M007 users + M003 entities/relations. Old importers ignore unknown keys.
111
+ users,
112
+ entities,
113
+ relations,
71
114
  preferences: selectPrefs.all(),
72
115
  lessons: selectLessons.all(),
73
116
  profile: selectProfile.all(),
@@ -7,7 +7,7 @@ export const forgetInput = {
7
7
  .positive()
8
8
  .describe("Row id to delete (id returned by remember/save_lesson)"),
9
9
  type: z
10
- .enum(["preference", "lesson", "interaction"])
10
+ .enum(["memory", "preference", "lesson", "interaction"])
11
11
  .optional()
12
12
  .describe("Which table the id belongs to. Recommended whenever known, because numeric ids can coincide across tables."),
13
13
  };
@@ -15,10 +15,14 @@ const indexTables = db.prepare("SELECT DISTINCT ref_table FROM search_index WHER
15
15
  const existsPreference = db.prepare("SELECT id FROM preferences WHERE id = ?");
16
16
  const existsLesson = db.prepare("SELECT id FROM lessons WHERE id = ?");
17
17
  const existsInteraction = db.prepare("SELECT id FROM interactions WHERE id = ?");
18
+ const existsMemory = db.prepare("SELECT id FROM memories WHERE id = ? AND status <> 'deleted'");
18
19
  const delPreference = db.prepare("DELETE FROM preferences WHERE id = ?");
19
20
  const delLesson = db.prepare("DELETE FROM lessons WHERE id = ?");
20
21
  const delInteraction = db.prepare("DELETE FROM interactions WHERE id = ?");
22
+ const delMemoryLinks = db.prepare("DELETE FROM memory_links WHERE source_memory_id = ? OR target_memory_id = ?");
21
23
  function existsIn(kind, id) {
24
+ if (kind === "memories")
25
+ return !!existsMemory.get(id);
22
26
  if (kind === "preferences") {
23
27
  return !!existsPreference.get(id);
24
28
  }
@@ -31,6 +35,7 @@ export async function forgetHandler(args) {
31
35
  try {
32
36
  const id = args.target_id;
33
37
  const kindOf = {
38
+ memory: "memories",
34
39
  preference: "preferences",
35
40
  lesson: "lessons",
36
41
  interaction: "interactions",
@@ -44,21 +49,37 @@ export async function forgetHandler(args) {
44
49
  const indexed = indexTables.all(id);
45
50
  const evidence = indexed
46
51
  .map((r) => r.ref_table)
47
- .filter((t) => t === "preferences" || t === "lessons" || t === "interactions");
52
+ .filter((t) => t === "memories" || t === "preferences" || t === "lessons" || t === "interactions");
48
53
  const candidates = evidence.length > 0
49
54
  ? evidence
50
- : ["preferences", "lessons", "interactions"];
55
+ : ["memories", "preferences", "lessons", "interactions"];
51
56
  targets = candidates.filter((k) => existsIn(k, id));
52
57
  }
53
58
  if (targets.length === 0) {
54
59
  return ok(`nothing found with id=${id}`);
55
60
  }
56
61
  const removed = [];
62
+ let linksRemoved = 0;
57
63
  db.transaction(() => {
58
64
  for (const kind of targets) {
59
65
  if (!existsIn(kind, id))
60
66
  continue;
61
- if (kind === "preferences") {
67
+ if (kind === "memories") {
68
+ db.prepare("UPDATE memories SET status = 'deleted' WHERE id = ?").run(id);
69
+ removeSearchIndex("memories", id);
70
+ removeEmbedding("memories", id);
71
+ // Batch A: cascade-delete memory_links referencing this id on BOTH sides
72
+ // in the same transaction so forget never leaves dangling graph edges.
73
+ // NOTE: entities/relations are intentionally NOT deleted here — they are
74
+ // shared graph nodes that may still be referenced by other memories;
75
+ // deleting them would corrupt the graph. Orphaned rows stay harmless
76
+ // and can be reclaimed by consolidate/GC later.
77
+ const info = delMemoryLinks.run(id, id);
78
+ const n = Number(info.changes ?? 0);
79
+ linksRemoved += n;
80
+ removed.push(`memory #${id} (${n} link(s) removed)`);
81
+ }
82
+ else if (kind === "preferences") {
62
83
  delPreference.run(id);
63
84
  removeSearchIndex("preferences", id);
64
85
  removeEmbedding("preferences", id);
@@ -4,8 +4,9 @@ import { join, dirname, resolve, sep } from "node:path";
4
4
  import { db, DB_PATH, nowISO, syncSearchIndex, upsertEmbedding, ok, err, } from "../db/index.js";
5
5
  import { createMemory } from "../db/repositories/memories.js";
6
6
  import { deduplicate } from "../memory/deduplicator.js";
7
- import { MEMORY_TYPES, SOURCE_TYPES } from "../memory/types.js";
7
+ import { MEMORY_TYPES, SOURCE_TYPES, LIFECYCLE_STATES, LINK_RELATIONS } from "../memory/types.js";
8
8
  import { EXPORTS_DIRNAME } from "../lib/config.js";
9
+ import { isIsoDateString } from "../lib/iso.js";
9
10
  import { embed } from "../lib/embed.js";
10
11
  export const importMemoryInput = {
11
12
  file: z
@@ -26,20 +27,6 @@ export const importMemoryInput = {
26
27
  .optional()
27
28
  .describe("Scope imported memories to a user (USER scope)"),
28
29
  };
29
- function isIsoDateString(s) {
30
- if (typeof s !== "string")
31
- return false;
32
- const d = Date.parse(s);
33
- if (Number.isNaN(d))
34
- return false;
35
- try {
36
- const iso = new Date(s).toISOString();
37
- return iso.length >= 10;
38
- }
39
- catch {
40
- return false;
41
- }
42
- }
43
30
  function isValidSource(s) {
44
31
  return SOURCE_TYPES.includes(s);
45
32
  }
@@ -86,19 +73,15 @@ export function importMemoryHandler(args) {
86
73
  let exportPrefs = null;
87
74
  let exportLessons = null;
88
75
  let exportProfile = null;
76
+ let exportLinks = null;
77
+ // Batch A: additive backup fields (may be absent in legacy v2 files -> null = backward compat).
78
+ let exportUsers = null;
79
+ let exportEntities = null;
80
+ let exportRelations = null;
89
81
  if (Array.isArray(parsed)) {
90
82
  memoryItems = parsed;
91
83
  }
92
- else if (parsed &&
93
- typeof parsed === "object" &&
94
- Array.isArray(parsed.memories)) {
95
- memoryItems = parsed.memories;
96
- }
97
- else if (parsed &&
98
- typeof parsed === "object" &&
99
- ("preferences" in parsed ||
100
- "lessons" in parsed ||
101
- "profile" in parsed)) {
84
+ else if (parsed && typeof parsed === "object") {
102
85
  const p = parsed;
103
86
  if (Array.isArray(p.preferences))
104
87
  exportPrefs = p.preferences;
@@ -108,15 +91,58 @@ export function importMemoryHandler(args) {
108
91
  exportProfile = p.profile;
109
92
  if (Array.isArray(p.memories))
110
93
  memoryItems = p.memories;
94
+ if (Array.isArray(p.memoryLinks))
95
+ exportLinks = p.memoryLinks;
96
+ // Accept both new keys and legacy absence (missing => null => skip, still pass).
97
+ if (Array.isArray(p.users))
98
+ exportUsers = p.users;
99
+ if (Array.isArray(p.entities))
100
+ exportEntities = p.entities;
101
+ if (Array.isArray(p.relations))
102
+ exportRelations = p.relations;
111
103
  }
112
104
  else {
113
105
  memoryItems = [];
114
106
  }
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.
110
+ let usersImported = 0;
111
+ let usersInvalid = 0;
112
+ if (exportUsers != null) {
113
+ const insertUser = db.prepare("INSERT OR IGNORE INTO users (external_id, name, created_at) VALUES (?, ?, ?)");
114
+ for (const u of exportUsers) {
115
+ const external = typeof u.externalId === "string"
116
+ ? u.externalId
117
+ : typeof u.external_id === "string"
118
+ ? u.external_id
119
+ : null;
120
+ if (!u ||
121
+ !external ||
122
+ external.length === 0 ||
123
+ external.length > 200 ||
124
+ /[\x00-\x1f]/.test(external)) {
125
+ usersInvalid++;
126
+ continue;
127
+ }
128
+ const created = (typeof u.createdAt === "string" && isIsoDateString(u.createdAt)
129
+ ? u.createdAt
130
+ : typeof u.created_at === "string" && isIsoDateString(u.created_at)
131
+ ? u.created_at
132
+ : null) ?? nowISO();
133
+ const name = typeof u.name === "string" ? u.name.slice(0, 500) : null;
134
+ usersImported++;
135
+ if (args.apply === true) {
136
+ insertUser.run(external, name, created);
137
+ }
138
+ }
139
+ }
115
140
  let wouldImport = 0;
116
141
  let skipped = 0;
117
142
  let invalid = 0;
118
143
  const log = [];
119
- for (const it of memoryItems) {
144
+ const importedIds = new Map();
145
+ for (const [idx, it] of memoryItems.entries()) {
120
146
  if (!it ||
121
147
  typeof it.content !== "string" ||
122
148
  it.content.length === 0 ||
@@ -141,6 +167,14 @@ export function importMemoryHandler(args) {
141
167
  invalid++;
142
168
  continue;
143
169
  }
170
+ if (it.salience != null && (typeof it.salience !== "number" || it.salience < 0 || it.salience > 1)) {
171
+ invalid++;
172
+ continue;
173
+ }
174
+ if (it.status != null && (typeof it.status !== "string" || !LIFECYCLE_STATES.includes(it.status))) {
175
+ invalid++;
176
+ continue;
177
+ }
144
178
  if (it.projectId != null && (typeof it.projectId !== "string" || it.projectId.length === 0 || it.projectId.length > 200)) {
145
179
  invalid++;
146
180
  continue;
@@ -155,10 +189,19 @@ export function importMemoryHandler(args) {
155
189
  }
156
190
  if (it.validFrom != null && (typeof it.validFrom !== "string" || !isIsoDateString(it.validFrom))) {
157
191
  invalid++;
192
+ log.push(`invalid row ${idx}: validFrom '${String(it.validFrom)}' rejected — must be a full ISO datetime with timezone offset (e.g. 2024-01-01T00:00:00.000Z), date-only '2024-01-01' is not accepted`);
158
193
  continue;
159
194
  }
160
195
  if (it.validUntil != null && (typeof it.validUntil !== "string" || !isIsoDateString(it.validUntil))) {
161
196
  invalid++;
197
+ log.push(`invalid row ${idx}: validUntil '${String(it.validUntil)}' rejected — must be a full ISO datetime with timezone offset (e.g. 2024-01-01T00:00:00.000Z), date-only '2024-01-01' is not accepted`);
198
+ continue;
199
+ }
200
+ if (typeof it.validFrom === "string" &&
201
+ typeof it.validUntil === "string" &&
202
+ new Date(it.validFrom) > new Date(it.validUntil)) {
203
+ invalid++;
204
+ log.push(`invalid row ${idx}: validFrom (${it.validFrom}) must not be later than validUntil (${it.validUntil})`);
162
205
  continue;
163
206
  }
164
207
  const dup = deduplicate(it.type, it.content);
@@ -169,13 +212,14 @@ export function importMemoryHandler(args) {
169
212
  }
170
213
  wouldImport++;
171
214
  if (args.apply === true) {
172
- createMemory({
215
+ const newId = createMemory({
173
216
  type: it.type,
174
217
  content: it.content,
175
218
  summary: typeof it.summary === "string" ? it.summary : null,
176
219
  source: it.source ?? "imported",
177
220
  confidence: typeof it.confidence === "number" ? it.confidence : 0.7,
178
221
  importance: typeof it.importance === "number" ? it.importance : 0.5,
222
+ salience: typeof it.salience === "number" ? it.salience : 0.5,
179
223
  projectId: typeof it.projectId === "string" ? it.projectId : null,
180
224
  sessionId: typeof it.sessionId === "string" ? it.sessionId : null,
181
225
  userId: typeof it.userId === "string"
@@ -187,8 +231,163 @@ export function importMemoryHandler(args) {
187
231
  validUntil: typeof it.validUntil === "string" ? it.validUntil : null,
188
232
  metadata: it.metadata ?? null,
189
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
+ }
240
+ }
241
+ let linksImported = 0;
242
+ let linksInvalid = 0;
243
+ if (exportLinks != null) {
244
+ 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) ||
248
+ (link.confidence != null && (typeof link.confidence !== "number" || link.confidence < 0 || link.confidence > 1))) {
249
+ linksInvalid++;
250
+ continue;
251
+ }
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());
254
+ }
255
+ linksImported++;
256
+ }
257
+ }
258
+ // Batch A: restore M003 entities/relations idempotently.
259
+ // - entities: dedupe by UNIQUE(canonical_name); keep oldId->newId map for relations.
260
+ // - relations: `relation` is free-form (e.g. co_occurs) so do NOT check LINK_RELATIONS
261
+ // here (that check stays only for memory_links above). Keep ISO validation as-is;
262
+ // another team will unify validation later. Missing arrays (legacy v2) => skip, still pass.
263
+ let entitiesImported = 0;
264
+ let entitiesInvalid = 0;
265
+ const importedEntityIds = new Map();
266
+ 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
+ for (const e of exportEntities) {
270
+ const name = typeof e.name === "string" ? e.name : "";
271
+ const canonical = typeof e.canonicalName === "string"
272
+ ? e.canonicalName
273
+ : typeof e.canonical_name === "string"
274
+ ? e.canonical_name
275
+ : "";
276
+ if (!e || name.length === 0 || name.length > 500 || canonical.length === 0 || canonical.length > 500) {
277
+ entitiesInvalid++;
278
+ continue;
279
+ }
280
+ 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
+ }
300
+ }
301
+ }
302
+ let relationsImported = 0;
303
+ let relationsInvalid = 0;
304
+ 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 (?, ?, ?, ?, ?, ?, ?, ?)");
309
+ for (const r of exportRelations) {
310
+ const oldSource = typeof r.sourceEntityId === "number"
311
+ ? r.sourceEntityId
312
+ : typeof r.source_entity_id === "number"
313
+ ? r.source_entity_id
314
+ : null;
315
+ const oldTarget = typeof r.targetEntityId === "number"
316
+ ? r.targetEntityId
317
+ : typeof r.target_entity_id === "number"
318
+ ? r.target_entity_id
319
+ : null;
320
+ const predicate = typeof r.relation === "string" ? r.relation : "";
321
+ const conf = r.confidence == null ? 0.5 : r.confidence;
322
+ const vf = typeof r.validFrom === "string"
323
+ ? r.validFrom
324
+ : typeof r.valid_from === "string"
325
+ ? r.valid_from
326
+ : null;
327
+ const vu = typeof r.validUntil === "string"
328
+ ? r.validUntil
329
+ : typeof r.valid_until === "string"
330
+ ? r.valid_until
331
+ : null;
332
+ const oldMem = typeof r.sourceMemoryId === "number"
333
+ ? r.sourceMemoryId
334
+ : typeof r.source_memory_id === "number"
335
+ ? r.source_memory_id
336
+ : null;
337
+ if (!r ||
338
+ oldSource == null ||
339
+ oldTarget == null ||
340
+ predicate.length === 0 ||
341
+ predicate.length > 200 ||
342
+ typeof conf !== "number" ||
343
+ conf < 0 ||
344
+ conf > 1 ||
345
+ (vf != null && !isIsoDateString(vf)) ||
346
+ (vu != null && !isIsoDateString(vu)) ||
347
+ (vf != null && vu != null && new Date(vf) > new Date(vu))) {
348
+ relationsInvalid++;
349
+ continue;
350
+ }
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) {
363
+ relationsInvalid++;
364
+ continue;
365
+ }
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
+ 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
+ }
190
388
  }
191
389
  }
390
+ const graphSuffix = `users ${usersImported} ok ${usersInvalid} invalid; entities ${entitiesImported} ok ${entitiesInvalid} invalid; relations ${relationsImported} ok ${relationsInvalid} invalid`;
192
391
  if (exportPrefs != null || exportLessons != null || exportProfile != null) {
193
392
  const prefArr = Array.isArray(exportPrefs) ? exportPrefs : [];
194
393
  const lessonArr = Array.isArray(exportLessons) ? exportLessons : [];
@@ -268,11 +467,11 @@ export function importMemoryHandler(args) {
268
467
  }
269
468
  }
270
469
  const mode = args.apply === true ? "applied" : "dry-run";
271
- const summary = `import ${mode}: ${wouldImport} memories to import, ${skipped} duplicate(s) skipped, ${invalid} invalid memories; preferences ${prefOk} ok ${prefInvalid} invalid; lessons ${lessonOk} ok ${lessonInvalid} invalid; profile ${profileOk} ok`;
470
+ 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}`;
272
471
  return ok(summary);
273
472
  }
274
473
  const mode = args.apply === true ? "applied" : "dry-run";
275
- const summary = `import ${mode}: ${wouldImport} to import, ${skipped} duplicate(s) skipped, ${invalid} invalid`;
474
+ const summary = `import ${mode}: ${wouldImport} to import, ${skipped} duplicate(s) skipped, ${invalid} invalid, ${linksImported} link(s) imported, ${linksInvalid} invalid link(s); ${graphSuffix}`;
276
475
  return ok(log.length ? `${summary}\n${log.join("\n")}` : summary);
277
476
  }
278
477
  catch (e) {
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { db, nowISO, ok, err, } from "../db/index.js";
3
3
  import { getMemoryById, createMemory, syncMemoryIndex, } from "../db/repositories/memories.js";
4
4
  import { supersede } from "../core/lifecycle-engine.js";
5
+ import { isoDateTimeSchema, isIsoDateString } from "../lib/iso.js";
5
6
  export const updateMemoryInput = {
6
7
  id: z.number().int().describe("Memory id to update"),
7
8
  content: z
@@ -12,9 +13,12 @@ export const updateMemoryInput = {
12
13
  summary: z.string().max(2000).nullable().optional().describe("New summary"),
13
14
  importance: z.number().min(0).max(1).optional(),
14
15
  confidence: z.number().min(0).max(1).optional(),
16
+ validFrom: z
17
+ .union([isoDateTimeSchema, z.null()])
18
+ .optional()
19
+ .describe("ISO timestamp or null to clear"),
15
20
  validUntil: z
16
- .string()
17
- .nullable()
21
+ .union([isoDateTimeSchema, z.null()])
18
22
  .optional()
19
23
  .describe("ISO timestamp or null to clear"),
20
24
  metadata: z.unknown().optional().describe("New metadata object (replaces)"),
@@ -30,6 +34,24 @@ export function updateMemoryHandler(args) {
30
34
  return err(`memory ${args.id} not found`);
31
35
  if (mem.status === "deleted")
32
36
  return err("cannot update a deleted memory");
37
+ if (args.validFrom !== undefined &&
38
+ args.validFrom !== null &&
39
+ !isIsoDateString(args.validFrom)) {
40
+ return err(`validFrom must be a full ISO datetime with timezone offset (e.g. 2024-01-01T00:00:00.000Z), got '${args.validFrom}'`);
41
+ }
42
+ if (args.validUntil !== undefined &&
43
+ args.validUntil !== null &&
44
+ !isIsoDateString(args.validUntil)) {
45
+ return err(`validUntil must be a full ISO datetime with timezone offset (e.g. 2024-01-01T00:00:00.000Z), got '${args.validUntil}'`);
46
+ }
47
+ // Effective range covers new values and values mixed with the existing row.
48
+ const effectiveValidFrom = args.validFrom !== undefined ? args.validFrom : mem.valid_from;
49
+ const effectiveValidUntil = args.validUntil !== undefined ? args.validUntil : mem.valid_until;
50
+ if (effectiveValidFrom != null &&
51
+ effectiveValidUntil != null &&
52
+ new Date(effectiveValidFrom) > new Date(effectiveValidUntil)) {
53
+ return err(`validFrom (${effectiveValidFrom}) must not be later than validUntil (${effectiveValidUntil})`);
54
+ }
33
55
  const supersedeContent = args.content !== undefined && (args.supersede ?? true);
34
56
  if (supersedeContent) {
35
57
  const externalId = mem.user_id
@@ -48,7 +70,7 @@ export function updateMemoryHandler(args) {
48
70
  projectId: mem.project_id,
49
71
  sessionId: mem.session_id,
50
72
  userId: externalId,
51
- validFrom: mem.valid_from,
73
+ validFrom: args.validFrom !== undefined ? args.validFrom : mem.valid_from,
52
74
  validUntil: args.validUntil !== undefined ? args.validUntil : mem.valid_until,
53
75
  metadata: args.metadata !== undefined
54
76
  ? args.metadata
@@ -73,6 +95,10 @@ export function updateMemoryHandler(args) {
73
95
  sets.push("confidence = ?");
74
96
  vals.push(args.confidence);
75
97
  }
98
+ if (args.validFrom !== undefined) {
99
+ sets.push("valid_from = ?");
100
+ vals.push(args.validFrom);
101
+ }
76
102
  if (args.validUntil !== undefined) {
77
103
  sets.push("valid_until = ?");
78
104
  vals.push(args.validUntil);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "th-memory-mcp",
3
- "version": "2.2.8",
3
+ "version": "2.2.9",
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",
@@ -31,7 +31,7 @@
31
31
  "version:check": "node scripts/sync-version.mjs --check",
32
32
  "start": "node dist/index.js",
33
33
  "inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
34
- "test": "npm run build && node --test test/*.test.mjs",
34
+ "test": "npm run build && node --test test/*.test.mjs test/smoke.mjs",
35
35
  "distill": "node dist/distill.js",
36
36
  "quickstart": "npm run build && node scripts/quickstart.mjs",
37
37
  "benchmark": "node repro/run.mjs",
package/dist/db.js DELETED
@@ -1,111 +0,0 @@
1
- import Database from "better-sqlite3";
2
- import { mkdirSync } from "node:fs";
3
- import { dirname } from "node:path";
4
- import { truncate } from "./lib/capture-core.js";
5
- import { serialize } from "./lib/embed.js";
6
- import { DEFAULT_DB_PATH } from "./lib/config.js";
7
- import { runMigrations } from "./migrations.js";
8
- export const DB_PATH = process.env.MEMORY_DB_PATH ?? DEFAULT_DB_PATH;
9
- function initDb() {
10
- try {
11
- mkdirSync(dirname(DB_PATH), { recursive: true });
12
- return new Database(DB_PATH);
13
- }
14
- catch (e) {
15
- const msg = e instanceof Error ? e.message : String(e);
16
- console.error(`[th-memory-mcp] cannot open DB at ${DB_PATH}: ${msg}`);
17
- process.exit(1);
18
- }
19
- }
20
- export const db = initDb();
21
- db.pragma("journal_mode = WAL");
22
- db.pragma("busy_timeout = 5000");
23
- db.exec(`
24
- CREATE TABLE IF NOT EXISTS interactions (
25
- id INTEGER PRIMARY KEY AUTOINCREMENT,
26
- ts TEXT NOT NULL,
27
- session_id TEXT,
28
- kind TEXT NOT NULL,
29
- content TEXT NOT NULL,
30
- meta TEXT
31
- );
32
-
33
- CREATE TABLE IF NOT EXISTS preferences (
34
- id INTEGER PRIMARY KEY AUTOINCREMENT,
35
- category TEXT NOT NULL,
36
- key TEXT NOT NULL,
37
- value TEXT NOT NULL,
38
- confidence REAL DEFAULT 0.5,
39
- source TEXT DEFAULT 'explicit',
40
- updated_at TEXT NOT NULL,
41
- UNIQUE(category, key)
42
- );
43
-
44
- CREATE TABLE IF NOT EXISTS lessons (
45
- id INTEGER PRIMARY KEY AUTOINCREMENT,
46
- situation TEXT NOT NULL,
47
- mistake TEXT NOT NULL,
48
- correction TEXT NOT NULL,
49
- created_at TEXT NOT NULL
50
- );
51
-
52
- CREATE TABLE IF NOT EXISTS profile (
53
- section TEXT PRIMARY KEY,
54
- content TEXT NOT NULL,
55
- updated_at TEXT NOT NULL
56
- );
57
-
58
- CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
59
- ref_table, ref_id, title, body
60
- );
61
-
62
- CREATE TABLE IF NOT EXISTS embeddings (
63
- ref_table TEXT NOT NULL,
64
- ref_id INTEGER NOT NULL,
65
- vec BLOB NOT NULL,
66
- PRIMARY KEY (ref_table, ref_id)
67
- );
68
- `);
69
- // v2 migration engine (non-destructive: only adds new tables + backfills from v1)
70
- runMigrations(db);
71
- export function nowISO() {
72
- return new Date().toISOString();
73
- }
74
- // re-exported from lib/capture-core.js (single source of truth)
75
- export { truncate };
76
- export function escapeLike(text) {
77
- return text.replace(/[\\%_]/g, (c) => "\\" + c);
78
- }
79
- export function buildFtsMatch(query) {
80
- const tokens = query.trim().split(/\s+/).filter(Boolean).slice(0, 8);
81
- return tokens.map((t) => `"${t.replace(/"/g, "")}"`).join(" ");
82
- }
83
- const insertSearchIndex = db.prepare("INSERT INTO search_index (ref_table, ref_id, title, body) VALUES (?, ?, ?, ?)");
84
- const deleteSearchIndex = db.prepare("DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?");
85
- export function syncSearchIndex(refTable, refId, title, body) {
86
- deleteSearchIndex.run(refTable, refId);
87
- insertSearchIndex.run(refTable, refId, title, body);
88
- }
89
- export function removeSearchIndex(refTable, refId) {
90
- deleteSearchIndex.run(refTable, refId);
91
- }
92
- // --- vector embeddings (lightweight local semantic search) ---
93
- const upsertEmbed = db.prepare(`INSERT INTO embeddings (ref_table, ref_id, vec) VALUES (?, ?, ?)
94
- ON CONFLICT(ref_table, ref_id) DO UPDATE SET vec = excluded.vec`);
95
- const deleteEmbed = db.prepare("DELETE FROM embeddings WHERE ref_table = ? AND ref_id = ?");
96
- const allEmbeds = db.prepare("SELECT ref_table, ref_id, vec FROM embeddings");
97
- export function upsertEmbedding(refTable, refId, vec) {
98
- upsertEmbed.run(refTable, refId, serialize(vec));
99
- }
100
- export function removeEmbedding(refTable, refId) {
101
- deleteEmbed.run(refTable, refId);
102
- }
103
- export function getAllEmbeddings() {
104
- return allEmbeds.all();
105
- }
106
- export function ok(text) {
107
- return { content: [{ type: "text", text }] };
108
- }
109
- export function err(text) {
110
- return ok(`error: ${truncate(text, 300)}`);
111
- }