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/ARCHITECTURE_v2.md +15 -5
- package/README.md +52 -4
- package/README.th.md +177 -177
- package/SECURITY.md +40 -0
- package/design.md +45 -41
- package/dist/core/context-engine.js +3 -3
- package/dist/core/retrieval-engine.js +6 -2
- package/dist/db/index.js +112 -22
- package/dist/db/migrations.js +53 -3
- package/dist/db/repositories/users.js +4 -0
- package/dist/index.js +15 -0
- package/dist/lib/capture-core.js +18 -2
- package/dist/memory/conflict-resolver.js +39 -8
- package/dist/tools/export_memory.js +7 -1
- package/dist/tools/recall.js +3 -3
- package/dist/tools/update_memory.js +6 -0
- package/package.json +13 -3
package/design.md
CHANGED
|
@@ -1,61 +1,65 @@
|
|
|
1
|
-
# th-memory-mcp — Design Notes (
|
|
1
|
+
# th-memory-mcp — Design Notes (Current)
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
8
|
+
- **Test suites:** 25 suites passing (0 fail) — run via `npm test` (CI on GitHub Actions)
|
|
10
9
|
|
|
11
|
-
##
|
|
12
|
-
- `db/` — better-sqlite3 (WAL mode), migrations
|
|
13
|
-
- `lib/embed.ts` —
|
|
14
|
-
- `retrieval/` — FTS5 +
|
|
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
|
|
23
|
-
- ✅ Hybrid retrieval (FTS +
|
|
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
|
|
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`
|
|
29
|
-
- `scopeFactorFor`
|
|
30
|
-
- ✅ Profile auto-projection — `profile.ts`
|
|
31
|
-
- ✅ Auto entity extraction — `entity-extractor.ts`
|
|
32
|
-
- ✅
|
|
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`
|
|
35
|
-
- Conflict quality (§27) — `test/conflict_benchmark.test.mjs` (100%
|
|
36
|
-
- E2E transport — `test/e2e_transport.test.mjs` (spawn server
|
|
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
|
|
40
|
-
| Scope |
|
|
41
|
-
|
|
42
|
-
| SESSION |
|
|
43
|
-
| PROJECT |
|
|
44
|
-
| USER |
|
|
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`
|
|
48
|
-
`preferences`
|
|
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
|
-
##
|
|
51
|
-
- **Trust model:**
|
|
52
|
-
- `preferences` / `lessons`
|
|
53
|
-
-
|
|
54
|
-
- **AI-assisted extraction
|
|
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
|
|
58
|
-
- v2.2.0 —
|
|
59
|
-
- v2.2.1 —
|
|
60
|
-
- v2.2.3 —
|
|
61
|
-
- v2.2.4 — docs:
|
|
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,
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
130
|
+
getDeleteSearchIndex().run(refTable, refId);
|
|
131
|
+
getInsertSearchIndex().run(refTable, refId, title, body);
|
|
88
132
|
}
|
|
89
133
|
export function removeSearchIndex(refTable, refId) {
|
|
90
|
-
|
|
134
|
+
getDeleteSearchIndex().run(refTable, refId);
|
|
91
135
|
}
|
|
92
136
|
// --- vector embeddings (lightweight local semantic search) ---
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
169
|
+
getUpsertEmbed().run(refTable, refId, serialize(vec));
|
|
99
170
|
}
|
|
100
171
|
export function removeEmbedding(refTable, refId) {
|
|
101
|
-
|
|
172
|
+
getDeleteEmbed().run(refTable, refId);
|
|
102
173
|
}
|
|
103
|
-
export function getAllEmbeddings() {
|
|
104
|
-
|
|
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
|
|
200
|
+
return { content: [{ type: "text", text: `error: ${truncate(text, 300)}` }], isError: true };
|
|
111
201
|
}
|
package/dist/db/migrations.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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`);
|
package/dist/lib/capture-core.js
CHANGED
|
@@ -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
|
-
.
|
|
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
|
|
54
|
-
if (
|
|
55
|
-
aAnt =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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;
|
package/dist/tools/recall.js
CHANGED
|
@@ -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
|