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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/adopt-content.mjs +12 -0
- package/deep-search.mjs +7 -22
- package/format-utils.mjs +26 -0
- package/hook-context.mjs +4 -1
- package/hook-llm.mjs +8 -1
- package/hook-memory.mjs +6 -2
- package/hook-semaphore.mjs +9 -2
- package/hook.mjs +5 -6
- package/lib/citation-tracker.mjs +85 -9
- package/lib/maintain-core.mjs +2 -1
- package/lib/observation-write.mjs +2 -1
- package/lib/rrf.mjs +42 -0
- package/mem-cli.mjs +79 -5
- package/package.json +2 -1
- package/search-engine.mjs +2 -1
- package/search-scoring.mjs +7 -2
- package/server.mjs +12 -1
- package/source-files.mjs +3 -0
- package/tfidf.mjs +29 -10
- package/utils.mjs +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
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.
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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('(')
|
|
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
|
|
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');
|
package/hook-semaphore.mjs
CHANGED
|
@@ -33,8 +33,15 @@ export async function acquireLLMSlot() {
|
|
|
33
33
|
if (fd !== undefined) closeSync(fd);
|
|
34
34
|
}
|
|
35
35
|
} catch {
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
-
|
|
376
|
-
|
|
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
|
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
2220
|
-
* --days N
|
|
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.
|
|
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, ' ');
|
package/search-scoring.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|