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 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();
@@ -102,8 +102,17 @@ class MemoryRetrieval {
102
102
  }
103
103
  calculateRelevance(memory, context, stats, evidenceCount) {
104
104
  let score = memory.relevance_score || 1.0;
105
- // Boost for keyword matches in memory value
106
- if (context.keywords && context.keywords.length > 0) {
105
+ // Lexical boost. Two sources, mutually exclusive:
106
+ // (a) FTS5 path storage attached a normalized bm25Score ∈ [0,1]; every
107
+ // returned candidate already matched the query (MATCH filtered), so
108
+ // there is no "no-overlap" case here.
109
+ // (b) LIKE path — no bm25Score; fall back to keyword-overlap counting.
110
+ if (memory.bm25Score !== undefined) {
111
+ // Fuse BM25 multiplicatively, preserving the LIKE path's dynamic range
112
+ // (~1x..4x) so decay/strength/evidence boosts behave identically.
113
+ score *= 1 + MemoryRetrieval.W_LEXICAL * memory.bm25Score;
114
+ }
115
+ else if (context.keywords && context.keywords.length > 0) {
107
116
  const memoryStr = JSON.stringify(memory.value).toLowerCase();
108
117
  let keywordMatches = 0;
109
118
  for (const keyword of context.keywords) {
@@ -268,6 +277,13 @@ class MemoryRetrieval {
268
277
  }
269
278
  }
270
279
  exports.MemoryRetrieval = MemoryRetrieval;
280
+ /**
281
+ * Fusion weight for the FTS5 BM25 lexical signal. Chosen so a top match
282
+ * (bm25Score=1) yields a ~4x boost, matching the LIKE path's full-match
283
+ * dynamic range (1 + matchRatio*3, ×1.5 all-match). Tune against the
284
+ * retrieval benchmark before flipping CLAUDE_RECALL_RETRIEVAL=fts to default.
285
+ */
286
+ MemoryRetrieval.W_LEXICAL = 3.0;
271
287
  MemoryRetrieval.TYPE_PRIORITY = {
272
288
  'correction': 6,
273
289
  'solution': 5.5, // hard-won reusable solutions — high signal, rank just below corrections
@@ -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
- if (typeof value === 'string')
79
- return value;
80
- if (typeof value === 'object' && value !== null) {
81
- return value.content || value.value || JSON.stringify(value);
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 String(value ?? '');
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 secrets
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
- const val = extractValue(r.value);
239
- if (containsSecret(val))
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 search enforcer hook-state so Claude is forced to re-load rules
101
- // after context compression. Without this, the enforcer thinks rules are
102
- // still loaded even though they may have been lost during compaction.
103
- resetEnforcerState(input?.session_id);
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 the search enforcer's hook-state file for this session,
107
- * forcing a fresh load_rules gate on the next tool call.
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 resetEnforcerState(sessionId) {
111
+ function resetSessionHookState(sessionId) {
110
112
  if (!sessionId) {
111
- (0, shared_1.hookLog)('precompact', 'No session_id — cannot reset enforcer state');
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 stateFile = path.join(os.homedir(), '.claude-recall', 'hook-state', `${safeId}.json`);
116
- try {
117
- if (fs.existsSync(stateFile)) {
118
- fs.unlinkSync(stateFile);
119
- (0, shared_1.hookLog)('precompact', `Reset enforcer state for session ${safeId} — rules will re-gate`);
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 matches) {
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
- (0, shared_1.hookLog)('rule-injector', `Injected ${matches.length} rule(s) for ${toolName} (top score=${matches[0].score.toFixed(3)})`);
148
- return formatInjection(matches, toolName);
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');
@@ -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
  };