th-memory-mcp 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  Long-term memory MCP server for OpenCode — stores preferences, lessons, and usage history in a single local SQLite file (100% local, no external API) so the AI can "remember and adapt" to the user through context-based learning.
9
9
 
10
- **Status:** v2.0.0 — a temporal, conflict-aware, hybrid-retrieval memory engine. 11 MCP tools, 12 passing test suites. Non-destructive schema migration from v1 (all v1 data preserved). New in v2: lifecycle states, temporal validity, conflict/dedup resolution, hybrid FTS+vector retrieval (RRF), memory graph, `get_context` assembly, and periodic consolidation.
10
+ **Status:** v2.1.0 — a temporal, conflict-aware, hybrid-retrieval memory engine. 16 MCP tools, 13 passing test suites. Non-destructive schema migration from v1 (all v1 data preserved). New in v2: lifecycle states, temporal validity, conflict/dedup resolution, hybrid FTS+vector retrieval (RRF), memory graph, `get_context` assembly, periodic consolidation, and `link_memory` / `merge_memory` / `update_memory` / `import_memory` / `extract_memories`.
11
11
 
12
12
  ## Requirements
13
13
 
@@ -63,12 +63,12 @@ setx MEMORY_DB_PATH "$PWD/data/memory.db"
63
63
  ```
64
64
  OpenCode ──┬─ Plugin learning-capture (Bun) ── auto-captures prompts/tool/error into DB
65
65
  │ └─ injects profile back into context on compaction
66
- └─ MCP th-memory-mcp (Node.js stdio) ── 11 tools read/write the same SQLite DB
66
+ └─ MCP th-memory-mcp (Node.js stdio) ── 16 tools read/write the same SQLite DB
67
67
 
68
68
  Global instructions (memory-protocol.md) teach the AI to use the tools
69
69
  ```
70
70
 
71
- See [design.md](design.md) for full details.
71
+ See [ARCHITECTURE_v2.md](ARCHITECTURE_v2.md) for the full architecture spec.
72
72
 
73
73
  ## Why th-memory-mcp?
74
74
 
@@ -78,7 +78,7 @@ LLMs don't remember you between sessions — every new chat starts blank. th-mem
78
78
  - **100% local & private** — a single SQLite file, no cloud, no external API. Secrets are filtered before anything is stored.
79
79
  - **Low overhead** — each tool call is capped (latency < 10 ms, bounded output size) and the AI only queries memory when it's actually useful, so it never bloats your context.
80
80
  - **Resilient** — every tool degrades gracefully; if the DB is unavailable the AI keeps working instead of crashing.
81
- - **Open & extensible** — MIT licensed, 11 documented tools, a rule-based distill, and an auto-capture plugin you can adapt.
81
+ - **Open & extensible** — MIT licensed, 16 documented tools, a rule-based distill, and an auto-capture plugin you can adapt.
82
82
 
83
83
  ## Works with other harnesses
84
84
 
@@ -89,7 +89,7 @@ our hooks bridge; Codex and Cursor use the tools manually (no hook runtime yet).
89
89
 
90
90
  | Feature | OpenCode | Claude Code | Qwen Code | Codex | Cursor |
91
91
  |---|---|---|---|---|---|
92
- | 11 MCP tools | ✅ | ✅ | ✅ | ✅ | ✅ |
92
+ | 16 MCP tools | ✅ | ✅ | ✅ | ✅ | ✅ |
93
93
  | Auto-capture (background) | ✅ plugin | ✅ [hooks](CLAUDE_CODE_HOOKS.md) | ⚠️ adapter | ❌ manual | ❌ Rules |
94
94
  | Profile injection | ✅ compaction | ✅ UserPromptSubmit | ❌ `get_profile` | ❌ `get_profile` | ❌ `get_profile` |
95
95
  | Local semantic search | ✅ (v2.0) | ✅ (v2.0) | ✅ (v2.0) | ✅ (v2.0) | ✅ (v2.0) |
@@ -148,9 +148,9 @@ anywhere is readable everywhere.
148
148
  | `node test/consolidation.test.mjs` | test clustering + derived memories |
149
149
  | `node test/benchmark.test.mjs` | latency benchmark over 300 memories |
150
150
  | `node test/security.test.mjs` | injection / safety checks |
151
- | `node test/smoke.mjs` | end-to-end smoke test over JSON-RPC (11 tools) |
151
+ | `node test/smoke.mjs` | end-to-end smoke test over JSON-RPC (16 tools) |
152
152
 
153
- ## Tools (11)
153
+ ## Tools (16)
154
154
 
155
155
  | Tool | Description |
156
156
  |------|-------------|
@@ -165,6 +165,11 @@ anywhere is readable everywhere.
165
165
  | `export_memory` | export memory to JSON under `data/exports/` only (filename auto-sanitized) |
166
166
  | `get_context` | assemble relevant memories for the current task via hybrid retrieval (+ optional graph expansion) with token budgeting |
167
167
  | `consolidate` | cluster similar memories via embedding similarity; optionally create derived/consolidated memories linked via `derived_from` |
