claude-recall 0.37.1 → 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/cli/claude-recall-cli.js +9 -0
- package/dist/core/retrieval.js +18 -2
- package/dist/hooks/memory-sync-hook.js +101 -8
- package/dist/hooks/precompact-preserve.js +24 -16
- package/dist/hooks/rule-injector.js +95 -5
- package/dist/memory/storage.js +161 -0
- package/dist/services/search-monitor.js +17 -0
- package/dist/services/session-link.js +92 -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
|
|
|
@@ -1096,6 +1096,15 @@ class ClaudeRecallCLI {
|
|
|
1096
1096
|
const stats = fs.statSync(dbPath);
|
|
1097
1097
|
console.log(` Path: ${dbPath}`);
|
|
1098
1098
|
console.log(` Size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
|
|
1099
|
+
// Surface the write-ahead log size. A WAL that has grown to a large
|
|
1100
|
+
// fraction of the DB usually means a long-lived connection isn't
|
|
1101
|
+
// checkpointing; it's truncated on the next clean shutdown (close()).
|
|
1102
|
+
const walPath = `${dbPath}-wal`;
|
|
1103
|
+
if (fs.existsSync(walPath)) {
|
|
1104
|
+
const walMb = fs.statSync(walPath).size / 1024 / 1024;
|
|
1105
|
+
const warn = walMb > 16 ? ' ⚠️ large — will truncate on next clean shutdown' : '';
|
|
1106
|
+
console.log(` WAL: ${walMb.toFixed(2)} MB${warn}`);
|
|
1107
|
+
}
|
|
1099
1108
|
}
|
|
1100
1109
|
// Memory stats
|
|
1101
1110
|
const memStats = this.memoryService.getStats();
|
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
|
|
@@ -75,12 +75,94 @@ function deriveAutoMemoryPath(cwd, homedir) {
|
|
|
75
75
|
* Extract display value from a memory record.
|
|
76
76
|
*/
|
|
77
77
|
function extractValue(value) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
// Always resolve to readable text. Handles every historical shape:
|
|
79
|
+
// - clean structured `{ title, description, content }` → prefer the title
|
|
80
|
+
// - failure content objects → "what_failed → what_should_do"
|
|
81
|
+
// - nested `{ content: { ... } }` / `{ content: "{...json...}" }` wrappers
|
|
82
|
+
// - stringified-JSON stored as a plain string
|
|
83
|
+
// Without this, a failure stored as `JSON.stringify(content)` rendered its
|
|
84
|
+
// raw JSON as the file title/slug (e.g. `[{"what_failed":"Bash command...`),
|
|
85
|
+
// and clean object-content memories stringified to "[object Object]".
|
|
86
|
+
let v = value;
|
|
87
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
88
|
+
if (typeof v === 'string') {
|
|
89
|
+
const t = v.trim();
|
|
90
|
+
if (t.startsWith('{') || t.startsWith('[')) {
|
|
91
|
+
try {
|
|
92
|
+
v = JSON.parse(t);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return v;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return v;
|
|
100
|
+
}
|
|
101
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
102
|
+
if (typeof v.title === 'string' && v.title.trim())
|
|
103
|
+
return v.title.trim();
|
|
104
|
+
if (typeof v.what_failed === 'string') {
|
|
105
|
+
return v.what_should_do ? `${v.what_failed} → ${v.what_should_do}` : v.what_failed;
|
|
106
|
+
}
|
|
107
|
+
const next = v.content ?? v.value ?? v.text;
|
|
108
|
+
if (next === undefined)
|
|
109
|
+
return JSON.stringify(v);
|
|
110
|
+
v = next;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
82
114
|
}
|
|
83
|
-
return
|
|
115
|
+
return typeof v === 'string' ? v : JSON.stringify(v ?? '');
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Generic default "lessons" the failure detectors emit when no fix has been
|
|
119
|
+
* paired yet. A promoted memory whose only takeaway is one of these teaches
|
|
120
|
+
* nothing — it just costs context. We skip syncing those to the file-based
|
|
121
|
+
* memory; the specific failure still lives in the DB, so fix-pairing and
|
|
122
|
+
* evidence counting are unaffected, and once a fix enriches what_should_do
|
|
123
|
+
* (e.g. "Fix: <command>") the memory syncs normally.
|
|
124
|
+
*/
|
|
125
|
+
const BOILERPLATE_LESSONS = new Set([
|
|
126
|
+
'check inputs and prerequisites before retrying',
|
|
127
|
+
'check command syntax, file paths, and prerequisites before running',
|
|
128
|
+
'review error details and adjust approach',
|
|
129
|
+
]);
|
|
130
|
+
/** Unwrap a memory value to the object that carries the failure fields, or null. */
|
|
131
|
+
function unwrapToFailureObject(value) {
|
|
132
|
+
let v = value;
|
|
133
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
134
|
+
if (typeof v === 'string') {
|
|
135
|
+
const t = v.trim();
|
|
136
|
+
if (t.startsWith('{')) {
|
|
137
|
+
try {
|
|
138
|
+
v = JSON.parse(t);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
148
|
+
if (typeof v.what_should_do === 'string' || typeof v.what_failed === 'string')
|
|
149
|
+
return v;
|
|
150
|
+
const next = v.content ?? v.value;
|
|
151
|
+
if (next === undefined)
|
|
152
|
+
return null;
|
|
153
|
+
v = next;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
function isBoilerplateFailure(rule) {
|
|
161
|
+
if (rule.crType !== 'failure')
|
|
162
|
+
return false;
|
|
163
|
+
const obj = unwrapToFailureObject(rule.value);
|
|
164
|
+
const wsd = obj && typeof obj.what_should_do === 'string' ? obj.what_should_do.trim().toLowerCase() : '';
|
|
165
|
+
return BOILERPLATE_LESSONS.has(wsd);
|
|
84
166
|
}
|
|
85
167
|
/**
|
|
86
168
|
* Check if a memory key matches test data patterns.
|
|
@@ -231,12 +313,23 @@ async function handleMemorySync(input) {
|
|
|
231
313
|
const memoryService = memory_1.MemoryService.getInstance();
|
|
232
314
|
// Get top rules ranked for sync
|
|
233
315
|
const rules = memoryService.getTopRulesForSync(projectId, MAX_SYNC_FILES);
|
|
234
|
-
// Filter out test data and
|
|
316
|
+
// Filter out test data, secrets, and boilerplate-only failure lessons.
|
|
235
317
|
const filtered = rules.filter(r => {
|
|
236
318
|
if (isTestData(r.key))
|
|
237
319
|
return false;
|
|
238
|
-
|
|
239
|
-
|
|
320
|
+
// Scan the FULL raw value for secrets, not just the display gist —
|
|
321
|
+
// extractValue now returns a summary that could omit a secret buried in
|
|
322
|
+
// a non-title field.
|
|
323
|
+
let raw;
|
|
324
|
+
try {
|
|
325
|
+
raw = typeof r.value === 'string' ? r.value : JSON.stringify(r.value);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
raw = String(r.value ?? '');
|
|
329
|
+
}
|
|
330
|
+
if (containsSecret(raw))
|
|
331
|
+
return false;
|
|
332
|
+
if (isBoilerplateFailure(r))
|
|
240
333
|
return false;
|
|
241
334
|
return true;
|
|
242
335
|
});
|
|
@@ -97,29 +97,37 @@ async function handlePrecompactPreserve(input) {
|
|
|
97
97
|
console.log(`💾 Recall: preserved ${stored} memories before context compression`);
|
|
98
98
|
}
|
|
99
99
|
(0, shared_1.hookLog)('precompact', `PreCompact sweep: stored ${stored} memories from ${entries.length} entries`);
|
|
100
|
-
// Reset
|
|
101
|
-
// after context compression. Without this, the enforcer thinks rules
|
|
102
|
-
// still loaded
|
|
103
|
-
|
|
100
|
+
// Reset per-session hook-state so Claude is forced to re-load AND re-inject
|
|
101
|
+
// rules after context compression. Without this, the enforcer thinks rules
|
|
102
|
+
// are still loaded and the rule-injector thinks it already injected them —
|
|
103
|
+
// but compaction may have dropped both from context.
|
|
104
|
+
resetSessionHookState(input?.session_id);
|
|
104
105
|
}
|
|
105
106
|
/**
|
|
106
|
-
* Delete
|
|
107
|
-
*
|
|
107
|
+
* Delete this session's per-session hook-state files, forcing a fresh
|
|
108
|
+
* load_rules gate (search enforcer) and re-injection (rule-injector) on the
|
|
109
|
+
* next tool call after compaction.
|
|
108
110
|
*/
|
|
109
|
-
function
|
|
111
|
+
function resetSessionHookState(sessionId) {
|
|
110
112
|
if (!sessionId) {
|
|
111
|
-
(0, shared_1.hookLog)('precompact', 'No session_id — cannot reset
|
|
113
|
+
(0, shared_1.hookLog)('precompact', 'No session_id — cannot reset session hook-state');
|
|
112
114
|
return;
|
|
113
115
|
}
|
|
114
116
|
const safeId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '_') || 'default';
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
const stateDir = path.join(os.homedir(), '.claude-recall', 'hook-state');
|
|
118
|
+
const stateFiles = [
|
|
119
|
+
path.join(stateDir, `${safeId}.json`), // search-enforcer gate state
|
|
120
|
+
path.join(stateDir, `rule-injector-${safeId}.json`), // rule-injector dedup set
|
|
121
|
+
];
|
|
122
|
+
for (const stateFile of stateFiles) {
|
|
123
|
+
try {
|
|
124
|
+
if (fs.existsSync(stateFile)) {
|
|
125
|
+
fs.unlinkSync(stateFile);
|
|
126
|
+
(0, shared_1.hookLog)('precompact', `Reset hook-state ${path.basename(stateFile)} for session ${safeId}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
(0, shared_1.hookLog)('precompact', `Failed to reset ${path.basename(stateFile)}: ${err.message}`);
|
|
120
131
|
}
|
|
121
|
-
}
|
|
122
|
-
catch (err) {
|
|
123
|
-
(0, shared_1.hookLog)('precompact', `Failed to reset enforcer state: ${err.message}`);
|
|
124
132
|
}
|
|
125
133
|
}
|
|
@@ -24,15 +24,52 @@
|
|
|
24
24
|
* effectiveness directly. This is the meter that replaces the broken
|
|
25
25
|
* citation-detection regex.
|
|
26
26
|
*/
|
|
27
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
28
|
+
if (k2 === undefined) k2 = k;
|
|
29
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
30
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
31
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
32
|
+
}
|
|
33
|
+
Object.defineProperty(o, k2, desc);
|
|
34
|
+
}) : (function(o, m, k, k2) {
|
|
35
|
+
if (k2 === undefined) k2 = k;
|
|
36
|
+
o[k2] = m[k];
|
|
37
|
+
}));
|
|
38
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
39
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
40
|
+
}) : function(o, v) {
|
|
41
|
+
o["default"] = v;
|
|
42
|
+
});
|
|
43
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
44
|
+
var ownKeys = function(o) {
|
|
45
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
46
|
+
var ar = [];
|
|
47
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
48
|
+
return ar;
|
|
49
|
+
};
|
|
50
|
+
return ownKeys(o);
|
|
51
|
+
};
|
|
52
|
+
return function (mod) {
|
|
53
|
+
if (mod && mod.__esModule) return mod;
|
|
54
|
+
var result = {};
|
|
55
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
56
|
+
__setModuleDefault(result, mod);
|
|
57
|
+
return result;
|
|
58
|
+
};
|
|
59
|
+
})();
|
|
27
60
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
61
|
exports.computeInjection = computeInjection;
|
|
29
62
|
exports.handleRuleInjector = handleRuleInjector;
|
|
63
|
+
const crypto_1 = require("crypto");
|
|
64
|
+
const fs = __importStar(require("fs"));
|
|
65
|
+
const path = __importStar(require("path"));
|
|
30
66
|
const shared_1 = require("./shared");
|
|
31
67
|
const memory_1 = require("../services/memory");
|
|
32
68
|
const config_1 = require("../services/config");
|
|
33
69
|
const outcome_storage_1 = require("../services/outcome-storage");
|
|
34
70
|
const rule_retrieval_1 = require("../services/rule-retrieval");
|
|
35
71
|
const memory_tools_1 = require("../mcp/tools/memory-tools");
|
|
72
|
+
const session_link_1 = require("../services/session-link");
|
|
36
73
|
const TYPE_LABELS = {
|
|
37
74
|
correction: 'correction',
|
|
38
75
|
devops: 'devops',
|
|
@@ -81,6 +118,44 @@ function formatInjection(matches, toolName) {
|
|
|
81
118
|
`but defer to safety and correctness if any conflict.\n${lines.join('\n')}\n` +
|
|
82
119
|
`</recalled-memory>`);
|
|
83
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Per-session injection dedup. The hook fires on EVERY tool call, so without
|
|
123
|
+
* this the same rule is re-injected verbatim before every Read/Bash/Edit —
|
|
124
|
+
* pure per-call token overhead, and identical repetition trains the model to
|
|
125
|
+
* tune the block out. We remember which rules were already injected this
|
|
126
|
+
* session (keyed by rule identity + a content hash, so a rule re-injects only
|
|
127
|
+
* if its content actually changed) and skip them next time.
|
|
128
|
+
*/
|
|
129
|
+
function injectionStateFile(sessionId) {
|
|
130
|
+
const safe = (sessionId || 'default').replace(/[^a-zA-Z0-9_-]/g, '_') || 'default';
|
|
131
|
+
return path.join((0, shared_1.hookStateDir)(), `rule-injector-${safe}.json`);
|
|
132
|
+
}
|
|
133
|
+
function loadInjectedSet(sessionId) {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(fs.readFileSync(injectionStateFile(sessionId), 'utf-8'));
|
|
136
|
+
if (Array.isArray(parsed?.injected))
|
|
137
|
+
return new Set(parsed.injected);
|
|
138
|
+
}
|
|
139
|
+
catch { /* no state yet — first injection this session */ }
|
|
140
|
+
return new Set();
|
|
141
|
+
}
|
|
142
|
+
function saveInjectedSet(sessionId, injected) {
|
|
143
|
+
try {
|
|
144
|
+
fs.writeFileSync(injectionStateFile(sessionId), JSON.stringify({ injected: [...injected] }), 'utf-8');
|
|
145
|
+
}
|
|
146
|
+
catch { /* best-effort — dedup is an optimization, never block the call */ }
|
|
147
|
+
}
|
|
148
|
+
function ruleInjectionId(rule) {
|
|
149
|
+
let payload;
|
|
150
|
+
try {
|
|
151
|
+
payload = JSON.stringify(rule.value);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
payload = String(rule.value);
|
|
155
|
+
}
|
|
156
|
+
const hash = (0, crypto_1.createHash)('sha1').update(payload).digest('hex').slice(0, 8);
|
|
157
|
+
return `${rule.type}:${rule.key}:${hash}`;
|
|
158
|
+
}
|
|
84
159
|
/**
|
|
85
160
|
* Runtime-agnostic core: rank active rules against this tool call, record
|
|
86
161
|
* the injections for outcome resolution, and return the formatted context
|
|
@@ -88,7 +163,7 @@ function formatInjection(matches, toolName) {
|
|
|
88
163
|
* runtime (Claude Code wants a hookSpecificOutput JSON envelope; Kiro adds
|
|
89
164
|
* raw stdout to context).
|
|
90
165
|
*/
|
|
91
|
-
async function computeInjection(toolName, toolInput, toolUseId) {
|
|
166
|
+
async function computeInjection(toolName, toolInput, toolUseId, sessionId = 'default') {
|
|
92
167
|
if (!toolName)
|
|
93
168
|
return null;
|
|
94
169
|
// Skip the hook for our own tools so we don't recursively inject rules
|
|
@@ -99,6 +174,10 @@ async function computeInjection(toolName, toolInput, toolUseId) {
|
|
|
99
174
|
}
|
|
100
175
|
const projectId = config_1.ConfigService.getInstance().getProjectId();
|
|
101
176
|
const memoryService = memory_1.MemoryService.getInstance();
|
|
177
|
+
// Record the harness session id for this project so the MCP server can
|
|
178
|
+
// correlate its own logs with hook-side state (#6). This hook runs on every
|
|
179
|
+
// tool call with the harness session_id, so the link stays fresh.
|
|
180
|
+
(0, session_link_1.writeHarnessSessionLink)(sessionId, projectId);
|
|
102
181
|
// Fetch all active rules for this project. We pass them all to the ranker
|
|
103
182
|
// because the ranking function is fast and we want sticky rules to surface
|
|
104
183
|
// even when token overlap is low.
|
|
@@ -126,10 +205,17 @@ async function computeInjection(toolName, toolInput, toolUseId) {
|
|
|
126
205
|
(0, shared_1.hookLog)('rule-injector', `No relevant rules for ${toolName} (scanned ${allRules.length})`);
|
|
127
206
|
return null;
|
|
128
207
|
}
|
|
208
|
+
// Drop rules already injected this session (unless their content changed).
|
|
209
|
+
const injected = loadInjectedSet(sessionId);
|
|
210
|
+
const freshMatches = matches.filter(m => !injected.has(ruleInjectionId(m.rule)));
|
|
211
|
+
if (freshMatches.length === 0) {
|
|
212
|
+
(0, shared_1.hookLog)('rule-injector', `All ${matches.length} matched rule(s) already injected this session (${sessionId}) — skipping`);
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
129
215
|
// Record each injection so PostToolUse can resolve it with the outcome
|
|
130
216
|
try {
|
|
131
217
|
const outcomeStorage = outcome_storage_1.OutcomeStorage.getInstance();
|
|
132
|
-
for (const m of
|
|
218
|
+
for (const m of freshMatches) {
|
|
133
219
|
outcomeStorage.recordRuleInjection({
|
|
134
220
|
rule_key: m.rule.key,
|
|
135
221
|
tool_name: toolName,
|
|
@@ -144,12 +230,16 @@ async function computeInjection(toolName, toolInput, toolUseId) {
|
|
|
144
230
|
// Non-critical — failure to record shouldn't block the injection itself
|
|
145
231
|
(0, shared_1.hookLog)('rule-injector', `Failed to record injections: ${err.message}`);
|
|
146
232
|
}
|
|
147
|
-
|
|
148
|
-
|
|
233
|
+
// Mark them injected so they don't repeat on the next tool call.
|
|
234
|
+
for (const m of freshMatches)
|
|
235
|
+
injected.add(ruleInjectionId(m.rule));
|
|
236
|
+
saveInjectedSet(sessionId, injected);
|
|
237
|
+
(0, shared_1.hookLog)('rule-injector', `Injected ${freshMatches.length} rule(s) for ${toolName} (top score=${freshMatches[0].score.toFixed(3)})`);
|
|
238
|
+
return formatInjection(freshMatches, toolName);
|
|
149
239
|
}
|
|
150
240
|
async function handleRuleInjector(input) {
|
|
151
241
|
try {
|
|
152
|
-
const additionalContext = await computeInjection(input?.tool_name ?? '', input?.tool_input ?? {}, input?.tool_use_id ?? '');
|
|
242
|
+
const additionalContext = await computeInjection(input?.tool_name ?? '', input?.tool_input ?? {}, input?.tool_use_id ?? '', input?.session_id ?? 'default');
|
|
153
243
|
if (!additionalContext) {
|
|
154
244
|
// Nothing to inject — print empty JSON so CC parses it cleanly
|
|
155
245
|
process.stdout.write('{}\n');
|
package/dist/memory/storage.js
CHANGED
|
@@ -44,11 +44,23 @@ 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');
|
|
50
57
|
// Ensure changes are synced to disk
|
|
51
58
|
this.db.pragma('synchronous = NORMAL');
|
|
59
|
+
// Cap WAL growth: after each checkpoint SQLite truncates the -wal file
|
|
60
|
+
// back to this ceiling, so it can't balloon (a stuck reader once left it
|
|
61
|
+
// at 34MB) and linger across restarts. Paired with a TRUNCATE checkpoint
|
|
62
|
+
// in close().
|
|
63
|
+
this.db.pragma('journal_size_limit = 8388608'); // 8 MB
|
|
52
64
|
this.initialize();
|
|
53
65
|
}
|
|
54
66
|
initialize() {
|
|
@@ -245,6 +257,85 @@ class MemoryStorage {
|
|
|
245
257
|
console.error('⚠️ Schema migration error:', error);
|
|
246
258
|
// Don't throw - let the database continue with existing schema
|
|
247
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 ');
|
|
248
339
|
}
|
|
249
340
|
/**
|
|
250
341
|
* Compute a SHA-256 content hash from the meaningful fields of a memory.
|
|
@@ -543,6 +634,24 @@ class MemoryStorage {
|
|
|
543
634
|
throw new Error('searchByContext requires context.project_id or context.includeAllProjects=true. ' +
|
|
544
635
|
'Calling without either would silently return memories from all projects.');
|
|
545
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
|
+
}
|
|
546
655
|
let query = 'SELECT * FROM memories WHERE 1=1';
|
|
547
656
|
const params = [];
|
|
548
657
|
if (context.project_id) {
|
|
@@ -590,6 +699,48 @@ class MemoryStorage {
|
|
|
590
699
|
const rows = stmt.all(...params);
|
|
591
700
|
return rows.map(row => this.rowToMemory(row));
|
|
592
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
|
+
}
|
|
593
744
|
deleteByKey(key) {
|
|
594
745
|
const stmt = this.db.prepare('DELETE FROM memories WHERE key = ?');
|
|
595
746
|
const result = stmt.run(key);
|
|
@@ -1055,6 +1206,16 @@ class MemoryStorage {
|
|
|
1055
1206
|
return this.db;
|
|
1056
1207
|
}
|
|
1057
1208
|
close() {
|
|
1209
|
+
// Flush the WAL into the main DB and truncate the -wal/-shm files so they
|
|
1210
|
+
// don't linger at tens of MB after the process exits. Best-effort: a
|
|
1211
|
+
// checkpoint can fail if another connection still holds a read lock, in
|
|
1212
|
+
// which case db.close() below still runs an implicit passive checkpoint.
|
|
1213
|
+
try {
|
|
1214
|
+
this.db.pragma('wal_checkpoint(TRUNCATE)');
|
|
1215
|
+
}
|
|
1216
|
+
catch {
|
|
1217
|
+
// ignore — proceed to close regardless
|
|
1218
|
+
}
|
|
1058
1219
|
this.db.close();
|
|
1059
1220
|
}
|
|
1060
1221
|
}
|
|
@@ -35,6 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.SearchMonitor = void 0;
|
|
37
37
|
const logging_1 = require("./logging");
|
|
38
|
+
const config_1 = require("./config");
|
|
39
|
+
const session_link_1 = require("./session-link");
|
|
38
40
|
const fs = __importStar(require("fs"));
|
|
39
41
|
const path = __importStar(require("path"));
|
|
40
42
|
const os = __importStar(require("os"));
|
|
@@ -58,6 +60,20 @@ class SearchMonitor {
|
|
|
58
60
|
fs.mkdirSync(dir, { recursive: true });
|
|
59
61
|
}
|
|
60
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Best-effort lookup of the harness session_id for this process's project.
|
|
65
|
+
* A hook records it (see session-link.ts); returns undefined if none has run
|
|
66
|
+
* yet or on any error — this must never disrupt a search.
|
|
67
|
+
*/
|
|
68
|
+
resolveHarnessSessionId() {
|
|
69
|
+
try {
|
|
70
|
+
const projectId = config_1.ConfigService.getInstance().getProjectId();
|
|
71
|
+
return (0, session_link_1.readHarnessSessionLink)(projectId) ?? undefined;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
61
77
|
recordSearch(query, resultCount, sessionId, source, context) {
|
|
62
78
|
if (!this.monitoringEnabled)
|
|
63
79
|
return;
|
|
@@ -66,6 +82,7 @@ class SearchMonitor {
|
|
|
66
82
|
query,
|
|
67
83
|
resultCount,
|
|
68
84
|
sessionId,
|
|
85
|
+
harnessSessionId: this.resolveHarnessSessionId(),
|
|
69
86
|
source,
|
|
70
87
|
context
|
|
71
88
|
};
|