claude-mem-lite 3.16.2 → 3.17.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.16.2",
13
+ "version": "3.17.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.16.2",
3
+ "version": "3.17.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/adopt-content.mjs CHANGED
@@ -43,6 +43,8 @@ PreToolUse hooks already run \`mem_recall\` for past lessons before Read/Edit/Wr
43
43
  | Deferring to a future session | \`mem_defer({title, priority:1|2|3, detail})\`; when fixed, add \`closes_deferred=[N]\` to \`mem_save\` |
44
44
  | Looking up past work / history | \`mem_search "keywords"\` · \`mem_recent\` · \`mem_timeline\` |
45
45
 
46
+ Path cost is round-trips, not milliseconds: the PreToolUse hook above already recalls (0 calls) — prefer it. For an explicit query, if these \`mem_*\` tools are deferred behind ToolSearch this session, the Bash CLI (exact path in the detail doc) is one call vs two (ToolSearch + call).
47
+
46
48
  Full tool + CLI tables, citation/decay rules, and save discipline → \`.claude/plugin_claude_mem_lite.md\``;
47
49
  }
48
50
 
@@ -77,6 +79,16 @@ PreToolUse hook 在你 Read / Edit / Write 文件前已自动 \`mem_recall\` 该
77
79
  \`mem_search\` / \`mem_recent\` / \`mem_recall\` / \`mem_get\` / \`mem_save\` / \`mem_timeline\` +
78
80
  \`mem_defer\` / \`mem_defer_list\` / \`mem_defer_drop\`。
79
81
 
82
+ ### 选 MCP 还是 CLI:按 round-trip,不是执行毫秒
83
+
84
+ 真正的开销是模型往返次数,不是工具执行——暖 MCP 调用 ~25ms、CLI 冷启 ~90ms,在一次推理(秒级)面前都是噪声。按往返次数选路:
85
+
86
+ 1. **被动 hook(0 往返)**:上面的 PreToolUse recall 已自动跑,最快,优先采纳,别重复调。
87
+ 2. **CLI via Bash(1 往返)**:工具多的会话里 \`mem_*\` 会被 defer 到 ToolSearch 后面——这时一次 MCP 调用 = ToolSearch + call = **2 往返**,而 Bash 跑一条 CLI 只 **1 往返**。派出去的子 agent 通常也拿不到 \`mem_*\` 工具,CLI 是它唯一的 1-往返路径。用下面「CLI 速查」表里的命令。
88
+ 3. **MCP 直调(已加载时 1 往返)**:\`mem_*\` 已在上下文里(未被 defer)就直接调,暖进程执行最快、省掉 ToolSearch。
89
+
90
+ 一句话:能让 hook 代劳就别调;要显式查,若得先 ToolSearch 才能用 \`mem_*\`,改跑 CLI。
91
+
80
92
  | 时机 | 工具 | 关键参数 |
81
93
  |------|------|----------|
82
94
  | Edit / Write 前 | \`mem_recall\` | \`file="<路径>"\`(hook 通常已代劳) |
package/deep-search.mjs CHANGED
@@ -34,6 +34,7 @@
34
34
  import { searchObservationsHybrid } from './search-engine.mjs';
35
35
  import { sanitizeFtsQuery } from './utils.mjs';
36
36
  import { RRF_K } from './tfidf.mjs';
37
+ import { rrfAccumulate } from './lib/rrf.mjs';
37
38
  import { llmRerankOrder, defaultRerankLLM } from './rerank.mjs';
38
39
 
39
40
  // original + up to 3 rewrites (keyword / concept-expansion / HyDE).
@@ -350,28 +351,12 @@ export async function rewriteQuery(query, { llm = defaultLLM, retries = 1, cache
350
351
  * match the hybrid path's convention) plus an rrfScore field.
351
352
  */
352
353
  export function rrfFuseN(rankedLists, k = RRF_K) {
353
- const scores = new Map();
354
- for (const list of rankedLists) {
355
- if (!Array.isArray(list)) continue;
356
- list.forEach((r, i) => {
357
- if (!r || r.id === undefined || r.id === null) return;
358
- const add = 1 / (k + i + 1);
359
- const prev = scores.get(r.id);
360
- if (prev) {
361
- prev.score += add;
362
- // Keep the row from the variant that ranked this id HIGHEST (lowest
363
- // index). searchObservationsHybrid emits query-dependent fields per
364
- // variant (notably the FTS snippet), so first-seen would often show the
365
- // weaker original/keyword variant's context; the best-ranked appearance
366
- // carries the most relevant snippet/match context (F10).
367
- if (i < prev.bestRank) { prev.row = r; prev.bestRank = i; }
368
- } else {
369
- scores.set(r.id, { row: r, score: add, bestRank: i });
370
- }
371
- });
372
- }
373
- return [...scores.values()]
374
- .sort((a, b) => b.score - a.score)
354
+ // Thin N-list adapter over the shared RRF core (lib/rrf.mjs). Emits full source
355
+ // rows with score = -rrfScore (negative = better, matching the hybrid path's
356
+ // convention) plus an rrfScore field. rrfAccumulate already keeps each id's
357
+ // best-ranked row, so query-dependent fields (notably the FTS snippet) come from
358
+ // the strongest variant rather than first-seen (F10).
359
+ return rrfAccumulate(rankedLists, k)
375
360
  .map(({ row, score }) => ({ ...row, score: -score, rrfScore: score }));
376
361
  }
377
362
 
package/format-utils.mjs CHANGED
@@ -24,6 +24,32 @@ export function truncate(str, max = 80) {
24
24
  return str.slice(0, end) + '\u2026';
25
25
  }
26
26
 
27
+ /**
28
+ * Render the PostToolUse error-recall hint block (hook.mjs::triggerErrorRecall).
29
+ * The single most-relevant hit (rows[0]) that carries a lesson_learned gets its
30
+ * lesson INLINED, so the agent can act with zero follow-up round-trips: the old
31
+ * "pointer + mem_get for details" form cost a deferred mem_get (2 model turns in
32
+ * tool-heavy sessions, where mem_* is gated behind ToolSearch) at the exact
33
+ * moment a fix is needed. Later rows stay as #ID pointers to keep the injected
34
+ * payload bounded (one body, not three). Upstream noise gating (low-signal title
35
+ * exclusion) is the SELECT's job (see triggerErrorRecall).
36
+ * @param {Array<{id:number,type:string,title:string,lesson_learned?:string}>} rows
37
+ * @returns {string} stdout block (trailing newline) or '' when there are no rows
38
+ */
39
+ export function formatErrorRecallHints(rows) {
40
+ if (!rows || rows.length === 0) return '';
41
+ const lines = rows.map((r, i) => {
42
+ const head = ` #${r.id} [${r.type}] ${truncate(r.title, 60)}`;
43
+ // Inline the lesson body for the single most-relevant hit only (bounded payload).
44
+ if (i === 0 && typeof r.lesson_learned === 'string' && r.lesson_learned.trim()) {
45
+ return `${head} \u2014 ${truncate(r.lesson_learned.trim(), 200)}`;
46
+ }
47
+ return head;
48
+ });
49
+ const ids = rows.map(r => r.id).join(',');
50
+ return `[claude-mem-lite] Related memories found for this error:\n${lines.join('\n')}\n \u2192 Use mem_get(ids=[${ids}]) for details.\n`;
51
+ }
52
+
27
53
  /**
28
54
  * Map observation type to its display emoji icon.
29
55
  * @param {string} type Observation type (decision, bugfix, feature, etc.)
package/hook-context.mjs CHANGED
@@ -244,7 +244,10 @@ export function cleanupClaudeMdLegacyBlock() {
244
244
 
245
245
  if (normalized === content) { dropMarker(); return; }
246
246
 
247
- const tmp = claudeMdPath + '.mem-tmp';
247
+ // Per-pid temp suffix so two concurrent first-run SessionStarts in the same
248
+ // project (e.g. two terminals) can't rename each other's half-written temp
249
+ // onto the user's tracked CLAUDE.md. Matches the idiom in hook-shared.mjs.
250
+ const tmp = claudeMdPath + `.mem-tmp-${process.pid}`;
248
251
  try {
249
252
  writeFileSync(tmp, normalized);
250
253
  renameSync(tmp, claudeMdPath);
package/hook-llm.mjs CHANGED
@@ -740,9 +740,15 @@ ${actionList}`;
740
740
  (parsed.type === 'bugfix' || parsed.type === 'decision') &&
741
741
  !process.env.CLAUDE_MEM_NO_LESSON_RETRY) {
742
742
  retryAttempted = true;
743
+ // The first callLLM released its slot in the finally above; this lesson
744
+ // retry is a SECOND LLM call and must re-acquire the semaphore or it
745
+ // bypasses LLM_SEM_MAX — a burst of bugfix/decision episodes would otherwise
746
+ // spawn unbounded concurrent Haiku calls. Under contention we skip the retry
747
+ // rather than exceed the limit (the lesson is an optional enhancement).
748
+ const retrySlot = await acquireLLMSlot();
743
749
  try {
744
750
  const retryPrompt = buildLessonRetryPrompt(episode, parsed);
745
- const retryRaw = await callLLM(retryPrompt, 10000);
751
+ const retryRaw = retrySlot ? await callLLM(retryPrompt, 10000) : null;
746
752
  if (retryRaw) {
747
753
  const retry = parseJsonFromLLM(retryRaw);
748
754
  const retryLesson = typeof retry?.lesson === 'string' ? retry.lesson.trim() : '';
@@ -754,6 +760,7 @@ ${actionList}`;
754
760
  }
755
761
  }
756
762
  } catch (e) { debugCatch(e, 'lesson-retry'); }
763
+ finally { if (retrySlot) releaseLLMSlot(); }
757
764
  }
758
765
  // v2.57.x B2: persist retry outcome counters. The retry path costs
759
766
  // 1 extra Haiku call per bugfix/decision episode; if recovered/attempts
package/hook-memory.mjs CHANGED
@@ -222,7 +222,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
222
222
  // Count original search terms (AND-separated groups), not expanded synonym tokens.
223
223
  const queryTokenCount = ftsQuery.includes(' AND ')
224
224
  ? ftsQuery.split(' AND ').length
225
- : ftsQuery.split(/\s+/).filter(t => t && !t.startsWith('(') || !t.endsWith(')')).length;
225
+ : ftsQuery.split(/\s+/).filter(t => t && !t.startsWith('(') && !t.endsWith(')')).length;
226
226
  // CJK-dominant queries bypass the token-count gate: a single CJK word becomes
227
227
  // 2-N overlapping bigrams (优化召回率 → 优化/召回/回率), inflating
228
228
  // queryTokenCount past the gate, so the AND-too-strict query never gets the OR
@@ -385,7 +385,11 @@ export function recallForFile(db, filePath, project) {
385
385
  `).all(project, cutoff, filePath, likePattern, MAX_FILE_RECALL);
386
386
  const now = Date.now();
387
387
  const updateStmt = db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?');
388
- for (const r of rows) updateStmt.run(now, r.id);
388
+ // Per-row try/catch for FTS trigger safety mirror the injection-bump loop
389
+ // (searchRelevantMemories) and project_non_obvious.md. Without it, one
390
+ // SQLITE_CORRUPT_VTAB on the access_count UPDATE trigger throws to the outer
391
+ // catch and discards the ENTIRE file-recall result set.
392
+ for (const r of rows) { try { updateStmt.run(now, r.id); } catch {} }
389
393
  return rows;
390
394
  } catch (e) {
391
395
  debugCatch(e, 'recallForFile');
@@ -33,8 +33,15 @@ export async function acquireLLMSlot() {
33
33
  if (fd !== undefined) closeSync(fd);
34
34
  }
35
35
  } catch {
36
- // Slot file already exists for this PID stale cleanup should have removed it;
37
- // retry and let O_CREAT|O_EXCL succeed on next iteration (avoid non-atomic fallback)
36
+ // Our own pid-named slot file already exists: a leftover from a prior acquire
37
+ // that never released (crash between acquire and releaseLLMSlot, or PID reuse).
38
+ // We are inside acquire and therefore do NOT currently hold a slot, so it is
39
+ // always stale — remove it and retry. The await is essential: a bare `continue`
40
+ // here re-hits the same EEXIST every iteration, a synchronous tight loop that
41
+ // pins a core until the 30s deadline (the age-based cleanup below is unreachable
42
+ // on this path — it only runs after a successful create).
43
+ try { unlinkSync(slotFile); } catch {}
44
+ await sleepMs(50 + Math.random() * 100);
38
45
  continue;
39
46
  }
40
47
 
package/hook.mjs CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  truncate, inferProject, detectBashSignificance,
27
27
  extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
28
28
  makeEntryDesc, scrubSecrets, stripPrivate, EDIT_TOOLS, debugCatch, debugLog,
29
- COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, OBS_BM25,
29
+ COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, OBS_BM25, notLowSignalTitleClause, formatErrorRecallHints,
30
30
  } from './utils.mjs';
31
31
  import {
32
32
  readEpisodeRaw, episodeFile,
@@ -363,19 +363,18 @@ function triggerErrorRecall(db, toolInput, response) {
363
363
 
364
364
  const nowR = Date.now();
365
365
  const rows = db.prepare(`
366
- SELECT o.id, o.type, o.title
366
+ SELECT o.id, o.type, o.title, o.lesson_learned
367
367
  FROM observations_fts
368
368
  JOIN observations o ON observations_fts.rowid = o.id
369
369
  WHERE observations_fts MATCH ? AND o.project = ?
370
+ AND ${notLowSignalTitleClause('o')}
370
371
  ORDER BY ${OBS_BM25}
371
372
  * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / 1209600000.0))
372
373
  LIMIT 3
373
374
  `).all(ftsQuery, project, nowR);
374
375
 
375
- if (rows.length > 0) {
376
- const hints = rows.map(r => ` #${r.id} [${r.type}] ${truncate(r.title, 60)}`).join('\n');
377
- process.stdout.write(`[claude-mem-lite] Related memories found for this error:\n${hints}\n → Use mem_get(ids=[${rows.map(r => r.id).join(',')}]) for details.\n`);
378
- }
376
+ const out = formatErrorRecallHints(rows);
377
+ if (out) process.stdout.write(out);
379
378
  } catch (e) { debugCatch(e, 'triggerErrorRecall'); }
380
379
  }
381
380
 
@@ -10,7 +10,8 @@
10
10
  // column UPDATE including access_count. Per-row UPDATEs wrapped in try-catch
11
11
  // to prevent SQLITE_CORRUPT_VTAB cascades from stopping the whole scan.
12
12
 
13
- import { readFileSync, existsSync } from 'fs';
13
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
14
+ import { join } from 'path';
14
15
  import { debugCatch } from '../utils.mjs';
15
16
 
16
17
  // `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
@@ -155,6 +156,11 @@ export function bumpCitationAccess(db, ids, project) {
155
156
  // Bounded type list mirrors observations.type CHECK + the events table's allowed
156
157
  // event_type values these surfaces can emit.
157
158
  const INJECTED_RE = /#(\d{1,7})\s+\[(bugfix|decision|change|discovery|feature|refactor|lesson)\]/g;
159
+ // Line-anchored variant: a genuine injected ROW begins (after its short indent) with
160
+ // `#NN [type]`. pre-tool-recall AND error-recall inline a lesson_learned body into the
161
+ // row; a body that quotes another obs ("same as #1234 [decision]") must NOT count as
162
+ // injected: that id would pollute the citation-decay denominator and falsely demote.
163
+ const INJECTED_ROW_RE = new RegExp('^\\s{0,6}' + INJECTED_RE.source);
158
164
 
159
165
  // Add a numeric obs id to `set` if it parses to a sane in-range positive int.
160
166
  function addObsId(set, raw) {
@@ -229,9 +235,10 @@ export function extractInjectedFromPreToolUse(transcriptPath, opts = {}) {
229
235
  const ids = new Set();
230
236
  eachHookAttachment(transcriptPath, ({ command, text }) => {
231
237
  if (!command.includes('pre-tool-recall')) return;
232
- INJECTED_RE.lastIndex = 0;
233
- let m;
234
- while ((m = INJECTED_RE.exec(text))) addObsId(ids, m[1]);
238
+ for (const line of text.split('\n')) {
239
+ const m = INJECTED_ROW_RE.exec(line);
240
+ if (m) addObsId(ids, m[1]);
241
+ }
235
242
  }, opts);
236
243
  return ids;
237
244
  }
@@ -293,11 +300,14 @@ export function extractInjectedFromErrorRecall(transcriptPath, opts = {}) {
293
300
  eachHookAttachment(transcriptPath, ({ command, text }) => {
294
301
  if (!command.includes('post-tool-use')) return;
295
302
  if (!text.includes('Related memories found for this error')) return;
296
- // INJECTED_RE requires `#NN [type]`, so the trailing
297
- // `→ Use mem_get(ids=[7933,8455])` line (bare numbers) is not matched.
298
- INJECTED_RE.lastIndex = 0;
299
- let m;
300
- while ((m = INJECTED_RE.exec(text))) addObsId(ids, m[1]);
303
+ // Per-line anchored: match only a row that STARTS with `#NN [type]` (after its
304
+ // indent), NOT every such token in the block. The inlined lesson body (v3.16.x)
305
+ // can quote another obs id, which must not enter the injected set; the trailing
306
+ // `Use mem_get(ids=[...])` line (bare numbers) is excluded too.
307
+ for (const line of text.split('\n')) {
308
+ const m = INJECTED_ROW_RE.exec(line);
309
+ if (m) addObsId(ids, m[1]);
310
+ }
301
311
  }, opts);
302
312
  return ids;
303
313
  }
@@ -353,6 +363,72 @@ export function extractAllInjected(transcriptPath, opts = {}) {
353
363
  ]);
354
364
  }
355
365
 
366
+ /**
367
+ * Cite-recall over ONE transcript file, using the SAME methodology as the
368
+ * citation-decay loop: injected = extractAllInjected (OUR hook injections only),
369
+ * cited = #NN in assistant text, ratio = |injected ∩ cited| / |injected|.
370
+ *
371
+ * Thread is keyed by FILE LOCATION, not the isSidechain field. Claude Code writes
372
+ * each subagent's turns to a SEPARATE file (<session>/subagents/agent-*.jsonl),
373
+ * NOT inline in the parent transcript (verified empirically: 0 isSidechain records
374
+ * across 60 parent transcripts; the subagent files carry isSidechain=true). So a
375
+ * whole subagent file IS the sidechain — aggregateProjectCiteRecall splits by path.
376
+ *
377
+ * @param {string} transcriptPath
378
+ * @returns {{injected: number, cited: number, recalled: number, ratio: number}}
379
+ */
380
+ export function computeThreadCiteRecall(transcriptPath) {
381
+ const injected = extractAllInjected(transcriptPath);
382
+ const cited = extractCitationsFromTranscript(transcriptPath);
383
+ let recalled = 0;
384
+ for (const id of injected) if (cited.has(id)) recalled++;
385
+ return {
386
+ injected: injected.size,
387
+ cited: cited.size,
388
+ recalled,
389
+ ratio: injected.size > 0 ? recalled / injected.size : 0,
390
+ };
391
+ }
392
+
393
+ /**
394
+ * Aggregate cite-recall across a project's transcripts, split MAIN vs SIDECHAIN
395
+ * by file location. `txDir` = ~/.claude/projects/<encoded>/. Main = the top-level
396
+ * <session>.jsonl files; sidechain = every <session>/subagents/agent-*.jsonl.
397
+ * Descends ONE level into the literal `subagents` subdir only — no unbounded
398
+ * recursion. mtime-gated by `cutoff` (epoch ms; 0 = no window).
399
+ *
400
+ * @param {string} txDir
401
+ * @param {{cutoff?: number}} [opts]
402
+ * @returns {{main: {injected:number,recalled:number,files:number}, sidechain: {injected:number,recalled:number,files:number,withInjections:number}}}
403
+ */
404
+ export function aggregateProjectCiteRecall(txDir, { cutoff = 0 } = {}) {
405
+ const main = { injected: 0, recalled: 0, files: 0 };
406
+ const sidechain = { injected: 0, recalled: 0, files: 0, withInjections: 0 };
407
+ const within = (p) => { try { return statSync(p).mtimeMs >= cutoff; } catch { return false; } };
408
+ let entries;
409
+ try { entries = readdirSync(txDir, { withFileTypes: true }); } catch { return { main, sidechain }; }
410
+ for (const ent of entries) {
411
+ const full = join(txDir, ent.name);
412
+ if (ent.isFile() && ent.name.endsWith('.jsonl')) {
413
+ if (!within(full)) continue;
414
+ const r = computeThreadCiteRecall(full);
415
+ main.injected += r.injected; main.recalled += r.recalled; main.files++;
416
+ } else if (ent.isDirectory()) {
417
+ let subFiles;
418
+ try { subFiles = readdirSync(join(full, 'subagents')); } catch { continue; }
419
+ for (const sf of subFiles) {
420
+ if (!sf.endsWith('.jsonl')) continue;
421
+ const sp = join(full, 'subagents', sf);
422
+ if (!within(sp)) continue;
423
+ const r = computeThreadCiteRecall(sp);
424
+ sidechain.injected += r.injected; sidechain.recalled += r.recalled; sidechain.files++;
425
+ if (r.injected > 0) sidechain.withInjections++;
426
+ }
427
+ }
428
+ }
429
+ return { main, sidechain };
430
+ }
431
+
356
432
  /**
357
433
  * True iff the transcript contains at least one non-whitespace text block from
358
434
  * a main-thread assistant turn. Gates the citation-decay loop so a tool-only
@@ -13,7 +13,7 @@
13
13
  // { projectFilter: 'AND project = ?' | '', baseParams: [project?] , staleAge, opCap }
14
14
 
15
15
  import { COMPRESSED_PENDING_PURGE, computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from '../utils.mjs';
16
- import { rebuildVocabulary, computeVector, _resetVocabCache } from '../tfidf.mjs';
16
+ import { rebuildVocabulary, computeVector, _resetVocabCache, vectorsEnabled } from '../tfidf.mjs';
17
17
  import { DEDUP_JACCARD_THRESHOLD, MINHASH_PRE_THRESHOLD as MINHASH_PRE_THRESHOLD_SRC, FUZZY_DEDUP_THRESHOLD, FUZZY_BODY_THRESHOLD, MINHASH_PREFILTER } from './dedup-constants.mjs';
18
18
 
19
19
  export const STALE_AGE_MS = 30 * 86400000;
@@ -326,6 +326,7 @@ export function maintenanceStats(db, { projectFilter, baseParams, staleAge }) {
326
326
 
327
327
  /** Rebuild the TF-IDF vocabulary + every active observation vector (own transaction). */