168
+ | `link_memory` | create a typed relationship between two memories in the graph (supports/contradicts/supersedes/derived_from/related_to/caused_by/depends_on) |
169
+ | `merge_memory` | merge a duplicate/near-duplicate into a canonical memory (source becomes superseded, provenance in `metadata.merged_from`) |
170
+ | `update_memory` | update mutable fields in place, or create a superseding memory when `content` changes (set `supersede=false` to edit in place) |
171
+ | `import_memory` | import memories from JSON (validates type, dedupes against existing, never overwrites blindly); dry-run by default, `apply=true` to insert |
172
+ | `extract_memories` | scan recent captured interactions for memory-intent phrases and propose memory candidates (deterministic, no LLM); dry-run by default, `apply=true` to create (source=captured) |
168
173
 
169
174
  ## Install with OpenCode
170
175
 
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.0.0 — engine ความจำแบบ temporal, conflict-aware, hybrid-retrieval 11 MCP tools, 12 ชุดเทสผ่าน อัปเกรด schema แบบ non-destructive จาก v1 (ข้อมูล v1 ทั้งหมดถูกเก็บรักษา) ฟีเจอร์ใหม่ใน v2: lifecycle states, temporal validity, การแก้ conflict/dedup, hybrid FTS+vector retrieval (RRF), memory graph, ประกอบ `get_context`, และ consolidation
10
+ **สถานะ:** v2.1.0 — engine ความจำแบบ temporal, conflict-aware, hybrid-retrieval 16 MCP tools, 13 ชุดเทสผ่าน อัปเกรด schema แบบ non-destructive จาก v1 (ข้อมูล v1 ทั้งหมดถูกเก็บรักษา) ฟีเจอร์ใหม่ใน v2: lifecycle states, temporal validity, การแก้ conflict/dedup, hybrid FTS+vector retrieval (RRF), memory graph, ประกอบ `get_context`, consolidation, และ `link_memory` / `merge_memory` / `update_memory` / `import_memory` / `extract_memories`
11
11
 
12
12
  > English: [README.md](README.md)
13
13
 
@@ -65,12 +65,12 @@ setx MEMORY_DB_PATH "$PWD/data/memory.db"
65
65
  ```
66
66
  OpenCode ──┬─ Plugin learning-capture (Bun) ── จับ prompt/tool/error ลง DB อัตโนมัติ
67
67
  │ └─ ฉีด profile กลับ context ตอน compaction
68
- └─ MCP th-memory-mcp (Node.js stdio) ── tools 11 ตัว อ่าน/เขียน SQLite เดียวกัน
68
+ └─ MCP th-memory-mcp (Node.js stdio) ── tools 16 ตัว อ่าน/เขียน SQLite เดียวกัน
69
69
 
70
70
  Global instructions (memory-protocol.md) สอน AI ใช้ tools
71
71
  ```
72
72
 
73
- รายละเอียดเต็มอยู่ใน [design.md](design.md) — คู่มืออัปเกรดจาก v1 ดูได้ที่ [MIGRATION_v2.md](MIGRATION_v2.md)
73
+ รายละเอียดสถาปัตยกรรมเต็มอยู่ใน [ARCHITECTURE_v2.md](ARCHITECTURE_v2.md) — คู่มืออัปเกรดจาก v1 ดูได้ที่ [MIGRATION_v2.md](MIGRATION_v2.md)
74
74
 
75
75
  ## ทำไมต้องใช้ th-memory-mcp?
76
76
 
@@ -107,7 +107,7 @@ LLM ไม่ได้จำคุณข้าม session — แชทใหม
107
107
  | `node test/security.test.mjs` | ตรวจการ injection / ความปลอดภัย |
108
108
  | `node test/smoke.mjs` | smoke test end-to-end ผ่าน JSON-RPC (11 tools) |
109
109
 
110
- ## Tools (11)
110
+ ## Tools (16)
111
111
 
112
112
  | Tool | คำอธิบาย |
113
113
  |------|----------|
@@ -122,6 +122,11 @@ LLM ไม่ได้จำคุณข้าม session — แชทใหม
122
122
  | `export_memory` | export ความจำเป็น JSON ลง `data/exports/` เท่านั้น (sanitize filename ให้เอง) |
123
123
  | `get_context` | ประกอบความจำที่เกี่ยวข้องกับงานปัจจุบันผ่าน hybrid retrieval (+ ขยายผ่าน memory graph ได้) พร้อม token budgeting |
124
124
  | `consolidate` | จัดคลัสเตอร์ความจำที่คล้ายกันด้วย embedding cosine และสร้าง derived/consolidated memory ที่ผูกด้วย `derived_from` ได้ |
125
+ | `link_memory` | สร้างความสัมพันธ์แบบมีประเภทระหว่างความจำสองอันในกราฟ |
126
+ | `merge_memory` | รวมความจำที่ซ้ำเข้ากับความจำหลัก (ต้นทางถูก superseded, เก็บ provenance ไว้ใน `metadata.merged_from`) |
127
+ | `update_memory` | อัปเดตฟิลด์ที่เปลี่ยนได้แบบไม่เปลี่ยนตัวตน หรือสร้างความจำแทนที่เมื่อ `content` เปลี่ยน (ตั้ง `supersede=false` เพื่อแก้ในที่) |
128
+ | `import_memory` | นำเข้าความจำจาก JSON (ตรวจสอบ type, dedup กับของเดิม, ไม่เขียนทับแบบมืดบอด); ค่าเริ่มต้น dry-run, ตั้ง `apply=true` เพื่อเพิ่ม |
129
+ | `extract_memories` | สแกน interactions ล่าสุดหาเจตนาบันทึกความจำ และเสนอ/สร้างความจำ (ไม่ใช้ LLM); ค่าเริ่มต้น dry-run, ตั้ง `apply=true` เพื่อสร้าง (source=captured) |
125
130
 
