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 +1 -0
- package/dist/core/retrieval.js +18 -2
- package/dist/memory/storage.js +146 -0
- package/docs/comparison-mem0.md +182 -0
- package/docs/design-hybrid-retrieval-fts5.md +207 -0
- package/docs/design-memory-links.md +281 -0
- package/package.json +2 -1
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
|
|
package/dist/core/retrieval.js
CHANGED
|
@@ -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
|
-
//
|
|
106
|
-
|
|
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
|
package/dist/memory/storage.js
CHANGED
|
@@ -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.
|
|
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",
|