th-memory-mcp 2.2.4 → 2.2.7

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/design.md CHANGED
@@ -1,61 +1,65 @@
1
- # th-memory-mcp — Design Notes (ปัจจุบัน)
1
+ # th-memory-mcp — Design Notes (Current)
2
2
 
3
- เอกสารนี้อัปเดตล่าสุดสอดคล้องกับสถานะจริงของโค้ด (หลังจบแผนฟีเจอร์อนาคตทั้งหมด ยกเว้น AI-assisted extraction ที่ตัดออก)
4
- สเปคฉบับเต็มอยู่ที่ `ARCHITECTURE_v2.md` (canonical spec) ไฟล์นี้สรุปภาพรวมและสถานะปัจจุบันเพื่อความสะดวก
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.
5
4
 
6
- ## สถานะปัจจุบัน
7
- - **เวอร์ชัน:** `package.json` = `2.2.4`
5
+ ## Current Status
6
+ - **Version:** `package.json` = `2.2.7`
8
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`)
9
- - **ชุดเทสต์:** 25 suites ผ่านหมด (0 fail) — รันผ่าน `npm test` (มี CI บน GitHub Actions)
8
+ - **Test suites:** 25 suites passing (0 fail) — run via `npm test` (CI on GitHub Actions)
10
9
 
11
- ## องค์ประกอบหลัก (src/)
12
- - `db/` — better-sqlite3 (WAL mode), migrations เชิงเส้น (M001–M007), repositories (`memories`, `users`, `preferences`, `lessons`)
13
- - `lib/embed.ts` — semantic vector แบบ hashing-trick (ไม่พึ่ง LLM/network)
14
- - `retrieval/` — FTS5 + vector → RRF fusion → scorer (confidence × importance × recency × scope)
10
+ ## Core Components (src/)
11
+ - `db/` — better-sqlite3 (WAL mode), linear migrations (M001–M007), repositories (`memories`, `users`, `preferences`, `lessons`)
12
+ - `lib/embed.ts` — hashing-trick lexical fuzzy matching (hashed n-gram similarity, 512-dim FNV-1a) (no LLM/network dependency)
13
+ - `retrieval/` — FTS5 + lexical fuzzy matching (hashed n-gram similarity, 512-dim FNV-1a) → RRF fusion → scorer (confidence × importance × recency × scope)
15
14
  - `memory/` — types, lifecycle-engine (decay/source-weights), conflict-resolver, deduplicator
16
15
  - `core/` — retrieval-engine, context-engine, graph-engine, consolidation-engine, entity-extractor
17
16
  - `tools/` — 16 MCP tool handlers
18
17
  - `index.ts` — MCP stdio server
19
18
 
20
- ## ฟีเจอร์ที่ทำเสร็จแล้ว
19
+ ## Completed Features
21
20
  - ✅ Temporal model — validity intervals, point-in-time retrieval, supersession chains, change detection
22
- - ✅ Conflict/dedup — normalize → exact → similar → classify (duplicate/update/contradiction/unrelated); ambiguous conflicts ถูกเก็บไว้ (link `contradicts`) ไม่เขียนทับเงียบๆ
23
- - ✅ Hybrid retrieval (FTS + vector, RRF)
21
+ - ✅ Conflict/dedup — normalize → exact → similar → classify (duplicate/update/contradiction/unrelated); ambiguous conflicts are kept (link `contradicts`) instead of silently overwriting
22
+ - ✅ Hybrid retrieval (FTS + lexical fuzzy matching (hashed n-gram similarity, 512-dim FNV-1a), RRF)
24
23
  - ✅ Memory graph — entities/relations + bounded traversal (`link_memory`)
25
- - ✅ Context engine — `get_context` with token budgeting, temporal filter, graph expansion
24
+ - ✅ Context engine — `get_context` with token budgeting, temporal filtering, graph expansion
26
25
  - ✅ Consolidation — clustering + derived memories (`derived_from` provenance)
27
26
  - ✅ Scope hierarchy — `USER` / `SESSION` / `PROJECT` / `GLOBAL` (migrations 006 + 007)
28
- - `createMemory` อนุมาน scope ตามลำดับ SESSION > PROJECT > USER > GLOBAL
29
- - `scopeFactorFor` boost ความจำที่เข้าข่ายบริบทปัจจุบัน (USER=1.0, PROJECT/SESSION ตามบริบท, GLOBAL เป็น base)
30
- - ✅ Profile auto-projection — `profile.ts` ดึงความจำสำคัญมาแทรกใน `[memories]`
31
- - ✅ Auto entity extraction — `entity-extractor.ts` สกัด entity แบบ heuristic (ไม่ใช้ LLM) ผูกเข้า graph ตอน consolidate
32
- - ✅ Benchmark in-repo:
27
+ - `createMemory` infers scope in order SESSION > PROJECT > USER > GLOBAL
28
+ - `scopeFactorFor` boosts memories matching the current context (USER=1.0, PROJECT/SESSION depending on context, GLOBAL as base)
29
+ - ✅ Profile auto-projection — `profile.ts` injects important memories into `[memories]`
30
+ - ✅ Auto entity extraction — `entity-extractor.ts` extracts entities heuristically (no LLM) and links them into the graph during consolidation
31
+ - ✅ In-repo benchmark:
33
32
  - Retrieval quality (§26) — `test/retrieval_benchmark.test.mjs` (Recall@5=1.00, Precision@5=0.92, MRR=1.00)
34
- - Perf (§29) — `test/benchmark.test.mjs` วัด latency ต่อ op ผ่าน CI
35
- - Conflict quality (§27) — `test/conflict_benchmark.test.mjs` (100% บนชุด 14 เคส ครบ 7 หมวด)
36
- - E2E transport — `test/e2e_transport.test.mjs` (spawn server ผ่าน StdioClientTransport)
33
+ - Perf (§29) — `test/benchmark.test.mjs` measures latency per operation via CI
34
+ - Conflict quality (§27) — `test/conflict_benchmark.test.mjs` (100% on 14 cases across 7 categories)
35
+ - E2E transport — `test/e2e_transport.test.mjs` (spawn server via StdioClientTransport)
36
+ - Two modes — `npm run benchmark` (normal, 180 records) and `npm run benchmark:heavy` (600 records + full suite), viewer at `benchmark/viewer/` (`npm run benchmark:viewer`, compares latest 3 versions)
37
37
  - ✅ CI pipeline — `.github/workflows/ci.yml` (ubuntu-latest, node 20, `npm ci`, `npm test`)
38
38
 
39
- ## Scope model (รายละเอียด)
40
- | Scope | เงื่อนไข | พฤติกรรม |
41
- |-------|----------|----------|
42
- | SESSION | มี `sessionId` | ผูกกับ session นั้น |
43
- | PROJECT | มี `projectId` (ไม่มี session) | ผูกกับ project นั้น |
44
- | USER | มี `userId` (ไม่มี session/project) | ผูกกับ user นั้น (auto-create row ใน `users`) |
45
- | GLOBAL | ไม่มีอะไรเลย | ความจำร่วมกันทั้งระบบ |
39
+ ## Scope Model (Details)
40
+ | Scope | Condition | Behavior |
41
+ |-------|-----------|----------|
42
+ | SESSION | has `sessionId` | bound to that session |
43
+ | PROJECT | has `projectId` (no session) | bound to that project |
44
+ | USER | has `userId` (no session/project) | bound to that user (auto-creates row in `users`) |
45
+ | GLOBAL | none | shared system-wide memory |
46
46
 
47
- `userId` ที่รับจาก client เป็น external identity (string) — ระบบไม่มีการ authenticate; ตัวตัดความเป็นของ client ทั้งหมด
48
- `preferences` และ `lessons` ยังคงเป็น global (ไม่มี user column)
47
+ `userId` received from the client is an external identity (string) — the system does not authenticate; it is entirely client-declared.
48
+ `preferences` and `lessons` remain global (no user column).
49
49
 
50
- ## ข้อจำกัดที่รู้อยู่ (known limitations)
51
- - **Trust model:** ไม่มี user authentication — `userId` คือสิ่งที่ client แจ้งมา (client-declared) เหมาะกับการ deploy แบบ local single-user ที่ไฟล์ SQLite เป็นของเจ้าของคนเดียว หากต้องการแยกผู้ใช้หลายคน แนะนำแก้ที่ระดับไฟล์ DB (หนึ่ง DB ต่อผู้ใช้) ไม่ใช่เพิ่ม auth ลงใน engine
52
- - `preferences` / `lessons` ไม่ถูกแบ่งตาม user (ยังเป็น global) — ยอมรับได้สำหรับ single-user
53
- - Semantic embedding ใช้ hashing-trick (deterministic, offline) — ไม่ใช่ embedding ระดับ LLM จึงมีขีดจำกัดเรื่อง paraphrase ที่ห่างกันมาก
54
- - **AI-assisted extraction ไม่พัฒนาต่อ** — เจ้าของตัดสินใจตัดหัวข้อนี้ออก `extract_memories` จึงเป็น deterministic heuristic เท่านั้น (ไม่ใช้ LLM) ตามหลักการออกแบบที่ว่า core engine ต้องไม่พึ่งพา external LLM API
50
+ ## Known Limitations
51
+ - **Trust model:** No user authentication — `userId` is client-declared. Suitable for local single-user deployment where the SQLite file is owned by a single user. For multi-user separation, use one DB file per user instead of adding auth to the engine.
52
+ - `preferences` / `lessons` are not partitioned by user (still global) — acceptable for single-user.
53
+ - Lexical fuzzy matching uses hashing-trick (hashed n-gram similarity, 512-dim FNV-1a, deterministic, offline) — not LLM-level / concept-level embedding, so distant paraphrases without shared tokens or 3-grams will not be linked (affects RRF vector signal and conflict-resolver threshold).
54
+ - **AI-assisted extraction discontinued** — the owner decided to remove this; `extract_memories` is therefore deterministic heuristic only (no LLM), per the design principle that the core engine must not depend on an external LLM API.
55
+ - **No encryption at rest (plaintext-at-rest) — Md-4:** `data/memory.db` (WAL mode, `better-sqlite3`) is a plain, unencrypted SQLite file. `100% local & private` means no cloud or network exfiltration — it does **not** mean encrypted at rest. Anyone with filesystem access (shared machine, backup, malware, stolen device) can read preferences/lessons/interactions in plaintext. For sensitive data, use OS-level full-disk encryption (BitLocker / FileVault / LUKS) or an opt-in SQLCipher build (requires native rebuild and key management). No SQLCipher/in-code encryption is applied by default and `src/db/index.ts` documents this explicitly.
55
56
 
56
57
  ## Release
57
- - v2.0.0 ปล่อยแล้ว (npm, GitHub Release, Official MCP Registry, Glama)
58
- - v2.2.0 — ปล่อยครบ: tag + GitHub Release, **npm publish เรียบร้อย**, Official MCP Registry ดึงจาก npm อัตโนมัติ (ไม่ต้องรัน mcp-publisher แยก), Glama Sync เรียบร้อย
59
- - v2.2.1 — แก้ Glama quality: เพิ่ม `pnpm.onlyBuiltDependencies: ["better-sqlite3"]` ให้ pnpm รันสคริปต์ดาวน์โหลด native binary สำหรับ Node 24, ขยับ better-sqlite3 เป็น `^12.9.0`, เพิ่ม override `ip-address@^10.2.0` (npm+pnpm) อุดช่องโหว่ XSS ผ่าน MCP SDK ไม่มีการเปลี่ยนโค้ด รอ `npm publish` + Glama ทดสอบใหม่
60
- - v2.2.3 — ชุดแก้ความปลอดภัย + ประสิทธิภาพตาม `report_checkup.md`: บังคับ scope filtering (ไม่คืน foreign USER/SESSION), กรอง graph ไม่ข้าม scope, ห้าม link ข้าม scope, export/import แบบ round-trip, ใช้ `realpath` กัน symlink, ตรวจ import เข้มงวด (enum/0..1/ISO), เลิก N+1 query (vector JOIN, bulk fetch, cap consolidation), benchmark cold/ablation และสวิตช์ `MEMORY_RETRIEVAL_MODE`, เพิ่ม 5 เทสต์ใหม่เป็น 25/25, benchmark รันครบทุก suite
61
- - v2.2.4 — docs: จัดหน้า badge ใน README ให้เป็นระเบียบ
58
+ - v2.0.0 released (npm, GitHub Release, Official MCP Registry, Glama)
59
+ - v2.2.0 — full release: tag + GitHub Release, **npm publish done**, Official MCP Registry auto-pulls from npm (no separate mcp-publisher run), Glama Sync done
60
+ - v2.2.1 — Glama quality fix: added `pnpm.onlyBuiltDependencies: ["better-sqlite3"]` so pnpm runs the native binary download script for Node 24, bumped better-sqlite3 to `^12.9.0`, added override `ip-address@^10.2.0` (npm+pnpm) to patch XSS via MCP SDK — no code change, waiting for `npm publish` + Glama re-test
61
+ - v2.2.3 — security + performance hardening per `report_checkup.md`: enforced scope filtering (no foreign USER/SESSION returned), graph scope isolation, disallow cross-scope links, round-trip export/import, `realpath` against symlink, strict import validation (enum/0..1/ISO), eliminated N+1 queries (vector JOIN, bulk fetch, cap consolidation), cold/ablation benchmark and `MEMORY_RETRIEVAL_MODE` switch, added 5 new tests to 25/25, benchmark runs all suites
62
+ - v2.2.4 — docs: tidy README badge layout
63
+ - v2.2.5 — docs: sync README/design/ARCHITECTURE/PUBLISH to project (lexical fuzzy matching, 25 suites, benchmark viewer for 3 versions, result versioning) + SECURITY.md + package files; keep v2.2.3 security hardening
64
+ - v2.2.6 — docs: translate all docs to English except readme.th.md (benchmark/README, benchmark/METHODOLOGY, repro/README, viewer HTML, PUBLISH checklist)
65
+ - v2.2.7 — bugfix: synced secret filter (6-pattern redact) between Claude hook and capture-core, fixed `err()` to return `isError:true` per MCP spec, fixed backup rotation (backup only when migrations pending + prune to 5), added hook error logging for SessionEnd distill; benchmark: upgraded to v2.3 spec (semantic-hard dataset, scope safety, graph effectiveness, token efficiency @128 budget, scalability profiles quick→extreme up to 10M, ablation with scope+graph, resumable generation, resource reporting)
@@ -33,11 +33,13 @@ export function getContext(opts = {}) {
33
33
  const limit = Math.min(Math.max(opts.limit ?? 10, 1), 50);
34
34
  const maxTokens = opts.maxTokens ?? 2000;
35
35
  const now = new Date();
36
+ const resolvedUid = opts.userId ? resolveUserId(opts.userId) : null;
36
37
  const seeds = retrieve(query, {
37
38
  limit,
38
39
  projectId: opts.projectId,
39
40
  sessionId: opts.sessionId,
40
41
  userId: opts.userId,
42
+ resolvedUid,
41
43
  includeArchived: opts.includeHistory,
42
44
  });
43
45
  const seedScores = new Map();
@@ -45,7 +47,6 @@ export function getContext(opts = {}) {
45
47
  seedScores.set(s.id, s.final_score);
46
48
  const ids = new Set(seeds.map((s) => s.id));
47
49
  if (opts.includeGraph) {
48
- const uid = opts.userId ? resolveUserId(opts.userId) : null;
49
50
  for (const s of seeds) {
50
51
  for (const n of traverse(s.id, { maxDepth: 1 })) {
51
52
  const m = db
@@ -58,7 +59,7 @@ export function getContext(opts = {}) {
58
59
  m.status === "archived" ||
59
60
  m.status === "superseded"))
60
61
  continue;
61
- if (!isScopeVisible(m, opts, uid))
62
+ if (!isScopeVisible(m, opts, resolvedUid))
62
63
  continue;
63
64
  if (!opts.includeHistory && !isCurrentlyValid(m, now))
64
65
  continue;
@@ -66,7 +67,6 @@ export function getContext(opts = {}) {
66
67
  }
67
68
  }
68
69
  }
69
- const resolvedUid = opts.userId ? resolveUserId(opts.userId) : null;
70
70
  const all = [...ids]
71
71
  .map((id) => db.prepare("SELECT * FROM memories WHERE id = ?").get(id))
72
72
  .filter((m) => !!m)
@@ -21,7 +21,11 @@ export function retrieve(query, opts = {}) {
21
21
  }
22
22
  const fused = rrfFuse(lists);
23
23
  const now = new Date();
24
- const resolvedUid = opts.userId ? resolveUserId(opts.userId) : null;
24
+ const resolvedUid = opts.resolvedUid !== undefined
25
+ ? opts.resolvedUid
26
+ : opts.userId
27
+ ? resolveUserId(opts.userId)
28
+ : null;
25
29
  const visible = (m) => {
26
30
  if (m.scope === "USER") {
27
31
  if (opts.userId == null)
@@ -64,7 +68,7 @@ export function retrieve(query, opts = {}) {
64
68
  const scope = scopeFactorFor(mem, {
65
69
  projectId: opts.projectId,
66
70
  sessionId: opts.sessionId,
67
- userId: opts.userId ? resolveUserId(opts.userId) : null,
71
+ userId: resolvedUid,
68
72
  });
69
73
  const fs = finalScore({
70
74
  rrf,
package/dist/db/index.js CHANGED
@@ -1,3 +1,15 @@
1
+ /**
2
+ * Security note — encryption at rest (Md-4):
3
+ * DB is plaintext-at-rest. `better-sqlite3` does NOT use SQLCipher or any
4
+ * file-level encryption by default. The file at DB_PATH (default
5
+ * data/memory.db, WAL mode) is readable by anyone with filesystem access
6
+ * (shared machine, backup, malware, stolen device). "100% local & private"
7
+ * means no network exfiltration — it does NOT mean encrypted at rest.
8
+ * If you need encryption at rest, use OS-level full-disk encryption
9
+ * (BitLocker / FileVault / LUKS) or migrate to an opt-in SQLCipher build
10
+ * (native rebuild + key management required). No in-code encryption is
11
+ * applied; treat the file as you would any plaintext local store.
12
+ */
1
13
  import Database from "better-sqlite3";
2
14
  import { mkdirSync } from "node:fs";
3
15
  import { dirname } from "node:path";
@@ -6,7 +18,9 @@ import { serialize } from "../lib/embed.js";
6
18
  import { DEFAULT_DB_PATH } from "../lib/config.js";
7
19
  import { runMigrations } from "./migrations.js";
8
20
  export const DB_PATH = process.env.MEMORY_DB_PATH ?? DEFAULT_DB_PATH;
9
- function initDb() {
21
+ let _db = null;
22
+ let _dbInitialized = false;
23
+ function createRawDb() {
10
24
  try {
11
25
  mkdirSync(dirname(DB_PATH), { recursive: true });
12
26
  return new Database(DB_PATH);
@@ -17,10 +31,12 @@ function initDb() {
17
31
  process.exit(1);
18
32
  }
19
33
  }
20
- export const db = initDb();
21
- db.pragma("journal_mode = WAL");
22
- db.pragma("busy_timeout = 5000");
23
- db.exec(`
34
+ function ensureDbInitialized(instance) {
35
+ if (_dbInitialized)
36
+ return;
37
+ instance.pragma("journal_mode = WAL");
38
+ instance.pragma("busy_timeout = 5000");
39
+ instance.exec(`
24
40
  CREATE TABLE IF NOT EXISTS interactions (
25
41
  id INTEGER PRIMARY KEY AUTOINCREMENT,
26
42
  ts TEXT NOT NULL,
@@ -66,8 +82,25 @@ CREATE TABLE IF NOT EXISTS embeddings (
66
82
  PRIMARY KEY (ref_table, ref_id)
67
83
  );
68
84
  `);
69
- // v2 migration engine (non-destructive: only adds new tables + backfills from v1)
70
- runMigrations(db);
85
+ runMigrations(instance);
86
+ _dbInitialized = true;
87
+ }
88
+ export function getDb() {
89
+ if (!_db) {
90
+ _db = createRawDb();
91
+ }
92
+ ensureDbInitialized(_db);
93
+ return _db;
94
+ }
95
+ export const db = new Proxy({}, {
96
+ get(_target, prop) {
97
+ const real = getDb();
98
+ const value = real[prop];
99
+ if (typeof value === "function")
100
+ return value.bind(real);
101
+ return value;
102
+ },
103
+ });
71
104
  export function nowISO() {
72
105
  return new Date().toISOString();
73
106
  }