126
131
  ## ติดตั้งกับ OpenCode
127
132
 
package/dist/index.js CHANGED
@@ -12,6 +12,11 @@ import { recentInteractionsInput, getRecentInteractionsHandler, } from "./tools/
12
12
  import { exportMemoryInput, exportMemoryHandler, } from "./tools/export_memory.js";
13
13
  import { contextInput, contextHandler } from "./tools/context.js";
14
14
  import { consolidateInput, consolidateHandler } from "./tools/consolidate.js";
15
+ import { linkMemoryInput, linkMemoryHandler } from "./tools/link_memory.js";
16
+ import { mergeMemoryInput, mergeMemoryHandler } from "./tools/merge_memory.js";
17
+ import { updateMemoryInput, updateMemoryHandler } from "./tools/update_memory.js";
18
+ import { importMemoryInput, importMemoryHandler } from "./tools/import_memory.js";
19
+ import { extractMemoriesInput, extractMemoriesHandler } from "./tools/extract_memories.js";
15
20
  import { VERSION } from "./lib/config.js";
16
21
  const server = new McpServer({
17
22
  name: "th-memory-mcp",
@@ -72,6 +77,31 @@ server.registerTool("consolidate", {
72
77
  description: "Cluster similar memories via embedding similarity and optionally create derived/consolidated memories linked via 'derived_from'. Use during periodic consolidation.",
73
78
  inputSchema: consolidateInput,
74
79
  }, (args) => consolidateHandler(args));
80
+ server.registerTool("link_memory", {
81
+ title: "Link two memories",
82
+ description: "Create a typed relationship between two memories in the graph (supports/contradicts/supersedes/derived_from/related_to/caused_by/depends_on).",
83
+ inputSchema: linkMemoryInput,
84
+ }, (args) => linkMemoryHandler(args));
85
+ server.registerTool("merge_memory", {
86
+ title: "Merge memories",
87
+ description: "Merge a duplicate/near-duplicate memory into a canonical one. The source becomes superseded and provenance is recorded in metadata.merged_from.",
88
+ inputSchema: mergeMemoryInput,
89
+ }, (args) => mergeMemoryHandler(args));
90
+ server.registerTool("update_memory", {
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).",
93
+ inputSchema: updateMemoryInput,
94
+ }, (args) => updateMemoryHandler(args));
95
+ server.registerTool("import_memory", {
96
+ title: "Import memories",
97
+ description: "Import memories from a JSON array or a .json file inside data/exports/. Validates type, dedupes against existing memories, and never overwrites active memory blindly. Dry-run by default; pass apply=true to insert.",
98
+ inputSchema: importMemoryInput,
99
+ }, (args) => importMemoryHandler(args));
100
+ server.registerTool("extract_memories", {
101
+ title: "Extract memories from interactions",
102
+ description: "Scan recent captured interactions for memory-intent phrases and propose memory candidates (deterministic, no LLM). Dry-run by default; pass apply=true to create them (source=captured).",
103
+ inputSchema: extractMemoriesInput,
104
+ }, (args) => extractMemoriesHandler(args));
75
105
  async function main() {
76
106
  const transport = new StdioServerTransport();
77
107
  await server.connect(transport);
@@ -0,0 +1,90 @@
1
+ import { z } from "zod";
2
+ import { db, ok, err } from "../db/index.js";
3
+ import { createMemory } from "../db/repositories/memories.js";
4
+ import { deduplicate } from "../memory/deduplicator.js";
5
+ // Deterministic, LLM-free intent heuristics (spec §19 "optional extraction").
6
+ // Scans captured interactions and proposes memory candidates. Safe by default:
7
+ // dry-run proposes; pass apply=true to actually create memories (source=captured).
8
+ const INTENT_PATTERNS = [
9
+ { re: /(?:remember|จำไว้ว่า|บันทึกว่า)\s+(?:that\s+)?(.+)/i, type: "FACT" },
10
+ {
11
+ re: /(?:i prefer|my preference is|ผมชอบ|ฉันชอบ|เราชอบ)\s+(.+)/i,
12
+ type: "PREFERENCE",
13
+ },
14
+ {
15
+ re: /(?:i use|เราใช้|ฉันใช้)\s+([^\s,]+)\s+(?:for|ในการ|เพื่อ)\s+(.+)/i,
16
+ type: "PREFERENCE",
17
+ },
18
+ {
19
+ re: /(?:don'?t use|ห้ามใช้|อย่าใช้)\s+(.+?)(?:,\s*(?:use|ใช้)\s+(.+))?/i,
20
+ type: "LESSON",
21
+ },
22
+ {
23
+ re: /(?:instead|use)\s+(.+?)\s+(?:rather than|แทน)\s+(.+)/i,
24
+ type: "LESSON",
25
+ },
26
+ ];
27
+ export const extractMemoriesInput = {
28
+ limit: z
29
+ .number()
30
+ .int()
31
+ .min(1)
32
+ .max(200)
33
+ .optional()
34
+ .describe("Max recent interactions to scan (default 50)"),
35
+ kind: z
36
+ .enum(["prompt", "tool_call", "error"])
37
+ .optional()
38
+ .describe("Interaction kind to scan (default prompt)"),
39
+ apply: z
40
+ .boolean()
41
+ .optional()
42
+ .describe("Create the proposed memories (default false = propose only)"),
43
+ };
44
+ export function extractMemoriesHandler(args) {
45
+ try {
46
+ const limit = args.limit ?? 50;
47
+ const kind = args.kind ?? "prompt";
48
+ const rows = db
49
+ .prepare("SELECT id, content FROM interactions WHERE kind = ? ORDER BY id DESC LIMIT ?")
50
+ .all(kind, limit);
51
+ const candidates = [];
52
+ for (const r of rows) {
53
+ for (const p of INTENT_PATTERNS) {
54
+ const m = r.content.match(p.re);
55
+ if (m) {
56
+ const clause = (m[1] || "").trim();
57
+ if (clause.length >= 3) {
58
+ candidates.push({
59
+ interactionId: r.id,
60
+ type: p.type,
61
+ content: clause,
62
+ });
63
+ }
64
+ break;
65
+ }
66
+ }
67
+ }
68
+ const distinct = candidates.filter((c) => deduplicate(c.type, c.content).verdict === "distinct");
69
+ if (args.apply === true) {
70
+ let n = 0;
71
+ for (const c of distinct) {
72
+ createMemory({
73
+ type: c.type,
74
+ content: c.content,
75
+ source: "captured",
76
+ });
77
+ n++;
78
+ }
79
+ return ok(`extracted and created ${n} memories from interactions (${candidates.length} candidates, ${candidates.length - distinct.length} duplicates skipped)`);
80
+ }
81
+ const preview = distinct
82
+ .slice(0, 20)
83
+ .map((c) => `[${c.type}] ${c.content}`)
84
+ .join("\n") || "(no candidates)";
85
+ return ok(`proposed ${distinct.length} memory candidate(s) from ${rows.length} ${kind} interactions (dry-run; pass apply=true to create):\n${preview}`);
86
+ }
87
+ catch (e) {
88
+ return err(e instanceof Error ? e.message : String(e));
89
+ }
90
+ }
@@ -0,0 +1,95 @@
1
+ import { z } from "zod";
2
+ import { readFileSync } from "node:fs";
3
+ import { join, dirname, resolve, sep } from "node:path";
4
+ import { DB_PATH, ok, err } from "../db/index.js";
5
+ import { createMemory } from "../db/repositories/memories.js";
6
+ import { deduplicate } from "../memory/deduplicator.js";
7
+ import { MEMORY_TYPES } from "../memory/types.js";
8
+ import { EXPORTS_DIRNAME } from "../lib/config.js";
9
+ export const importMemoryInput = {
10
+ file: z
11
+ .string()
12
+ .optional()
13
+ .describe("Path to a .json export file (must be inside data/exports/)"),
14
+ json: z
15
+ .string()
16
+ .optional()
17
+ .describe("Inline JSON: an array of memory objects, or { memories: [...] }"),
18
+ apply: z
19
+ .boolean()
20
+ .optional()
21
+ .describe("Actually insert memories (default false = dry run, just report)"),
22
+ };
23
+ export function importMemoryHandler(args) {
24
+ try {
25
+ let raw;
26
+ if (args.json) {
27
+ raw = args.json;
28
+ }
29
+ else if (args.file) {
30
+ const exportDir = join(dirname(DB_PATH), EXPORTS_DIRNAME);
31
+ const full = resolve(args.file);
32
+ const allowed = resolve(exportDir);
33
+ if (!full.startsWith(allowed + sep))
34
+ return err(`file must be inside ${exportDir}`);
35
+ if (!full.endsWith(".json"))
36
+ return err("file must end with .json");
37
+ raw = readFileSync(full, "utf8");
38
+ }
39
+ else {
40
+ return err("provide either file or json");
41
+ }
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(raw);
45
+ }
46
+ catch {
47
+ return err("invalid JSON");
48
+ }
49
+ const items = Array.isArray(parsed)
50
+ ? parsed
51
+ : Array.isArray(parsed.memories)
52
+ ? (parsed.memories)
53
+ : [];
54
+ let wouldImport = 0;
55
+ let skipped = 0;
56
+ let invalid = 0;
57
+ const log = [];
58
+ for (const it of items) {
59
+ if (!it ||
60
+ typeof it.content !== "string" ||
61
+ !MEMORY_TYPES.includes(it.type)) {
62
+ invalid++;
63
+ continue;
64
+ }
65
+ const dup = deduplicate(it.type, it.content);
66
+ if (dup.verdict === "duplicate") {
67
+ skipped++;
68
+ log.push(`skip duplicate -> existing ${dup.existingId}`);
69
+ continue;
70
+ }
71
+ wouldImport++;
72
+ if (args.apply === true) {
73
+ createMemory({
74
+ type: it.type,
75
+ content: it.content,
76
+ summary: typeof it.summary === "string" ? it.summary : null,
77
+ source: it.source ?? "imported",
78
+ confidence: typeof it.confidence === "number" ? it.confidence : 0.7,
79
+ importance: typeof it.importance === "number" ? it.importance : 0.5,
80
+ projectId: typeof it.projectId === "string" ? it.projectId : null,
81
+ sessionId: typeof it.sessionId === "string" ? it.sessionId : null,
82
+ validFrom: typeof it.validFrom === "string" ? it.validFrom : null,
83
+ validUntil: typeof it.validUntil === "string" ? it.validUntil : null,
84
+ metadata: it.metadata ?? null,
85
+ });
86
+ }
87
+ }
88
+ const mode = args.apply === true ? "applied" : "dry-run";
89
+ const summary = `import ${mode}: ${wouldImport} to import, ${skipped} duplicate(s) skipped, ${invalid} invalid`;
90
+ return ok(log.length ? `${summary}\n${log.join("\n")}` : summary);
91
+ }
92
+ catch (e) {
93
+ return err(e instanceof Error ? e.message : String(e));
94
+ }
95
+ }
@@ -0,0 +1,31 @@
1
+ import { z } from "zod";
2
+ import { ok, err } from "../db/index.js";
3
+ import { linkMemories } from "../core/graph-engine.js";
4
+ import { getMemoryById } from "../db/repositories/memories.js";
5
+ import { LINK_RELATIONS } from "../memory/types.js";
6
+ export const linkMemoryInput = {
7
+ sourceId: z.number().int().describe("Source memory id"),
8
+ targetId: z.number().int().describe("Target memory id"),
9
+ relation: z
10
+ .enum(LINK_RELATIONS)
11
+ .describe("Link relation (supports/contradicts/supersedes/derived_from/related_to/caused_by/depends_on)"),
12
+ };
13
+ export function linkMemoryHandler(args) {
14
+ try {
15
+ const src = getMemoryById(args.sourceId);
16
+ const tgt = getMemoryById(args.targetId);
17
+ if (!src)
18
+ return err(`source memory ${args.sourceId} not found`);
19
+ if (!tgt)
20
+ return err(`target memory ${args.targetId} not found`);
21
+ if (src.status === "deleted" || tgt.status === "deleted")
22
+ return err("cannot link deleted memories");
23
+ if (src.id === tgt.id)
24
+ return err("cannot link a memory to itself");
25
+ linkMemories(args.sourceId, args.targetId, args.relation);
26
+ return ok(`linked memory ${args.sourceId} -[${args.relation}]-> ${args.targetId}`);
27
+ }
28
+ catch (e) {
29
+ return err(e instanceof Error ? e.message : String(e));
30
+ }
31
+ }
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ import { db, nowISO, ok, err } from "../db/index.js";
3
+ import { getMemoryById } from "../db/repositories/memories.js";
4
+ import { supersede } from "../core/lifecycle-engine.js";
5
+ export const mergeMemoryInput = {
6
+ sourceId: z
7
+ .number()
8
+ .int()
9
+ .describe("Memory to merge away (becomes superseded)"),
10
+ targetId: z
11
+ .number()
12
+ .int()
13
+ .describe("Canonical memory to keep (becomes active)"),
14
+ };
15
+ function mergeMetadata(existing, mergedId) {
16
+ let obj = {};
17
+ if (existing) {
18
+ try {
19
+ obj = JSON.parse(existing);
20
+ }
21
+ catch {
22
+ obj = {};
23
+ }
24
+ }
25
+ const from = Array.isArray(obj.merged_from) ? obj.merged_from : [];
26
+ from.push(mergedId);
27
+ obj.merged_from = from;
28
+ return JSON.stringify(obj);
29
+ }
30
+ export function mergeMemoryHandler(args) {
31
+ try {
32
+ const src = getMemoryById(args.sourceId);
33
+ const tgt = getMemoryById(args.targetId);
34
+ if (!src)
35
+ return err(`source memory ${args.sourceId} not found`);
36
+ if (!tgt)
37
+ return err(`target memory ${args.targetId} not found`);
38
+ if (src.id === tgt.id)
39
+ return err("cannot merge a memory into itself");
40
+ if (src.status === "deleted" || tgt.status === "deleted")
41
+ return err("cannot merge deleted memories");
42
+ db.prepare("UPDATE memories SET metadata = ?, updated_at = ? WHERE id = ?").run(mergeMetadata(tgt.metadata, src.id), nowISO(), tgt.id);
43
+ supersede(src.id, tgt.id);
44
+ return ok(`merged memory ${src.id} into ${tgt.id} (source superseded, provenance recorded in metadata.merged_from)`);
45
+ }
46
+ catch (e) {
47
+ return err(e instanceof Error ? e.message : String(e));
48
+ }
49
+ }
@@ -0,0 +1,98 @@
1
+ import { z } from "zod";
2
+ import { db, nowISO, ok, err, } from "../db/index.js";
3
+ import { getMemoryById, createMemory, syncMemoryIndex, } from "../db/repositories/memories.js";
4
+ import { supersede } from "../core/lifecycle-engine.js";
5
+ export const updateMemoryInput = {
6
+ id: z.number().int().describe("Memory id to update"),
7
+ content: z
8
+ .string()
9
+ .max(2000)
10
+ .optional()
11
+ .describe("New content. When provided, a superseding memory is created (supersede=true) unless supersede=false."),
12
+ summary: z.string().max(2000).nullable().optional().describe("New summary"),
13
+ importance: z.number().min(0).max(1).optional(),
14
+ confidence: z.number().min(0).max(1).optional(),
15
+ validUntil: z
16
+ .string()
17
+ .nullable()
18
+ .optional()
19
+ .describe("ISO timestamp or null to clear"),
20
+ metadata: z.unknown().optional().describe("New metadata object (replaces)"),
21
+ supersede: z
22
+ .boolean()
23
+ .optional()
24
+ .describe("If content changes, create a superseding memory instead of editing in place (default true)"),
25
+ };
26
+ export function updateMemoryHandler(args) {
27
+ try {
28
+ const mem = getMemoryById(args.id);
29
+ if (!mem)
30
+ return err(`memory ${args.id} not found`);
31
+ if (mem.status === "deleted")
32
+ return err("cannot update a deleted memory");
33
+ const supersedeContent = args.content !== undefined && (args.supersede ?? true);
34
+ if (supersedeContent) {
35
+ const newId = createMemory({
36
+ type: mem.type,
37
+ content: args.content,
38
+ summary: args.summary ?? mem.summary,
39
+ source: mem.source,
40
+ confidence: args.confidence ?? mem.confidence,
41
+ importance: args.importance ?? mem.importance,
42
+ salience: mem.salience,
43
+ projectId: mem.project_id,
44
+ sessionId: mem.session_id,
45
+ validFrom: mem.valid_from,
46
+ validUntil: args.validUntil !== undefined ? args.validUntil : mem.valid_until,
47
+ metadata: args.metadata !== undefined
48
+ ? args.metadata
49
+ : mem.metadata
50
+ ? JSON.parse(mem.metadata)
51
+ : null,
52
+ });
53
+ supersede(mem.id, newId);
54
+ return ok(`created superseding memory id=${newId} for old id=${mem.id} (old now superseded)`);
55
+ }
56
+ const sets = [];
57
+ const vals = [];
58
+ if (args.summary !== undefined) {
59
+ sets.push("summary = ?");
60
+ vals.push(args.summary);
61
+ }
62
+ if (args.importance !== undefined) {
63
+ sets.push("importance = ?");
64
+ vals.push(args.importance);
65
+ }
66
+ if (args.confidence !== undefined) {
67
+ sets.push("confidence = ?");
68
+ vals.push(args.confidence);
69
+ }
70
+ if (args.validUntil !== undefined) {
71
+ sets.push("valid_until = ?");
72
+ vals.push(args.validUntil);
73
+ }
74
+ if (args.metadata !== undefined) {
75
+ sets.push("metadata = ?");
76
+ vals.push(JSON.stringify(args.metadata));
77
+ }
78
+ if (args.content !== undefined) {
79
+ sets.push("content = ?");
80
+ vals.push(args.content);
81
+ }
82
+ if (sets.length === 0)
83
+ return ok(`no mutable fields provided; memory ${mem.id} unchanged`);
84
+ sets.push("updated_at = ?");
85
+ vals.push(nowISO());
86
+ vals.push(mem.id);
87
+ db.prepare(`UPDATE memories SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
88
+ if (args.content !== undefined) {
89
+ const m = getMemoryById(mem.id);
90
+ if (m)
91
+ syncMemoryIndex(m.id, m.type, m.content);
92
+ }
93
+ return ok(`updated memory ${mem.id} in place`);
94
+ }
95
+ catch (e) {
96
+ return err(e instanceof Error ? e.message : String(e));
97
+ }
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "th-memory-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.1.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",
@@ -17,7 +17,7 @@
17
17
  "dist",
18
18
  "README.md",
19
19
  "LICENSE",
20
- "design.md",
20
+ "ARCHITECTURE_v2.md",
21
21
  "opencode.example.json",
22
22
  "AGENTS.memory.example.md"
23
23
  ],
@@ -26,7 +26,7 @@
26
26
  "prepublishOnly": "npm run build",
27
27
  "start": "node dist/index.js",
28
28
  "inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
29
- "test": "npm run build && node test/capture.test.mjs && node test/distill.test.mjs && node test/lifecycle.test.mjs && node test/temporal.test.mjs && node test/conflict.test.mjs && node test/retrieval.test.mjs && node test/graph.test.mjs && node test/context.test.mjs && node test/consolidation.test.mjs && node test/benchmark.test.mjs && node test/security.test.mjs && node test/smoke.mjs",
29
+ "test": "npm run build && node test/capture.test.mjs && node test/distill.test.mjs && node test/lifecycle.test.mjs && node test/temporal.test.mjs && node test/conflict.test.mjs && node test/retrieval.test.mjs && node test/graph.test.mjs && node test/context.test.mjs && node test/consolidation.test.mjs && node test/benchmark.test.mjs && node test/security.test.mjs && node test/tools_v21.test.mjs && node test/smoke.mjs",
30
30
  "distill": "node dist/distill.js",
31
31
  "quickstart": "npm run build && node scripts/quickstart.mjs"
32
32
  },
package/design.md DELETED
@@ -1,98 +0,0 @@
1
- # th-memory-mcp v2 — Implementation Plan (design.md)
2
-
3
- **Source of truth:** `ARCHITECTURE_v2.md` (on GitHub, baseline v1.2.2).
4
- This file is the working plan for the Building Agent — read it before continuing implementation.
5
-
6
- ## Goal
7
- Evolve th-memory-mcp from a structured local memory MCP into a durable, temporal,
8
- conflict-aware, hybrid-retrieval memory engine. Local-first, offline, SQLite, no
9
- mandatory cloud/LLM. Keep v1 behavior working during the transition.
10
-
11
- ## Current v1 state (summary)
12
- - 9 MCP tools: remember, recall, get_profile, save_lesson, search_history, forget, memory_stats, get_recent_interactions, export_memory.
13
- - Schema (inline `CREATE TABLE IF NOT EXISTS` in `src/db.ts`): `interactions`, `preferences`, `lessons`, `profile`, FTS5 `search_index`, `embeddings` (BLOB, 512-dim hashing-trick vectors).
14
- - No migration system; no schema version table.
15
- - Capture logic triplicated: `src/lib/capture-core.ts`, `src/plugin/learning-capture.ts` (Bun), `scripts/claude-capture.mjs`.
16
- - Semantic search = full in-memory linear scan over all embeddings every `recall`.
17
- - Version metadata inconsistent: `package.json` 1.2.2 vs `config.ts` VERSION 1.1.0 vs `smoke.mjs` assertion 1.1.0 (fix in Phase 10).
18
-
19
- ## Decisions (Phase 1)
20
- - **Migrations are TS modules** (`src/db/migrations.ts`) exporting an ordered `MIGRATIONS` array + `runMigrations(db)`. Each `up(db)` is idempotent (`CREATE TABLE IF NOT EXISTS`) and tracked in `schema_meta`. This avoids `.sql` file-copy issues under `tsc` while keeping deterministic order (spec allows implementation differences).
21
- - **Reuse existing `search_index` + `embeddings`** for v2 `memories` (ref_table = `'memories'`). No new FTS table needed.
22
- - **Non-destructive:** v1 tables (`preferences`, `lessons`, `interactions`, `profile`) are preserved. v2 adds `memories`, `entities`, `relations`, `memory_links`, `schema_meta`.
23
- - **Backfill (M005):** map `preferences → memories(type=PREFERENCE)`, `lessons → memories(type=LESSON)`, sync FTS+embeddings. Guarded by `v1_backfilled` flag so it runs once. `recall` is unaffected because it filters by `ref_table IN ('preferences','lessons')`.
24
- - **No dual-write yet.** v1 tools keep writing only to v1 tables. v2 `memories` is seeded by backfill; new v2 tools (later phases) write to `memories`. Dedup/merge of backfilled vs new entries is Phase 4.
25
- - **Repository layer** (`src/db/repositories/memories.ts`) provides `createMemory`, `getMemoryById`, `setStatus`, `softDelete`, `syncMemoryIndex`, `searchMemories` (FTS + semantic blend, status/project filtering). Not yet wired to a public tool (that is Phase 7 `get_context`).
26
-
27
- ## Phased roadmap
28
- See `ARCHITECTURE_v2.md` §35 for the canonical phase list. Status tracked in the session todo list.
29
-
30
- ## This session (deliverables so far)
31
-
32
- ### Phase 1 — Core abstraction (DONE)
33
- - [x] `src/memory/types.ts` — unified `MemoryType`, `SourceType`, `LifecycleState`, `Scope`, `LinkRelation`, `MemoryRecord`.
34
- - [x] `src/db/migrations.ts` — migration engine + 5 migrations (schema_meta, memories+indexes, entities/relations, memory_links, v1 backfill).
35
- - [x] `src/db/repositories/memories.ts` — core CRUD + index sync + `searchMemories` (hybrid FTS + semantic blend).
36
- - [x] `src/db/index.ts` — call `runMigrations(db)` after existing DDL (non-destructive).
37
- - [x] Build + full test suite green (capture/distill/smoke).
38
-
39
- ### Phase 2 — Lifecycle engine (DONE)
40
- - [x] `src/memory/decay.ts` — `recencyFactor`, per-type `DECAY_LAMBDA_BY_TYPE` (policy classes, not constants).
41
- - [x] `src/memory/source-weights.ts` — `SOURCE_WEIGHTS` map (spec §8).
42
- - [x] `src/memory/scorer.ts` — `computeSalience` (weighted, configurable), `computeConfidence` (source weight + diminishing returns), `salienceForMemory`.
43
- - [x] `src/core/lifecycle-engine.ts` — `canTransition`, `transitionStatus`, `reinforce`, `touch`, `supersede` (sets old=superseded, new=active + `supersedes_id` + `memory_links`), `archive`, `softDelete`, `LifecycleError`.
44
- - [x] `test/lifecycle.test.mjs` — 17 checks (decay, scorer, transitions, supersession, archive). Added to `npm test`.
45
-
46
- ### Phase 3 — Temporal model (DONE)
47
- - [x] `src/core/temporal-engine.ts` — `setValidity`, `memoriesValidAt` (point-in-time truth), `supersessionChain` (oldest→newest), `changesBetween` (change detection).
48
- - [x] `test/temporal.test.mjs` — 7 checks (validity intervals, historical retrieval, supersession chains, change detection). Added to `npm test`.
49
-
50
- ### Phase 4 — Conflict & dedup (DONE)
51
- - [x] `src/memory/deduplicator.ts` — `normalizeText`, `findExactMatch`, `findSimilar`, `deduplicate` (spec §11).
52
- - [x] `src/memory/conflict-resolver.ts` — `isContradiction`, `classifyRelationship` (duplicate/update/contradiction/unrelated), `findRelated`, `resolveConflict` (merge duplicate / supersede update / link contradiction, preserving ambiguous evidence per §12).
53
- - [x] `test/conflict.test.mjs` — 14 checks. Added to `npm test`.
54
- - [x] **Bug fix (v1 too):** `src/lib/embed.ts` `serialize`/`deserialize` rewrote with `DataView` + explicit `byteOffset`. Old code used `Buffer.from(buf).buffer` which can carry a non-zero pool `byteOffset`, corrupting vectors (magnitude ~1e37). This silently broke v1 semantic search.
55
-
56
- ### Phase 5 — Hybrid retrieval (DONE)
57
- - [x] `src/retrieval/fts.ts` — `ftsSearch` (FTS5 over `search_index`, status/project filters, `ORDER BY rank`).
58
- - [x] `src/retrieval/vector.ts` — `vectorSearch` (cosine over `embeddings`, floor 0.15, filters).
59
- - [x] `src/retrieval/fusion.ts` — `rrfFuse` (Reciprocal Rank Fusion, k=60).
60
- - [x] `src/retrieval/scorer.ts` — `finalScore` (RRF × confidence × importance × recency × scope) + `scopeFactorFor`.
61
- - [x] `src/core/retrieval-engine.ts` — `retrieve` (FTS + vector → RRF → scoring/rerank → filter → topK).
62
- - [x] `searchMemories` in repository now delegates to `retrieve` (hybrid). `buildFtsMatch` switched to OR for better recall.
63
- - [x] `test/retrieval.test.mjs` — 7 checks. Added to `npm test`.
64
-
65
- ### Phase 6 — Graph engine (DONE)
66
- - [x] `src/core/graph-engine.ts` — `createEntity` (canonical dedup, aliases in metadata), `addRelation` (source_entity_id/relation/target_entity_id), `linkMemories`, `traverse` (bounded BFS over `memory_links`, maxDepth 1–5, relationFilter), `neighbors`.
67
- - [x] `test/graph.test.mjs` — 7 checks (linking, bounded traversal depth, relation filter, entity dedup, relation insert). Added to `npm test`.
68
- - [x] Note: `entities` columns are `(name, canonical_name, type, metadata)`; `relations` use `source_entity_id/relation/target_entity_id`; `memory_links` PK `(source_memory_id, relation, target_memory_id)`.
69
-
70
- ### Phase 7 — Context engine (DONE)
71
- - [x] `src/core/context-engine.ts` — `getContext` (hybrid retrieve → optional graph expansion → temporal validity filter → token budgeting/truncation).
72
- - [x] `src/tools/context.ts` — `contextInput` (zod) + `contextHandler` (returns assembled context text).
73
- - [x] Wired `get_context` MCP tool into `index.ts` (now 10 tools total).
74
- - [x] `test/context.test.mjs` — 7 checks (assembly, graph expansion, token budget, temporal validity). Added to `npm test`.
75
- - [x] Updated `test/smoke.mjs` to expect 10 tools.
76
-
77
- ### Phase 8 — Consolidation (DONE)
78
- - [x] `src/core/consolidation-engine.ts` — `clusterMemories` (embedding cosine + union-find), `createDerivedMemory` (type DERIVED, source consolidated, links `derived_from`), `getProvenance`.
79
- - [x] `src/tools/consolidate.ts` — `consolidateInput` + `consolidateHandler` (read-only cluster listing + optional `derive`).
80
- - [x] Wired `consolidate` MCP tool into `index.ts` (now 11 tools total).
81
- - [x] `test/consolidation.test.mjs` — 5 checks. Added to `npm test`.
82
- - [x] Added `DERIVED` to `MEMORY_TYPES`; added `DERIVED` lambda to `decay.ts`.
83
-
84
- ### Phase 9 — Benchmark & security suite (DONE)
85
- - [x] `test/benchmark.test.mjs` — 2 checks (retrieve over 300 memories < 2000ms).
86
- - [x] `test/security.test.mjs` — 5 checks (FTS injection quoting, safe retrieve, malicious content stored verbatim, extreme budget, parameterized SQL).
87
- - [x] Both added to `npm test` (now 12 suites).
88
-
89
- ### Phase 10 — v2 release (DOCS DONE; PUBLISH PENDING USER)
90
- - [x] `MIGRATION_v2.md` written (non-destructive upgrade guide, rollback notes).
91
- - [x] `README.md` updated to v2.0.0 (11 tools, v2 architecture, new test scripts).
92
- - [ ] Version bump to `2.0.0` + `npm publish --otp=CODE` (needs user OTP).
93
- - [ ] `git commit` + `git push` + GitHub Release v2.0.0 (needs user).
94
- - [ ] `.\mcp-publisher.exe publish` (Official MCP Registry; needs user GitHub OAuth + OTP).
95
- - [ ] Glama: claim ownership + sync from GitHub.
96
-
97
- ## Next
98
- All v2 engine phases (0–9) complete and tested. Remaining: user-driven release steps above. After release, future work could include automatic entity extraction in consolidation and a periodic auto-consolidate scheduler.