th-memory-mcp 1.2.1 → 2.0.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 CHANGED
@@ -1,308 +1,98 @@
1
- # Design: Adaptive Memory MCP behavior-learning memory system for OpenCode
2
-
3
- > Project: D:\Coding_Project\mcp
4
- > Date: 2026-08-26 (rev.3as-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)
1
+ # th-memory-mcp v2Implementation Plan (design.md)
2
+
3
+ **Source of truth:** `ARCHITECTURE_v2.md` (on GitHub, baseline v1.2.2).
4
+ This file is the working plan for the Building Agent read it before continuing implementation.
5
+
6
+ ## Goal
7
+ Evolve th-memory-mcp from a structured local memory MCP into a durable, temporal,
8
+ conflict-aware, hybrid-retrieval memory engine. Local-first, offline, SQLite, no
9
+ mandatory cloud/LLM. Keep v1 behavior working during the transition.
10
+
11
+ ## Current v1 state (summary)
12
+ - 9 MCP tools: remember, recall, get_profile, save_lesson, search_history, forget, memory_stats, get_recent_interactions, export_memory.
13
+ - Schema (inline `CREATE TABLE IF NOT EXISTS` in `src/db.ts`): `interactions`, `preferences`, `lessons`, `profile`, FTS5 `search_index`, `embeddings` (BLOB, 512-dim hashing-trick vectors).
14
+ - No migration system; no schema version table.
15
+ - Capture logic triplicated: `src/lib/capture-core.ts`, `src/plugin/learning-capture.ts` (Bun), `scripts/claude-capture.mjs`.
16
+ - Semantic search = full in-memory linear scan over all embeddings every `recall`.
17
+ - Version metadata inconsistent: `package.json` 1.2.2 vs `config.ts` VERSION 1.1.0 vs `smoke.mjs` assertion 1.1.0 (fix in Phase 10).
18
+
19
+ ## Decisions (Phase 1)
20
+ - **Migrations are TS modules** (`src/db/migrations.ts`) exporting an ordered `MIGRATIONS` array + `runMigrations(db)`. Each `up(db)` is idempotent (`CREATE TABLE IF NOT EXISTS`) and tracked in `schema_meta`. This avoids `.sql` file-copy issues under `tsc` while keeping deterministic order (spec allows implementation differences).
21
+ - **Reuse existing `search_index` + `embeddings`** for v2 `memories` (ref_table = `'memories'`). No new FTS table needed.
22
+ - **Non-destructive:** v1 tables (`preferences`, `lessons`, `interactions`, `profile`) are preserved. v2 adds `memories`, `entities`, `relations`, `memory_links`, `schema_meta`.
23
+ - **Backfill (M005):** map `preferences → memories(type=PREFERENCE)`, `lessons → memories(type=LESSON)`, sync FTS+embeddings. Guarded by `v1_backfilled` flag so it runs once. `recall` is unaffected because it filters by `ref_table IN ('preferences','lessons')`.
24
+ - **No dual-write yet.** v1 tools keep writing only to v1 tables. v2 `memories` is seeded by backfill; new v2 tools (later phases) write to `memories`. Dedup/merge of backfilled vs new entries is Phase 4.
25
+ - **Repository layer** (`src/db/repositories/memories.ts`) provides `createMemory`, `getMemoryById`, `setStatus`, `softDelete`, `syncMemoryIndex`, `searchMemories` (FTS + semantic blend, status/project filtering). Not yet wired to a public tool (that is Phase 7 `get_context`).
26
+
27
+ ## Phased roadmap
28
+ See `ARCHITECTURE_v2.md` §35 for the canonical phase list. Status tracked in the session todo list.
29
+
30
+ ## This session (deliverables so far)
31
+
32
+ ### Phase 1 — Core abstraction (DONE)
33
+ - [x] `src/memory/types.ts` — unified `MemoryType`, `SourceType`, `LifecycleState`, `Scope`, `LinkRelation`, `MemoryRecord`.
34
+ - [x] `src/db/migrations.ts` — migration engine + 5 migrations (schema_meta, memories+indexes, entities/relations, memory_links, v1 backfill).
35
+ - [x] `src/db/repositories/memories.ts` — core CRUD + index sync + `searchMemories` (hybrid FTS + semantic blend).
36
+ - [x] `src/db/index.ts` — call `runMigrations(db)` after existing DDL (non-destructive).
37
+ - [x] Build + full test suite green (capture/distill/smoke).
38
+
39
+ ### Phase 2 Lifecycle engine (DONE)
40
+ - [x] `src/memory/decay.ts` — `recencyFactor`, per-type `DECAY_LAMBDA_BY_TYPE` (policy classes, not constants).
41
+ - [x] `src/memory/source-weights.ts` — `SOURCE_WEIGHTS` map (spec §8).
42
+ - [x] `src/memory/scorer.ts` — `computeSalience` (weighted, configurable), `computeConfidence` (source weight + diminishing returns), `salienceForMemory`.
43
+ - [x] `src/core/lifecycle-engine.ts` — `canTransition`, `transitionStatus`, `reinforce`, `touch`, `supersede` (sets old=superseded, new=active + `supersedes_id` + `memory_links`), `archive`, `softDelete`, `LifecycleError`.
44
+ - [x] `test/lifecycle.test.mjs` — 17 checks (decay, scorer, transitions, supersession, archive). Added to `npm test`.
45
+
46
+ ### Phase 3 — Temporal model (DONE)
47
+ - [x] `src/core/temporal-engine.ts` — `setValidity`, `memoriesValidAt` (point-in-time truth), `supersessionChain` (oldest→newest), `changesBetween` (change detection).
48
+ - [x] `test/temporal.test.mjs` — 7 checks (validity intervals, historical retrieval, supersession chains, change detection). Added to `npm test`.
49
+
50
+ ### Phase 4 — Conflict & dedup (DONE)
51
+ - [x] `src/memory/deduplicator.ts` — `normalizeText`, `findExactMatch`, `findSimilar`, `deduplicate` (spec §11).
52
+ - [x] `src/memory/conflict-resolver.ts` `isContradiction`, `classifyRelationship` (duplicate/update/contradiction/unrelated), `findRelated`, `resolveConflict` (merge duplicate / supersede update / link contradiction, preserving ambiguous evidence per §12).
53
+ - [x] `test/conflict.test.mjs` — 14 checks. Added to `npm test`.
54
+ - [x] **Bug fix (v1 too):** `src/lib/embed.ts` `serialize`/`deserialize` rewrote with `DataView` + explicit `byteOffset`. Old code used `Buffer.from(buf).buffer` which can carry a non-zero pool `byteOffset`, corrupting vectors (magnitude ~1e37). This silently broke v1 semantic search.
55
+
56
+ ### Phase 5 Hybrid retrieval (DONE)
57
+ - [x] `src/retrieval/fts.ts` — `ftsSearch` (FTS5 over `search_index`, status/project filters, `ORDER BY rank`).
58
+ - [x] `src/retrieval/vector.ts``vectorSearch` (cosine over `embeddings`, floor 0.15, filters).
59
+ - [x] `src/retrieval/fusion.ts` — `rrfFuse` (Reciprocal Rank Fusion, k=60).
60
+ - [x] `src/retrieval/scorer.ts` `finalScore` (RRF × confidence × importance × recency × scope) + `scopeFactorFor`.
61
+ - [x] `src/core/retrieval-engine.ts` — `retrieve` (FTS + vector → RRF → scoring/rerank → filter → topK).
62
+ - [x] `searchMemories` in repository now delegates to `retrieve` (hybrid). `buildFtsMatch` switched to OR for better recall.
63
+ - [x] `test/retrieval.test.mjs` 7 checks. Added to `npm test`.
64
+
65
+ ### Phase 6 — Graph engine (DONE)
66
+ - [x] `src/core/graph-engine.ts` — `createEntity` (canonical dedup, aliases in metadata), `addRelation` (source_entity_id/relation/target_entity_id), `linkMemories`, `traverse` (bounded BFS over `memory_links`, maxDepth 1–5, relationFilter), `neighbors`.
67
+ - [x] `test/graph.test.mjs` — 7 checks (linking, bounded traversal depth, relation filter, entity dedup, relation insert). Added to `npm test`.
68
+ - [x] Note: `entities` columns are `(name, canonical_name, type, metadata)`; `relations` use `source_entity_id/relation/target_entity_id`; `memory_links` PK `(source_memory_id, relation, target_memory_id)`.
69
+
70
+ ### Phase 7 — Context engine (DONE)
71
+ - [x] `src/core/context-engine.ts` `getContext` (hybrid retrieve optional graph expansion → temporal validity filter → token budgeting/truncation).
72
+ - [x] `src/tools/context.ts` `contextInput` (zod) + `contextHandler` (returns assembled context text).
73
+ - [x] Wired `get_context` MCP tool into `index.ts` (now 10 tools total).
74
+ - [x] `test/context.test.mjs` — 7 checks (assembly, graph expansion, token budget, temporal validity). Added to `npm test`.
75
+ - [x] Updated `test/smoke.mjs` to expect 10 tools.
76
+
77
+ ### Phase 8 — Consolidation (DONE)
78
+ - [x] `src/core/consolidation-engine.ts` `clusterMemories` (embedding cosine + union-find), `createDerivedMemory` (type DERIVED, source consolidated, links `derived_from`), `getProvenance`.
79
+ - [x] `src/tools/consolidate.ts` `consolidateInput` + `consolidateHandler` (read-only cluster listing + optional `derive`).
80
+ - [x] Wired `consolidate` MCP tool into `index.ts` (now 11 tools total).
81
+ - [x] `test/consolidation.test.mjs` — 5 checks. Added to `npm test`.
82
+ - [x] Added `DERIVED` to `MEMORY_TYPES`; added `DERIVED` lambda to `decay.ts`.
83
+
84
+ ### Phase 9 — Benchmark & security suite (DONE)
85
+ - [x] `test/benchmark.test.mjs` — 2 checks (retrieve over 300 memories < 2000ms).
86
+ - [x] `test/security.test.mjs` — 5 checks (FTS injection quoting, safe retrieve, malicious content stored verbatim, extreme budget, parameterized SQL).
87
+ - [x] Both added to `npm test` (now 12 suites).
88
+
89
+ ### Phase 10 — v2 release (DOCS DONE; PUBLISH PENDING USER)
90
+ - [x] `MIGRATION_v2.md` written (non-destructive upgrade guide, rollback notes).
91
+ - [x] `README.md` updated to v2.0.0 (11 tools, v2 architecture, new test scripts).
92
+ - [ ] Version bump to `2.0.0` + `npm publish --otp=CODE` (needs user OTP).
93
+ - [ ] `git commit` + `git push` + GitHub Release v2.0.0 (needs user).
94
+ - [ ] `.\mcp-publisher.exe publish` (Official MCP Registry; needs user GitHub OAuth + OTP).
95
+ - [ ] Glama: claim ownership + sync from GitHub.
96
+
97
+ ## Next
98
+ All v2 engine phases (0–9) complete and tested. Remaining: user-driven release steps above. After release, future work could include automatic entity extraction in consolidation and a periodic auto-consolidate scheduler.
@@ -0,0 +1,87 @@
1
+ import { db, getAllEmbeddings } from "../db/index.js";
2
+ import { cosine, deserialize } from "../lib/embed.js";
3
+ import { createMemory } from "../db/repositories/memories.js";
4
+ import { linkMemories } from "./graph-engine.js";
5
+ // Group similar active memories into clusters via embedding cosine + union-find (spec §16)
6
+ export function clusterMemories(opts = {}) {
7
+ const threshold = opts.threshold ?? 0.7;
8
+ const minSize = opts.minClusterSize ?? 2;
9
+ const rows = getAllEmbeddings();
10
+ const valid = rows.filter((r) => {
11
+ const m = db
12
+ .prepare("SELECT status, project_id FROM memories WHERE id = ?")
13
+ .get(r.ref_id);
14
+ if (!m)
15
+ return false;
16
+ if (!opts.includeArchived &&
17
+ (m.status === "deleted" || m.status === "archived"))
18
+ return false;
19
+ if (opts.projectId &&
20
+ !(m.project_id === opts.projectId || m.project_id === null))
21
+ return false;
22
+ return true;
23
+ });
24
+ const vecs = new Map();
25
+ for (const r of valid)
26
+ vecs.set(r.ref_id, deserialize(r.vec));
27
+ const ids = [...vecs.keys()];
28
+ const parent = new Map();
29
+ ids.forEach((id) => parent.set(id, id));
30
+ function find(x) {
31
+ let root = parent.get(x);
32
+ while (root !== undefined && root !== x) {
33
+ const next = parent.get(root);
34
+ if (next === undefined)
35
+ break;
36
+ parent.set(x, next);
37
+ x = root;
38
+ root = next;
39
+ }
40
+ return root ?? x;
41
+ }
42
+ function union(a, b) {
43
+ const ra = find(a);
44
+ const rb = find(b);
45
+ if (ra !== rb)
46
+ parent.set(ra, rb);
47
+ }
48
+ for (let i = 0; i < ids.length; i++) {
49
+ const a = ids[i];
50
+ for (let j = i + 1; j < ids.length; j++) {
51
+ const b = ids[j];
52
+ if (cosine(vecs.get(a), vecs.get(b)) >= threshold) {
53
+ union(a, b);
54
+ }
55
+ }
56
+ }
57
+ const groups = new Map();
58
+ for (const id of ids) {
59
+ const root = find(id);
60
+ if (!groups.has(root))
61
+ groups.set(root, []);
62
+ groups.get(root).push(id);
63
+ }
64
+ return [...groups.values()].filter((g) => g.length >= minSize);
65
+ }
66
+ // Create a derived/consolidated memory and link its sources via `derived_from` (spec §16)
67
+ export function createDerivedMemory(input) {
68
+ const id = createMemory({
69
+ type: "DERIVED",
70
+ content: input.content,
71
+ summary: input.summary ?? null,
72
+ source: "consolidated",
73
+ projectId: input.projectId ?? null,
74
+ });
75
+ for (const src of input.sourceIds) {
76
+ if (src !== id)
77
+ linkMemories(src, id, "derived_from");
78
+ }
79
+ return id;
80
+ }
81
+ export function getProvenance(memoryId) {
82
+ const rows = db
83
+ .prepare(`SELECT source_memory_id FROM memory_links
84
+ WHERE target_memory_id = ? AND relation = 'derived_from'`)
85
+ .all(memoryId);
86
+ return rows.map((r) => r.source_memory_id);
87
+ }
@@ -0,0 +1,50 @@
1
+ import { db } from "../db/index.js";
2
+ import { retrieve } from "./retrieval-engine.js";
3
+ import { traverse } from "./graph-engine.js";
4
+ function isCurrentlyValid(m, now) {
5
+ if (m.valid_from && new Date(m.valid_from) > now)
6
+ return false;
7
+ if (m.valid_until && new Date(m.valid_until) < now)
8
+ return false;
9
+ return true;
10
+ }
11
+ // Assemble relevant memories for the current task (spec §15)
12
+ export function getContext(opts = {}) {
13
+ const query = opts.query ?? "";
14
+ const limit = Math.min(Math.max(opts.limit ?? 10, 1), 50);
15
+ const maxTokens = opts.maxTokens ?? 2000;
16
+ const now = new Date();
17
+ const seeds = retrieve(query, {
18
+ limit,
19
+ projectId: opts.projectId,
20
+ includeArchived: opts.includeHistory,
21
+ });
22
+ const seedScores = new Map();
23
+ for (const s of seeds)
24
+ seedScores.set(s.id, s.final_score);
25
+ const ids = new Set(seeds.map((s) => s.id));
26
+ if (opts.includeGraph) {
27
+ for (const s of seeds) {
28
+ for (const n of traverse(s.id, { maxDepth: 1 }))
29
+ ids.add(n.memoryId);
30
+ }
31
+ }
32
+ const all = [...ids]
33
+ .map((id) => db.prepare("SELECT * FROM memories WHERE id = ?").get(id))
34
+ .filter((m) => !!m)
35
+ .filter((m) => opts.includeHistory || isCurrentlyValid(m, now));
36
+ all.sort((a, b) => (seedScores.get(b.id) ?? 0) - (seedScores.get(a.id) ?? 0));
37
+ let used = 0;
38
+ const out = [];
39
+ let truncated = false;
40
+ for (const m of all) {
41
+ const t = Math.ceil((m.content?.length ?? 0) / 4) + 8;
42
+ if (used + t > maxTokens && out.length > 0) {
43
+ truncated = true;
44
+ break;
45
+ }
46
+ used += t;
47
+ out.push({ ...m, final_score: seedScores.get(m.id) ?? 0, viaGraph: !seedScores.has(m.id) });
48
+ }
49
+ return { query, memories: out, tokenEstimate: used, truncated };
50
+ }
@@ -0,0 +1,67 @@
1
+ import { db, nowISO } from "../db/index.js";
2
+ export function createEntity(input) {
3
+ const canonical = input.name.toLowerCase().trim();
4
+ const existing = db
5
+ .prepare("SELECT id FROM entities WHERE canonical_name = ?")
6
+ .get(canonical);
7
+ if (existing)
8
+ return existing.id;
9
+ const info = db
10
+ .prepare(`INSERT INTO entities (name, canonical_name, type, metadata)
11
+ VALUES (?, ?, ?, ?)`)
12
+ .run(input.name, canonical, input.type ?? "concept", JSON.stringify({ aliases: input.aliases ?? [] }));
13
+ return Number(info.lastInsertRowid);
14
+ }
15
+ export function addRelation(input) {
16
+ const info = db
17
+ .prepare(`INSERT INTO relations
18
+ (source_entity_id, relation, target_entity_id, confidence, source_memory_id, metadata)
19
+ VALUES (?, ?, ?, ?, ?, ?)`)
20
+ .run(input.subjectId, input.predicate, input.objectId, input.confidence ?? 0.5, input.sourceMemoryId ?? null, JSON.stringify({}));
21
+ return Number(info.lastInsertRowid);
22
+ }
23
+ export function linkMemories(sourceId, targetId, relation) {
24
+ db.prepare(`INSERT OR IGNORE INTO memory_links
25
+ (source_memory_id, relation, target_memory_id, created_at)
26
+ VALUES (?, ?, ?, ?)`).run(sourceId, relation, targetId, nowISO());
27
+ }
28
+ // Bounded BFS over memory_links starting from a memory (spec §14)
29
+ export function traverse(startMemoryId, opts = {}) {
30
+ const maxDepth = Math.min(Math.max(opts.maxDepth ?? 2, 1), 5);
31
+ const visited = new Set([startMemoryId]);
32
+ const queue = [
33
+ { id: startMemoryId, depth: 0, relation: null },
34
+ ];
35
+ const result = [];
36
+ while (queue.length) {
37
+ const cur = queue.shift();
38
+ if (cur.depth >= maxDepth)
39
+ continue;
40
+ const links = db
41
+ .prepare(`SELECT target_memory_id, relation FROM memory_links
42
+ WHERE source_memory_id = ?`)
43
+ .all(cur.id);
44
+ for (const l of links) {
45
+ if (opts.relationFilter && !opts.relationFilter.includes(l.relation)) {
46
+ continue;
47
+ }
48
+ if (!visited.has(l.target_memory_id)) {
49
+ visited.add(l.target_memory_id);
50
+ result.push({
51
+ memoryId: l.target_memory_id,
52
+ depth: cur.depth + 1,
53
+ relation: l.relation,
54
+ });
55
+ queue.push({
56
+ id: l.target_memory_id,
57
+ depth: cur.depth + 1,
58
+ relation: l.relation,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ return result;
64
+ }
65
+ export function neighbors(memoryId) {
66
+ return traverse(memoryId, { maxDepth: 1 });
67
+ }