claude-recall 0.36.1 → 0.37.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
@@ -191,6 +191,7 @@ Once installed, Claude Recall works in the background (CC = Claude Code):
191
191
  | **Sub-agent spawned** | Rules are injected into the sub-agent; its outcome is captured | ✓ | | |
192
192
  | **Session exit** | An auto-checkpoint (`{completed, remaining, blockers}`) is saved for next time | ✓ | ✓ | |
193
193
  | **End of session** | Failure patterns become candidate lessons; validated ones are promoted to rules | ✓ | ✓ | |
194
+ | **Hard-won success** | A goal that failed repeatedly then finally worked is captured as a reusable `solution` ([details](#success-capture)) | ✓ | ✓ | |
194
195
  | **Once a day** | The memory janitor reviews stored rules with the runtime's LLM: demotes noise, merges duplicates, rewrites vague rules ([details](#memory-janitor)) | ✓ | | ✓ |
195
196
 
196
197
  Classification runs on each runtime's **own** LLM — Claude Code via headless `claude -p` on your subscription; Kiro via `kiro-cli chat --no-interactive` on Kiro credits — with regex as the fallback. **An exported `ANTHROPIC_API_KEY` is never touched** unless you explicitly opt in with `CLAUDE_RECALL_PREFER_API_KEY=1`. No API key is ever required; no configuration needed.
@@ -277,7 +278,7 @@ claude-recall stats # Memory statistics (--global for all p
277
278
  claude-recall list # List memories, newest first (-t <type>, --all, --json, --global)
278
279
  claude-recall search "query" # Search memories (--global, --json, --project <id>)
279
280
  claude-recall store "content" # Store memory directly
280
- claude-recall store "content" -t <type> # Type: preference, correction, failure, devops, project-knowledge
281
+ claude-recall store "content" -t <type> # Type: preference, correction, failure, devops, project-knowledge, solution
281
282
  claude-recall export backup.json # Export current project (--global for all)
282
283
  claude-recall import backup.json # Import memories from JSON
283
284
  claude-recall delete <key> # Delete one memory by key (get keys from `search`)
@@ -353,6 +354,15 @@ action → outcome event → episode → candidate lesson → promotion → acti
353
354
 
354
355
  Failures become candidate lessons (deduplicated by similarity); lessons seen 2+ times (or once, if severe) are promoted to active rules; every just-in-time injection (Claude Code, Pi) is recorded and resolved against the tool's outcome, building per-rule effectiveness data over time.
355
356
 
357
+ ### Success capture
358
+
359
+ Auto-capture is failure-biased by design — it learns from what breaks. But a hard-won *success* is just as reusable: the command, flag, or sequence you finally landed after several dead ends. Claude Recall captures those as a first-class `solution` memory, two ways:
360
+
361
+ - **Automatically**, when a session shows a goal that failed **repeatedly** (≥2 distinct failed attempts) and then finally worked — it stores the reusable technique, generalized away from the one-off task. The multi-failure gate is deliberate: a first-try success or an unresolved struggle captures nothing, so routine wins don't become noise.
362
+ - **Deliberately**, when you (or the agent) call `store_memory` with `type: "solution"` — the intended home for "I cracked this, don't make me re-derive it."
363
+
364
+ A solution is active immediately (no wait for a second occurrence — you rarely crack the same hard thing twice), injected at every surface alongside your other rules, and ranked just below explicit corrections. List them with `claude-recall list --type solution`. Unlike other rules, solutions are exempt from the never-cited auto-demote sweep, so a rarely-needed-but-valuable win isn't retired.
365
+
356
366
  ### Memory janitor
357
367
 
358
368
  Automatic capture inevitably stores some noise — a conversational fragment misfiled as a preference, the same lesson in five wordings, a rule too vague to act on. Counters can flag *unused* rules (`CLAUDE_RECALL_AUTO_DEMOTE`), but they can't tell a rarely-cited gem from junk. Once a day, the **memory janitor** has the runtime's own LLM (same backend policy as capture — your Claude subscription or Kiro credits, never an API key unless you opted in) review the stored rules and:
@@ -795,22 +795,44 @@ class ClaudeRecallCLI {
795
795
  }
796
796
  /**
797
797
  * Pull the human-readable text out of a memory value, whether it's a raw
798
- * string, a JSON string, or a structured object with a content/value field.
798
+ * string, a JSON string, or a structured object. Failure memories nest their
799
+ * payload as an OBJECT under `content` ({ what_failed, why_failed, ... }), so
800
+ * a naive `String(value.content)` renders "[object Object]" — unwrap nested
801
+ * content/value/text wrappers and surface the failure gist instead.
799
802
  */
800
803
  extractContent(value) {
801
804
  let v = value;
802
- if (typeof v === 'string') {
803
- try {
804
- v = JSON.parse(v);
805
+ // Descend through nested wrappers. The payload can be an object, a JSON
806
+ // string, or an object whose content/value/text is itself a JSON string
807
+ // (failure memories come in both shapes) — so parse-as-we-go, bounded.
808
+ for (let depth = 0; depth < 6; depth++) {
809
+ if (typeof v === 'string') {
810
+ const t = v.trim();
811
+ if (t.startsWith('{') || t.startsWith('[')) {
812
+ try {
813
+ v = JSON.parse(t);
814
+ continue;
815
+ }
816
+ catch {
817
+ return v;
818
+ }
819
+ }
820
+ return v;
805
821
  }
806
- catch {
807
- return value;
822
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
823
+ // Failure-shaped object: show what broke → what to do instead.
824
+ if (typeof v.what_failed === 'string') {
825
+ return v.what_should_do ? `${v.what_failed} → ${v.what_should_do}` : v.what_failed;
826
+ }
827
+ const next = v.content ?? v.value ?? v.text;
828
+ if (next === undefined)
829
+ return JSON.stringify(v);
830
+ v = next;
831
+ continue;
808
832
  }
833
+ break;
809
834
  }
810
- if (v && typeof v === 'object') {
811
- return String(v.content ?? v.value ?? v.text ?? JSON.stringify(v));
812
- }
813
- return String(v);
835
+ return typeof v === 'string' ? v : JSON.stringify(v);
814
836
  }
815
837
  /**
816
838
  * Export memories to a file
@@ -450,7 +450,7 @@ class KiroCommands {
450
450
  : `project: ${projectId} (from working directory)`);
451
451
  line('•', `total memories (all projects): ${stats.total}`);
452
452
  const rules = ms.loadActiveRules(projectId);
453
- const ruleCount = rules.preferences.length + rules.corrections.length + rules.failures.length + rules.devops.length;
453
+ const ruleCount = rules.preferences.length + rules.corrections.length + rules.failures.length + rules.devops.length + (rules.solutions ?? []).length;
454
454
  line(ruleCount > 0 ? '✓' : '•', `active rules for this project: ${ruleCount}`);
455
455
  }
456
456
  catch (err) {
@@ -270,6 +270,7 @@ class MemoryRetrieval {
270
270
  exports.MemoryRetrieval = MemoryRetrieval;
271
271
  MemoryRetrieval.TYPE_PRIORITY = {
272
272
  'correction': 6,
273
+ 'solution': 5.5, // hard-won reusable solutions — high signal, rank just below corrections
273
274
  'project-knowledge': 5,
274
275
  'preference': 4,
275
276
  'devops': 3,
@@ -125,11 +125,12 @@ function formatRulesForContext() {
125
125
  const sections = [
126
126
  section('Preferences', rules.preferences),
127
127
  section('Corrections', rules.corrections),
128
+ section('Solutions (hard-won — reuse these)', rules.solutions ?? []),
128
129
  section('Failures', rules.failures),
129
130
  section('DevOps Rules', rules.devops),
130
131
  ].filter((s) => s !== null);
131
132
  const total = rules.preferences.length + rules.corrections.length
132
- + rules.failures.length + rules.devops.length;
133
+ + rules.failures.length + rules.devops.length + (rules.solutions ?? []).length;
133
134
  return { body: sections.join('\n\n'), total };
134
135
  }
135
136
  /**
@@ -257,6 +257,11 @@ const SESSION_EXTRACTION_PROMPT = `You are analyzing a coding session transcript
257
257
 
258
258
  The transcript shows tool calls (Bash, Edit, Read, Grep, etc.) and their results, plus user and assistant messages. Your primary job is to identify CAUSE-AND-EFFECT patterns — what failed, why, and what fixed it.
259
259
 
260
+ PRIORITY 0 — Hard-won solutions (type "solution"):
261
+ The MOST valuable thing to capture. Look for a goal the agent attempted, FAILED at REPEATEDLY (two or more distinct failed attempts, errors, wrong approaches, or retries on the same objective), and then FINALLY got working. Capture the reusable technique that cracked it — the command, config, flag, sequence, or approach that worked — phrased so a FUTURE session facing the same goal can apply it directly.
262
+ STRICT gate: only emit a "solution" when there is clear evidence of (a) multiple failed attempts AND (b) an eventual success on that same goal. A task that worked first try, or that never resolved, is NOT a solution — skip it.
263
+ Generalize away the one-off specifics (this repo's file names, this task's data) but KEEP the reusable mechanism. Example: after several failed attempts, "To submit to a Kaggle competition programmatically, use \`kaggle competitions submit -c <slug> -f <file> -m <msg>\` after \`kaggle config set -n competition -v <slug>\` — the web-form flow can't be scripted."
264
+
260
265
  PRIORITY 1 — Failure → Fix sequences:
261
266
  Look for tool calls that failed (errors, timeouts, non-zero exits) followed by a different approach that succeeded. Extract the lesson as an imperative rule.
262
267
  Examples:
@@ -280,7 +285,7 @@ Do NOT extract:
280
285
  - Anything in the EXISTING MEMORIES list below
281
286
 
282
287
  Respond with ONLY valid JSON (no markdown fences):
283
- [{"type":"project-knowledge|preference|devops|failure","content":"<imperative statement>","confidence":0.0-1.0}]
288
+ [{"type":"solution|project-knowledge|preference|devops|failure","content":"<imperative statement>","confidence":0.0-1.0}]
284
289
 
285
290
  Return [] if nothing durable was learned. Max 10 items. Each content should be a concise, actionable rule (e.g. "Pipe 'y' to scripts/upgrade-sandbox.sh — it has an interactive confirmation prompt").`;
286
291
  /**
@@ -299,7 +304,7 @@ async function extractSessionLearningsWithLLM(summary, existingMemories) {
299
304
  const results = parseJSON(text);
300
305
  if (!Array.isArray(results))
301
306
  return null;
302
- const validTypes = ['project-knowledge', 'preference', 'devops', 'failure'];
307
+ const validTypes = ['project-knowledge', 'preference', 'devops', 'failure', 'solution'];
303
308
  return results
304
309
  .filter((r) => r && validTypes.includes(r.type) && typeof r.content === 'string' && r.content.length > 5)
305
310
  .map((r) => ({
@@ -31,6 +31,9 @@ function formatRules(rules) {
31
31
  if (rules.corrections.length > 0) {
32
32
  sections.push('## Corrections\n' + rules.corrections.map(m => `- ${extractVal(m.value)}`).join('\n'));
33
33
  }
34
+ if ((rules.solutions ?? []).length > 0) {
35
+ sections.push('## Solutions (hard-won — reuse these)\n' + rules.solutions.map(m => `- ${extractVal(m.value)}`).join('\n'));
36
+ }
34
37
  if (rules.failures.length > 0) {
35
38
  sections.push('## Failures\n' + rules.failures.map(m => `- ${extractVal(m.value)}`).join('\n'));
36
39
  }
@@ -44,7 +47,7 @@ async function handlePostCompactReload(_input) {
44
47
  const projectId = config_1.ConfigService.getInstance().getProjectId();
45
48
  const rules = memory_1.MemoryService.getInstance().loadActiveRules(projectId);
46
49
  const totalRules = rules.preferences.length + rules.corrections.length +
47
- rules.failures.length + rules.devops.length;
50
+ rules.failures.length + rules.devops.length + (rules.solutions ?? []).length;
48
51
  if (totalRules === 0)
49
52
  return;
50
53
  const body = formatRules(rules);
@@ -108,6 +108,7 @@ async function computeInjection(toolName, toolInput, toolUseId) {
108
108
  ...activeRules.corrections,
109
109
  ...activeRules.failures,
110
110
  ...activeRules.devops,
111
+ ...(activeRules.solutions ?? []),
111
112
  ].map(m => ({
112
113
  key: m.key,
113
114
  type: m.type,
@@ -34,6 +34,9 @@ function formatRulesCompact(rules) {
34
34
  if (rules.corrections.length > 0) {
35
35
  sections.push('Corrections:\n' + rules.corrections.map(m => `- ${extractVal(m.value)}`).join('\n'));
36
36
  }
37
+ if ((rules.solutions ?? []).length > 0) {
38
+ sections.push('Solutions (hard-won — reuse these):\n' + rules.solutions.map(m => `- ${extractVal(m.value)}`).join('\n'));
39
+ }
37
40
  if (rules.failures.length > 0) {
38
41
  sections.push('Failures:\n' + rules.failures.map(m => `- ${extractVal(m.value)}`).join('\n'));
39
42
  }
@@ -52,7 +55,7 @@ async function handleSubagentStart(input) {
52
55
  const projectId = config_1.ConfigService.getInstance().getProjectId();
53
56
  const rules = memory_1.MemoryService.getInstance().loadActiveRules(projectId);
54
57
  const totalRules = rules.preferences.length + rules.corrections.length +
55
- rules.failures.length + rules.devops.length;
58
+ rules.failures.length + rules.devops.length + (rules.solutions ?? []).length;
56
59
  if (totalRules === 0)
57
60
  return;
58
61
  const body = formatRulesCompact(rules);
@@ -108,7 +108,7 @@ class MemoryTools {
108
108
  },
109
109
  {
110
110
  name: 'store_memory',
111
- description: 'Store a rule or learning. Use for: corrections, preferences, devops rules, failures. The stored rule is immediately active in this conversation.',
111
+ description: 'Store a rule or learning. Use for: corrections, preferences, devops rules, failures, and solutions. IMPORTANT: when you crack a hard problem after real trial-and-error (a working command, config, or sequence you had to discover), store it with type "solution" — auto-capture only learns from failures, never from your wins, so a hard-won success is lost unless you save it here. The stored rule is immediately active in this conversation.',
112
112
  inputSchema: {
113
113
  type: 'object',
114
114
  properties: {
@@ -118,7 +118,7 @@ class MemoryTools {
118
118
  },
119
119
  metadata: {
120
120
  type: 'object',
121
- description: 'Optional metadata. Set "type" to one of: preference, correction, devops, failure'
121
+ description: 'Optional metadata. Set "type" to one of: preference, correction, devops, failure, solution (use "solution" for a hard-won working fix you discovered through trial-and-error)'
122
122
  },
123
123
  scope: {
124
124
  type: 'string',
@@ -146,7 +146,7 @@ class MemoryTools {
146
146
  },
147
147
  type: {
148
148
  type: 'string',
149
- description: 'Filter by memory type: preference, correction, devops, failure, project-knowledge'
149
+ description: 'Filter by memory type: preference, correction, devops, failure, project-knowledge, solution'
150
150
  },
151
151
  projectId: {
152
152
  type: 'string',
@@ -221,7 +221,7 @@ class MemoryTools {
221
221
  throw new Error('Content is required and must be a string');
222
222
  }
223
223
  // Use metadata.type as the memory type so it appears in future load_rules calls
224
- const validTypes = ['preference', 'correction', 'devops', 'failure', 'project-knowledge', 'tool-use'];
224
+ const validTypes = ['preference', 'correction', 'devops', 'failure', 'project-knowledge', 'solution', 'tool-use'];
225
225
  const detectedType = (metadata?.type && validTypes.includes(metadata.type))
226
226
  ? metadata.type
227
227
  : 'preference';
@@ -336,8 +336,9 @@ class MemoryTools {
336
336
  }
337
337
  return kept;
338
338
  };
339
- // Allocate in priority order (corrections first to protect high-signal items).
339
+ // Allocate in priority order (corrections + solutions first to protect high-signal items).
340
340
  const keptCorrections = takeBounded(rules.corrections);
341
+ const keptSolutions = takeBounded([...(rules.solutions ?? [])].sort(byCiteThenFresh));
341
342
  const keptPreferences = takeBounded([...rules.preferences].sort(byCiteThenFresh));
342
343
  const keptDevops = takeBounded([...rules.devops].sort(byCiteThenFresh));
343
344
  const keptFailures = takeBounded(rules.failures, 3);
@@ -376,6 +377,12 @@ class MemoryTools {
376
377
  }).join('\n'));
377
378
  }
378
379
  }
380
+ if (keptSolutions.length > 0) {
381
+ sections.push('## Solutions (hard-won — reuse these before re-deriving)\n' + keptSolutions.map(m => {
382
+ const val = formatRuleValue(m.value) + precisionNudge(m.value);
383
+ return `- ${val}`;
384
+ }).join('\n'));
385
+ }
379
386
  if (keptDevops.length > 0) {
380
387
  sections.push('## DevOps Rules\n' + keptDevops.map(m => {
381
388
  const val = formatRuleValue(m.value) + precisionNudge(m.value);
@@ -388,8 +395,8 @@ class MemoryTools {
388
395
  sections.push(`*${droppedCount} more rules available via \`search_memory\`. Run \`npx claude-recall outcomes\` for full stats.*`);
389
396
  }
390
397
  const totalRules = keptPreferences.length + keptCorrections.length +
391
- keptFailures.length + keptDevops.length;
392
- const keptAll = [...keptPreferences, ...keptCorrections, ...keptFailures, ...keptDevops];
398
+ keptFailures.length + keptDevops.length + keptSolutions.length;
399
+ const keptAll = [...keptPreferences, ...keptCorrections, ...keptFailures, ...keptDevops, ...keptSolutions];
393
400
  const resultTokens = this.estimateTokens(keptAll);
394
401
  // Record to SearchMonitor so monitoring/stats still work
395
402
  this.searchMonitor.recordSearch('load_rules', totalRules, context.sessionId, 'mcp', { tool: 'load_rules', tokenMetrics: { resultTokens, tokensSaved: totalRules > 0 ? totalRules * 200 : 0 } });
@@ -436,6 +443,7 @@ class MemoryTools {
436
443
  corrections: keptCorrections.length,
437
444
  failures: keptFailures.length,
438
445
  devops: keptDevops.length,
446
+ solutions: keptSolutions.length,
439
447
  total: totalRules,
440
448
  dropped: droppedCount,
441
449
  },
@@ -1060,7 +1060,7 @@ class MemoryStorage {
1060
1060
  }
1061
1061
  exports.MemoryStorage = MemoryStorage;
1062
1062
  /** Rule-type memories: the only types subject to fuzzy dedup and retro-dedup. */
1063
- MemoryStorage.RULE_TYPES = ['preference', 'correction', 'failure', 'devops', 'project-knowledge'];
1063
+ MemoryStorage.RULE_TYPES = ['preference', 'correction', 'failure', 'devops', 'project-knowledge', 'solution'];
1064
1064
  /**
1065
1065
  * Supersession sentinels written by automatic hygiene passes (as opposed to
1066
1066
  * a USER preference override, where superseded_by names the winning key).
@@ -71,6 +71,9 @@ function formatRules(rules) {
71
71
  if (rules.corrections.length > 0) {
72
72
  sections.push('## Corrections\n' + rules.corrections.map(m => `- ${extractVal(m.value)}`).join('\n'));
73
73
  }
74
+ if ((rules.solutions ?? []).length > 0) {
75
+ sections.push('## Solutions (hard-won — reuse these)\n' + rules.solutions.map(m => `- ${extractVal(m.value)}`).join('\n'));
76
+ }
74
77
  if (rules.failures.length > 0) {
75
78
  sections.push('## Failures\n' + rules.failures.map(m => `- ${extractVal(m.value)}`).join('\n'));
76
79
  }
@@ -456,11 +456,18 @@ class MemoryService {
456
456
  .slice(0, 5);
457
457
  // DevOps: all active rules
458
458
  const devops = this.storage.searchByContext({ ...searchContext, type: 'devops' }).filter(isActive);
459
+ // Solutions: hard-won reusable resolutions, top 5 by timestamp
460
+ const allSolutions = this.storage.searchByContext({ ...searchContext, type: 'solution' });
461
+ const solutions = allSolutions
462
+ .filter(isActive)
463
+ .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
464
+ .slice(0, 5);
459
465
  const counts = [
460
466
  preferences.length && `${preferences.length} preferences`,
461
467
  corrections.length && `${corrections.length} corrections`,
462
468
  failures.length && `${failures.length} failures`,
463
469
  devops.length && `${devops.length} devops rules`,
470
+ solutions.length && `${solutions.length} solutions`,
464
471
  ].filter(Boolean);
465
472
  const summary = counts.length > 0
466
473
  ? `Loaded ${counts.join(', ')}`
@@ -468,16 +475,16 @@ class MemoryService {
468
475
  this.logger.info('MemoryService', summary, { projectId: pid });
469
476
  // Increment load_count for all returned rules
470
477
  const allIds = [
471
- ...preferences, ...corrections, ...failures, ...devops
478
+ ...preferences, ...corrections, ...failures, ...devops, ...solutions
472
479
  ].map(m => m.id).filter((id) => id !== undefined);
473
480
  if (allIds.length > 0) {
474
481
  this.storage.incrementLoadCounts(allIds);
475
482
  }
476
- return { preferences, corrections, failures, devops, summary };
483
+ return { preferences, corrections, failures, devops, solutions, summary };
477
484
  }
478
485
  catch (error) {
479
486
  this.logger.logServiceError('MemoryService', 'loadActiveRules', error);
480
- return { preferences: [], corrections: [], failures: [], devops: [], summary: 'Error loading rules' };
487
+ return { preferences: [], corrections: [], failures: [], devops: [], solutions: [], summary: 'Error loading rules' };
481
488
  }
482
489
  }
483
490
  /**
@@ -440,7 +440,7 @@ async function extractSessionLearnings(entries, sessionId, projectId, maxStore =
440
440
  try {
441
441
  const ms = memory_1.MemoryService.getInstance();
442
442
  const rules = ms.loadActiveRules(projectId);
443
- const all = [...rules.preferences, ...rules.corrections, ...rules.failures, ...rules.devops];
443
+ const all = [...rules.preferences, ...rules.corrections, ...rules.failures, ...rules.devops, ...(rules.solutions ?? [])];
444
444
  for (const m of all.slice(0, 20)) {
445
445
  const val = typeof m.value === 'object' ? (m.value?.content || JSON.stringify(m.value)) : String(m.value);
446
446
  existingMemories.push(truncate(val, 80));
@@ -0,0 +1,185 @@
1
+ # Design note: FTS5 / BM25 hybrid retrieval
2
+
3
+ **Status:** proposal — not yet approved for implementation
4
+ **Date:** 2026-07-22 · **Baseline:** v0.36.2
5
+ **Scope of this note:** lexical BM25 ranking via SQLite FTS5. Local embeddings /
6
+ semantic similarity are a deliberately-separate later phase (§9).
7
+
8
+ ---
9
+
10
+ ## 1. Goal
11
+
12
+ Replace the crude `LIKE '%kw%'` candidate filter + `includes()` keyword boost
13
+ with real **BM25 ranking** from SQLite's built-in FTS5 — improving recall for
14
+ paraphrase and ranking quality as the corpus grows, **without adding a single
15
+ dependency and without giving up the local-only / offline promise.**
16
+
17
+ Non-goals: embeddings, vector search, entity graphs (see §9).
18
+
19
+ ## 2. Current retrieval, precisely
20
+
21
+ Two stages, both in-process:
22
+
23
+ 1. **Candidate fetch** — `MemoryStorage.searchByContext()` (`src/memory/storage.ts:666`).
24
+ When keywords are present it hard-filters rows with `value LIKE ?` per keyword
25
+ (`storage.ts:698-713`): ≥3 keywords → require ≥2 matches; <3 → match any. The
26
+ match is against the **raw JSON string of the `value` column** — so it matches
27
+ field names as well as content, and misses any paraphrase.
28
+ 2. **Re-rank** — `MemoryRetrieval.calculateRelevance()` (`src/core/retrieval.ts:141`).
29
+ A multiplicative pipeline: base `relevance_score` × keyword boost
30
+ (`retrieval.ts:150-174`, up to ~6× via `String.includes`) × time-decay
31
+ forgetting curve (`:176-181`) × project/file boosts (`:184-189`) × strength
32
+ (`:191-193`) × evidence (`:196-198`) × helpfulness prior (`:201-204`) ×
33
+ staleness penalty (`:206-212`). Then sorted by `TYPE_PRIORITY` then score,
34
+ sliced to top 5 (`:105-128`).
35
+
36
+ The weakest link is the **lexical layer** — steps 1 and the boost in step 2.
37
+ Everything else (decay, strength, evidence, project scoping) is worth keeping
38
+ exactly as-is; this change is surgical to the lexical component.
39
+
40
+ ## 3. Proposed design
41
+
42
+ ### 3.1 An external-content FTS5 table, kept in sync by triggers
43
+
44
+ Add one virtual table plus three triggers. **No change to the `memories` table
45
+ and no change to the TypeScript write path** — the triggers do the syncing in
46
+ SQL, so every writer (upsert, import, janitor, migrations) stays covered
47
+ automatically. That decoupling is the main reason to prefer this over a
48
+ `search_text` column maintained in `save()`.
49
+
50
+ ```sql
51
+ -- external-content FTS mirror of memories.value, keyed by memories.id
52
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
53
+ value,
54
+ content='memories',
55
+ content_rowid='id'
56
+ );
57
+
58
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
59
+ INSERT INTO memories_fts(rowid, value) VALUES (new.id, new.value);
60
+ END;
61
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
62
+ INSERT INTO memories_fts(memories_fts, rowid, value) VALUES('delete', old.id, old.value);
63
+ END;
64
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
65
+ INSERT INTO memories_fts(memories_fts, rowid, value) VALUES('delete', old.id, old.value);
66
+ INSERT INTO memories_fts(rowid, value) VALUES (new.id, new.value);
67
+ END;
68
+ ```
69
+
70
+ **v1 indexes the raw `value` JSON.** The default `unicode61` tokenizer splits on
71
+ braces/quotes/punctuation, so JSON structure mostly falls away; field-name tokens
72
+ (`what_failed`, `content`) are minor noise and can be dropped later by indexing a
73
+ derived plaintext projection (a refinement, not a v1 requirement).
74
+
75
+ ### 3.2 Candidate fetch via MATCH
76
+
77
+ Replace the `LIKE` branch in `searchByContext` with an FTS `MATCH` that returns
78
+ candidate ids **and** their BM25 rank, while preserving the existing scope
79
+ predicate (`project_id = ? OR scope = 'universal' OR project_id IS NULL`,
80
+ `storage.ts:680-684`) and type filter:
81
+
82
+ ```sql
83
+ SELECT m.*, bm25(memories_fts) AS bm25_rank
84
+ FROM memories_fts
85
+ JOIN memories m ON m.id = memories_fts.rowid
86
+ WHERE memories_fts MATCH ?
87
+ AND (m.project_id = ? OR m.scope = 'universal' OR m.project_id IS NULL)
88
+ ORDER BY bm25_rank; -- SQLite bm25 is negative; more-negative = better
89
+ ```
90
+
91
+ Empty/purely-stopword queries keep today's behavior (return all scoped rows, no
92
+ lexical filter) so `load_rules`-style "give me everything" calls are unaffected.
93
+
94
+ ### 3.3 Query sanitization (must-have, not optional)
95
+
96
+ Raw user keywords fed to `MATCH` are a **syntax hazard** — bare `AND`/`OR`/`NEAR`,
97
+ hyphens, quotes, and `*` are FTS5 operators and throw `SQLITE_ERROR` on malformed
98
+ input. Every term must be wrapped as a quoted phrase and OR-joined:
99
+
100
+ ```
101
+ "kaggle" OR "submission" OR "api"
102
+ ```
103
+
104
+ A bad query must **fall back to the LIKE path**, never crash retrieval.
105
+
106
+ ### 3.4 Score fusion
107
+
108
+ Compute a normalized `bm25Score ∈ [0,1]` per candidate (min-max across the
109
+ candidate set, Mem0-style) and **replace** the `includes()` boost at
110
+ `retrieval.ts:150-174` with it — leaving every other multiplicative term
111
+ untouched:
112
+
113
+ ```ts
114
+ // was: score *= 1 + matchRatio * 3.0 (+1.5 all-match / ×0.3 no-overlap)
115
+ score *= 1 + wLexical * bm25Score; // wLexical ~ 3.0 to preserve current dynamic range
116
+ ```
117
+
118
+ Keeping the fusion multiplicative means decay/strength/evidence/project boosts
119
+ behave identically — the only thing that changes is *how the lexical signal is
120
+ measured*. This bounds the blast radius to one term.
121
+
122
+ ## 4. Migration & backfill
123
+
124
+ In `migrateSchema()` (`storage.ts`, alongside the existing `CREATE INDEX IF NOT
125
+ EXISTS` migrations ~`:119-149`):
126
+
127
+ 1. Feature-detect FTS5 (see §5). If absent → skip everything, leave a flag off.
128
+ 2. Create the vtable + triggers (idempotent).
129
+ 3. Backfill once: `INSERT INTO memories_fts(rowid, value) SELECT id, value FROM memories`
130
+ guarded by "only if the FTS table is empty."
131
+
132
+ Backfill cost is a single scan — trivial at the 10k-row cap.
133
+
134
+ ## 5. Feature detection, fallback, rollback
135
+
136
+ - **Detect** at init by attempting `CREATE VIRTUAL TABLE … USING fts5` in a
137
+ try/catch (verified working in the bundled `better-sqlite3`, but self-built or
138
+ exotic SQLite may lack it). On failure, set `ftsAvailable = false` and use the
139
+ existing LIKE path everywhere. **Retrieval must work identically with FTS off.**
140
+ - **Rollback is safe and non-destructive:** the FTS table + triggers are derived,
141
+ redundant data. `DROP TRIGGER … ; DROP TABLE memories_fts;` reverts to LIKE with
142
+ zero risk to `memories`.
143
+
144
+ ## 6. Config / opt-in
145
+
146
+ `CLAUDE_RECALL_RETRIEVAL = fts | like`. Ship **`like` as default** first so the
147
+ change is inert on upgrade, flip to `fts` as default only after the benchmark
148
+ (§7) shows a win. This mirrors how AUTO_DEMOTE / the janitor shipped off-by-default.
149
+
150
+ ## 7. Benchmark tie-in (finding #4)
151
+
152
+ This change is the reason to stand up the LongMemEval-subset harness **first**:
153
+ without a before/after number we can't tell whether BM25 actually helps or just
154
+ adds surface area. Track both **recall/accuracy** and **tokens-per-query** (the
155
+ 2000-token `load_rules` budget is the natural denominator). The harness doubles as
156
+ the regression guard for the fusion weights.
157
+
158
+ ## 8. Risks / open questions
159
+
160
+ - **Tokenizing raw JSON** dilutes ranking with field-name tokens. Acceptable for
161
+ v1; the clean fix (derived plaintext column) means a write-path change.
162
+ - **`is_active` / superseded rows**: `searchByContext` currently returns inactive
163
+ rows too (no `is_active` predicate at `storage.ts:677`). Confirm FTS candidate
164
+ fetch matches whatever the intended active-set semantics are — don't silently
165
+ change them in this PR.
166
+ - **Fusion weight `wLexical`** needs tuning against the benchmark; the ×0.3
167
+ no-overlap penalty has no direct BM25 equivalent (non-matches simply aren't
168
+ returned by MATCH) — verify that doesn't over-promote weak matches.
169
+ - **WAL + triggers**: triggers run inside the same transaction as the write; the
170
+ existing `wal_checkpoint(TRUNCATE)` after writes is unaffected.
171
+
172
+ ## 9. Later phase (out of scope here)
173
+
174
+ Optional local embeddings (`sqlite-vec` or a small local model) for semantic
175
+ similarity, gated behind an opt-in flag so the default stays dependency-free —
176
+ fused as a third signal exactly like Mem0 (semantic + BM25 + entity). BM25 first;
177
+ it captures most of the paraphrase win at none of the cost.
178
+
179
+ ## 10. Suggested PR breakdown
180
+
181
+ 1. FTS vtable + triggers + migration + feature-detect + backfill (no retrieval
182
+ wiring yet — pure additive, inert).
183
+ 2. LongMemEval-subset benchmark harness (measure the `LIKE` baseline).
184
+ 3. Candidate fetch via MATCH + sanitization + fusion, behind `CLAUDE_RECALL_RETRIEVAL=fts`.
185
+ 4. Flip default to `fts` once the benchmark confirms the win.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-recall",
3
- "version": "0.36.1",
3
+ "version": "0.37.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": {