th-memory-mcp 1.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/design.md ADDED
@@ -0,0 +1,308 @@
1
+ # Design: Adaptive Memory MCP — behavior-learning memory system for OpenCode
2
+
3
+ > Project: D:\Coding_Project\mcp
4
+ > Date: 2026-08-26 (rev.3 — as-built updated after all phases implemented)
5
+ > Status: **implementation complete** — server v1.1.0, 9 tools, tests passing 70/70 assertions
6
+
7
+ ## 1. Overview
8
+
9
+ A system that lets OpenCode "remember and adapt" to the user, composed of 3 parts:
10
+
11
+ 1. **MCP Server (th-memory-mcp v1.1.0)** — stores/retrieves preferences, lessons, and usage history in SQLite, exposing 9 tools the AI can call
12
+ 2. **OpenCode Plugin (learning-capture)** — hooks events to auto-capture prompts/tool usage and injects the profile back into context on compaction
13
+ 3. **Global Instructions (memory-protocol.md)** — the Memory Protocol rules, attached to every agent/session via `"instructions"` in the global opencode.json
14
+
15
+ ### Important constraints
16
+ LLM APIs are **not trained on our data** — the only real "learning" possible is **context-based learning**:
17
+ - capture behavior → distill into preferences/lessons
18
+ - recall into context at the start of a new session (AI calls `recall` / plugin injects)
19
+ This is the same mechanism behind the memory features of leading AI products.
20
+
21
+ ## 2. Architecture
22
+
23
+ ```
24
+ ┌────────────────────────────────────────────┐
25
+ │ OpenCode │
26
+ │ │
27
+ │ ┌──────────────────┐ ┌───────────────┐ │
28
+ │ │ learning-capture │ │ AI Agent │ │
29
+ │ │ Plugin (Bun) │ │ │ │
30
+ │ │ - message.updated│ │ calls MCP │ │
31
+ │ │ - tool.execute.* │ │ tools │ │
32
+ │ │ - compacting* │ │ │ │
33
+ │ └────────┬─────────┘ └──────┬────────┘ │
34
+ └───────────┼─────────────────────┼──────────┘
35
+ │ write (bun:sqlite) │ read/write (stdio JSON-RPC)
36
+ ▼ ▼
37
+ ┌─────────────────────────────────────┐
38
+ │ th-memory-mcp v1.1.0 (Node+SDK) │
39
+ │ better-sqlite3 (WAL) ◀── shared ── │
40
+ │ Tools (9): remember, recall, │
41
+ │ get_profile, save_lesson, │
42
+ │ search_history, forget, │
43
+ │ memory_stats, │
44
+ │ get_recent_interactions, │
45
+ │ export_memory │
46
+ └─────────────────────────────────────┘
47
+
48
+
49
+ D:/Coding_Project/mcp/data/memory.db
50
+ ```
51
+
52
+ (*) compaction hook = `experimental.session.compacting` used in Phase 3
53
+
54
+ ### Learning loop
55
+ 1. **Capture** — plugin auto-writes prompts/tool usage to `interactions`; AI also saves preferences/lessons via tools
56
+ 2. **Distill** — summarize raw logs into profile: rule-based via `npm run distill` (Thai tokenization with Intl.Segmenter + prune older than 30 days) and AI-assisted via the Smart Distill workflow in the protocol
57
+ 3. **Recall** — new session: AI calls `get_profile` + `recall(topic)` per the Memory Protocol (global instructions)
58
+ 4. **Inject** — plugin auto-injects the profile on session compaction (`experimental.session.compacting`)
59
+
60
+ ## 3. Data Model (SQLite)
61
+
62
+ DB file: `data/memory.db` (path overridable via `MEMORY_DB_PATH`)
63
+ WAL mode + busy_timeout=5000 on every connection
64
+
65
+ ```sql
66
+ -- raw behavior (plugin writes)
67
+ CREATE TABLE interactions (
68
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
69
+ ts TEXT NOT NULL, -- ISO datetime
70
+ session_id TEXT,
71
+ kind TEXT NOT NULL, -- 'prompt' | 'tool_call' | 'error'
72
+ content TEXT NOT NULL, -- text (truncated per rules)
73
+ meta TEXT -- JSON extra, e.g. tool name, project dir
74
+ );
75
+
76
+ -- user preferences/requirements (AI/plugin writes)
77
+ CREATE TABLE preferences (
78
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
79
+ category TEXT NOT NULL, -- work_style | coding_pref | language | domain | other
80
+ key TEXT NOT NULL,
81
+ value TEXT NOT NULL,
82
+ confidence REAL DEFAULT 0.5, -- 0..1, +0.1 per repeated confirmation
83
+ source TEXT DEFAULT 'explicit', -- explicit | corrected | inferred
84
+ updated_at TEXT NOT NULL,
85
+ UNIQUE(category, key)
86
+ );
87
+
88
+ -- lessons from corrections
89
+ CREATE TABLE lessons (
90
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
91
+ situation TEXT NOT NULL, -- original situation
92
+ mistake TEXT NOT NULL, -- what was done wrong
93
+ correction TEXT NOT NULL, -- correct approach
94
+ created_at TEXT NOT NULL
95
+ );
96
+
97
+ -- distilled profile
98
+ CREATE TABLE profile (
99
+ section TEXT PRIMARY KEY, -- identity | goals | style | notes
100
+ content TEXT NOT NULL,
101
+ updated_at TEXT NOT NULL
102
+ );
103
+
104
+ -- virtual table for search
105
+ CREATE VIRTUAL TABLE search_index USING fts5(
106
+ ref_table, ref_id, title, body
107
+ );
108
+ ```
109
+
110
+ ## 4. MCP Tools spec
111
+
112
+ Server name: `th-memory-mcp`, version **1.1.0**, transport stdio
113
+ Every tool returns `{ content: [{ type: "text", text }] }`; errors must be caught and returned as a message (never crash)
114
+
115
+ | Tool | Args (zod) | Behavior |
116
+ |------|-----------|----------|
117
+ | `remember` | `category` enum, `key`: string, `value`: string | upsert preferences; same key → confidence += 0.1 (cap 1.0), update value+updated_at |
118
+ | `recall` | `topic`: string, `limit`?: number (default 8) | FTS5 search search_index (preferences+lessons) + latest 20 matching interactions; grouped text, ≤ ~2000 chars |
119
+ | `get_profile` | (none) | profile sections + top preferences (confidence desc, limit 15) + latest 5 lessons |
120
+ | `save_lesson` | `situation`, `mistake`, `correction`: string | insert lessons + update search_index |
121
+ | `search_history` | `query`: string, `limit`?: number (default 10) | FTS5 in interactions (kind='prompt'), 200-char snippets per row |
122
+ | `forget` | `target_id`: number, `type`? enum("preference","lesson","interaction") | delete from table by id (+type prevents cross-table id clash) + sync search_index |
123
+ | `memory_stats` | (none) | counts by kind + DB size + oldest/newest interaction + profile sections; ≤1500 chars |
124
+ | `get_recent_interactions` | `limit`? (default 20, max 100), `kind`? enum("prompt","tool_call","error") | latest rows formatted `[id] ts [kind] content(300)`; ≤4000 chars |
125
+ | `export_memory` | `includeInteractions`? bool (default false), `filename`? string | write JSON only under `data/exports/` (sanitize filename `[A-Za-z0-9._-]`, no `..`); return path+size+preview ≤500 chars |
126
+
127
+ ## 5. Plugin spec (learning-capture)
128
+
129
+ File: `src/plugin/learning-capture.ts` → deploy to `~/.config/opencode/plugins/learning-capture.ts`
130
+ Runtime: Bun (OpenCode plugins run on Bun) → uses `bun:sqlite` on the same DB (WAL supports multi-process)
131
+
132
+ ```ts
133
+ // as-built: self-contained single file — logic inline, synced with src/lib/capture-core.ts
134
+ // (declares minimal types itself; does not import @opencode-ai/plugin to avoid module resolution issues)
135
+ import { Database } from "bun:sqlite"
136
+
137
+ export const LearningCapture = async (ctx) => {
138
+ const db = new Database(process.env.MEMORY_DB_PATH ?? "D:/Coding_Project/mcp/data/memory.db")
139
+ db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")
140
+ // CREATE TABLE IF NOT EXISTS interactions (...) in case DB was never created
141
+ const dedupe = createDedupe()
142
+ return {
143
+ event: async ({ event }) => {
144
+ // message.updated (role=user) → insert kind='prompt' (truncate 4000, dedupe by message id)
145
+ // session.error → insert kind='error'
146
+ },
147
+ "tool.execute.after": async (input, output) => {
148
+ // insert kind='tool_call' (dedupe by callID, truncate 500)
149
+ },
150
+ "experimental.session.compacting": async (input, output) => {
151
+ // buildProfileText(db): profile sections + top preferences (confidence desc, 15)
152
+ // + latest 5 lessons → ≤3000 chars → output.context.push(txt)
153
+ // wrap everything in try/catch silently — failed injection does no harm
154
+ },
155
+ }
156
+ }
157
+ ```
158
+
159
+ Capture rules:
160
+ - Dedupe by message id (prevent duplicate events) — keep a Set of recorded ids in process memory
161
+ - Never store secrets: filter lines matching `/(api[_-]?key|secret|token|password)\s*[=:]/i` before saving
162
+ - Every write must try/catch — the plugin must never crash OpenCode
163
+
164
+ ## 6. Making the AI use memory (Memory Protocol)
165
+
166
+ Installed at 2 levels:
167
+
168
+ 1. **Global (in use)** — `~/.config/opencode/memory-protocol.md` attached via `"instructions"` in global opencode.json → covers **every agent, every session** without switching agents
169
+ 2. **Project-level (alternative)** — copy from `AGENTS.memory.example.md` into a project's AGENTS.md
170
+
171
+ Protocol essentials:
172
+ - call `get_profile` + `recall` before a new/complex task
173
+ - `save_lesson` immediately when the user corrects you / `remember` immediately when the user states a preference / never guess — if recall finds nothing, ask
174
+ - `search_history` when suspecting a prior conversation / `forget` after confirming with the user
175
+ - call memory tools only when necessary (not every message) / never store secrets / if memory is offline, continue gracefully
176
+
177
+ **Smart Distill**: when the user asks "summarize memory" → `get_recent_interactions(limit=50)` → analyze real patterns → save insights via `remember`/`save_lesson` → summarize to the user with the list of new items
178
+
179
+ ## 7. File structure
180
+
181
+ ```
182
+ D:\Coding_Project\mcp\
183
+ ├── design.md # this document (rev.3 as-built)
184
+ ├── README.md # usage guide + scripts + tools
185
+ ├── package.json # type: module, scripts: build/start/distill/test
186
+ ├── tsconfig.json # NodeNext, ES2022, strict; exclude src/plugin + test
187
+ ├── .gitignore # node_modules, dist, data/
188
+ ├── data\ # memory.db (+wal/shm) and exports\ (git ignored)
189
+ ├── src\
190
+ │ ├── index.ts # McpServer v1.1.0 + registerTool ×9 + StdioServerTransport
191
+ │ ├── db.ts # schema init, WAL, helper query, FTS sync
192
+ │ ├── lib\
193
+ │ │ ├── capture-core.ts # pure logic: filterSecrets/truncate/dedupe/buildRow/INSERT_SQL
194
+ │ │ └── distill-core.ts # pure logic: tokenize(Thai)/computeStats/formatProfileSections
195
+ │ ├── distill.ts # CLI: runDistill(db) + prune (RETENTION_DAYS default 30)
196
+ │ ├── tools\
197
+ │ │ ├── remember.ts recall.ts profile.ts lesson.ts history.ts forget.ts
198
+ │ │ ├── memory_stats.ts recent_interactions.ts export_memory.ts
199
+ │ └── plugin\
200
+ │ └── learning-capture.ts # self-contained Bun plugin → deploy copy to ~/.config/opencode/plugins/
201
+ ├── test\
202
+ │ ├── smoke.mjs # 53 checks end-to-end JSON-RPC (spawns real server)
203
+ │ ├── capture.test.mjs # 8 checks (capture-core + SQL insert)
204
+ │ └── distill.test.mjs # 9 checks (tokenize/stats/runDistill/prune/idempotent)
205
+ ├── AGENTS.memory.example.md # Memory Protocol + Smart Distill (project-level)
206
+ └── opencode.example.json # example mcp config
207
+ ```
208
+
209
+ ## 8. Technology
210
+
211
+ | Part | Choice | Reason |
212
+ |------|---------|--------|
213
+ | MCP Server | Node.js ≥ 20 + TypeScript + `@modelcontextprotocol/sdk@1.30.0` + zod | official standard |
214
+ | DB (server) | `better-sqlite3@12.x` + FTS5 | fast sync API, easy, prebuilt binary (no compile) |
215
+ | DB (plugin) | `bun:sqlite` (built-in) | plugin runs on Bun, no native module install |
216
+ | Thai tokenization | `Intl.Segmenter("th", { granularity: "word" })` + whitespace fallback | segment Thai (no spaces) built into Node |
217
+
218
+ > as-built note: the plugin is **self-contained** (declares minimal types in-file), so `@opencode-ai/plugin` is not required
219
+
220
+ ## 9. Sub-tasks
221
+
222
+ ### Phase 1 — MVP: MCP Server ✅ 2026-08-25
223
+ 1. Init project: `"type": "module"`, deps: `@modelcontextprotocol/sdk`, `zod`, `better-sqlite3`; devDeps: `typescript`, `@types/node`, `@types/better-sqlite3`, `@opencode-ai/plugin`
224
+ 2. `src/db.ts`: schema per §3, WAL, busy_timeout, helper + FTS sync
225
+ 3. First 6 tools per §4 spec (separate files in `src/tools/` — later expanded to 9 in Phase 4)
226
+ 4. `src/index.ts`: McpServer("th-memory-mcp") + register + StdioServerTransport (**no console.log — stderr only**)
227
+ 5. Build + smoke test with MCP Inspector (`npx @modelcontextprotocol/inspector node dist/index.js`) — remember → recall → forget
228
+ 6. Create `opencode.example.json`:
229
+
230
+ ```json
231
+ {
232
+ "$schema": "https://opencode.ai/config.json",
233
+ "mcp": {
234
+ "memory": {
235
+ "type": "local",
236
+ "command": ["node", "D:/Coding_Project/mcp/dist/index.js"],
237
+ "enabled": true,
238
+ "environment": {}
239
+ }
240
+ }
241
+ }
242
+ ```
243
+
244
+ 7. Create `AGENTS.memory.example.md` per §6
245
+ 8. Guide user: merge config → restart OpenCode → test "remember I prefer pnpm" then ask back in a new session
246
+
247
+ ### Phase 2 — Plugin auto-capture ✅ 2026-08-26
248
+ 9. `src/plugin/learning-capture.ts` per §5 (dedupe + secret filter + try/catch everywhere)
249
+ 10. Copy to `~/.config/opencode/plugins/learning-capture.ts` → restart OpenCode → use a while → verify `interactions` has data (`search_history` finds old prompts)
250
+
251
+ ### Phase 3 — Inject + Distill ✅ 2026-08-26
252
+ 11. Add hook `"experimental.session.compacting"` to plugin: `output.context.push(profile text)` from get_profile logic
253
+ 12. Distill script: rule-based summarize interactions → profile sections (`npm run distill`, Thai tokenize via Intl.Segmenter) + prune older than RETENTION_DAYS
254
+
255
+ ### Phase 4 — Insight & Safety ✅ 2026-08-26
256
+ 13. 3 new tools: `memory_stats` / `get_recent_interactions` / `export_memory` (sanitize filename + write only under data/exports/) — server bump v1.1.0
257
+ 14. Smart Distill workflow added to memory-protocol.md (global) + AGENTS.memory.example.md + README.md
258
+
259
+ > as-built note: global instructions (`memory-protocol.md` via `"instructions"` in opencode.json) replace a dedicated agent — covers every agent without switching; smoke test expanded to 53 checks including security cases (unsafe filename rejected)
260
+
261
+ ## 10. Risks and mitigation
262
+
263
+ | Risk | Impact | Mitigation |
264
+ |------|--------|-----------|
265
+ | Context bloat from long recall | token waste | cap 2000 chars/tool call, default limit |
266
+ | Wrong/stale memory | AI goes wrong | confidence + updated_at + tool forget + user review |
267
+ | SQLite accessed by 2 processes (Bun+Node) | lock error | WAL mode + busy_timeout=5000 |
268
+ | `message.updated` fires often | DB bloat/duplicate | dedupe by message id + truncate |
269
+ | Secret leaks to DB | security | regex filter before every write |
270
+ | stdout mixed with logs | protocol breaks | stderr only in server code |
271
+ | Invalid config | OpenCode won't start | add `$schema` validated against https://opencode.ai/config.json |
272
+
273
+ ## 11. Dependencies
274
+
275
+ - Node.js ≥ 20, npm
276
+ - OpenCode supporting plugins + MCP (current version)
277
+ - No external service/API — 100% local (privacy by design)
278
+
279
+ ## 12. Performance Budget (acceptance criteria)
280
+
281
+ Building Agent must implement within this budget:
282
+
283
+ | Item | Budget | Check |
284
+ |------|--------|-------|
285
+ | Query latency per tool call | < 100 ms (local SQLite) | time in smoke test |
286
+ | Max output per tool | `recall` ≤ 2000 chars, `search_history` ≤ 200 chars/row, `get_profile` ≤ 3000 chars | assert in code (always truncate) |
287
+ | Default limit | recall=8, search_history=10 rows | default in zod schema |
288
+ | Plugin write per event | < 5 ms, fire-and-forget (no event-loop block) | code review |
289
+ | Server startup | < 2 s to ready for initialize | time it |
290
+
291
+ **Measured (2026-08-26):** latency per tool call **1–9 ms**, startup **792–997 ms**, every tool within budget, tests **70/70** (smoke 53 + capture 8 + distill 9)
292
+
293
+ ### Overhead prevention
294
+ - Memory Protocol calls memory **only on new/complex tasks**, never every message
295
+ - Graceful degradation: if DB/server errors, return a short error message and let the AI continue immediately; no tight retry until timeout
296
+ - Never auto-inject profile every turn — inject only on compaction (Phase 3)
297
+
298
+ ### Long-term risks to monitor
299
+ - Memory quality decay (self-contradiction) → use confidence + updated_at + forget + distill (Phase 3)
300
+ - DB growth → FTS5 index supports it; plan periodic VACUUM/optimize
301
+
302
+ ## 13. Next phases (Optional / Future)
303
+
304
+ - Semantic search with embeddings (local model or API) instead of FTS5
305
+ - Usage statistics dashboard (small web app reading the DB)
306
+ - Multi-project memory scoping (by directory/worktree)
307
+ - Import memory from export file (export side done in Phase 4)
308
+ - Automatic LLM-assisted distill via OpenCode SDK (instead of user-triggered command)
package/dist/db.js ADDED
@@ -0,0 +1,86 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { truncate } from "./lib/capture-core.js";
5
+ import { DEFAULT_DB_PATH } from "./lib/config.js";
6
+ export const DB_PATH = process.env.MEMORY_DB_PATH ?? DEFAULT_DB_PATH;
7
+ function initDb() {
8
+ try {
9
+ mkdirSync(dirname(DB_PATH), { recursive: true });
10
+ return new Database(DB_PATH);
11
+ }
12
+ catch (e) {
13
+ const msg = e instanceof Error ? e.message : String(e);
14
+ console.error(`[th-memory-mcp] cannot open DB at ${DB_PATH}: ${msg}`);
15
+ process.exit(1);
16
+ }
17
+ }
18
+ export const db = initDb();
19
+ db.pragma("journal_mode = WAL");
20
+ db.pragma("busy_timeout = 5000");
21
+ db.exec(`
22
+ CREATE TABLE IF NOT EXISTS interactions (
23
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
24
+ ts TEXT NOT NULL,
25
+ session_id TEXT,
26
+ kind TEXT NOT NULL,
27
+ content TEXT NOT NULL,
28
+ meta TEXT
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS preferences (
32
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33
+ category TEXT NOT NULL,
34
+ key TEXT NOT NULL,
35
+ value TEXT NOT NULL,
36
+ confidence REAL DEFAULT 0.5,
37
+ source TEXT DEFAULT 'explicit',
38
+ updated_at TEXT NOT NULL,
39
+ UNIQUE(category, key)
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS lessons (
43
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ situation TEXT NOT NULL,
45
+ mistake TEXT NOT NULL,
46
+ correction TEXT NOT NULL,
47
+ created_at TEXT NOT NULL
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS profile (
51
+ section TEXT PRIMARY KEY,
52
+ content TEXT NOT NULL,
53
+ updated_at TEXT NOT NULL
54
+ );
55
+
56
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
57
+ ref_table, ref_id, title, body
58
+ );
59
+ `);
60
+ export function nowISO() {
61
+ return new Date().toISOString();
62
+ }
63
+ // re-exported from lib/capture-core.js (single source of truth)
64
+ export { truncate };
65
+ export function escapeLike(text) {
66
+ return text.replace(/[\\%_]/g, (c) => "\\" + c);
67
+ }
68
+ export function buildFtsMatch(query) {
69
+ const tokens = query.trim().split(/\s+/).filter(Boolean).slice(0, 8);
70
+ return tokens.map((t) => `"${t.replace(/"/g, "")}"`).join(" ");
71
+ }
72
+ const insertSearchIndex = db.prepare("INSERT INTO search_index (ref_table, ref_id, title, body) VALUES (?, ?, ?, ?)");
73
+ const deleteSearchIndex = db.prepare("DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?");
74
+ export function syncSearchIndex(refTable, refId, title, body) {
75
+ deleteSearchIndex.run(refTable, refId);
76
+ insertSearchIndex.run(refTable, refId, title, body);
77
+ }
78
+ export function removeSearchIndex(refTable, refId) {
79
+ deleteSearchIndex.run(refTable, refId);
80
+ }
81
+ export function ok(text) {
82
+ return { content: [{ type: "text", text }] };
83
+ }
84
+ export function err(text) {
85
+ return ok(`error: ${truncate(text, 300)}`);
86
+ }
@@ -0,0 +1,68 @@
1
+ // distill CLI: summarize interactions -> profile sections + prune old rows.
2
+ // CLI script (NOT a stdio server) — console output is allowed here.
3
+ import { pathToFileURL } from "node:url";
4
+ import DatabaseCtor from "better-sqlite3";
5
+ import { DEFAULT_DB_PATH } from "./lib/config.js";
6
+ import { computeStats, formatProfileSections, } from "./lib/distill-core.js";
7
+ const DEFAULT_RETENTION_DAYS = 30;
8
+ const MS_PER_DAY = 86_400_000;
9
+ const UPSERT_PROFILE_SQL = `
10
+ INSERT INTO profile (section, content, updated_at) VALUES (?, ?, ?)
11
+ ON CONFLICT(section) DO UPDATE SET content=excluded.content, updated_at=excluded.updated_at`;
12
+ export function retentionCutoff(now = new Date()) {
13
+ const daysRaw = process.env.RETENTION_DAYS;
14
+ const parsed = Number(daysRaw);
15
+ const days = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_RETENTION_DAYS;
16
+ return new Date(now.getTime() - days * MS_PER_DAY).toISOString();
17
+ }
18
+ export function runDistill(db) {
19
+ const rows = db
20
+ .prepare("SELECT kind, content, meta, ts FROM interactions")
21
+ .all();
22
+ const stats = computeStats(rows);
23
+ const sections = formatProfileSections(stats);
24
+ const updated_at = new Date().toISOString();
25
+ const upsert = db.prepare(UPSERT_PROFILE_SQL);
26
+ const updatedSections = [];
27
+ for (const [section, content] of Object.entries(sections)) {
28
+ upsert.run(section, content, updated_at);
29
+ updatedSections.push(section);
30
+ }
31
+ const cutoff = retentionCutoff();
32
+ const pruned = db.prepare("DELETE FROM interactions WHERE ts < ?").run(cutoff)
33
+ .changes;
34
+ return { stats, updatedSections, pruned, cutoff };
35
+ }
36
+ function main() {
37
+ let db = null;
38
+ try {
39
+ db = new DatabaseCtor(DEFAULT_DB_PATH);
40
+ db.pragma("journal_mode = WAL");
41
+ db.pragma("busy_timeout = 5000");
42
+ const summary = runDistill(db);
43
+ console.error(`[distill] prompts=${summary.stats.totalPrompts} days=${summary.stats.promptDays} ` +
44
+ `tools=${summary.stats.topTools.length} dirs=${summary.stats.topDirs.length} ` +
45
+ `keywords=${summary.stats.topKeywords.length}`);
46
+ console.error(`[distill] profile sections updated: ${summary.updatedSections.join(", ")}`);
47
+ console.error(`[distill] pruned ${summary.pruned} interaction(s) older than ${summary.cutoff}`);
48
+ return 0;
49
+ }
50
+ catch (e) {
51
+ const msg = e instanceof Error ? e.message : String(e);
52
+ try {
53
+ console.error(`[distill] failed: ${msg}`);
54
+ }
55
+ catch { }
56
+ return 1;
57
+ }
58
+ finally {
59
+ try {
60
+ if (db)
61
+ db.close();
62
+ }
63
+ catch { }
64
+ }
65
+ }
66
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
67
+ process.exit(main());
68
+ }
package/dist/index.js ADDED
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { rememberInput, rememberHandler } from "./tools/remember.js";
5
+ import { recallInput, recallHandler } from "./tools/recall.js";
6
+ import { getProfileHandler } from "./tools/profile.js";
7
+ import { saveLessonInput, saveLessonHandler } from "./tools/lesson.js";
8
+ import { searchHistoryInput, searchHistoryHandler } from "./tools/history.js";
9
+ import { forgetInput, forgetHandler } from "./tools/forget.js";
10
+ import { memoryStatsHandler } from "./tools/memory_stats.js";
11
+ import { recentInteractionsInput, getRecentInteractionsHandler, } from "./tools/recent_interactions.js";
12
+ import { exportMemoryInput, exportMemoryHandler, } from "./tools/export_memory.js";
13
+ import { VERSION } from "./lib/config.js";
14
+ const server = new McpServer({
15
+ name: "th-memory-mcp",
16
+ version: VERSION,
17
+ });
18
+ server.registerTool("remember", {
19
+ title: "Remember preference",
20
+ description: "Save or update a user preference (category+key upsert). Re-saving the same key increases confidence by 0.1 (cap 1.0). Returns the row id for forget().",
21
+ inputSchema: rememberInput,
22
+ }, (args) => rememberHandler(args));
23
+ server.registerTool("recall", {
24
+ title: "Recall memory",
25
+ description: "Search preferences + lessons via full-text index, plus recent matching interactions. Use before starting a new task.",
26
+ inputSchema: recallInput,
27
+ }, (args) => recallHandler(args));
28
+ server.registerTool("get_profile", {
29
+ title: "Get user profile",
30
+ description: "Get the distilled user profile: profile sections, top preferences by confidence (max 15), and the 5 most recent lessons.",
31
+ inputSchema: {},
32
+ }, () => getProfileHandler());
33
+ server.registerTool("save_lesson", {
34
+ title: "Save lesson",
35
+ description: "Record a lesson learned from a correction: what situation, what mistake, what is the correct way. Call immediately after the user corrects your work.",
36
+ inputSchema: saveLessonInput,
37
+ }, (args) => saveLessonHandler(args));
38
+ server.registerTool("search_history", {
39
+ title: "Search prompt history",
40
+ description: "Search past user prompts (kind='prompt') by keyword. Returns timestamped snippets truncated to 200 chars each.",
41
+ inputSchema: searchHistoryInput,
42
+ }, (args) => searchHistoryHandler(args));
43
+ server.registerTool("forget", {
44
+ title: "Forget a memory entry",
45
+ description: "Delete one memory row (preference, lesson or interaction) by id and sync the search index. Pass type when you know it (ids from remember are preference ids, from save_lesson are lesson ids).",
46
+ inputSchema: forgetInput,
47
+ }, (args) => forgetHandler(args));
48
+ server.registerTool("memory_stats", {
49
+ title: "Memory statistics",
50
+ description: "Summarize memory usage: interaction counts by kind, preference/lesson totals, DB file size, oldest/newest interaction timestamps, and profile sections.",
51
+ inputSchema: {},
52
+ }, () => memoryStatsHandler());
53
+ server.registerTool("get_recent_interactions", {
54
+ title: "Recent interactions",
55
+ description: "List recently captured raw interactions (newest first) as [id] ts [kind] content lines. Optionally filter by kind. Use for auditing history or before distilling memory.",
56
+ inputSchema: recentInteractionsInput,
57
+ }, (args) => getRecentInteractionsHandler(args));
58
+ server.registerTool("export_memory", {
59
+ title: "Export memory to JSON",
60
+ description: "Export preferences, lessons, profile (and optionally raw interactions) to a JSON file under data/exports/. Only writes inside that directory. Returns the file path, size in bytes and a JSON preview.",
61
+ inputSchema: exportMemoryInput,
62
+ }, (args) => exportMemoryHandler(args));
63
+ async function main() {
64
+ const transport = new StdioServerTransport();
65
+ await server.connect(transport);
66
+ console.error(`[th-memory-mcp] ready on stdio`);
67
+ }
68
+ main().catch((e) => {
69
+ console.error("[th-memory-mcp] fatal:", e instanceof Error ? e.message : e);
70
+ process.exit(1);
71
+ });
@@ -0,0 +1,46 @@
1
+ export const SECRET_LINE = /(api[_-]?key|secret|token|password)\s*[=:]/i;
2
+ export const CAPTURE_KINDS = ["prompt", "tool_call", "error"];
3
+ export const LIMITS = {
4
+ prompt: 4000,
5
+ tool_call: 500,
6
+ error: 500,
7
+ };
8
+ export function filterSecrets(text) {
9
+ return text
10
+ .split("\n")
11
+ .filter((line) => !SECRET_LINE.test(line))
12
+ .join("\n");
13
+ }
14
+ export function truncate(text, max) {
15
+ if (text.length <= max)
16
+ return text;
17
+ return text.slice(0, max - 1) + "\u2026";
18
+ }
19
+ export function createDedupe(maxSize = 1000) {
20
+ const live = new Set();
21
+ const order = [];
22
+ return {
23
+ seen(id) {
24
+ if (live.has(id))
25
+ return true;
26
+ live.add(id);
27
+ order.push(id);
28
+ while (order.length > maxSize) {
29
+ const evicted = order.shift();
30
+ if (evicted !== undefined)
31
+ live.delete(evicted);
32
+ }
33
+ return false;
34
+ },
35
+ };
36
+ }
37
+ export function buildRow(kind, content, opts) {
38
+ return {
39
+ ts: new Date().toISOString(),
40
+ session_id: opts?.sessionId ?? null,
41
+ kind,
42
+ content: filterSecrets(truncate(content, LIMITS[kind])),
43
+ meta: JSON.stringify(opts?.meta) ?? null,
44
+ };
45
+ }
46
+ export const INSERT_SQL = "INSERT INTO interactions (ts, session_id, kind, content, meta) VALUES (?, ?, ?, ?, ?)";
@@ -0,0 +1,6 @@
1
+ // config: shared constants. Pure module — no side effects, no I/O.
2
+ import { fileURLToPath } from "node:url";
3
+ export const VERSION = "1.1.0";
4
+ // dist/lib/config.js -> <project>/data/memory.db (independent of cwd).
5
+ export const DEFAULT_DB_PATH = fileURLToPath(new URL("../../data/memory.db", import.meta.url));
6
+ export const EXPORTS_DIRNAME = "exports";