@@ -78,34 +111,91 @@ export function escapeLike(text) {
78
111
  }
79
112
  export function buildFtsMatch(query) {
80
113
  const tokens = query.trim().split(/\s+/).filter(Boolean).slice(0, 8);
81
- return tokens.map((t) => `"${t.replace(/"/g, "")}"`).join(" OR ");
114
+ return tokens
115
+ .map((t) => {
116
+ const safe = t.replace(/\\/g, "\\\\").replace(/"/g, '""');
117
+ return `"${safe}"`;
118
+ })
119
+ .join(" OR ");
120
+ }
121
+ let _insertSearchIndex = null;
122
+ function getInsertSearchIndex() {
123
+ return (_insertSearchIndex ??= db.prepare("INSERT INTO search_index (ref_table, ref_id, title, body) VALUES (?, ?, ?, ?)"));
124
+ }
125
+ let _deleteSearchIndex = null;
126
+ function getDeleteSearchIndex() {
127
+ return (_deleteSearchIndex ??= db.prepare("DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?"));
82
128
  }
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
129
  export function syncSearchIndex(refTable, refId, title, body) {
86
- deleteSearchIndex.run(refTable, refId);
87
- insertSearchIndex.run(refTable, refId, title, body);
130
+ getDeleteSearchIndex().run(refTable, refId);
131
+ getInsertSearchIndex().run(refTable, refId, title, body);
88
132
  }
89
133
  export function removeSearchIndex(refTable, refId) {
90
- deleteSearchIndex.run(refTable, refId);
134
+ getDeleteSearchIndex().run(refTable, refId);
91
135
  }
92
136
  // --- 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");
137
+ let _upsertEmbed = null;
138
+ function getUpsertEmbed() {
139
+ return (_upsertEmbed ??= db.prepare(`INSERT INTO embeddings (ref_table, ref_id, vec) VALUES (?, ?, ?)
140
+ ON CONFLICT(ref_table, ref_id) DO UPDATE SET vec = excluded.vec`));
141
+ }
142
+ let _deleteEmbed = null;
143
+ function getDeleteEmbed() {
144
+ return (_deleteEmbed ??= db.prepare("DELETE FROM embeddings WHERE ref_table = ? AND ref_id = ?"));
145
+ }
146
+ let _allEmbeds = null;
147
+ function getAllEmbeds() {
148
+ return (_allEmbeds ??= db.prepare("SELECT ref_table, ref_id, vec FROM embeddings"));
149
+ }
150
+ let _scopedEmbeds = null;
151
+ function getScopedEmbeds() {
152
+ return (_scopedEmbeds ??= db.prepare(`
153
+ SELECT e.ref_table as ref_table, e.ref_id as ref_id, e.vec as vec
154
+ FROM embeddings e
155
+ JOIN memories m ON m.id = e.ref_id
156
+ WHERE e.ref_table = 'memories'
157
+ AND m.status = 'active'
158
+ AND (m.scope = 'GLOBAL'
159
+ OR (m.scope = 'USER' AND m.user_id = @uid)
160
+ OR (m.scope = 'SESSION' AND m.session_id = @sid)
161
+ OR (m.scope = 'PROJECT' AND m.project_id = @pid))
162
+ `));
163
+ }
164
+ let _legacyEmbeds = null;
165
+ function getLegacyEmbeds() {
166
+ return (_legacyEmbeds ??= db.prepare("SELECT ref_table, ref_id, vec FROM embeddings WHERE ref_table IN ('preferences','lessons')"));
167
+ }
97
168
  export function upsertEmbedding(refTable, refId, vec) {
98
- upsertEmbed.run(refTable, refId, serialize(vec));
169
+ getUpsertEmbed().run(refTable, refId, serialize(vec));
99
170
  }
100
171
  export function removeEmbedding(refTable, refId) {
101
- deleteEmbed.run(refTable, refId);
172
+ getDeleteEmbed().run(refTable, refId);
102
173
  }
103
- export function getAllEmbeddings() {
104
- return allEmbeds.all();
174
+ export function getAllEmbeddings(opts = {}) {
175
+ const hasScopeFilter = opts.uid !== undefined || opts.sid !== undefined || opts.pid !== undefined;
176
+ if (!hasScopeFilter) {
177
+ const uid = null;
178
+ const sid = null;
179
+ const pid = null;
180
+ try {
181
+ const memRows = getScopedEmbeds().all({ uid, sid, pid });
182
+ const legacyRows = getLegacyEmbeds().all();
183
+ return [...memRows, ...legacyRows];
184
+ }
185
+ catch {
186
+ return getAllEmbeds().all();
187
+ }
188
+ }
189
+ const uid = opts.uid ?? null;
190
+ const sid = opts.sid ?? null;
191
+ const pid = opts.pid ?? null;
192
+ const memRows = getScopedEmbeds().all({ uid, sid, pid });
193
+ const legacyRows = getLegacyEmbeds().all();
194
+ return [...memRows, ...legacyRows];
105
195
  }
106
196
  export function ok(text) {
107
197
  return { content: [{ type: "text", text }] };
108
198
  }
109
199
  export function err(text) {
110
- return ok(`error: ${truncate(text, 300)}`);
200
+ return { content: [{ type: "text", text: `error: ${truncate(text, 300)}` }], isError: true };
111
201
  }
@@ -1,3 +1,5 @@
1
+ import { copyFileSync, readdirSync, unlinkSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
1
3
  import { embed, serialize } from "../lib/embed.js";
2
4
  const M001_schema_meta = {
3
5
  id: "001_schema_meta",
@@ -168,14 +170,62 @@ export const MIGRATIONS = [
168
170
  M006_scope,
169
171
  M007_user,
170
172
  ];
173
+ const KEEP_BACKUPS = 5;
174
+ function pruneOldBackups(dbPath) {
175
+ try {
176
+ const dir = dirname(dbPath);
177
+ const base = basename(dbPath);
178
+ const prefix = `${base}.backup-`;
179
+ const entries = readdirSync(dir);
180
+ const backups = entries.filter((n) => n.startsWith(prefix)).sort();
181
+ if (backups.length > KEEP_BACKUPS) {
182
+ for (const name of backups.slice(0, backups.length - KEEP_BACKUPS)) {
183
+ try {
184
+ unlinkSync(join(dir, name));
185
+ }
186
+ catch { }
187
+ }
188
+ }
189
+ }
190
+ catch { }
191
+ }
171
192
  export function runMigrations(db) {
172
193
  db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`);
173
- for (const m of MIGRATIONS) {
194
+ const pending = MIGRATIONS.filter((m) => {
174
195
  const applied = db
175
196
  .prepare("SELECT value FROM schema_meta WHERE key = ?")
176
197
  .get(m.id);
177
- if (applied)
178
- continue;
198
+ return !applied;
199
+ });
200
+ if (pending.length === 0) {
201
+ try {
202
+ const dbPath = db.name;
203
+ if (dbPath && dbPath !== ":memory:")
204
+ pruneOldBackups(dbPath);
205
+ }
206
+ catch { }
207
+ return;
208
+ }
209
+ const shouldBackup = process.env.MEMORY_BACKUP_ON_MIGRATE !== "0" &&
210
+ process.env.MEMORY_BACKUP_ON_MIGRATE !== "false";
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
+ }
223
+ pruneOldBackups(dbPath);
224
+ }
225
+ }
226
+ catch { }
227
+ }
228
+ for (const m of pending) {
179
229
  const tx = db.transaction(() => {
180
230
  m.up(db);
181
231
  db.prepare("INSERT INTO schema_meta (key, value) VALUES (?, '1') ON CONFLICT(key) DO UPDATE SET value = '1'").run(m.id);
@@ -2,6 +2,10 @@ import { db, nowISO } from "../index.js";
2
2
  // Resolve or create a user by external identity (what clients pass as `userId`).
3
3
  // Returns the internal user id.
4
4
  export function ensureUser(externalId, name) {
5
+ if (externalId.length === 0 || externalId.length > 200)
6
+ throw new Error("externalId must be 1-200 chars");
7
+ if (/[\x00-\x1f]/.test(externalId))
8
+ throw new Error("externalId contains invalid characters");
5
9
  const existing = db
6
10
  .prepare("SELECT id FROM users WHERE external_id = ?")
7
11
  .get(externalId);
package/dist/index.js CHANGED
@@ -102,7 +102,22 @@ server.registerTool("extract_memories", {
102
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
103
  inputSchema: extractMemoriesInput,
104
104
  }, (args) => extractMemoriesHandler(args));
105
+ function isSupportedHarness() {
106
+ const env = process.env;
107
+ if (env.OPENCODE || env.CLAUDE_CODE_ENTRYPOINT || env.CLAUDECODE)
108
+ return true;
109
+ const harness = (env.MCP_HARNESS ?? env.HARNESS ?? "").toLowerCase();
110
+ if (harness.includes("opencode") || harness.includes("claude"))
111
+ return true;
112
+ const argv = process.argv.join(" ").toLowerCase();
113
+ if (argv.includes("opencode") || argv.includes("claude"))
114
+ return true;
115
+ return false;
116
+ }
105
117
  async function main() {
118
+ if (!isSupportedHarness()) {
119
+ console.error("[th-memory-mcp] warning: running outside OpenCode/Claude Code — auto-capture unavailable, MCP tools still work. See README 'Works with other harnesses'.");
120
+ }
106
121
  const transport = new StdioServerTransport();
107
122
  await server.connect(transport);
108
123
  console.error(`[th-memory-mcp] ready on stdio`);
@@ -1,4 +1,12 @@
1
1
  export const SECRET_LINE = /(api[_-]?key|secret|token|password)\s*[=:]/i;
2
+ export const SECRET_PATTERNS = [
3
+ /(api[_-]?key|secret|token|password|auth|bearer|credential|private[_-]?key|access[_-]?key|database[_-]?url|connection[_-]?string)\s*[=:]\s*\S+/i,
4
+ /(sk-[a-zA-Z0-9]{20,})/i,
5
+ /(ghp_[a-zA-Z0-9]{36})/i,
6
+ /(glpat-[a-zA-Z0-9\-]{20,})/i,
7
+ /(Bearer\s+[a-zA-Z0-9\-_]+)/i,
8
+ /(-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----)/i,
9
+ ];
2
10
  export const CAPTURE_KINDS = ["prompt", "tool_call", "error"];
3
11
  export const LIMITS = {
4
12
  prompt: 4000,
@@ -8,13 +16,21 @@ export const LIMITS = {
8
16
  export function filterSecrets(text) {
9
17
  return text
10
18
  .split("\n")
11
- .filter((line) => !SECRET_LINE.test(line))
19
+ .map((line) => {
20
+ for (const p of SECRET_PATTERNS) {
21
+ if (p.test(line))
22
+ return line.replace(p, "[REDACTED]");
23
+ }
24
+ return line;
25
+ })
12
26
  .join("\n");
13
27
  }
14
28
  export function truncate(text, max) {
29
+ if (max <= 0)
30
+ return "";
15
31
  if (text.length <= max)
16
32
  return text;
17
- return text.slice(0, max - 1) + "\u2026";
33
+ return text.slice(0, Math.max(0, max - 1)) + "\u2026";
18
34
  }
19
35
  export function createDedupe(maxSize = 1000) {
20
36
  const live = new Set();
@@ -24,11 +24,27 @@ const NEGATION = [
24
24
  // Opposite-value lexicon for ambiguous preference conflicts (preserve, don't
25
25
  // silently supersede). A pair is contradictory when each side names a different
26
26
  // antonym from this set.
27
- const ANTONYMS = new Set([
27
+ export const ANTONYMS = new Set([
28
28
  "tabs", "spaces", "vim", "emacs", "light", "dark",
29
29
  "mysql", "postgres", "windows", "mac", "linux",
30
30
  "react", "vue", "ios", "android",
31
+ "ชา", "กาแฟ", "แมว", "สุนัข", "กลางคืน", "กลางวัน", "ร้อน", "เย็น", "หวาน", "เค็ม",
32
+ "เปิด", "ปิด", "ซ้าย", "ขวา", "บน", "ล่าง", "ก่อน", "หลัง", "ไทย", "อังกฤษ",
31
33
  ]);
34
+ function parseAntonymsExtra(raw) {
35
+ return raw.split(/[\s,;\n|:\/]+/).map((s) => s.trim().toLowerCase()).filter(Boolean);
36
+ }
37
+ if (process.env.MEMORY_ANTONYMS_EXTRA) {
38
+ for (const w of parseAntonymsExtra(process.env.MEMORY_ANTONYMS_EXTRA))
39
+ ANTONYMS.add(w);
40
+ }
41
+ export function addAntonyms(...words) {
42
+ for (const w of words.flat()) {
43
+ for (const t of parseAntonymsExtra(String(w)))
44
+ ANTONYMS.add(t);
45
+ }
46
+ }
47
+ export function getAntonyms() { return [...ANTONYMS]; }
32
48
  export function isContradiction(a, b) {
33
49
  const na = NEGATION.some((re) => re.test(a));
34
50
  const nb = NEGATION.some((re) => re.test(b));
@@ -46,17 +62,32 @@ export function isContradiction(a, b) {
46
62
  // (e.g. "Prefer tabs" vs "Prefer spaces"). Used to preserve ambiguous
47
63
  // conflicts as contradictions instead of destructively superseding them.
48
64
  export function hasAntonymPair(a, b) {
65
+ const lowerA = a.toLowerCase();
66
+ const lowerB = b.toLowerCase();
49
67
  const ta = tokenSet(a);
50
68
  const tb = tokenSet(b);
51
69
  let aAnt;
52
70
  let bAnt;
53
- for (const t of ta)
54
- if (ANTONYMS.has(t))
55
- aAnt = t;
56
- for (const t of tb)
57
- if (ANTONYMS.has(t))
58
- bAnt = t;
59
- return !!aAnt && !!bAnt && aAnt !== bAnt;
71
+ for (const w of ANTONYMS) {
72
+ if (ta.has(w) || lowerA.includes(w))
73
+ aAnt = w;
74
+ if (tb.has(w) || lowerB.includes(w))
75
+ bAnt = w;
76
+ }
77
+ // Find distinct pair: ensure each side has at least one and they are different
78
+ if (!aAnt || !bAnt)
79
+ return false;
80
+ if (aAnt === bAnt) {
81
+ // Check if there are other hits that give distinct pair
82
+ const hitsA = [...ANTONYMS].filter((w) => ta.has(w) || lowerA.includes(w));
83
+ const hitsB = [...ANTONYMS].filter((w) => tb.has(w) || lowerB.includes(w));
84
+ for (const ha of hitsA)
85
+ for (const hb of hitsB)
86
+ if (ha !== hb)
87
+ return true;
88
+ return false;
89
+ }
90
+ return true;
60
91
  }
61
92
  function tokenSet(s) {
62
93
  return new Set(s.toLowerCase().match(/[a-z0-9ก-์]+/gi) ?? []);
@@ -1,4 +1,4 @@
1
- import { mkdirSync, writeFileSync, statSync } from "node:fs";
1
+ import { mkdirSync, writeFileSync, statSync, statfsSync } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { db, DB_PATH, nowISO, truncate, ok, err, } from "../db/index.js";
@@ -79,6 +79,12 @@ export async function exportMemoryHandler(args) {
79
79
  };
80
80
  const json = JSON.stringify(payload, null, 2);
81
81
  mkdirSync(EXPORT_DIR, { recursive: true });
82
+ try {
83
+ const stats = statfsSync(EXPORT_DIR);
84
+ if (json.length > stats.bavail * stats.bsize * 0.9)
85
+ return err("insufficient disk space for export");
86
+ }
87
+ catch { }
82
88
  const filePath = join(EXPORT_DIR, filename);
83
89
  writeFileSync(filePath, json, "utf8");
84
90
  const size = statSync(filePath).size;
@@ -44,14 +44,14 @@ export async function recallHandler(args) {
44
44
  const line = prefLine(Number(r.ref_id));
45
45
  if (line) {
46
46
  prefLines += line + "\n";
47
- seen.add(`p:${r.ref_id}`);
47
+ seen.add(`p:${String(r.ref_id)}`);
48
48
  }
49
49
  }
50
50
  else if (r.ref_table === "lessons") {
51
51
  const line = lessonLine(Number(r.ref_id));
52
52
  if (line) {
53
53
  lessonLines += line + "\n";
54
- seen.add(`l:${r.ref_id}`);
54
+ seen.add(`l:${String(r.ref_id)}`);
55
55
  }
56
56
  }
57
57
  }
@@ -76,7 +76,7 @@ export async function recallHandler(args) {
76
76
  .sort((a, b) => b.score - a.score)
77
77
  .slice(0, limit);
78
78
  for (const x of scored) {
79
- const key = `${x.table[0]}:${x.id}`;
79
+ const key = `${x.table[0]}:${String(x.id)}`;
80
80
  if (seen.has(key))
81
81
  continue;
82
82
  if (x.table === "preferences") {
@@ -32,6 +32,11 @@ export function updateMemoryHandler(args) {
32
32
  return err("cannot update a deleted memory");
33
33
  const supersedeContent = args.content !== undefined && (args.supersede ?? true);
34
34
  if (supersedeContent) {
35
+ const externalId = mem.user_id
36
+ ? db
37
+ .prepare("SELECT external_id FROM users WHERE id = ?")
38
+ .get(mem.user_id)?.external_id ?? null
39
+ : null;
35
40
  const newId = createMemory({
36
41
  type: mem.type,
37
42
  content: args.content,
@@ -42,6 +47,7 @@ export function updateMemoryHandler(args) {
42
47
  salience: mem.salience,
43
48
  projectId: mem.project_id,
44
49
  sessionId: mem.session_id,
50
+ userId: externalId,
45
51
  validFrom: mem.valid_from,
46
52
  validUntil: args.validUntil !== undefined ? args.validUntil : mem.valid_until,
47
53
  metadata: args.metadata !== undefined