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