328
328
  export function rebuildVectors(db) {
329
+ if (!vectorsEnabled()) return { ok: false, reason: 'vector arm disabled (set CLAUDE_MEM_VECTORS=1 to re-enable)', updated: 0, total: 0 };
329
330
  _resetVocabCache();
330
331
  const vocab = rebuildVocabulary(db);
331
332
  if (!vocab) return { ok: false, reason: 'no observations to build vocabulary from' };
@@ -8,7 +8,7 @@
8
8
  // Statement-only: callers own the transaction boundary (both wrap the row + files
9
9
  // + vector writes in one db.transaction so a failure can't leave a partial row).
10
10
 
11
- import { getVocabulary, computeVector } from '../tfidf.mjs';
11
+ import { getVocabulary, computeVector, vectorsEnabled } from '../tfidf.mjs';
12
12
  import { debugCatch, cjkBigrams } from '../utils.mjs';
13
13
 
14
14
  // Canonical column order — must mirror the observations schema (schema.mjs).
@@ -56,6 +56,7 @@ export function insertObservationFiles(db, obsId, files) {
56
56
  * observation over a missing vector).
57
57
  */
58
58
  export function insertObservationVector(db, obsId, vecText) {
59
+ if (!vectorsEnabled()) return; // Phase-1: vector arm disabled by default (audit 2026-06-27)
59
60
  try {
60
61
  const vocab = getVocabulary(db);
61
62
  if (!vocab) return;
package/lib/rrf.mjs ADDED
@@ -0,0 +1,42 @@
1
+ // lib/rrf.mjs — Reciprocal Rank Fusion core (single source of truth, D#42).
2
+ //
3
+ // Both tfidf.rrfMerge (2-list, minimal { id, rrfScore } output) and
4
+ // deep-search.rrfFuseN (N-list, full-row output with best-rank row selection)
5
+ // are thin shape-adapters over rrfAccumulate below. Keeping the scoring math in
6
+ // one dependency-free place prevents the two from silently drifting (different k
7
+ // or formula — the prior state where each had its own hand-written loop). Callers
8
+ // pass their own RRF_K default; 60 is the canonical value.
9
+
10
+ /**
11
+ * Core Reciprocal Rank Fusion accumulator. For each id, sums 1/(k + rank + 1)
12
+ * across every ranked list it appears in, and keeps the source row from the list
13
+ * where the id ranked highest (lowest index) — query-dependent fields like an FTS
14
+ * snippet then come from the best-ranked appearance. Callers that only need the id
15
+ * ignore the row.
16
+ *
17
+ * @param {Array<Array<{id:any}>>} rankedLists one entry per ranked list; each list
18
+ * is ordered best-first. Non-array entries and rows with null/undefined id are skipped.
19
+ * @param {number} [k=60] RRF constant.
20
+ * @returns {Array<{ id:any, row:object, score:number }>} descending by fused score;
21
+ * ties preserve first-list-first insertion order (stable sort).
22
+ */
23
+ export function rrfAccumulate(rankedLists, k = 60) {
24
+ const scores = new Map();
25
+ for (const list of rankedLists) {
26
+ if (!Array.isArray(list)) continue;
27
+ list.forEach((r, i) => {
28
+ if (!r || r.id === undefined || r.id === null) return;
29
+ const add = 1 / (k + i + 1);
30
+ const prev = scores.get(r.id);
31
+ if (prev) {
32
+ prev.score += add;
33
+ if (i < prev.bestRank) { prev.row = r; prev.bestRank = i; }
34
+ } else {
35
+ scores.set(r.id, { row: r, score: add, bestRank: i });
36
+ }
37
+ });
38
+ }
39
+ return [...scores.values()]
40
+ .sort((a, b) => b.score - a.score)
41
+ .map(({ row, score }) => ({ id: row.id, row, score }));
42
+ }
package/mem-cli.mjs CHANGED
@@ -15,6 +15,7 @@ import { ensureRegistryDb, upsertResource } from './registry.mjs';
15
15
  import { searchResources } from './registry-retriever.mjs';
16
16
  import { computeFunnel, formatFunnel, computeSweep, formatSweep, DEFAULT_SWEEP_FLOORS, DEFAULT_SWEEP_MARGINS } from './registry-recommend.mjs';
17
17
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
18
+ import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
18
19
  import {
19
20
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
20
21
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
@@ -26,8 +27,9 @@ import { buildSessionContextLines } from './hook-context.mjs';
26
27
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
27
28
  import { parseIntFlag, isNumericToken } from './lib/cli-flags.mjs';
28
29
  import { auditMemdir, memdirPath } from './memdir.mjs';
30
+ import { aggregateProjectCiteRecall } from './lib/citation-tracker.mjs';
29
31
  import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
30
- import { join, sep } from 'path';
32
+ import { join, sep, dirname } from 'path';
31
33
  import { readFileSync, existsSync, readdirSync } from 'fs';
32
34
 
33
35
  // v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
@@ -1097,6 +1099,18 @@ async function cmdStats(db, args) {
1097
1099
  AND created_at_epoch < ? ${projectFilter}
1098
1100
  `).get(thirtyDaysAgo, ...baseParams);
1099
1101
  const noiseRatio = obsTotal.c > 0 ? lowVal.c / obsTotal.c : 0;
1102
+ // Low-signal-title population: template / tool-log titles (Modified, Worked on,
1103
+ // Error while working, Error:, node/npm/npx …) that the retrieval layer already
1104
+ // filters out by default. The imp=1 "Low-value" metric above structurally can't
1105
+ // see these — they often carry inflated importance and recent access — so the
1106
+ // health gauge under-reports real noise without this line. Same LOW_SIGNAL
1107
+ // pattern source as the read-side filter (lib/low-signal-patterns.mjs).
1108
+ const lowSignalTitle = db.prepare(`
1109
+ SELECT COUNT(*) as c FROM observations
1110
+ WHERE NOT ${buildNotLowSignalSql()}
1111
+ AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
1112
+ `).get(...baseParams);
1113
+ const lowSignalRatio = obsTotal.c > 0 ? lowSignalTitle.c / obsTotal.c : 0;
1100
1114
  const compressedCount = db.prepare(
1101
1115
  `SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}`
1102
1116
  ).get(...baseParams);
@@ -1142,6 +1156,8 @@ async function cmdStats(db, args) {
1142
1156
  avg_importance: Number((avgImp.v ?? 1).toFixed(2)),
1143
1157
  low_value_count: lowVal.c,
1144
1158
  noise_ratio: Number(noiseRatio.toFixed(4)),
1159
+ low_signal_titles: lowSignalTitle.c,
1160
+ low_signal_ratio: Number(lowSignalRatio.toFixed(4)),
1145
1161
  compressed: compressedCount.c,
1146
1162
  superseded_only: supersededOnlyCount.c,
1147
1163
  hook_errors_24h: hookErrors24h,
@@ -1178,6 +1194,7 @@ async function cmdStats(db, args) {
1178
1194
  out(` Est. tokens: ${tokenEst.t ?? 0}`);
1179
1195
  out(` Avg importance: ${(avgImp.v ?? 1).toFixed(2)}`);
1180
1196
  out(` Low-value (imp=1, never accessed, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`);
1197
+ out(` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`);
1181
1198
  out(` Compressed: ${compressedCount.c}`);
1182
1199
  out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(DB_DIR, 'runtime/hook-errors')}` : ''}`);
1183
1200
  // Tier-1 firing counters for ① file-intel + ② reread-guard (recorded by
@@ -1187,7 +1204,7 @@ async function cmdStats(db, args) {
1187
1204
  const rrN = featAgg.reread_warn?.count ?? 0;
1188
1205
  const metricsOn = process.env.CLAUDE_MEM_METRICS === '1';
1189
1206
  out(` Feature injections (7d): 📄 file-intel ${fiN} · 🔁 reread-warn ${rrN}${(!metricsOn && fiN + rrN === 0) ? ' (set CLAUDE_MEM_METRICS=1 to record)' : ''}`);
1190
- if (noiseRatio > 0.6) out(' ⚠️ High noise ratio — consider running mem compress');
1207
+ if (noiseRatio > 0.6 || lowSignalRatio > 0.3) out(' ⚠️ High noise ratio — consider running mem maintain / compress');
1191
1208
  out('');
1192
1209
  // Tier counts only live (uncompressed, non-superseded) observations — surface the
1193
1210
  // full decomposition so live + compressed + superseded = Total adds up cleanly.
@@ -1901,7 +1918,23 @@ function cmdMaintain(db, args) {
1901
1918
  }
1902
1919
 
1903
1920
  if (ops.includes('purge_stale')) {
1904
- const retainDays = parseInt(flags['retain-days'], 10) || 30;
1921
+ // --retain-days: default 30 when absent; reject negative / 0 / NaN / out-of-range.
1922
+ // A negative value made retainCutoff a FUTURE timestamp → purged the entire
1923
+ // pending-purge backlog regardless of age; 0/garbage silently became 30 and
1924
+ // masked typos. Parity with the mem_maintain MCP zod bound [7, 365].
1925
+ let retainDays = 30;
1926
+ if (flags['retain-days'] !== undefined) {
1927
+ // Number + isInteger (not parseInt) so "7.5"/"30x" are rejected rather than
1928
+ // silently truncated to 7/30 — parity with the mem_maintain zod .int() bound.
1929
+ const parsed = Number(flags['retain-days']);
1930
+ if (!Number.isInteger(parsed) || parsed < 7 || parsed > 365) {
1931
+ // fail() only sets exitCode + writes stderr; it does NOT throw, so we
1932
+ // MUST return or execution falls through to the DELETE with the bad value.
1933
+ fail(`[mem] --retain-days must be an integer in [7, 365] (got "${flags['retain-days']}")`);
1934
+ return;
1935
+ }
1936
+ retainDays = parsed;
1937
+ }
1905
1938
  const retainCutoff = Date.now() - retainDays * 86400000;
1906
1939
  // T2-P0-A (CLI parity): purge_stale is the only DELETE in this code path — require
1907
1940
  // --confirm so a mis-typed `maintain execute --ops purge_stale` can't wipe rows silently.
@@ -2210,20 +2243,61 @@ function cmdMemdirAudit(args) {
2210
2243
  if (nonCompliant > 0) process.exitCode = 1;
2211
2244
  }
2212
2245
 
2246
+ // `citation-stats --sidechain`: subagent (sidechain) cite-recall, the blind spot the
2247
+ // main decay loop excludes (it runs mainOnly). aggregateProjectCiteRecall scans THIS
2248
+ // project's transcripts: top-level <session>.jsonl = main, and
2249
+ // <session>/subagents/agent-*.jsonl = sidechain (descends ONE level into the literal
2250
+ // subagents/ dir only, no unbounded recursion). Same methodology, so comparable.
2251
+ function _reportSidechainCiteRecall({ days, json }) {
2252
+ const cutoff = Date.now() - days * 86400 * 1000;
2253
+ // memdir = ~/.claude/projects/<encoded>/memory; transcripts are its siblings.
2254
+ const txDir = dirname(memdirPath(process.cwd()));
2255
+ const { main, sidechain } = aggregateProjectCiteRecall(txDir, { cutoff });
2256
+ const rate = b => (b.injected > 0 ? (100 * b.recalled / b.injected) : null);
2257
+ const sideRate = rate(sidechain), mainRate = rate(main);
2258
+
2259
+ if (json) {
2260
+ out(JSON.stringify({
2261
+ window_days: days,
2262
+ main: { ...main, rate: mainRate },
2263
+ sidechain: { ...sidechain, rate: sideRate },
2264
+ }));
2265
+ return;
2266
+ }
2267
+
2268
+ const pct = r => (r === null ? '—' : `${r.toFixed(1)}%`);
2269
+ out(`Sidechain (subagent) cite-recall — last ${days}d:`);
2270
+ out(` main ${pct(mainRate).padStart(6)} recalled ${main.recalled} / injected ${main.injected} (${main.files} transcript(s))`);
2271
+ out(` sidechain ${pct(sideRate).padStart(6)} recalled ${sidechain.recalled} / injected ${sidechain.injected} (${sidechain.files} subagent file(s), ${sidechain.withInjections} with injections)`);
2272
+ if (sidechain.files > 0 && sidechain.injected === 0) {
2273
+ out(' → subagent transcripts exist but received ZERO memory injections: claude-mem-lite');
2274
+ out(' hooks do NOT fire inside subagents (no PreToolUse/PostToolUse recall, no');
2275
+ out(' SessionStart block, no mem_* tools). Subagents are memory-blind — giving them');
2276
+ out(' memory needs a NEW surface (inject at Agent/Task dispatch), not deepening.');
2277
+ } else if (sidechain.files === 0) {
2278
+ out(' → no subagent transcripts in window.');
2279
+ }
2280
+ }
2281
+
2213
2282
  /**
2214
2283
  * `citation-stats` — visualize the citation-decay feedback loop:
2215
2284
  * per-project cite rate + active decay queue + recently promoted.
2216
2285
  * Read-only over observations.
2217
2286
  *
2218
2287
  * Flags:
2219
- * --json machine-readable output
2220
- * --days N project cite-rate window (default 7)
2288
+ * --json machine-readable output
2289
+ * --days N project cite-rate window (default 7)
2290
+ * --sidechain subagent (sidechain) cite-recall vs main — the decay-loop blind spot
2221
2291
  */
2222
2292
  function cmdCitationStats(db, args) {
2223
2293
  const { flags } = parseArgs(args);
2224
2294
  const json = flags.json === true || flags.json === 'true';
2225
2295
  const days = parseIntFlag(flags.days, { name: '--days', defaultValue: 7, max: 365 });
2226
2296
 
2297
+ if (flags.sidechain === true || flags.sidechain === 'true') {
2298
+ return _reportSidechainCiteRecall({ days, json });
2299
+ }
2300
+
2227
2301
  const cutoff = Date.now() - days * 86400 * 1000;
2228
2302
  const perProject = db.prepare(`
2229
2303
  SELECT project,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.16.2",
3
+ "version": "3.17.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -81,6 +81,7 @@
81
81
  "lib/recall-core.mjs",
82
82
  "lib/timeline-core.mjs",
83
83
  "lib/search-core.mjs",
84
+ "lib/rrf.mjs",
84
85
  "lib/compress-core.mjs",
85
86
  "lib/maintain-core.mjs",
86
87
  "lib/dedup-constants.mjs",
package/search-engine.mjs CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  notLowSignalTitleClause, LOW_SIGNAL_TITLE,
12
12
  relaxFtsQueryToOr, debugLog, debugCatch, estimateTokens,
13
13
  } from './utils.mjs';
14
- import { getVocabulary, computeVector, vectorSearch, rrfMerge } from './tfidf.mjs';
14
+ import { getVocabulary, computeVector, vectorSearch, rrfMerge, vectorsEnabled } from './tfidf.mjs';
15
15
  import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
16
16
 
17
17
  // Scoring expressions — full adds project boost + access bonus; simple is for
@@ -395,6 +395,7 @@ export function searchObservationsHybrid(db, ctx) {
395
395
 
396
396
  // Vector search + RRF hybrid merge
397
397
  try {
398
+ if (!vectorsEnabled()) return results; // Phase-1: vector arm disabled → BM25-only path (audit 2026-06-27)
398
399
  const vocab = getVocabulary(db);
399
400
  if (!vocab) return results;
400
401
  const queryText = ftsQuery.replace(/['"()]/g, ' ');
@@ -15,9 +15,14 @@ import { CLI_INVOKE } from './cli-path.mjs';
15
15
  // Decision-rules sections; keeps the irreducible CLI/MCP tool list. Intended
16
16
  // for users who adopted invited-memory (MEMORY.md sentinel carries the same
17
17
  // triggers at higher authority). Default false preserves v2.31.2 behavior.
18
+ // The CLI-vs-MCP round-trip routing rule lives in BASE (not VERBOSE) on purpose:
19
+ // it must reach adopted (quiet) projects too, where tool-heavy sessions defer the
20
+ // mem_* tools behind ToolSearch (CLI via Bash = 1 model round-trip; ToolSearch +
21
+ // call = 2). Execution latency is NOT the lever — warm MCP (~25ms) actually beats
22
+ // CLI cold-start (~90ms); both are noise against one model turn. Round-trips are.
18
23
 
19
24
  const INSTRUCTIONS_BASE = [
20
- 'Long-term memory across sessions. Hooks auto-inject context; CLI preferred for explicit queries.',
25
+ 'Long-term memory across sessions. Hooks auto-inject context (0 round-trips) prefer adopting that over any call. For an explicit query, pick the path with fewer model round-trips (CLI vs MCP below).',
21
26
  '',
22
27
  `CLI (via Bash) — invoke as \`${CLI_INVOKE} <cmd>\` (resolves on any install shape; the bare \`claude-mem-lite\` shorthand works only after an optional global \`npm i -g claude-mem-lite\`):`,
23
28
  ` ${CLI_INVOKE} search "query" — FTS5 full-text search`,
@@ -27,7 +32,7 @@ const INSTRUCTIONS_BASE = [
27
32
  ` ${CLI_INVOKE} get 42,43 — full details by ID`,
28
33
  ` ${CLI_INVOKE} timeline --anchor 42 — chronological context`,
29
34
  '',
30
- 'MCP tools: mem_search, mem_recent, mem_save, mem_get, mem_recall, mem_timeline for programmatic access (always availableno PATH/CLI install needed).',
35
+ 'MCP tools: mem_search, mem_recent, mem_save, mem_get, mem_recall, mem_timeline. If already loaded, call directly (warm server, fastest path). In tool-heavy sessions these are deferred behind ToolSearch if using one would cost a ToolSearch load first, run the Bash CLI above instead: one call, not two. Neither needs a PATH/CLI install.',
31
36
  'mem_save: Save non-obvious insights (bugfix lessons, architecture decisions).',
32
37
  'Search tips: short keywords (2-3 words), filter with obs_type when relevant.',
33
38
  ];
package/server.mjs CHANGED
@@ -12,6 +12,7 @@ import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, b
12
12
  import { searchObservationsHybrid } from './search-engine.mjs';
13
13
  import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
14
14
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
15
+ import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
15
16
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
16
17
  import { buildSearchFtsQuery, parseDateBounds, coreRunSearchPipeline } from './lib/search-core.mjs';
17
18
  import {
@@ -864,6 +865,15 @@ server.registerTool(
864
865
  `).get(thirtyDaysAgo, ...baseParams);
865
866
 
866
867
  const noiseRatio = obsTotal.c > 0 ? lowVal.c / obsTotal.c : 0;
868
+ // Low-signal-title population (template / tool-log titles the read-side filter
869
+ // already excludes). The imp=1 "Low-value" metric can't see these, so the
870
+ // gauge under-reports real noise without it. See lib/low-signal-patterns.mjs.
871
+ const lowSignalTitle = db.prepare(`
872
+ SELECT COUNT(*) as c FROM observations
873
+ WHERE NOT ${buildNotLowSignalSql()}
874
+ AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
875
+ `).get(...baseParams);
876
+ const lowSignalRatio = obsTotal.c > 0 ? lowSignalTitle.c / obsTotal.c : 0;
867
877
  const compressedCount = db.prepare(`
868
878
  SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}
869
879
  `).get(...baseParams);
@@ -900,8 +910,9 @@ server.registerTool(
900
910
  ` Est. tokens: ${tokenEst.t ?? 0}`,
901
911
  ` Avg importance: ${(avgImp.v ?? 1).toFixed(2)}`,
902
912
  ` Low-value (imp=1, never accessed, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`,
913
+ ` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`,
903
914
  ` Compressed: ${compressedCount.c}`,
904
- ...(noiseRatio > 0.6 ? [' ⚠️ High noise ratio — consider running mem_compress'] : []),
915
+ ...((noiseRatio > 0.6 || lowSignalRatio > 0.3) ? [' ⚠️ High noise ratio — consider running mem_compress / maintain'] : []),
905
916
  '',
906
917
  // Tier counts only live (uncompressed, non-superseded) observations — surface
907
918
  // the full decomposition so live + compressed + superseded = Total adds up cleanly.
package/source-files.mjs CHANGED
@@ -114,6 +114,9 @@ export const SOURCE_FILES = [
114
114
  // `timeline`/`search` and mem_timeline/mem_search on auto-update.
115
115
  'lib/timeline-core.mjs',
116
116
  'lib/search-core.mjs',
117
+ // Reciprocal Rank Fusion core (D#42 single source-of-truth); transitively
118
+ // reached via tfidf.mjs (rrfMerge) and deep-search.mjs (rrfFuseN).
119
+ 'lib/rrf.mjs',
117
120
  // Shared "compress old low-value observations into weekly summaries" core.
118
121
  // Statically imported by mem-cli.mjs (cmdCompress), server.mjs (mem_compress),
119
122
  // and hook.mjs (handleAutoCompress) — same single-source-of-truth pattern as
package/tfidf.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  import { cjkBigrams } from './utils.mjs';
7
7
  import { BASE_STOP_WORDS } from './stop-words.mjs';
8
8
  import { createHash } from 'crypto';
9
+ import { rrfAccumulate } from './lib/rrf.mjs';
9
10
 
10
11
  export const VOCAB_DIM = 512;
11
12
  export const MIN_COSINE_SIMILARITY = 0.05;
@@ -15,6 +16,21 @@ export const VECTOR_SCAN_LIMIT = 500;
15
16
  // dominate. 60 is the de-facto RRF default and balances the two retrievers here.
16
17
  export const RRF_K = 60;
17
18
 
19
+ // Phase-1 disable of the TF-IDF vector arm (memory-quality audit 2026-06-27).
20
+ // The arm reads ~0 benchmark lift (ci-gate hybrid_over_bm25 = 0), pays a
21
+ // computeVector on every observation write, only scans the most-recent
22
+ // VECTOR_SCAN_LIMIT rows (misses older memories), and drifted unnoticed for
23
+ // months. Default OFF. To re-enable: set CLAUDE_MEM_VECTORS=1 AND run
24
+ // `claude-mem-lite maintain execute --ops rebuild_vectors` to repopulate vectors for
25
+ // observations created while disabled — re-enabling WITHOUT a rebuild leaves a
26
+ // partially-populated index (the primary + enrich/compress/optimize write paths all
27
+ // stopped writing while off) → silently degraded hybrid recall until the next
28
+ // rebuild. Tables + code are retained pending Phase-2 removal. Read at call time (not
29
+ // import) so tests + the benchmark A/B can toggle it via env.
30
+ export function vectorsEnabled() {
31
+ return process.env.CLAUDE_MEM_VECTORS === '1';
32
+ }
33
+
18
34
  const VOCAB_STOP_WORDS = new Set([
19
35
  ...BASE_STOP_WORDS,
20
36
  'now','only','still','here','there','up','out','am',
@@ -275,6 +291,14 @@ export function rebuildVocabulary(db, opts) {
275
291
  * @returns {object|null} vocabulary
276
292
  */
277
293
  export function getVocabulary(db) {
294
+ // Phase-1 choke point: when the vector arm is disabled there is no vocabulary to
295
+ // serve. Returning null here makes EVERY vector write/read path skip via its
296
+ // existing `if (vocab)` guard — including the enrich (hook-llm), compress
297
+ // (compress-core) and optimize (hook-optimize) write paths that don't go through
298
+ // insertObservationVector — so no path can be "missed" and observation_vectors
299
+ // stays uniformly stale (not half-populated) while disabled. computeVector is
300
+ // null-safe, so even a caller that skips its guard no-ops rather than crashes.
301
+ if (!vectorsEnabled()) return null;
278
302
  if (_vocabCache) return _vocabCache;
279
303
 
280
304
  // Try loading from persisted vocab_state
@@ -423,14 +447,9 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
423
447
  * @returns {{ id: number, rrfScore: number }[]}
424
448
  */
425
449
  export function rrfMerge(bm25Results, vectorResults, k = RRF_K) {
426
- const scores = new Map();
427
- bm25Results.forEach((r, i) => {
428
- scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (k + i + 1));
429
- });
430
- vectorResults.forEach((r, i) => {
431
- scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (k + i + 1));
432
- });
433
- return [...scores.entries()]
434
- .sort((a, b) => b[1] - a[1])
435
- .map(([id, score]) => ({ id, rrfScore: score }));
450
+ // Thin 2-list adapter over the shared RRF core (lib/rrf.mjs). Emits the minimal
451
+ // { id, rrfScore } shape this module's callers (search-engine.mjs) expect; the
452
+ // accumulator's best-rank row tracking is irrelevant here and ignored.
453
+ return rrfAccumulate([bm25Results, vectorResults], k)
454
+ .map(({ id, score }) => ({ id, rrfScore: score }));
436
455
  }
package/utils.mjs CHANGED
@@ -14,7 +14,7 @@ export { cjkBigrams, extractCjkSynonymTokens, extractCjkKeywords, extractCjkLike
14
14
  export { resolveProject, _resetProjectCache } from './project-utils.mjs';
15
15
  export { scrubSecrets, SECRET_PATTERNS } from './secret-scrub.mjs';
16
16
  export { stripPrivate } from './lib/private-strip.mjs';
17
- export { truncate, typeIcon, fmtDate, fmtTime, isoWeekKey } from './format-utils.mjs';
17
+ export { truncate, typeIcon, fmtDate, fmtTime, isoWeekKey, formatErrorRecallHints } from './format-utils.mjs';
18
18
  export { computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from './hash-utils.mjs';
19
19
  export { detectBashSignificance, extractErrorKeywords, extractFilePaths, stripTestSuffix } from './bash-utils.mjs';
20
20