claude-recall 0.37.2 → 0.38.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 CHANGED
@@ -464,6 +464,7 @@ Defaults work out of the box; tune via environment variables as needed.
464
464
  | `CLAUDE_RECALL_LLM_TIMEOUT_MS` | `5000` | Timeout for hook-context LLM calls (classification, hindsight hints). Hooks fall back to regex when it fires. |
465
465
  | `CLAUDE_RECALL_STOP_DEBOUNCE_MS` | `300000` | Debounce for the heavy Stop-hook pipeline (episodes, session extraction, promotion). `0` disables. |
466
466
  | `CLAUDE_RECALL_PROJECT_ID` | *(cwd)* | Pin the project scope to a fixed id, overriding working-directory detection. |
467
+ | `CLAUDE_RECALL_RETRIEVAL` | `like` | Lexical retrieval engine: `fts` uses SQLite FTS5 / BM25 ranking for better paraphrase recall; `like` (default) uses the legacy substring filter. Opt-in; falls back to `like` automatically if the SQLite build lacks FTS5. See [docs/design-hybrid-retrieval-fts5.md](docs/design-hybrid-retrieval-fts5.md). |
467
468
 
468
469
  ---
469
470
 
@@ -102,8 +102,17 @@ class MemoryRetrieval {
102
102
  }
