claude-mem-lite 2.34.5 → 2.35.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": "2.34.5",
13
+ "version": "2.35.0",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.34.5",
3
+ "version": "2.35.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/hook-llm.mjs CHANGED
@@ -371,8 +371,9 @@ export function buildDegradedTitle(episode) {
371
371
  }
372
372
 
373
373
  if (files.length > 0) {
374
- const names = files.map(f => basename(f)).slice(0, 3).join(', ');
375
- const suffix = files.length > 3 ? ` +${files.length - 3} more` : '';
374
+ const uniqueNames = [...new Set(files.map(f => basename(f)))];
375
+ const names = uniqueNames.slice(0, 3).join(', ');
376
+ const suffix = uniqueNames.length > 3 ? ` +${uniqueNames.length - 3} more` : '';
376
377
  if (hasError) {
377
378
  // Include the triggering command for richer context: "Error: dispatch.mjs — npm test failed"
378
379
  const errEntry = episode.entries.find(e => e.isError);
package/hooks/hooks.json CHANGED
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "PreToolUse": [
22
22
  {
23
- "matcher": "Edit|Write|NotebookEdit",
23
+ "matcher": "Edit|Write|NotebookEdit|Read",
24
24
  "hooks": [
25
25
  {
26
26
  "type": "command",
package/install.mjs CHANGED
@@ -489,7 +489,10 @@ async function install() {
489
489
  };
490
490
 
491
491
  const memPreToolRecall = {
492
- matcher: 'Edit|Write|NotebookEdit',
492
+ // v2.34.6: Read added to cover planning-Read (pre-Edit exploration).
493
+ // Read-path uses a tighter filter (lesson_learned required, top-1,
494
+ // 120-char truncation, silent-on-empty) — see scripts/pre-tool-recall.js.
495
+ matcher: 'Edit|Write|NotebookEdit|Read',
493
496
  hooks: [
494
497
  {
495
498
  type: 'command',
@@ -1062,11 +1065,13 @@ async function doctor() {
1062
1065
  }
1063
1066
 
1064
1067
  // Dependencies
1065
- const bsPath = join(INSTALL_DIR, 'node_modules', 'better-sqlite3');
1066
- if (existsSync(bsPath)) {
1067
- ok('better-sqlite3: installed');
1068
- } else {
1069
- fail('better-sqlite3: not installed (run install again)');
1068
+ try {
1069
+ const Database = (await import('better-sqlite3')).default;
1070
+ const probe = new Database(':memory:');
1071
+ probe.close();
1072
+ ok('better-sqlite3: verified (import + open OK)');
1073
+ } catch (e) {
1074
+ fail(`better-sqlite3: import/init failed (${e.message})`);
1070
1075
  issues++;
1071
1076
  }
1072
1077
 
@@ -1190,6 +1195,26 @@ async function doctor() {
1190
1195
  warn('Update state: failed to read');
1191
1196
  }
1192
1197
 
1198
+ // Dev drift: in dev-mode installs, all SOURCE_FILES entries should be
1199
+ // symlinks. A plain file means an earlier install (or manual cp) copied it,
1200
+ // so edits in the repo won't propagate to INSTALL_DIR — hook runtime and
1201
+ // test runtime silently diverge.
1202
+ try {
1203
+ const { checkDevDrift } = await import('./lib/doctor-drift.mjs');
1204
+ const r = checkDevDrift(INSTALL_DIR, SOURCE_FILES);
1205
+ if (r.drift) {
1206
+ const names = r.details.join(', ');
1207
+ const suffix = r.plainCount > r.details.length ? ` +${r.plainCount - r.details.length} more` : '';
1208
+ warn(`Dev drift: ${r.plainCount} non-symlink file(s) in dev install: ${names}${suffix} (re-run: node install.mjs install --dev)`);
1209
+ issues++;
1210
+ } else if (r.devMode) {
1211
+ ok(`Dev drift: clean (${r.symlinkCount} symlinks, 0 plain)`);
1212
+ }
1213
+ // Prod (all plain) install: no message — dev-drift is a dev-only concern.
1214
+ } catch (e) {
1215
+ warn('Dev drift: check failed — ' + e.message);
1216
+ }
1217
+
1193
1218
  // Stale temp files
1194
1219
  try {
1195
1220
  const runtimeDir = join(INSTALL_DIR, 'runtime');
@@ -1222,7 +1247,8 @@ async function doctor() {
1222
1247
  const Database = (await import('better-sqlite3')).default;
1223
1248
  const db = new Database(DB_PATH, { readonly: true });
1224
1249
  const obsCount = db.prepare('SELECT COUNT(*) as cnt FROM observations').get()?.cnt || 0;
1225
- const sessCount = db.prepare('SELECT COUNT(*) as cnt FROM sdk_sessions').get()?.cnt || 0;
1250
+ // Align with stats / MCP mem_stats: session_summaries, not sdk_sessions
1251
+ const sessCount = db.prepare('SELECT COUNT(*) as cnt FROM session_summaries').get()?.cnt || 0;
1226
1252
  db.close();
1227
1253
  ok(`DB stats: ${sizeMB}MB, ${obsCount} observations, ${sessCount} sessions`);
1228
1254
  } catch (e) {
@@ -0,0 +1,46 @@
1
+ // Dev-drift check: in dev-mode installs (symlinked to project repo), every
2
+ // managed source file in INSTALL_DIR should be a symlink. A regular file
3
+ // means an earlier install copied it (e.g. install.mjs before it was added
4
+ // to SOURCE_FILES) or someone ran `cp` manually — edits won't propagate
5
+ // from the repo, testing vs runtime will silently diverge.
6
+ //
7
+ // Returns: { devMode, drift, details } — devMode=false when no symlinks
8
+ // detected (prod copy install), drift=true when in dev-mode AND at least
9
+ // one SOURCE_FILES entry is a plain file.
10
+
11
+ import { existsSync, lstatSync } from 'fs';
12
+ import { join } from 'path';
13
+
14
+ export function checkDevDrift(installDir, sourceFiles) {
15
+ if (!existsSync(installDir)) {
16
+ return { devMode: false, drift: false, symlinkCount: 0, plainCount: 0, plainFiles: [], missingCount: 0, details: [] };
17
+ }
18
+ const symlinkFiles = [];
19
+ const plainFiles = [];
20
+ const missing = [];
21
+ for (const rel of sourceFiles) {
22
+ const p = join(installDir, rel);
23
+ if (!existsSync(p)) { missing.push(rel); continue; }
24
+ try {
25
+ const st = lstatSync(p);
26
+ if (st.isSymbolicLink()) symlinkFiles.push(rel);
27
+ else plainFiles.push(rel);
28
+ } catch {
29
+ missing.push(rel);
30
+ }
31
+ }
32
+ // devMode detection: if ≥1 symlink exists among source files, consider
33
+ // this a dev install. (Prod install is all plain files → drift=false
34
+ // because there's nothing to drift from.)
35
+ const devMode = symlinkFiles.length > 0;
36
+ const drift = devMode && plainFiles.length > 0;
37
+ return {
38
+ devMode,
39
+ drift,
40
+ symlinkCount: symlinkFiles.length,
41
+ plainCount: plainFiles.length,
42
+ plainFiles,
43
+ missingCount: missing.length,
44
+ details: plainFiles.slice(0, 5),
45
+ };
46
+ }
@@ -0,0 +1,60 @@
1
+ // Single source of truth for LOW_SIGNAL title patterns.
2
+ //
3
+ // "LOW_SIGNAL" = hook-llm fallback titles written when Haiku summarization
4
+ // is unavailable or skipped ("Modified X", "Worked on X", "Reviewed N files:",
5
+ // raw "Error: ..." logs, bare "node/npm/npx <cmd>" etc.). Empirical data:
6
+ // ~544 such entries in production, 18 ever accessed (3.3% retrieval rate).
7
+ //
8
+ // Three consumers must stay in sync (pre-β: by hand-mirrored comments);
9
+ // post-β: all three derive from this module.
10
+ // 1. utils.mjs::LOW_SIGNAL_TITLE — regex for write-side importance cap
11
+ // 2. scoring-sql.mjs::notLowSignalTitleClause — SQL NOT LIKE chain for read-side filter
12
+ // 3. scripts/pre-tool-recall.js — inline SQL (standalone cold-start script)
13
+ //
14
+ // This module is intentionally dependency-free so scripts/pre-tool-recall.js can
15
+ // import it without inflating the ~30ms cold-start budget.
16
+
17
+ /**
18
+ * Each entry has:
19
+ * - like: SQLite LIKE pattern (anchored; % = any chars)
20
+ * - regex: JS regex source fragment (MUST match the same title set as `like`)
21
+ *
22
+ * Adding/removing entries requires updating the sync test (tests/low-signal-sync.test.mjs).
23
+ */
24
+ export const LOW_SIGNAL_PATTERNS = [
25
+ { like: 'Modified %', regex: '^Modified ' },
26
+ { like: 'Worked on %', regex: '^Worked on ' },
27
+ { like: 'Reviewed % files:%', regex: '^Reviewed \\d+ files:' },
28
+ { like: 'Codebase exploration%', regex: '^Codebase exploration' },
29
+ { like: 'Error while working%', regex: '^Error while working' },
30
+ { like: 'Error in %', regex: '^Error in ' },
31
+ { like: 'Error: %', regex: '^Error: ' },
32
+ { like: '# %', regex: '^# ' },
33
+ { like: 'node %', regex: '^node ' },
34
+ { like: 'npm %', regex: '^npm ' },
35
+ { like: 'npx %', regex: '^npx ' },
36
+ { like: '(no description)%', regex: '^\\(no description\\)' },
37
+ { like: '%(error)', regex: '\\(error\\)$' },
38
+ ];
39
+
40
+ /**
41
+ * Build the combined regex that matches ANY LOW_SIGNAL pattern.
42
+ * Equivalent to the hand-written `LOW_SIGNAL_TITLE` before β refactor.
43
+ */
44
+ export function buildLowSignalRegex() {
45
+ const src = LOW_SIGNAL_PATTERNS.map(p => `(?:${p.regex})`).join('|');
46
+ return new RegExp(src);
47
+ }
48
+
49
+ /**
50
+ * Build the SQL NOT LIKE clause chain, optionally prefixed with a table alias.
51
+ * Output is a single parenthesized AND-chain — safe to combine with other AND/OR.
52
+ *
53
+ * @param {string} [alias=''] Table alias (e.g. 'o') — empty for unqualified.
54
+ * @returns {string} SQL boolean expression
55
+ */
56
+ export function buildNotLowSignalSql(alias = '') {
57
+ const p = alias ? `${alias}.` : '';
58
+ const clauses = LOW_SIGNAL_PATTERNS.map(({ like }) => `${p}title NOT LIKE '${like}'`);
59
+ return '(\n ' + clauses.join('\n AND ') + '\n )';
60
+ }
@@ -0,0 +1,130 @@
1
+ // Shared quality-dashboard computation — used by both mem-cli.mjs (CLI
2
+ // `stats --quality`) and server.mjs (MCP `mem_stats({quality: true})`).
3
+ // Splits pure data aggregation from text rendering so MCP handlers don't
4
+ // collide with CLI's `out()` stdout-write pattern.
5
+
6
+ import { notLowSignalTitleClause } from '../scoring-sql.mjs';
7
+ import { truncate } from '../format-utils.mjs';
8
+
9
+ export function computeQualityStats(db, { project, days }) {
10
+ const projectFilter = project ? 'AND project = ?' : '';
11
+ const baseParams = project ? [project] : [];
12
+ const cutoff = Date.now() - days * 86400000;
13
+
14
+ // LOW_SIGNAL match = NOT notLowSignal. Shared helper keeps SQL in sync
15
+ // with scoring-sql.mjs and pre-tool-recall.js Edit-fallback filter.
16
+ const lowSignalIsMatchExpr = `NOT ${notLowSignalTitleClause('')}`;
17
+
18
+ // Narrative-text proxy for bugfix investigations that never landed a fix.
19
+ const unresolvedNarrativeExpr = `(
20
+ LOWER(COALESCE(narrative,'')) LIKE '%not yet identified%'
21
+ OR LOWER(COALESCE(narrative,'')) LIKE '%not yet resolved%'
22
+ OR LOWER(COALESCE(narrative,'')) LIKE '%not yet fixed%'
23
+ OR LOWER(COALESCE(narrative,'')) LIKE '%root cause not%'
24
+ OR LOWER(COALESCE(narrative,'')) LIKE '%still fail%'
25
+ OR LOWER(COALESCE(narrative,'')) LIKE '%errors persisted%'
26
+ OR LOWER(COALESCE(narrative,'')) LIKE '%persisted on retry%'
27
+ )`;
28
+
29
+ const windowRow = db.prepare(`
30
+ SELECT
31
+ COUNT(*) as total,
32
+ SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson,
33
+ SUM(CASE WHEN ${lowSignalIsMatchExpr} THEN 1 ELSE 0 END) as low_signal,
34
+ SUM(CASE WHEN type = 'bugfix' THEN 1 ELSE 0 END) as bugfix_total,
35
+ SUM(CASE WHEN type = 'bugfix' AND ${unresolvedNarrativeExpr} THEN 1 ELSE 0 END) as bugfix_unresolved
36
+ FROM observations
37
+ WHERE created_at_epoch >= ? ${projectFilter}
38
+ `).get(cutoff, ...baseParams);
39
+
40
+ const allTimeRow = db.prepare(`
41
+ SELECT
42
+ COUNT(*) as total,
43
+ SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson,
44
+ SUM(CASE WHEN ${lowSignalIsMatchExpr} THEN 1 ELSE 0 END) as low_signal
45
+ FROM observations
46
+ WHERE 1=1 ${projectFilter}
47
+ `).get(...baseParams);
48
+
49
+ const typeRows = db.prepare(`
50
+ SELECT
51
+ type,
52
+ COUNT(*) as total,
53
+ SUM(CASE WHEN COALESCE(access_count, 0) > 0 THEN 1 ELSE 0 END) as accessed,
54
+ SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson
55
+ FROM observations
56
+ WHERE created_at_epoch >= ? ${projectFilter}
57
+ GROUP BY type
58
+ ORDER BY total DESC
59
+ `).all(cutoff, ...baseParams);
60
+
61
+ const topLessons = db.prepare(`
62
+ SELECT id, type, title, lesson_learned, COALESCE(access_count, 0) as ac
63
+ FROM observations
64
+ WHERE lesson_learned IS NOT NULL AND lesson_learned != ''
65
+ AND COALESCE(access_count, 0) > 0
66
+ AND COALESCE(compressed_into, 0) = 0
67
+ ${projectFilter}
68
+ ORDER BY ac DESC
69
+ LIMIT 5
70
+ `).all(...baseParams);
71
+
72
+ return { windowRow, allTimeRow, typeRows, topLessons, project, days };
73
+ }
74
+
75
+ export function formatQualityReport(data) {
76
+ const { windowRow, allTimeRow, typeRows, topLessons, project, days } = data;
77
+ const pct = (n, d) => d > 0 ? (100 * n / d).toFixed(1) : '0.0';
78
+ const scope = project ? ` — ${project}` : '';
79
+ const lines = [];
80
+ lines.push(`[mem] Quality snapshot${scope} — window: ${days}d`);
81
+ lines.push('────────────────────────────────────────────────────');
82
+ lines.push(` Writes (${days}d): ${windowRow.total} observations`);
83
+
84
+ const lessonPct = pct(windowRow.with_lesson, windowRow.total);
85
+ const allLessonPct = pct(allTimeRow.with_lesson, allTimeRow.total);
86
+ lines.push(` Lesson rate: ${windowRow.with_lesson} / ${windowRow.total} (${lessonPct}%) [all-time: ${allTimeRow.with_lesson} / ${allTimeRow.total} = ${allLessonPct}%]`);
87
+
88
+ const noisePct = pct(windowRow.low_signal, windowRow.total);
89
+ const allNoisePct = pct(allTimeRow.low_signal, allTimeRow.total);
90
+ lines.push(` LOW_SIGNAL: ${windowRow.low_signal} / ${windowRow.total} (${noisePct}%) [all-time: ${allTimeRow.low_signal} / ${allTimeRow.total} = ${allNoisePct}%]`);
91
+
92
+ if (windowRow.bugfix_total > 0) {
93
+ const unresolvedPct = pct(windowRow.bugfix_unresolved, windowRow.bugfix_total);
94
+ lines.push(` Unresolved bugfix: ${windowRow.bugfix_unresolved} / ${windowRow.bugfix_total} (${unresolvedPct}%) [investigation-only narratives — should trend ↓ with R-6 manual-save contract]`);
95
+ }
96
+ lines.push('');
97
+
98
+ if (typeRows.length > 0) {
99
+ lines.push(` Type breakdown (${days}d):`);
100
+ for (const r of typeRows) {
101
+ const hit = pct(r.accessed, r.total);
102
+ const lp = pct(r.with_lesson, r.total);
103
+ const typeLabel = r.type.padEnd(10);
104
+ lines.push(` ${typeLabel}${String(r.total).padStart(5)} hit ${hit.padStart(5)}% lesson ${lp.padStart(5)}%`);
105
+ }
106
+ lines.push('');
107
+ }
108
+
109
+ if (topLessons.length > 0) {
110
+ lines.push(' Top accessed lessons (all-time):');
111
+ for (const l of topLessons) {
112
+ const t = truncate(l.lesson_learned, 80);
113
+ lines.push(` #${l.id} [${l.type}] (${l.ac}x) ${t}`);
114
+ }
115
+ lines.push('');
116
+ }
117
+
118
+ // R-2 watchdog — format matches historical cmdStats for test stability
119
+ const lessonNum = parseFloat(lessonPct);
120
+ const noiseNum = parseFloat(noisePct);
121
+ const lessonGap = (lessonNum - 15).toFixed(1);
122
+ const noiseGap = (noiseNum - 30).toFixed(1);
123
+ const lessonStatus = lessonNum >= 15 ? '✅' : '🔴';
124
+ const noiseStatus = noiseNum <= 30 ? '✅' : '🔴';
125
+ lines.push(' Targets (R-2 watchdog):');
126
+ lines.push(` ${lessonStatus} Lesson rate ≥ 15% → currently ${lessonPct}% (gap ${lessonGap >= 0 ? '+' : ''}${lessonGap}pp)`);
127
+ lines.push(` ${noiseStatus} LOW_SIGNAL ≤ 30% → currently ${noisePct}% (gap ${noiseGap >= 0 ? '+' : ''}${noiseGap}pp)`);
128
+
129
+ return lines.join('\n');
130
+ }
package/mem-cli.mjs CHANGED
@@ -902,132 +902,17 @@ function cmdSave(db, args) {
902
902
  // Targets (aspirational, not enforced):
903
903
  // - Lesson rate ≥ 15% (current baseline ~4.4%)
904
904
  // - LOW_SIGNAL rate ≤ 30% (current baseline ~49.4%)
905
- function renderQualityReport(db, { project, days }) {
906
- const projectFilter = project ? 'AND project = ?' : '';
907
- const baseParams = project ? [project] : [];
908
- const now = Date.now();
909
- const cutoff = now - days * 86400000;
910
-
911
- // LOW_SIGNAL is the inverse of notLowSignalTitleClause() inline a SUM(CASE)
912
- // that flips the sign so we count titles that DO match the LOW_SIGNAL regex.
913
- const lowSignalIsMatchExpr = `NOT ${notLowSignalTitleClause('')}`;
914
-
915
- // Unresolved-bugfix detection: narrative-text proxies for "investigation in progress,
916
- // never reached a fix". Heuristic — false positives possible (e.g. a real lesson noting
917
- // "the bug persists in legacy clients"), but the directional signal is what we care about.
918
- // R-7 micro-experiment surfaced this pollution: ~3/5 of randomly-sampled bugfix narratives
919
- // explicitly ended with "root cause not yet identified".
920
- const unresolvedNarrativeExpr = `(
921
- LOWER(COALESCE(narrative,'')) LIKE '%not yet identified%'
922
- OR LOWER(COALESCE(narrative,'')) LIKE '%not yet resolved%'
923
- OR LOWER(COALESCE(narrative,'')) LIKE '%not yet fixed%'
924
- OR LOWER(COALESCE(narrative,'')) LIKE '%root cause not%'
925
- OR LOWER(COALESCE(narrative,'')) LIKE '%still fail%'
926
- OR LOWER(COALESCE(narrative,'')) LIKE '%errors persisted%'
927
- OR LOWER(COALESCE(narrative,'')) LIKE '%persisted on retry%'
928
- )`;
929
-
930
- // In-window aggregates
931
- const windowRow = db.prepare(`
932
- SELECT
933
- COUNT(*) as total,
934
- SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson,
935
- SUM(CASE WHEN ${lowSignalIsMatchExpr} THEN 1 ELSE 0 END) as low_signal,
936
- SUM(CASE WHEN type = 'bugfix' THEN 1 ELSE 0 END) as bugfix_total,
937
- SUM(CASE WHEN type = 'bugfix' AND ${unresolvedNarrativeExpr} THEN 1 ELSE 0 END) as bugfix_unresolved
938
- FROM observations
939
- WHERE created_at_epoch >= ? ${projectFilter}
940
- `).get(cutoff, ...baseParams);
941
-
942
- // All-time aggregates (context for recent numbers)
943
- const allTimeRow = db.prepare(`
944
- SELECT
945
- COUNT(*) as total,
946
- SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson,
947
- SUM(CASE WHEN ${lowSignalIsMatchExpr} THEN 1 ELSE 0 END) as low_signal
948
- FROM observations
949
- WHERE 1=1 ${projectFilter}
950
- `).get(...baseParams);
951
-
952
- // Per-type: count, hit rate (access_count > 0), lesson rate
953
- const typeRows = db.prepare(`
954
- SELECT
955
- type,
956
- COUNT(*) as total,
957
- SUM(CASE WHEN COALESCE(access_count, 0) > 0 THEN 1 ELSE 0 END) as accessed,
958
- SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson
959
- FROM observations
960
- WHERE created_at_epoch >= ? ${projectFilter}
961
- GROUP BY type
962
- ORDER BY total DESC
963
- `).all(cutoff, ...baseParams);
964
-
965
- // Top-5 most-accessed lessons (all-time, this project scope)
966
- const topLessons = db.prepare(`
967
- SELECT id, type, title, lesson_learned, COALESCE(access_count, 0) as ac
968
- FROM observations
969
- WHERE lesson_learned IS NOT NULL AND lesson_learned != ''
970
- AND COALESCE(access_count, 0) > 0
971
- AND COALESCE(compressed_into, 0) = 0
972
- ${projectFilter}
973
- ORDER BY ac DESC
974
- LIMIT 5
975
- `).all(...baseParams);
976
-
977
- const pct = (n, d) => d > 0 ? (100 * n / d).toFixed(1) : '0.0';
978
- const scope = project ? ` — ${project}` : '';
979
- out(`[mem] Quality snapshot${scope} — window: ${days}d`);
980
- out('────────────────────────────────────────────────────');
981
- out(` Writes (${days}d): ${windowRow.total} observations`);
982
-
983
- const lessonPct = pct(windowRow.with_lesson, windowRow.total);
984
- const allLessonPct = pct(allTimeRow.with_lesson, allTimeRow.total);
985
- out(` Lesson rate: ${windowRow.with_lesson} / ${windowRow.total} (${lessonPct}%) [all-time: ${allTimeRow.with_lesson} / ${allTimeRow.total} = ${allLessonPct}%]`);
986
-
987
- const noisePct = pct(windowRow.low_signal, windowRow.total);
988
- const allNoisePct = pct(allTimeRow.low_signal, allTimeRow.total);
989
- out(` LOW_SIGNAL: ${windowRow.low_signal} / ${windowRow.total} (${noisePct}%) [all-time: ${allTimeRow.low_signal} / ${allTimeRow.total} = ${allNoisePct}%]`);
990
-
991
- if (windowRow.bugfix_total > 0) {
992
- const unresolvedPct = pct(windowRow.bugfix_unresolved, windowRow.bugfix_total);
993
- out(` Unresolved bugfix: ${windowRow.bugfix_unresolved} / ${windowRow.bugfix_total} (${unresolvedPct}%) [investigation-only narratives — should trend ↓ with R-6 manual-save contract]`);
994
- }
995
- out('');
996
-
997
- if (typeRows.length > 0) {
998
- out(` Type breakdown (${days}d):`);
999
- for (const r of typeRows) {
1000
- const hit = pct(r.accessed, r.total);
1001
- const lp = pct(r.with_lesson, r.total);
1002
- const typeLabel = r.type.padEnd(10);
1003
- // padStart(5) on count so rows align up to 5-digit totals (99999).
1004
- out(` ${typeLabel}${String(r.total).padStart(5)} hit ${hit.padStart(5)}% lesson ${lp.padStart(5)}%`);
1005
- }
1006
- out('');
1007
- }
1008
-
1009
- if (topLessons.length > 0) {
1010
- out(' Top accessed lessons (all-time):');
1011
- for (const l of topLessons) {
1012
- const t = truncate(l.lesson_learned, 80);
1013
- out(` #${l.id} [${l.type}] (${l.ac}x) ${t}`);
1014
- }
1015
- out('');
1016
- }
1017
-
1018
- // R-2 watchdog — explicit targets make progress legible.
1019
- const lessonNum = parseFloat(lessonPct);
1020
- const noiseNum = parseFloat(noisePct);
1021
- const lessonGap = (lessonNum - 15).toFixed(1);
1022
- const noiseGap = (noiseNum - 30).toFixed(1);
1023
- const lessonStatus = lessonNum >= 15 ? '✅' : '🔴';
1024
- const noiseStatus = noiseNum <= 30 ? '✅' : '🔴';
1025
- out(' Targets (R-2 watchdog):');
1026
- out(` ${lessonStatus} Lesson rate ≥ 15% → currently ${lessonPct}% (gap ${lessonGap >= 0 ? '+' : ''}${lessonGap}pp)`);
1027
- out(` ${noiseStatus} LOW_SIGNAL ≤ 30% → currently ${noisePct}% (gap ${noiseGap >= 0 ? '+' : ''}${noiseGap}pp)`);
905
+ // Batch A CLI↔MCP alignment: CLI `stats --quality` and MCP `mem_stats({quality:true})`
906
+ // share the same computation + formatting via lib/stats-quality.mjs. This wrapper
907
+ // keeps the cmdStats call-site unchanged (stays sync-compatible) by delegating
908
+ // to a dynamic import + sync function chain inside an async caller.
909
+ async function renderQualityReport(db, { project, days }) {
910
+ const { computeQualityStats, formatQualityReport } = await import('./lib/stats-quality.mjs');
911
+ out(formatQualityReport(computeQualityStats(db, { project, days })));
1028
912
  }
1029
913
 
1030
- function cmdStats(db, args) {
914
+
915
+ async function cmdStats(db, args) {
1031
916
  const { flags } = parseArgs(args);
1032
917
  const project = flags.project ? resolveProject(db, flags.project) : null;
1033
918
  const days = parseInt(flags.days, 10) || 30;
@@ -1036,7 +921,7 @@ function cmdStats(db, args) {
1036
921
  // the baseline metric dashboard for the future Haiku prompt A/B test.
1037
922
  const quality = flags.quality === true || flags.quality === 'true';
1038
923
  if (quality) {
1039
- renderQualityReport(db, { project, days });
924
+ await renderQualityReport(db, { project, days });
1040
925
  return;
1041
926
  }
1042
927
 
@@ -2025,6 +1910,7 @@ Commands:
2025
1910
  --tier T Filter by tier (working|active|archive, observations only)
2026
1911
  --sort S Sort: relevance (default), time, importance
2027
1912
  --or Use OR instead of AND between search terms
1913
+ --include-noise Include hook-llm fallback titles ("Modified X", raw error logs)
2028
1914
 
2029
1915
  recent [N] Show N most recent observations (default 10)
2030
1916
  --project P Filter by project
@@ -2467,7 +2353,7 @@ export async function run(argv) {
2467
2353
  case 'maintain': cmdMaintain(db, cmdArgs); break;
2468
2354
  case 'optimize': await cmdOptimize(db, cmdArgs); break;
2469
2355
  case 'fts-check': cmdFtsCheck(db, cmdArgs); break;
2470
- case 'stats': cmdStats(db, cmdArgs); break;
2356
+ case 'stats': await cmdStats(db, cmdArgs); break;
2471
2357
  case 'context': cmdContext(db, cmdArgs); break;
2472
2358
  case 'browse': cmdBrowse(db, cmdArgs); break;
2473
2359
  case 'registry': cmdRegistry(db, cmdArgs); break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.34.5",
3
+ "version": "2.35.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -47,6 +47,9 @@
47
47
  "lib/plan-reader.mjs",
48
48
  "lib/startup-dashboard.mjs",
49
49
  "lib/doctor-benchmark.mjs",
50
+ "lib/doctor-drift.mjs",
51
+ "lib/stats-quality.mjs",
52
+ "lib/low-signal-patterns.mjs",
50
53
  "registry.mjs",
51
54
  "registry-retriever.mjs",
52
55
  "registry-indexer.mjs",
package/scoring-sql.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  // scoring-sql.mjs — SQL constants for BM25 scoring and temporal decay.
2
2
  // Extracted from utils.mjs for focused module boundaries.
3
3
 
4
+ import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
5
+
4
6
  // ─── Type-Differentiated Recency Decay ──────────────────────────────────────
5
7
 
6
8
  /** Recency half-life per observation type (in milliseconds) */
@@ -74,25 +76,10 @@ export const TYPE_QUALITY_CASE = `(
74
76
  * @param {string} [alias='o'] Table alias for the observations row. Use '' for unqualified.
75
77
  * @returns {string} SQL boolean expression (already parenthesized; safe to combine with AND/OR)
76
78
  */
79
+ // β refactor (#7877 applied): delegated to lib/low-signal-patterns.mjs.
80
+ // The SQL path (this), the regex path (utils.mjs::LOW_SIGNAL_TITLE), and the
81
+ // pre-tool-recall.js inline SQL now all derive from one authoritative
82
+ // pattern list. Previously hand-mirrored with "keep in sync" comments.
77
83
  export function notLowSignalTitleClause(alias = 'o') {
78
- const p = alias ? `${alias}.` : '';
79
- // Bug #2 fix: replace `title != '(error)'` (exact match only) with
80
- // `title NOT LIKE '%(error)'` (suffix match) so titles like
81
- // "gh release list ... (error)" — produced when makeEntryDesc tags a failed
82
- // tool invocation — are excluded too. The LIKE form subsumes the exact match.
83
- // Keep in sync with LOW_SIGNAL_TITLE regex in utils.mjs.
84
- return `(
85
- ${p}title NOT LIKE 'Modified %'
86
- AND ${p}title NOT LIKE 'Worked on %'
87
- AND ${p}title NOT LIKE 'Reviewed % files:%'
88
- AND ${p}title NOT LIKE 'Error while working%'
89
- AND ${p}title NOT LIKE 'Error in %'
90
- AND ${p}title NOT LIKE 'Error: %'
91
- AND ${p}title NOT LIKE '# %'
92
- AND ${p}title NOT LIKE 'node %'
93
- AND ${p}title NOT LIKE 'npm %'
94
- AND ${p}title NOT LIKE 'npx %'
95
- AND ${p}title NOT LIKE '(no description)%'
96
- AND ${p}title NOT LIKE '%(error)'
97
- )`;
84
+ return buildNotLowSignalSql(alias);
98
85
  }
@@ -70,10 +70,11 @@ try {
70
70
  // (notably sdscc) silently drop plain-text stdout from PreToolUse — the previous
71
71
  // console.log() form would render on stock CC but no-op on those variants.
72
72
  // Token budget: ~4 chars per token, 4000 token limit = 16000 chars.
73
+ const portablePath = resolvedPath.startsWith(homedir()) ? '~' + resolvedPath.slice(homedir().length) : resolvedPath;
73
74
  let additionalContext;
74
75
  if (content.length > 16000) {
75
76
  const summary = content.slice(0, 800);
76
- additionalContext = `<skill-bridge name="${row.name}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Use mem_use(name="${row.name}") to load full content.`;
77
+ additionalContext = `<skill-bridge name="${row.name}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Read("${portablePath}") to load full content.`;
77
78
  } else {
78
79
  additionalContext = `<skill-bridge name="${row.name}" source="managed">\n${content}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`;
79
80
  }
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // claude-mem-lite: PreToolUse file recall — injects lessons before Edit/Write
3
- // Lightweight standalone (~30ms): only imports better-sqlite3, fs, path, os
3
+ // Lightweight standalone (~30ms): only imports better-sqlite3, fs, path, os,
4
+ // and the pure-data lib/low-signal-patterns.mjs (zero runtime deps, ~1ms overhead).
4
5
  // Safety: readonly DB, exit 0 always, 3s timeout
5
6
 
6
7
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
7
8
  import { basename, join } from 'path';
8
9
  import { homedir } from 'os';
10
+ import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
9
11
 
10
12
  // CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR env overrides allow tests and debug tools to
11
13
  // point the hook at an isolated DB + cooldown dir without touching the user's real state.
@@ -91,14 +93,23 @@ try {
91
93
  // Parse event
92
94
  let filePath;
93
95
  let sessionId;
96
+ let toolName;
94
97
  try {
95
98
  const event = JSON.parse(input);
96
99
  filePath = event.tool_input?.file_path;
97
100
  sessionId = event.session_id || null;
101
+ toolName = event.tool_name || null;
98
102
  } catch { process.exit(0); }
99
103
 
100
104
  if (!filePath) process.exit(0);
101
105
 
106
+ // v2.34.6 Gap 3: Read-side recall with asymmetric quiet-mode. Reads have
107
+ // lower per-event information value than Edits (passive observation, may
108
+ // not lead to action), so inject less per Read. Cooldown is shared with
109
+ // Edit via per-filePath session state — Read→Edit in the same session is
110
+ // not double-injected. See CHANGELOG v2.34.6 for the data behind 120/1/no-nudge.
111
+ const isRead = toolName === 'Read';
112
+
102
113
  // v2.33.1: session-scoped cooldown. Within one session, same file recalls
103
114
  // once; cross-session, each session gets fresh nudges. Legacy 5-min global
104
115
  // cooldown only applies when no session_id is present.
@@ -135,6 +146,26 @@ try {
135
146
  // Priority: 1) observations with lesson_learned (most actionable for preventing repeat bugs)
136
147
  // 2) bugfix/decision types with importance>=2 (contextual history)
137
148
  // Skip pure change/discovery without lessons — they add noise without actionable value.
149
+ //
150
+ // v2.34.6: Read tightens the filter to require lesson_learned (drops type-OR
151
+ // fallback — decision/bugfix WITHOUT lesson add context noise to passive Reads
152
+ // where the agent isn't committed to a change). Edit/Write keep the wider
153
+ // filter for decision-point context.
154
+ // LOW_SIGNAL title patterns — auto-generated hook-llm fallback titles carry
155
+ // no actionable guidance. β refactor (#7877 applied): derived from
156
+ // lib/low-signal-patterns.mjs so this cold-start script, utils.mjs regex,
157
+ // and scoring-sql.mjs SQL share one authoritative list.
158
+ const notLowSignalSql = buildNotLowSignalSql('o');
159
+ // Edit: bugfix/decision without lesson_learned is admitted only when the
160
+ // title isn't a LOW_SIGNAL auto-fallback (those carry pipe-delimited raw
161
+ // output or filename-stubs, no guidance value for the about-to-Edit agent).
162
+ const typeFallback = isRead
163
+ ? 'AND o.lesson_learned IS NOT NULL AND o.lesson_learned != \'\''
164
+ : `AND (
165
+ (o.lesson_learned IS NOT NULL AND o.lesson_learned != '')
166
+ OR (o.type IN ('bugfix', 'decision') AND ${notLowSignalSql})
167
+ )`;
168
+ const obsLimit = isRead ? 1 : 2;
138
169
  const rows = db.prepare(`
139
170
  SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned
140
171
  FROM observations o
@@ -145,14 +176,11 @@ try {
145
176
  AND o.superseded_at IS NULL
146
177
  AND o.created_at_epoch > ?
147
178
  AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
148
- AND (
149
- (o.lesson_learned IS NOT NULL AND o.lesson_learned != '')
150
- OR o.type IN ('bugfix', 'decision')
151
- )
179
+ ${typeFallback}
152
180
  ORDER BY
153
181
  CASE WHEN o.lesson_learned IS NOT NULL AND o.lesson_learned != '' THEN 0 ELSE 1 END,
154
182
  o.created_at_epoch DESC
155
- LIMIT 2
183
+ LIMIT ${obsLimit}
156
184
  `).all(project, cutoff, filePath, likePattern);
157
185
 
158
186
  // T9: also query the `events` table — after T9, bugfix/lesson/decision/etc.
@@ -163,6 +191,11 @@ try {
163
191
  // matching "myfoo.mjs".
164
192
  const fnameEscaped = fname.replace(/%/g, '\\%').replace(/_/g, '\\_');
165
193
  const filePathEscaped = filePath.replace(/%/g, '\\%').replace(/_/g, '\\_');
194
+ // v2.34.6: Read also tightens the events query — only rows with a non-empty
195
+ // body (= lesson equivalent). Edit path keeps the wider net since the agent
196
+ // is about to change the file and benefits from any contextual signal.
197
+ const eventsBodyFilter = isRead ? "AND body IS NOT NULL AND body != ''" : '';
198
+ const eventsLimit = isRead ? 1 : 2;
166
199
  let eventRows = [];
167
200
  try {
168
201
  eventRows = db.prepare(`
@@ -173,25 +206,29 @@ try {
173
206
  AND superseded_at_epoch IS NULL
174
207
  AND created_at_epoch > ?
175
208
  AND (file_paths LIKE ? ESCAPE '\\' OR file_paths LIKE ? ESCAPE '\\')
209
+ ${eventsBodyFilter}
176
210
  ORDER BY created_at_epoch DESC
177
- LIMIT 2
211
+ LIMIT ${eventsLimit}
178
212
  `).all(project, cutoff, `%"${fnameEscaped}"%`, `%"${filePathEscaped}"%`);
179
213
  } catch { /* events table may not exist on pre-v2.31 DBs — silent */ }
180
214
 
181
- // Merge: observations first (they carry richer lesson_learned), then events,
182
- // capped at 3 total so the injected context stays small per Edit/Write.
183
- const allRows = [...rows, ...eventRows].slice(0, 3);
215
+ // Merge: observations first (they carry richer lesson_learned), then events.
216
+ // Edit/Write caps at 3 total; Read caps at 1 (single most-actionable hit).
217
+ const mergeCap = isRead ? 1 : 3;
218
+ const allRows = [...rows, ...eventRows].slice(0, mergeCap);
184
219
 
185
220
  // v2.31 T2: emit JSON with hookSpecificOutput.additionalContext so the message
186
221
  // reliably renders across CC variants (sdscc drops plain-text stdout from PreToolUse).
187
222
  // suppressOutput:true hides it from transcript mode per CC hook docs.
188
223
  const lines = [];
224
+ // v2.34.6: Read mode uses 120-char truncation (Edit mode keeps the 240-char
225
+ // cap from R3-UX). Rationale: Read is a one-shot nudge with 1 lesson max;
226
+ // Edit is a 3-lesson decision-support injection where the fuller lesson tail
227
+ // carries the actionable "Fix:" guidance — short enough per-lesson at 240,
228
+ // but the total payload is bounded by the 3-row limit and the cooldown.
229
+ const LESSON_MAX = isRead ? 120 : 240;
189
230
  if (allRows.length > 0) {
190
231
  lines.push(`[mem] Lessons for ${fname}:`);
191
- // R3-UX: raised from 120 → 240 after measuring 97% of lessons exceed 120 chars
192
- // (p50=218, avg=247). Previous limit truncated the actionable "Fix:" tail in 80%
193
- // of lessons containing it. 3 × 240 ≈ 180 tokens/Edit — negligible context cost.
194
- const LESSON_MAX = 240;
195
232
  for (const r of allRows) {
196
233
  if (r.lesson_learned) {
197
234
  const lesson = r.lesson_learned.length > LESSON_MAX
@@ -205,22 +242,29 @@ try {
205
242
  lines.push(` #${r.id} [${r.type}] ${title}`);
206
243
  }
207
244
  }
208
- } else {
209
- // R-4: emit a short backfill reminder instead of staying silent.
210
- // Two goals: (1) Claude sees that the system actually ran, (2) Claude is
211
- // nudged to save a lesson when solving a non-obvious bug. The reminder
212
- // is one line to minimize per-Edit context cost.
245
+ } else if (!isRead) {
246
+ // R-4: Edit/Write empty short backfill reminder. Two goals: (1) Claude
247
+ // sees that the system actually ran, (2) Claude is nudged to save a lesson
248
+ // after a non-obvious bug. Reminder is one line to keep per-Edit cost low.
249
+ //
250
+ // v2.34.6: Read does NOT emit this nudge. Read is passive — the agent
251
+ // isn't necessarily about to solve anything, so /lesson prompts are noise.
252
+ // Empty Reads exit silently, saving ~60 tokens × (every empty-file Read).
213
253
  lines.push(`[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: /lesson --file ${fname} "<root cause + fix>"`);
214
254
  }
215
255
 
216
- process.stdout.write(JSON.stringify({
217
- suppressOutput: true,
218
- hookSpecificOutput: {
219
- hookEventName: 'PreToolUse',
220
- additionalContext: lines.join('\n'),
221
- },
222
- }));
223
- // Cooldown applies to BOTH branches so the reminder doesn't spam every Edit.
256
+ if (lines.length > 0) {
257
+ process.stdout.write(JSON.stringify({
258
+ suppressOutput: true,
259
+ hookSpecificOutput: {
260
+ hookEventName: 'PreToolUse',
261
+ additionalContext: lines.join('\n'),
262
+ },
263
+ }));
264
+ }
265
+ // Cooldown applies on ALL branches (including silent-Read) so subsequent
266
+ // calls on the same file in the same session don't re-query — preserving
267
+ // the per-filePath invariant that underpins Read→Edit dedup.
224
268
  cooldown[filePath] = now;
225
269
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
226
270
  } catch {
package/server.mjs CHANGED
@@ -105,9 +105,22 @@ const RECENCY_HALF_LIFE_MS = DEFAULT_DECAY_HALF_LIFE_MS;
105
105
 
106
106
  // ─── MCP Server ─────────────────────────────────────────────────────────────
107
107
 
108
+ // Emit one-line instructions-mode trace on stderr so debugging the "why did
109
+ // the server send BASE instead of BASE+VERBOSE?" path doesn't require reading
110
+ // three files (server.mjs → hook-shared.mjs → memdir.mjs). CLAUDE_MEM_QUIET_TRACE=0
111
+ // opts out. stderr doesn't pollute the MCP stdio protocol channel.
112
+ const _quiet = effectiveQuiet();
113
+ if (process.env.CLAUDE_MEM_QUIET_TRACE !== '0') {
114
+ const reason = process.env.MEM_QUIET_HOOKS === '1'
115
+ ? 'env:MEM_QUIET_HOOKS=1'
116
+ : _quiet ? 'adopted:MEMORY.md-sentinel' : 'none';
117
+ const mode = _quiet ? 'BASE' : 'BASE+VERBOSE';
118
+ process.stderr.write(`[mem] instructions: ${mode} reason=${reason}\n`);
119
+ }
120
+
108
121
  const server = new McpServer(
109
122
  { name: 'claude-mem-lite', version: PKG_VERSION },
110
- { instructions: buildServerInstructions(effectiveQuiet()) },
123
+ { instructions: buildServerInstructions(_quiet) },
111
124
  );
112
125
 
113
126
  // Track MCP request activity for idle-time cleanup (see idle timer below)
@@ -558,7 +571,13 @@ server.registerTool(
558
571
  if (args.project) args = { ...args, project: resolveProject(args.project) };
559
572
  const limit = args.limit ?? 20;
560
573
  const offset = args.offset ?? 0;
561
- const ftsQuery = sanitizeFtsQuery(args.query);
574
+ // args.or (Batch A CLI↔MCP alignment): force OR from start, matching
575
+ // CLI `search --or`. The default path still does AND with OR-fallback
576
+ // inside searchObservations when AND returns 0.
577
+ let ftsQuery = sanitizeFtsQuery(args.query);
578
+ if (ftsQuery && args.or) {
579
+ ftsQuery = relaxFtsQueryToOr(ftsQuery) || ftsQuery;
580
+ }
562
581
  const searchType = args.type;
563
582
  const currentProject = inferProject();
564
583
 
@@ -1083,6 +1102,16 @@ server.registerTool(
1083
1102
  safeHandler(async (args) => {
1084
1103
  if (args.project) args = { ...args, project: resolveProject(args.project) };
1085
1104
  const days = args.days ?? 30;
1105
+
1106
+ // Batch A CLI↔MCP alignment: quality:true → quality dashboard (lesson
1107
+ // rate, LOW_SIGNAL rate, per-type hit/lesson %, top lessons, R-2 watchdog).
1108
+ // Same computation + format as CLI `stats --quality` via lib/stats-quality.mjs.
1109
+ if (args.quality) {
1110
+ const { computeQualityStats, formatQualityReport } = await import('./lib/stats-quality.mjs');
1111
+ const data = computeQualityStats(db, { project: args.project, days });
1112
+ return { content: [{ type: 'text', text: formatQualityReport(data) }] };
1113
+ }
1114
+
1086
1115
  const cutoff = Date.now() - days * 86400000;
1087
1116
  const projectFilter = args.project ? 'AND project = ?' : '';
1088
1117
  const baseParams = args.project ? [args.project] : [];
package/source-files.mjs CHANGED
@@ -34,6 +34,9 @@ export const SOURCE_FILES = [
34
34
  'lib/git-state.mjs',
35
35
  'lib/startup-dashboard.mjs',
36
36
  'lib/doctor-benchmark.mjs',
37
+ 'lib/doctor-drift.mjs',
38
+ 'lib/stats-quality.mjs',
39
+ 'lib/low-signal-patterns.mjs',
37
40
  // v2.32 invited-memory: memdir primitives + adopt/unadopt CLI
38
41
  'memdir.mjs',
39
42
  'adopt-content.mjs',
package/tool-schemas.mjs CHANGED
@@ -42,6 +42,7 @@ export const memSearchSchema = {
42
42
  offset: coerceInt.pipe(z.number().int().min(0)).optional().describe('Offset for pagination'),
43
43
  sort: z.enum(['relevance', 'time', 'importance']).optional().describe('Sort order: relevance (default, BM25), time (newest first), importance (highest first)'),
44
44
  include_noise: z.boolean().optional().describe('Include hook-llm fallback titles ("Modified X", "Worked on X", raw error logs) — hidden by default as they have ~3% access rate'),
45
+ or: coerceBool.optional().describe('Force OR semantics between query terms from the start (default: AND with automatic OR-fallback when AND returns 0). Aligns with CLI --or.'),
45
46
  };
46
47
 
47
48
  export const memRecentSchema = {
@@ -81,6 +82,7 @@ export const memSaveSchema = {
81
82
  export const memStatsSchema = {
82
83
  project: z.string().optional().describe('Filter by project'),
83
84
  days: coerceInt.pipe(z.number().int().min(1).max(365)).optional().describe('Look back N days (default 30)'),
85
+ quality: coerceBool.optional().describe('Return quality dashboard (lesson rate, LOW_SIGNAL rate, per-type hit/lesson %, top-accessed lessons, R-2 watchdog) instead of default stats. Aligns with CLI --quality.'),
84
86
  };
85
87
 
86
88
  export const memCompressSchema = {
package/utils.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { basename, dirname, resolve, sep } from 'path';
6
6
  import { execSync } from 'child_process';
7
+ import { buildLowSignalRegex } from './lib/low-signal-patterns.mjs';
7
8
 
8
9
  // ─── Re-exports from extracted modules ──────────────────────────────────────
9
10
  // Backward compatibility: all consumers import from utils.mjs
@@ -94,8 +95,12 @@ export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']);
94
95
  // 2. \(error\)$ — title ends with '(error)' (Bug #2 fix: previously this was
95
96
  // inside the prefix group with a meaningless $, so only the exact title '(error)' matched.
96
97
  // Tool-fragment titles like 'gh release list ... (error)' leaked through.)
97
- // Keep in sync with notLowSignalTitleClause() in scoring-sql.mjs.
98
- export const LOW_SIGNAL_TITLE = /^(Error (while working|in)|Error: |Modified |Worked on |Reviewed \d+ files:|# |node |npm |npx |\(no description\))|\(error\)$/;
98
+ //
99
+ // β refactor (#7877 applied): derived from lib/low-signal-patterns.mjs so the
100
+ // regex path (this) and the SQL NOT LIKE path (scoring-sql.mjs::notLowSignalTitleClause)
101
+ // and pre-tool-recall.js inline SQL all share one authoritative pattern list.
102
+ // Previously these were hand-mirrored via "keep in sync" comments.
103
+ export const LOW_SIGNAL_TITLE = buildLowSignalRegex();
99
104
 
100
105
  export function computeRuleImportance(episode) {
101
106
  let importance = 1;