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 +11 -1
- package/dist/cli/claude-recall-cli.js +32 -10
- package/dist/cli/commands/kiro-commands.js +1 -1
- package/dist/core/retrieval.js +1 -0
- package/dist/hooks/kiro-hooks.js +2 -1
- package/dist/hooks/llm-classifier.js +7 -2
- package/dist/hooks/post-compact-reload.js +4 -1
- package/dist/hooks/rule-injector.js +1 -0
- package/dist/hooks/subagent-hooks.js +4 -1
- package/dist/mcp/tools/memory-tools.js +15 -7
- package/dist/memory/storage.js +1 -1
- package/dist/pi/extension.js +3 -0
- package/dist/services/memory.js +10 -3
- package/dist/shared/event-processors.js +1 -1
- package/docs/design-hybrid-retrieval-fts5.md +185 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
807
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/core/retrieval.js
CHANGED
|
@@ -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,
|
package/dist/hooks/kiro-hooks.js
CHANGED
|
@@ -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);
|
|
@@ -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
|
},
|
package/dist/memory/storage.js
CHANGED
|
@@ -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).
|
package/dist/pi/extension.js
CHANGED
|
@@ -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
|
}
|
package/dist/services/memory.js
CHANGED
|
@@ -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