103
103
  calculateRelevance(memory, context, stats, evidenceCount) {
104
104
  let score = memory.relevance_score || 1.0;
105
- // Boost for keyword matches in memory value
106
- if (context.keywords && context.keywords.length > 0) {
105
+ // Lexical boost. Two sources, mutually exclusive:
106
+ // (a) FTS5 path storage attached a normalized bm25Score ∈ [0,1]; every
107
+ // returned candidate already matched the query (MATCH filtered), so
108
+ // there is no "no-overlap" case here.
109
+ // (b) LIKE path — no bm25Score; fall back to keyword-overlap counting.
110
+ if (memory.bm25Score !== undefined) {
111
+ // Fuse BM25 multiplicatively, preserving the LIKE path's dynamic range
112
+ // (~1x..4x) so decay/strength/evidence boosts behave identically.
113
+ score *= 1 + MemoryRetrieval.W_LEXICAL * memory.bm25Score;
114
+ }
115
+ else if (context.keywords && context.keywords.length > 0) {
107
116
  const memoryStr = JSON.stringify(memory.value).toLowerCase();
108
117
  let keywordMatches = 0;
109
118
  for (const keyword of context.keywords) {
@@ -268,6 +277,13 @@ class MemoryRetrieval {
268
277
  }
269
278
  }
270
279
  exports.MemoryRetrieval = MemoryRetrieval;
280
+ /**
281
+ * Fusion weight for the FTS5 BM25 lexical signal. Chosen so a top match
282
+ * (bm25Score=1) yields a ~4x boost, matching the LIKE path's full-match
283
+ * dynamic range (1 + matchRatio*3, ×1.5 all-match). Tune against the
284
+ * retrieval benchmark before flipping CLAUDE_RECALL_RETRIEVAL=fts to default.
285
+ */
286
+ MemoryRetrieval.W_LEXICAL = 3.0;
271
287
  MemoryRetrieval.TYPE_PRIORITY = {
272
288
  'correction': 6,
273
289
  'solution': 5.5, // hard-won reusable solutions — high signal, rank just below corrections
@@ -44,6 +44,13 @@ const path = __importStar(require("path"));
44
44
  const test_pollution_1 = require("../services/test-pollution");
45
45
  class MemoryStorage {
46
46
  constructor(dbPath) {
47
+ /**
48
+ * Whether the FTS5 mirror table + triggers are present and usable. Set once
49
+ * during setupFts(); when false, searchByContext always uses the LIKE path
50
+ * regardless of retrievalMode (self-built/exotic SQLite may lack FTS5).
51
+ */
52
+ this.ftsAvailable = false;
53
+ this.retrievalMode = (process.env.CLAUDE_RECALL_RETRIEVAL || 'like').trim().toLowerCase();
47
54
  this.db = new better_sqlite3_1.default(dbPath);
48
55
  // Enable WAL mode for better concurrency and to ensure writes are visible
49
56
  this.db.pragma('journal_mode = WAL');
@@ -250,6 +257,85 @@ class MemoryStorage {
250
257
  console.error('⚠️ Schema migration error:', error);
251
258
  // Don't throw - let the database continue with existing schema
252
259
  }
260
+ // FTS5 lexical index (v0.38.0+). Separate from the try block above so a
261
+ // FTS5-less SQLite build degrades to the LIKE path instead of aborting the
262
+ // whole migration.
263
+ this.setupFts();
264
+ }
265
+ /**
266
+ * Create the FTS5 mirror of memories.value (external-content table kept in
267
+ * sync by triggers) and backfill it once. Feature-detected: if this SQLite
268
+ * build lacks FTS5, ftsAvailable stays false and retrieval uses LIKE.
269
+ *
270
+ * The table + triggers are derived, redundant data — dropping them reverts to
271
+ * LIKE with zero risk to `memories`. Triggers do the syncing in SQL so every
272
+ * writer (upsert, INSERT OR REPLACE, delete, import) stays covered without a
273
+ * TypeScript write-path change.
274
+ */
275
+ setupFts() {
276
+ try {
277
+ // Was the mirror already present before this startup? If so, the triggers
278
+ // below have been maintaining it and we must NOT re-backfill. We cannot
279
+ // gauge this from `count(*) FROM memories_fts`: for an external-content
280
+ // table that count proxies to the content table, so it reads non-zero
281
+ // even when the index is empty (the legacy-upgrade bug).
282
+ const existed = !!this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='memories_fts'").get();
283
+ this.db.exec(`
284
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
285
+ value,
286
+ content='memories',
287
+ content_rowid='id'
288
+ );
289
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
290
+ INSERT INTO memories_fts(rowid, value) VALUES (new.id, new.value);
291
+ END;
292
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
293
+ INSERT INTO memories_fts(memories_fts, rowid, value) VALUES('delete', old.id, old.value);
294
+ END;
295
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
296
+ INSERT INTO memories_fts(memories_fts, rowid, value) VALUES('delete', old.id, old.value);
297
+ INSERT INTO memories_fts(rowid, value) VALUES (new.id, new.value);
298
+ END;
299
+ `);
300
+ // First time the mirror is created (fresh DB or legacy upgrade): backfill
301
+ // the index from existing rows via FTS5's canonical 'rebuild' command —
302
+ // the correct way to populate an external-content index from content. A
303
+ // single scan, trivial at the 10k row cap. Skipped on later startups.
304
+ if (!existed) {
305
+ this.db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')");
306
+ }
307
+ this.ftsAvailable = true;
308
+ }
309
+ catch (error) {
310
+ // FTS5 unavailable (self-built SQLite without the extension) — leave the
311
+ // flag off; searchByContext uses the LIKE path everywhere.
312
+ this.ftsAvailable = false;
313
+ }
314
+ }
315
+ /**
316
+ * Turn extracted keywords into a safe FTS5 MATCH expression: each term is
317
+ * stripped to word characters, wrapped as a quoted prefix token, and
318
+ * OR-joined — e.g. `"kaggle"* OR "submission"*`. Prefix (`*`) mimics the
319
+ * substring reach of the old LIKE filter ("auth" still matches
320
+ * "authentication"). Returns '' when nothing usable remains, so the caller
321
+ * falls back to LIKE rather than issuing an empty MATCH.
322
+ *
323
+ * Sanitization is mandatory: bare AND/OR/NEAR, quotes, hyphens and `*` are
324
+ * FTS5 operators and throw SQLITE_ERROR on malformed input.
325
+ */
326
+ sanitizeFtsMatch(keywords) {
327
+ const terms = [];
328
+ for (const kw of keywords) {
329
+ // Keep only letters/digits/space; collapse everything else (quotes,
330
+ // hyphens, parens, operators) to spaces, then take the first token.
331
+ const cleaned = String(kw).toLowerCase().replace(/[^a-z0-9\s]/g, ' ').trim();
332
+ if (!cleaned)
333
+ continue;
334
+ const token = cleaned.split(/\s+/)[0];
335
+ if (token)
336
+ terms.push(`"${token}"*`);
337
+ }
338
+ return [...new Set(terms)].join(' OR ');
253
339
  }
254
340
  /**
255
341
  * Compute a SHA-256 content hash from the meaningful fields of a memory.
@@ -548,6 +634,24 @@ class MemoryStorage {
548
634
  throw new Error('searchByContext requires context.project_id or context.includeAllProjects=true. ' +
549
635
  'Calling without either would silently return memories from all projects.');
550
636
  }
637
+ // FTS5 lexical path (opt-in). Only when a keyword filter would otherwise be
638
+ // applied — empty/stopword queries keep the LIKE branch's "return all
639
+ // scoped rows" behaviour so load_rules-style calls are unaffected.
640
+ if (this.retrievalMode === 'fts' &&
641
+ this.ftsAvailable &&
642
+ context.keywords &&
643
+ context.keywords.length > 0) {
644
+ const matchExpr = this.sanitizeFtsMatch(context.keywords);
645
+ if (matchExpr) {
646
+ try {
647
+ return this.searchByContextFts(context, matchExpr);
648
+ }
649
+ catch {
650
+ // Malformed MATCH or FTS error — never crash retrieval; fall through
651
+ // to the LIKE path below.
652
+ }
653
+ }
654
+ }
551
655
  let query = 'SELECT * FROM memories WHERE 1=1';
552
656
  const params = [];
553
657
  if (context.project_id) {
@@ -595,6 +699,48 @@ class MemoryStorage {
595
699
  const rows = stmt.all(...params);
596
700
  return rows.map(row => this.rowToMemory(row));
597
701
  }
702
+ /**
703
+ * FTS5 candidate fetch: MATCH replaces the LIKE keyword filter while every
704
+ * other predicate (scope, file_path, type) is preserved verbatim. Attaches a
705
+ * normalized bm25Score ∈ [0,1] to each row (1 = best match in this set) for
706
+ * the retrieval-layer fusion. Throws on a malformed MATCH — the caller
707
+ * catches and falls back to LIKE.
708
+ */
709
+ searchByContextFts(context, matchExpr) {
710
+ let query = `SELECT m.*, bm25(memories_fts) AS bm25_rank
711
+ FROM memories_fts
712
+ JOIN memories m ON m.id = memories_fts.rowid
713
+ WHERE memories_fts MATCH ?`;
714
+ const params = [matchExpr];
715
+ if (context.project_id) {
716
+ query += ' AND (m.project_id = ? OR m.scope = ? OR m.project_id IS NULL)';
717
+ params.push(context.project_id, 'universal');
718
+ }
719
+ if (context.file_path) {
720
+ query += ' AND m.file_path = ?';
721
+ params.push(context.file_path);
722
+ }
723
+ if (context.type) {
724
+ query += ' AND m.type = ?';
725
+ params.push(context.type);
726
+ }
727
+ // SQLite bm25() is negative; more-negative = better, so ascending is best-first.
728
+ query += ' ORDER BY bm25_rank';
729
+ const rows = this.db.prepare(query).all(...params);
730
+ if (rows.length === 0)
731
+ return [];
732
+ // Min-max normalize -bm25 (so higher = better) across the candidate set.
733
+ const raws = rows.map(r => -r.bm25_rank);
734
+ const min = Math.min(...raws);
735
+ const max = Math.max(...raws);
736
+ const span = max - min;
737
+ return rows.map((row, i) => {
738
+ const memory = this.rowToMemory(row);
739
+ // Degenerate set (single row, or all equally ranked) → all are the best match.
740
+ memory.bm25Score = span > 0 ? (raws[i] - min) / span : 1.0;
741
+ return memory;
742
+ });
743
+ }
598
744
  deleteByKey(key) {
599
745
  const stmt = this.db.prepare('DELETE FROM memories WHERE key = ?');
600
746
  const result = stmt.run(key);
@@ -0,0 +1,182 @@
1
+ # Claude Recall vs. mem0
2
+
3
+ A positioning and architecture comparison against [mem0](https://github.com/mem0ai/mem0)
4
+ (mem0ai), currently the most popular open-source agent-memory project
5
+ (~63k GitHub stars). Written 2026-08-17.
6
+
7
+ Both give AI agents persistent memory across conversations, but they are built
8
+ for **different consumers** and on **opposite philosophies**: mem0 is an
9
+ LLM-native memory *backend for app developers*; Claude Recall is a *purpose-built,
10
+ zero-dependency memory tool for Claude Code*.
11
+
12
+ ## At a glance
13
+
14
+ | Dimension | **mem0** | **Claude Recall** |
15
+ |---|---|---|
16
+ | Primary consumer | App developers embedding memory in their own agents | Claude Code users (the coding agent itself) |
17
+ | Delivery | Python/TS SDK + REST API + hosted Platform; optional MCP servers | MCP server + Claude Code hooks, installed via npm |
18
+ | Storage | Vector DB (Qdrant default) + optional graph DB | Local SQLite (WAL mode), single file `~/.claude-recall/claude-recall.db` |
19
+ | Retrieval | Embedding / semantic vector search (top-k) | Keyword ranking + time-decay + context/usage scoring |
20
+ | Extraction | **LLM-driven** — LLM extracts facts, then ADD/UPDATE/DELETE/NOOP reconciliation | Rule/heuristic extractors (preferences, failures, patterns); no LLM in the hot injection path |
21
+ | Hard dependencies | **LLM API key + embeddings pipeline** (defaults to OpenAI) | None — no API key, no embeddings, no external services |
22
+ | Cost per write | LLM tokens + latency on every memory write | ~0 (local compute, ~10–30 ms keyword rank) |
23
+ | Scoping | `user_id` / `agent_id` / `run_id` (session) | Project-scoped (universal vs. project-specific), local machine |
24
+ | License / lang | Apache 2.0; Python + TS | TypeScript |
25
+ | Maturity | ~63k stars; commercial hosted Platform | Early-stage OSS on npm |
26
+
27
+ ## Where they genuinely differ
28
+
29
+ **1. LLM-native vs. deterministic.** This is the core split. mem0 runs an LLM on
30
+ *every write*: it extracts salient facts, then (classically) does a second LLM
31
+ pass that decides whether to **ADD / UPDATE / DELETE / NOOP** against
32
+ semantically-similar existing memories. That buys real **consolidation and
33
+ contradiction resolution** — but costs tokens, latency, and an API key. Claude
34
+ Recall is deterministic: SHA-256 content-hash dedup at write time, keyword +
35
+ decay + usage ranking at read time, and the just-in-time rule-injector runs *no*
36
+ LLM in the hot path. Recall trades mem0's semantic smarts for **zero marginal
37
+ cost and no network dependency**.
38
+
39
+ **2. Retrieval mechanics.** mem0 embeds the query and returns top-k semantically
40
+ similar memories — it will match "how do I deploy" to a memory phrased "release
41
+ process" even with no shared words. Recall's keyword ranking
42
+ (`src/core/retrieval.ts`) will not catch that paraphrase. This is exactly the gap
43
+ `docs/design-hybrid-retrieval-fts5.md` targets — SQLite FTS5 narrows it without
44
+ adding an embeddings dependency.
45
+
46
+ **3. Who does the remembering.** mem0 is a **library you call** —
47
+ `client.add(messages, user_id=...)` / `client.search(query, ...)`. Your app
48
+ decides when to write and read. Claude Recall **inserts itself into the agent's
49
+ loop** via Claude Code hooks: the rule-injector fires on PreToolUse and pushes
50
+ matching rules adjacent to the action, the enforcer gates tool use until rules
51
+ load, PreCompact preserves memory before compaction. mem0 is passive
52
+ infrastructure; Recall is an active participant in the session.
53
+
54
+ **4. Multi-tenant vs. single-user-local.** mem0 is built for many users on shared
55
+ infrastructure (`user_id`/`agent_id`/`run_id`, hosted cloud). Recall is
56
+ single-developer, single-machine, project-scoped — memories never leave
57
+ `~/.claude-recall/`. That is a privacy/simplicity win and a "no sync across your
58
+ machines" limitation.
59
+
60
+ ## The overlap worth noting
61
+
62
+ mem0 has two MCP paths onto coding agents: the **Platform MCP** (cloud) and
63
+ **OpenMemory MCP** — a *local-first, self-hosted* MCP server that auto-captures
64
+ coding preferences and injects relevant memories into any MCP agent.
65
+ **OpenMemory is mem0's closest analog to Claude Recall** and the most direct
66
+ competitor. Key contrast even there: OpenMemory still runs the LLM extraction
67
+ pipeline (needs a key); Recall does not.
68
+
69
+ ## Ideas Recall could borrow
70
+
71
+ - **LLM-based reconcile pass** (optional, background, off the hot path) —
72
+ mem0's ADD/UPDATE/DELETE/NOOP is stronger dedup than content-hash equality
73
+ (catches "same fact, different words").
74
+ - **Semantic retrieval** — the FTS5 design note is the pragmatic middle ground;
75
+ a full embeddings option would close the paraphrase gap but breaks the
76
+ no-API-key promise.
77
+ - **Temporal/relationship reasoning** — mem0's graph layer wins on multi-hop and
78
+ "what changed over time" queries. Recall's preference-versioning
79
+ (`superseded_by`) is a lighter take on the same idea. See the assessment below.
80
+
81
+ ## Bottom line
82
+
83
+ mem0 optimizes for *semantic recall quality and multi-tenant scale*, accepting
84
+ LLM cost and an API-key dependency. Claude Recall optimizes for *zero-dependency,
85
+ zero-cost, deep Claude Code integration*, accepting weaker paraphrase matching.
86
+ They are not really competitors except at the OpenMemory-MCP boundary — Recall's
87
+ differentiator is that it needs nothing but Node and a local SQLite file, and it
88
+ lives *inside* the agent's action loop rather than beside it.
89
+
90
+ ---
91
+
92
+ # Assessment: should Recall adopt mem0's graph-memory approach?
93
+
94
+ ## What mem0's graph layer actually is
95
+
96
+ mem0's graph memory stores an **entity-relationship graph alongside** the vector
97
+ store. On each write, an LLM pipeline runs: **entity extraction → relationship
98
+ establishment → conflict detection → graph update** (with an update resolver for
99
+ temporal conflicts). Retrieval can then traverse relationships, not just match
100
+ text. Reported payoff on LOCOMO: the graph variant scores ~2% higher overall than
101
+ the base config, but the base-vs-graph gains concentrate where it matters —
102
+ **+29.3 on temporal reasoning and +25.2 on multi-hop** questions ("what did I
103
+ decide about X *after* I changed Y?").
104
+
105
+ Two caveats that reshape the "adopt it" question:
106
+ - In the **v3 OSS rewrite (~Apr 2026)** mem0 **removed the graph layer from
107
+ open source** and replaced it with lighter **spaCy-based entity linking**
108
+ (entities stored in a parallel vector collection). Full graph memory now lives
109
+ on the **hosted Platform**. So even mem0 concluded the LLM-driven graph was too
110
+ heavy for the embedded/OSS tier.
111
+ - The graph's value is realized through **traversal queries** — a class of query
112
+ Recall does not currently ask.
113
+
114
+ ## How this maps onto Recall today
115
+
116
+ Recall's schema (`src/memory/schema.sql`) is a single flat `memories` table.
117
+ There is exactly one relationship edge modeled: preference versioning
118
+ (`superseded_by` / `superseded_at` / `is_active`) — effectively a one-hop
119
+ "replaced-by" chain. Retrieval (`src/core/retrieval.ts`) is pure per-memory
120
+ scoring: keyword overlap × time-decay × project/file match × usage strength ×
121
+ evidence. There is **no traversal, no entity model, no cross-memory linking**.
122
+
123
+ So adopting graph memory is not a tweak — it means adding (a) an entity/relation
124
+ data model, (b) an extraction step to populate it, and (c) traversal-aware
125
+ retrieval to consume it. Each has a cost.
126
+
127
+ ### The extraction problem is the blocker
128
+
129
+ mem0's graph quality comes from an **LLM** reading each memory and emitting
130
+ `(subject, relation, object)` triples with conflict resolution. Recall's entire
131
+ design premise is **no LLM and no API key in the pipeline**. To match mem0 you
132
+ would either:
133
+ - **Add an LLM dependency** → breaks the zero-dependency promise, adds cost and
134
+ latency to every write, needs a key. Non-starter for the core product.
135
+ - **Use spaCy/NER** (mem0's own v3 fallback) → a heavy Python/native dependency
136
+ in a Node project, and NER on short dev-preference snippets ("commit with
137
+ `--no-gpg-sign`", "merge before next PR") yields sparse, low-value entities.
138
+ The corpus is imperative rules, not narrative prose with rich named entities.
139
+ - **Regex/heuristic entity tagging** → cheap and dependency-free, but produces a
140
+ thin graph that mostly re-encodes what keyword scoring already captures.
141
+
142
+ ### The demand problem
143
+
144
+ The graph pays off on **multi-hop and temporal** questions. Recall's actual
145
+ retrieval trigger is "surface rules relevant to *this tool call*" — a
146
+ single-hop, relevance-ranked lookup fired by the rule-injector. It does not ask
147
+ "trace the chain of decisions about auth across sessions." Until there is a
148
+ consumer that issues traversal queries, a graph is infrastructure with no reader.
149
+
150
+ ## Verdict: **not worth a full graph layer — adopt the one idea that fits**
151
+
152
+ A mem0-style LLM-driven knowledge graph is **over-engineered for Recall's corpus,
153
+ consumers, and constraints**, and mem0 itself pulled it out of OSS for the same
154
+ weight reasons. Recommendation, in priority order:
155
+
156
+ 1. **Do first — semantic retrieval via FTS5** (already scoped in
157
+ `docs/design-hybrid-retrieval-fts5.md`). This solves the *real* observed gap
158
+ (paraphrase matching) at a fraction of the cost, no new dependency. Higher ROI
159
+ than any graph work.
160
+
161
+ 2. **Cheap, high-value slice of the graph idea — generalize `superseded_by` into
162
+ a lightweight typed-link table.** Recall already models one edge type. A small
163
+ `memory_links(from_key, to_key, relation)` table — relations like
164
+ `supersedes`, `refines`, `contradicts`, `depends-on` — captures ~80% of the
165
+ temporal/versioning value with:
166
+ - no LLM (links come from existing signals: the promotion engine already
167
+ knows evidence chains; preference override already knows supersession;
168
+ content-hash near-misses can suggest `refines`),
169
+ - no new runtime dependency (one SQLite table + indexes),
170
+ - a bounded blast radius (retrieval can optionally expand one hop from a
171
+ top-ranked memory, off the hot path).
172
+
173
+ This gives "show me the current rule *and what it replaced*" and "this failure
174
+ plus its fix" without the extraction machinery.
175
+
176
+ 3. **Do not build** — full entity-relationship extraction, graph DB backend
177
+ (Neo4j/Kuzu/etc.), or LLM triple extraction. Wrong cost/benefit for a
178
+ local, zero-dependency, single-developer tool.
179
+
180
+ **One-line recommendation:** skip the graph *layer*; ship FTS5 first, then, if a
181
+ traversal consumer emerges, add a single `memory_links` table that generalizes
182
+ the existing `superseded_by` edge — not an LLM knowledge graph.
@@ -0,0 +1,207 @@
1
+ # Design note: FTS5 / BM25 hybrid retrieval
2
+
3
+ **Status:** ✅ implemented behind `CLAUDE_RECALL_RETRIEVAL=fts` (default `like`).
4
+ PRs 1 & 3 of §10 shipped together — vtable + triggers + feature-detect +
5
+ backfill, and the MATCH candidate fetch + sanitization + BM25 fusion. Still open:
6
+ the benchmark harness (PR 2) and flipping the default (PR 4), which is gated on
7
+ that benchmark showing a win.
8
+ **Date:** 2026-07-22 (proposal) · implemented 2026-08-17 · **Baseline:** v0.37.2
9
+ **Scope of this note:** lexical BM25 ranking via SQLite FTS5. Local embeddings /
10
+ semantic similarity are a deliberately-separate later phase (§9).
11
+
12
+ **Implementation notes (deltas from the proposal below):**
13
+ - FTS terms are wrapped as **prefix** tokens (`"auth"*`), not plain phrases, so a
14
+ short keyword still reaches a longer token (`auth` → `authentication`) —
15
+ preserving the LIKE `%kw%` substring reach. Sanitizer:
16
+ `MemoryStorage.sanitizeFtsMatch` (`src/memory/storage.ts`).
17
+ - Backfill uses FTS5's canonical `INSERT INTO memories_fts(memories_fts)
18
+ VALUES('rebuild')`, gated on whether the vtable **existed before startup** (via
19
+ `sqlite_master`). The proposal's "only if the FTS table is empty" guard is
20
+ **wrong for external-content tables** — `count(*)` there proxies to the content
21
+ table and reads non-zero even with an empty index (would silently skip the
22
+ legacy-upgrade backfill; covered by a regression test).
23
+ - Fusion is `score *= 1 + W_LEXICAL * bm25Score` with `W_LEXICAL = 3.0`
24
+ (`src/core/retrieval.ts`); `bm25Score ∈ [0,1]` is min-max normalized in
25
+ `MemoryStorage.searchByContextFts` and carried on `Memory.bm25Score`.
26
+ - Tests: `tests/unit/storage-fts.test.ts` (7 cases: match+score, prefix recall,
27
+ scope isolation, trigger sync on update/delete, sanitize-to-empty safety,
28
+ legacy backfill, and no-bm25Score-on-`like`).
29
+
30
+ ---
31
+
32
+ ## 1. Goal
33
+
34
+ Replace the crude `LIKE '%kw%'` candidate filter + `includes()` keyword boost
35
+ with real **BM25 ranking** from SQLite's built-in FTS5 — improving recall for
36
+ paraphrase and ranking quality as the corpus grows, **without adding a single
37
+ dependency and without giving up the local-only / offline promise.**
38
+
39
+ Non-goals: embeddings, vector search, entity graphs (see §9).
40
+
41
+ ## 2. Current retrieval, precisely
42
+
43
+ Two stages, both in-process:
44
+
45
+ 1. **Candidate fetch** — `MemoryStorage.searchByContext()` (`src/memory/storage.ts:666`).
46
+ When keywords are present it hard-filters rows with `value LIKE ?` per keyword
47
+ (`storage.ts:698-713`): ≥3 keywords → require ≥2 matches; <3 → match any. The
48
+ match is against the **raw JSON string of the `value` column** — so it matches
49
+ field names as well as content, and misses any paraphrase.
50
+ 2. **Re-rank** — `MemoryRetrieval.calculateRelevance()` (`src/core/retrieval.ts:141`).
51
+ A multiplicative pipeline: base `relevance_score` × keyword boost
52
+ (`retrieval.ts:150-174`, up to ~6× via `String.includes`) × time-decay
53
+ forgetting curve (`:176-181`) × project/file boosts (`:184-189`) × strength
54
+ (`:191-193`) × evidence (`:196-198`) × helpfulness prior (`:201-204`) ×
55
+ staleness penalty (`:206-212`). Then sorted by `TYPE_PRIORITY` then score,
56
+ sliced to top 5 (`:105-128`).
57
+
58
+ The weakest link is the **lexical layer** — steps 1 and the boost in step 2.
59
+ Everything else (decay, strength, evidence, project scoping) is worth keeping
60
+ exactly as-is; this change is surgical to the lexical component.
61
+
62
+ ## 3. Proposed design
63
+
64
+ ### 3.1 An external-content FTS5 table, kept in sync by triggers
65
+
66
+ Add one virtual table plus three triggers. **No change to the `memories` table
67
+ and no change to the TypeScript write path** — the triggers do the syncing in
68
+ SQL, so every writer (upsert, import, janitor, migrations) stays covered
69
+ automatically. That decoupling is the main reason to prefer this over a
70
+ `search_text` column maintained in `save()`.
71
+
72
+ ```sql
73
+ -- external-content FTS mirror of memories.value, keyed by memories.id
74
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
75
+ value,
76
+ content='memories',
77
+ content_rowid='id'
78
+ );
79
+
80
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
81
+ INSERT INTO memories_fts(rowid, value) VALUES (new.id, new.value);
82
+ END;
83
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
84
+ INSERT INTO memories_fts(memories_fts, rowid, value) VALUES('delete', old.id, old.value);
85
+ END;
86
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
87
+ INSERT INTO memories_fts(memories_fts, rowid, value) VALUES('delete', old.id, old.value);
88
+ INSERT INTO memories_fts(rowid, value) VALUES (new.id, new.value);
89
+ END;
90
+ ```
91
+
92
+ **v1 indexes the raw `value` JSON.** The default `unicode61` tokenizer splits on
93
+ braces/quotes/punctuation, so JSON structure mostly falls away; field-name tokens
94
+ (`what_failed`, `content`) are minor noise and can be dropped later by indexing a
95
+ derived plaintext projection (a refinement, not a v1 requirement).
96
+
97
+ ### 3.2 Candidate fetch via MATCH
98
+
99
+ Replace the `LIKE` branch in `searchByContext` with an FTS `MATCH` that returns
100
+ candidate ids **and** their BM25 rank, while preserving the existing scope
101
+ predicate (`project_id = ? OR scope = 'universal' OR project_id IS NULL`,
102
+ `storage.ts:680-684`) and type filter:
103
+
104
+ ```sql
105
+ SELECT m.*, bm25(memories_fts) AS bm25_rank
106
+ FROM memories_fts
107
+ JOIN memories m ON m.id = memories_fts.rowid
108
+ WHERE memories_fts MATCH ?
109
+ AND (m.project_id = ? OR m.scope = 'universal' OR m.project_id IS NULL)
110
+ ORDER BY bm25_rank; -- SQLite bm25 is negative; more-negative = better
111
+ ```
112
+
113
+ Empty/purely-stopword queries keep today's behavior (return all scoped rows, no
114
+ lexical filter) so `load_rules`-style "give me everything" calls are unaffected.
115
+
116
+ ### 3.3 Query sanitization (must-have, not optional)
117
+
118
+ Raw user keywords fed to `MATCH` are a **syntax hazard** — bare `AND`/`OR`/`NEAR`,
119
+ hyphens, quotes, and `*` are FTS5 operators and throw `SQLITE_ERROR` on malformed
120
+ input. Every term must be wrapped as a quoted phrase and OR-joined:
121
+
122
+ ```
123
+ "kaggle" OR "submission" OR "api"
124
+ ```
125
+
126
+ A bad query must **fall back to the LIKE path**, never crash retrieval.
127
+
128
+ ### 3.4 Score fusion
129
+
130
+ Compute a normalized `bm25Score ∈ [0,1]` per candidate (min-max across the
131
+ candidate set, Mem0-style) and **replace** the `includes()` boost at
132
+ `retrieval.ts:150-174` with it — leaving every other multiplicative term
133
+ untouched:
134
+
135
+ ```ts
136
+ // was: score *= 1 + matchRatio * 3.0 (+1.5 all-match / ×0.3 no-overlap)
137
+ score *= 1 + wLexical * bm25Score; // wLexical ~ 3.0 to preserve current dynamic range
138
+ ```
139
+
140
+ Keeping the fusion multiplicative means decay/strength/evidence/project boosts
141
+ behave identically — the only thing that changes is *how the lexical signal is
142
+ measured*. This bounds the blast radius to one term.
143
+
144
+ ## 4. Migration & backfill
145
+
146
+ In `migrateSchema()` (`storage.ts`, alongside the existing `CREATE INDEX IF NOT
147
+ EXISTS` migrations ~`:119-149`):
148
+
149
+ 1. Feature-detect FTS5 (see §5). If absent → skip everything, leave a flag off.
150
+ 2. Create the vtable + triggers (idempotent).
151
+ 3. Backfill once: `INSERT INTO memories_fts(rowid, value) SELECT id, value FROM memories`
152
+ guarded by "only if the FTS table is empty."
153
+
154
+ Backfill cost is a single scan — trivial at the 10k-row cap.
155
+
156
+ ## 5. Feature detection, fallback, rollback
157
+
158
+ - **Detect** at init by attempting `CREATE VIRTUAL TABLE … USING fts5` in a
159
+ try/catch (verified working in the bundled `better-sqlite3`, but self-built or
160
+ exotic SQLite may lack it). On failure, set `ftsAvailable = false` and use the
161
+ existing LIKE path everywhere. **Retrieval must work identically with FTS off.**
162
+ - **Rollback is safe and non-destructive:** the FTS table + triggers are derived,
163
+ redundant data. `DROP TRIGGER … ; DROP TABLE memories_fts;` reverts to LIKE with
164
+ zero risk to `memories`.
165
+
166
+ ## 6. Config / opt-in
167
+
168
+ `CLAUDE_RECALL_RETRIEVAL = fts | like`. Ship **`like` as default** first so the
169
+ change is inert on upgrade, flip to `fts` as default only after the benchmark
170
+ (§7) shows a win. This mirrors how AUTO_DEMOTE / the janitor shipped off-by-default.
171
+
172
+ ## 7. Benchmark tie-in (finding #4)
173
+
174
+ This change is the reason to stand up the LongMemEval-subset harness **first**:
175
+ without a before/after number we can't tell whether BM25 actually helps or just
176
+ adds surface area. Track both **recall/accuracy** and **tokens-per-query** (the
177
+ 2000-token `load_rules` budget is the natural denominator). The harness doubles as
178
+ the regression guard for the fusion weights.
179
+
180
+ ## 8. Risks / open questions
181
+
182
+ - **Tokenizing raw JSON** dilutes ranking with field-name tokens. Acceptable for
183
+ v1; the clean fix (derived plaintext column) means a write-path change.
184
+ - **`is_active` / superseded rows**: `searchByContext` currently returns inactive
185
+ rows too (no `is_active` predicate at `storage.ts:677`). Confirm FTS candidate
186
+ fetch matches whatever the intended active-set semantics are — don't silently
187
+ change them in this PR.
188
+ - **Fusion weight `wLexical`** needs tuning against the benchmark; the ×0.3
189
+ no-overlap penalty has no direct BM25 equivalent (non-matches simply aren't
190
+ returned by MATCH) — verify that doesn't over-promote weak matches.
191
+ - **WAL + triggers**: triggers run inside the same transaction as the write; the
192
+ existing `wal_checkpoint(TRUNCATE)` after writes is unaffected.
193
+
194
+ ## 9. Later phase (out of scope here)
195
+
196
+ Optional local embeddings (`sqlite-vec` or a small local model) for semantic
197
+ similarity, gated behind an opt-in flag so the default stays dependency-free —
198
+ fused as a third signal exactly like Mem0 (semantic + BM25 + entity). BM25 first;
199
+ it captures most of the paraphrase win at none of the cost.
200
+
201
+ ## 10. Suggested PR breakdown
202
+
203
+ 1. FTS vtable + triggers + migration + feature-detect + backfill (no retrieval
204
+ wiring yet — pure additive, inert).
205
+ 2. LongMemEval-subset benchmark harness (measure the `LIKE` baseline).
206
+ 3. Candidate fetch via MATCH + sanitization + fusion, behind `CLAUDE_RECALL_RETRIEVAL=fts`.
207
+ 4. Flip default to `fts` once the benchmark confirms the win.
@@ -0,0 +1,281 @@
1
+ # Design note: `memory_links` — a typed relationship edge for memories
2
+
3
+ **Status:** proposal — not yet approved for implementation
4
+ **Date:** 2026-08-17 · **Baseline:** v0.37.2
5
+ **Scope of this note:** one additive SQLite table that generalizes the existing
6
+ `superseded_by` edge into typed links between memories, plus an optional one-hop
7
+ expansion in retrieval. **No LLM, no new runtime dependency, no graph DB.**
8
+ Deliberately *not* a mem0-style knowledge graph — see
9
+ `docs/comparison-mem0.md` for why that is over-engineered for this corpus.
10
+
11
+ ---
12
+
13
+ ## 1. Goal
14
+
15
+ Recall already knows how memories relate to each other — but it throws almost all
16
+ of that structure away. Today the only persisted edge is `memories.superseded_by`
17
+ (one column, overloaded), and several relationships we *compute at write time*
18
+ (fuzzy near-duplicates, failure→fix pairings, promotion provenance) are either
19
+ collapsed into a single value blob or discarded after a boolean/sentinel is set.
20
+
21
+ The goal is a **single typed-edge table** that:
22
+ - generalizes the one existing edge (`supersedes`) into a small relation
23
+ vocabulary,
24
+ - **persists structure we already derive** rather than recomputing or losing it,
25
+ - enables a bounded **one-hop expansion** at retrieval ("show the current rule
26
+ *and* what it replaced"; "this failure *and* its fix"),
27
+
28
+ all with **zero new dependency**, additive and inert by default, and a
29
+ non-destructive rollback — the same discipline as the FTS5 note.
30
+
31
+ **Non-goals:** LLM triple extraction, entity-relationship modeling, multi-hop
32
+ traversal, a graph database backend. If a traversal consumer never materializes,
33
+ this table is cheap to drop.
34
+
35
+ ## 2. What relationships exist today, precisely
36
+
37
+ ### 2.1 The one real edge: `superseded_by`
38
+
39
+ The old row carries the edge: `MemoryStorage.markSuperseded`
40
+ (`src/memory/storage.ts:949-956`) sets `is_active=0, superseded_by=<winning key>,
41
+ superseded_at=<now>`. It is driven from two places:
42
+ - **Preference override** — `MemoryService.storePreferenceWithOverride`
43
+ (`src/services/memory.ts:407-451`) → `markSupersededPreferences`
44
+ (`memory.ts:479-497`), joining old→new rows by `preference_key`.
45
+ - **Fuzzy newest-wins / hygiene** — but here `superseded_by` is **overloaded**
46
+ with *sentinel strings* rather than a key: `'auto-demote'`
47
+ (`storage.ts:1029`), `'auto-dedup'` (`storage.ts:1198`), `'janitor'`
48
+ (`storage.ts:1062-1068`), enumerated as `REVIVABLE_SENTINELS`
49
+ (`storage.ts:316`). `promoteRule` (`storage.ts:1218-1228`) revives only
50
+ sentinel-superseded rows, refusing rows superseded by a real key.
51
+
52
+ So `superseded_by` conflates two meanings (a real old→new pointer vs. a
53
+ "demoted-by-process" tag) on one column. That overloading is exactly why a
54
+ *separate* typed-edge table is cleaner than adding more columns here.
55
+
56
+ ### 2.2 Relationships we compute but do not persist as edges
57
+
58
+ - **Fuzzy near-duplicate** — `findFuzzyDuplicate` (Jaccard ≥ 0.65,
59
+ `storage.ts:324-353`, used in `save()` at `:459-468`) and retroactive
60
+ `dedupSimilar` (`storage.ts:1121-1210`). `dedupSimilar` computes
61
+ `{winnerKey, loserKey, similarity}` per collapse **but returns it only** — the
62
+ loser is marked `superseded_by='auto-dedup'` and the graded similarity is lost.
63
+ - **Failure → fix pairing** — `tool-outcome-watcher.ts` pairs a later Bash
64
+ success to a pending failure (Jaccard ≥ 0.3 within a 5-min window,
65
+ `:276-309`) and **merges the fix into the failure's own value blob**
66
+ (`mergeIntoValue(pf.memoryKey, { what_should_do: 'Fix: …' })`, `:293-295`).
67
+ The pairing state lives in an **ephemeral session JSON file**
68
+ (`<sessionId>-failures.json`, `:49-74`), never a DB edge.
69
+ - **Promotion provenance** — `PromotionEngine.promote`
70
+ (`src/services/promotion-engine.ts:73-112`) mints a `promoted_<ts>_<rand>`
71
+ memory and writes its key back to `candidate_lessons.promoted_memory_key`
72
+ (`updateLessonStatus`, `src/services/outcome-storage.ts:180-185`). This is a
73
+ soft lesson→memory FK; the promoted memory has **no** column pointing to its
74
+ source evidence — only `evidence_count`/`source` embedded in its JSON value.
75
+
76
+ ### 2.3 The soft-FK neighborhood a links table would join
77
+
78
+ `outcome-storage.ts` already maintains several key/id references to
79
+ `memories.key` with no SQL `REFERENCES` constraint (all created in
80
+ `storage.ts:172-280`): `candidate_lessons.promoted_memory_key`,
81
+ `memory_stats.memory_key`, `rule_injection_events.rule_key`, plus
82
+ `*.episode_id → episodes.id`. A `memory_links` table would live beside these and
83
+ follow the same soft-FK convention.
84
+
85
+ **Content-hash is exact-equality only** (`computeContentHash`,
86
+ `storage.ts:292-305`; checked in `save()` at `:408-434`) — there is no existing
87
+ notion of *graded* similarity surviving anywhere. That is the gap §3.2 fills for
88
+ `refines`/`duplicate-of`.
89
+
90
+ ## 3. Proposed design
91
+
92
+ ### 3.1 The table
93
+
94
+ One table, alongside the outcome-storage tables, created in the same
95
+ `migrateSchema()` block:
96
+
97
+ ```sql
98
+ CREATE TABLE IF NOT EXISTS memory_links (
99
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
100
+ from_key TEXT NOT NULL, -- soft FK -> memories.key
101
+ to_key TEXT NOT NULL, -- soft FK -> memories.key
102
+ relation TEXT NOT NULL, -- see vocabulary below
103
+ strength REAL, -- e.g. Jaccard for refines/duplicate-of; NULL otherwise
104
+ source TEXT NOT NULL, -- which mechanism created it (override|dedup|fix-pairing|promotion)
105
+ created_at INTEGER NOT NULL,
106
+ UNIQUE(from_key, to_key, relation)
107
+ );
108
+
109
+ CREATE INDEX IF NOT EXISTS idx_memory_links_from ON memory_links(from_key);
110
+ CREATE INDEX IF NOT EXISTS idx_memory_links_to ON memory_links(to_key);
111
+ CREATE INDEX IF NOT EXISTS idx_memory_links_rel ON memory_links(relation);
112
+ ```
113
+
114
+ **Directed edges.** `from_key → to_key`. Read "`from` *relation* `to`":
115
+ `old supersedes-> new` is stored as `from_key=new, to_key=old, relation='supersedes'`
116
+ (the *new* memory supersedes the old), matching the natural query direction
117
+ ("given the memory I surfaced, what did it replace?").
118
+
119
+ **Relation vocabulary (v1, closed set — validate on insert):**
120
+
121
+ | relation | meaning | source signal (already computed) |
122
+ |---|---|---|
123
+ | `supersedes` | A replaces B (versioning) | preference override / real-key `markSuperseded` (§2.1) |
124
+ | `duplicate-of` | A collapsed B (near-identical) | `dedupSimilar` winner→loser, `strength`=Jaccard (§2.2) |
125
+ | `refines` | A is a fuzzy relative of B, kept | `findFuzzyDuplicate` near-miss below the collapse threshold |
126
+ | `fixed-by` | failure A resolved by B | fix pairing (§2.2) — **needs a target**, see §3.3 |
127
+ | `promoted-from` | lesson memory A promoted from source B | promotion provenance (§2.2) |
128
+
129
+ `contradicts` and `depends-on` are **deferred** — we have no non-LLM signal for
130
+ them today (see §7). Keep the set closed so retrieval can reason about it.
131
+
132
+ ### 3.2 Populating links — persist, don't recompute
133
+
134
+ Each link comes from a mechanism that **already runs**; we add a single insert
135
+ next to work already being done. No new scans, no LLM.
136
+
137
+ - **`supersedes`** — in `markSuperseded` (`storage.ts:949-956`), when
138
+ `supersededBy` is a **real key** (not a `REVIVABLE_SENTINELS` value), also
139
+ insert `(from=supersededBy, to=key, relation='supersedes', source='override')`.
140
+ Sentinel supersessions are skipped — they are process hygiene, not semantic
141
+ edges. This is the migration of the one existing edge onto the new table.
142
+ - **`duplicate-of`** — in `dedupSimilar` (`storage.ts:1121-1210`), where
143
+ `{winnerKey, loserKey, similarity}` is *already computed and currently
144
+ discarded*, insert `(from=winnerKey, to=loserKey, relation='duplicate-of',
145
+ strength=similarity, source='dedup')` before marking the loser.
146
+ - **`refines`** — in `findFuzzyDuplicate`'s caller in `save()`
147
+ (`storage.ts:459-468`): when a candidate is a near-match but *above* the
148
+ keep-threshold (not collapsed), record `refines` with the Jaccard strength
149
+ instead of throwing the score away.
150
+ - **`promoted-from`** — in `PromotionEngine.promote` (`promotion-engine.ts:110`),
151
+ right where it already calls `updateLessonStatus(candidateId, 'promoted',
152
+ key)`: insert `(from=key, to=<best source memory key>, relation='promoted-from',
153
+ source='promotion')`. Note the source is a *lesson/evidence* reference, so this
154
+ one may point at a `candidate_lessons.id` rather than a `memories.key`; see §7.
155
+ - **`fixed-by`** — see §3.3 (the only source that needs a shape change first).
156
+
157
+ All inserts are best-effort and wrapped so a link failure never blocks the
158
+ underlying write — links are an optimization, exactly like the FTS5 mirror.
159
+
160
+ ### 3.3 The `fixed-by` caveat
161
+
162
+ Today the fix is **merged into the failure's own value blob**
163
+ (`what_should_do`), so there is no second memory to point at — `from=failure,
164
+ to=fix` has no `to`. Two honest options:
165
+ 1. **v1: skip `fixed-by`.** The fix already lives in the failure memory; a
166
+ self-link adds nothing. Lowest effort, no behavior change.
167
+ 2. **later: stop merging, store the fix as its own `solution`-type memory** and
168
+ link `failure --fixed-by--> solution`. This is a capture-path change with real
169
+ value (the fix becomes independently retrievable) but is out of scope here and
170
+ should be its own proposal.
171
+
172
+ Recommend option 1 for v1: `fixed-by` stays in the vocabulary but is not emitted
173
+ until the capture side is reworked.
174
+
175
+ ### 3.4 Consuming links — optional one-hop expansion, off the hot path
176
+
177
+ Retrieval stays exactly as it is (`src/core/retrieval.ts` — keyword × decay ×
178
+ strength × evidence × …, top-5). Links are consumed **after** ranking, not inside
179
+ the scoring loop, so the hot path is untouched:
180
+
181
+ ```ts
182
+ // after findRelevant() returns the top-5, before formatting for load_rules:
183
+ for (const m of top) {
184
+ m.related = storage.getLinks(m.key, ['supersedes', 'refines']); // one indexed lookup
185
+ }
186
+ ```
187
+
188
+ The MCP `load_rules` / rule-injector formatter can then optionally append
189
+ "↳ replaces: <old snippet>" under a surfaced rule. This is **presentation-layer
190
+ enrichment**, gated behind a flag (§5), and never changes ranking or the top-5
191
+ selection — bounding the blast radius to the formatter.
192
+
193
+ ## 4. Migration & backfill
194
+
195
+ In `migrateSchema()` (`storage.ts`, alongside the outcome-storage table
196
+ migrations `:172-280`):
197
+
198
+ 1. Create `memory_links` + indexes (idempotent).
199
+ 2. **Backfill `supersedes` once** from existing data:
200
+ `INSERT OR IGNORE INTO memory_links(from_key, to_key, relation, source, created_at)
201
+ SELECT superseded_by, key, 'supersedes', 'override', superseded_at
202
+ FROM memories
203
+ WHERE superseded_by IS NOT NULL
204
+ AND superseded_by NOT IN ('auto-demote','auto-dedup','janitor')`
205
+ — i.e. only real-key supersessions, sentinels excluded. Guard with "only if
206
+ `memory_links` is empty." Trivial at the 10k-row cap.
207
+ 3. No backfill is possible for `duplicate-of`/`refines`/`promoted-from` (the
208
+ graded signal was never stored) — those accrue going forward.
209
+
210
+ ## 5. Feature detection, config, rollback
211
+
212
+ - **Opt-in:** `CLAUDE_RECALL_MEMORY_LINKS = off | write | expand`.
213
+ `off` (default on upgrade) = no inserts, no reads — fully inert.
214
+ `write` = populate links but don't consume them (lets the table accrue data
215
+ and be inspected via CLI before trusting it in retrieval).
216
+ `expand` = also do the §3.4 one-hop enrichment.
217
+ Ships **`off` by default**, mirroring how the janitor / AUTO_DEMOTE / the
218
+ proposed FTS5 flag shipped.
219
+ - **Rollback is non-destructive:** the table is derived, redundant data.
220
+ `DROP TABLE memory_links;` reverts with zero risk to `memories` — the
221
+ authoritative `superseded_by` column is untouched (we mirror it, never replace
222
+ it).
223
+ - **Retention:** add `memory_links` to `pruneOldData`
224
+ (`outcome-storage.ts:391-417`) — delete edges whose `from_key`/`to_key` no
225
+ longer exist in `memories` (orphan sweep), same pattern as the `memory_stats`
226
+ orphan cleanup (`:408-410`).
227
+
228
+ ## 6. Why this is worth doing (and where it stops)
229
+
230
+ - It **captures signal already computed and currently discarded**
231
+ (`dedupSimilar` similarity, `findFuzzyDuplicate` near-misses) — near-zero
232
+ marginal cost, since the work already happens.
233
+ - It **de-overloads `superseded_by`** by giving semantic edges a typed home,
234
+ without a destructive migration.
235
+ - It delivers the concretely useful query — "current rule + what it replaced",
236
+ "promoted lesson + its provenance" — that mem0's graph is credited for, at a
237
+ fraction of the cost and with **no LLM and no new dependency**.
238
+
239
+ It stops short of a knowledge graph on purpose: no entity extraction, no
240
+ multi-hop, no `contradicts`/`depends-on` until a non-LLM signal exists. If the
241
+ one-hop expansion proves unused, the table is dropped and nothing else changes.
242
+
243
+ ## 7. Risks / open questions
244
+
245
+ - **`superseded_by` overloading** must be respected in the `supersedes` emitter
246
+ and backfill — never mirror a sentinel value as a semantic edge. The
247
+ `REVIVABLE_SENTINELS` list (`storage.ts:316`) is the single source of truth for
248
+ the exclusion.
249
+ - **`promoted-from` target type.** Promotion provenance points at a
250
+ *candidate_lesson*/evidence, not always a `memories.key`. Either (a) point
251
+ `to_key` at the source memory when one exists, or (b) give the table a
252
+ nullable `to_kind` discriminator. Recommend deferring `promoted-from` to a
253
+ second pass and shipping v1 with just `supersedes` + `duplicate-of` + `refines`.
254
+ - **Edge staleness.** A `to_key` can be pruned/superseded after the link is
255
+ written; the §5 orphan sweep handles deletion, but a link to a now-*inactive*
256
+ memory should be filtered at read time (join `is_active` in `getLinks`).
257
+ - **No demand yet.** The rule-injector asks single-hop, relevance-ranked
258
+ questions; until `load_rules`/injector formatting actually renders related
259
+ edges, the `expand` mode has no reader. Ship `write` mode first, inspect the
260
+ accrued edges via a CLI (`claude-recall links <key>`), and only wire `expand`
261
+ once the data looks useful — same "measure before flipping the default"
262
+ discipline as the FTS5 benchmark.
263
+ - **Directionality bugs.** `supersedes` is stored new→old; the backfill maps
264
+ `superseded_by`(=winner) → `from_key` and `key`(=loser) → `to_key`. Get this
265
+ wrong and expansion shows the *old* text as current. One focused unit test on
266
+ the backfill direction is mandatory.
267
+
268
+ ## 8. Suggested PR breakdown
269
+
270
+ 1. `memory_links` table + indexes + migration + backfill of real-key
271
+ `supersedes` + orphan sweep in `pruneOldData`. Pure additive, inert
272
+ (`MEMORY_LINKS=off`). Includes the backfill-direction unit test.
273
+ 2. Emit `supersedes` (real-key branch of `markSuperseded`) and `duplicate-of` /
274
+ `refines` (from `dedupSimilar` / `findFuzzyDuplicate`), behind
275
+ `MEMORY_LINKS=write`. + `getLinks()` accessor and a `claude-recall links <key>`
276
+ CLI for inspection.
277
+ 3. One-hop `expand` enrichment in the `load_rules` / rule-injector formatter,
278
+ behind `MEMORY_LINKS=expand`. Presentation only; no ranking change.
279
+ 4. (separate proposal) Rework failure→fix capture to store the fix as its own
280
+ `solution` memory and emit `fixed-by`; add `promoted-from` with a resolved
281
+ target. Only after 1–3 prove the model useful.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-recall",
3
- "version": "0.37.2",
3
+ "version": "0.38.0",
4
4
  "description": "Persistent memory for Claude Code and Pi with native Skills integration, automatic capture, failure learning, and project scoping",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -41,6 +41,7 @@
41
41
  "test:coverage:unit": "jest --coverage --maxWorkers=50% tests/unit",
42
42
  "test:coverage:integration": "jest --coverage --maxWorkers=50% tests/integration",
43
43
  "test:benchmarks": "jest tests/benchmarks",
44
+ "bench:retrieval": "ts-node tests/benchmarks/retrieval-benchmark.ts",
44
45
  "test:experimental": "jest tests/experimental",
45
46
  "build": "tsc && mkdir -p dist/memory dist/config && cp src/memory/schema.sql dist/memory/ && (cp -r src/config/* dist/config/ 2>/dev/null || true) && chmod +x dist/cli/claude-recall-cli.js",
46
47
  "build:cli": "tsc && chmod +x dist/cli/claude-recall-cli.js",