claude-mem-lite 2.34.6 → 2.36.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.6",
13
+ "version": "2.36.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.6",
3
+ "version": "2.36.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
@@ -16,6 +16,7 @@ import {
16
16
  sessionFile, getSessionId, openDb, callLLM, sleep,
17
17
  } from './hook-shared.mjs';
18
18
  import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
19
+ import { isNoiseObservation } from './lib/low-signal-patterns.mjs';
19
20
 
20
21
  // T9: memdir-incompatible types live in the `events` table, not `observations`.
21
22
  // Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
@@ -69,6 +70,14 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
69
70
  VALUES (?, ?, ?, ?, ?, 'active')
70
71
  `).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
71
72
 
73
+ // P0: write-side noise block — LOW_SIGNAL title with no recoverable signal
74
+ // (no lesson, importance<2, empty facts, thin narrative) is dropped before
75
+ // dedup/MinHash/vector work. Opt-out: CLAUDE_MEM_KEEP_LOW_SIGNAL=1.
76
+ if (isNoiseObservation(obs)) {
77
+ debugLog('saveObservation', `dropped noise: ${truncate(obs.title || '', 60)}`);
78
+ return null;
79
+ }
80
+
72
81
  // Three-tier dedup — returns null (not throw) for dedup hits
73
82
  // Tier 1 (fast): 5-min Jaccard on titles
74
83
  const fiveMinAgo = now.getTime() - DEDUP_WINDOW_MS;
@@ -371,8 +380,9 @@ export function buildDegradedTitle(episode) {
371
380
  }
372
381
 
373
382
  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` : '';
383
+ const uniqueNames = [...new Set(files.map(f => basename(f)))];
384
+ const names = uniqueNames.slice(0, 3).join(', ');
385
+ const suffix = uniqueNames.length > 3 ? ` +${uniqueNames.length - 3} more` : '';
376
386
  if (hasError) {
377
387
  // Include the triggering command for richer context: "Error: dispatch.mjs — npm test failed"
378
388
  const errEntry = episode.entries.find(e => e.isError);
@@ -463,6 +473,38 @@ export function buildImmediateObservation(episode) {
463
473
  };
464
474
  }
465
475
 
476
+ // ─── Lesson retry prompt (P3) ───────────────────────────────────────────────
477
+
478
+ /**
479
+ * Build a lesson-focused retry prompt after Haiku's first pass for
480
+ * bugfix/decision returned null/empty/'none'. Narrow ask: one non-obvious
481
+ * insight a future session would benefit from — either root cause (bugfix)
482
+ * or tradeoff (decision).
483
+ *
484
+ * @param {object} episode
485
+ * @param {object} firstPass — parsed first-pass response (title, type, narrative)
486
+ * @returns {string} prompt
487
+ */
488
+ export function buildLessonRetryPrompt(episode, firstPass) {
489
+ const actionList = episode.entries.map((e, i) =>
490
+ `${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`
491
+ ).join('\n');
492
+ const typeHint = firstPass.type === 'bugfix'
493
+ ? 'For this bugfix: what was the root cause + how to spot it next time? Example: "FTS5 trigger fires on any UPDATE — wrap access_count writes in try/catch."'
494
+ : 'For this decision: what tradeoff was made + why? Example: "Chose single-source module over schema column because 1 drift point, not 4."';
495
+ return `A ${firstPass.type} episode just completed. First-pass title: "${firstPass.title || 'untitled'}".
496
+
497
+ Actions:
498
+ ${actionList}
499
+
500
+ ${typeHint}
501
+
502
+ If the work was purely mechanical with no insight worth remembering, reply {"lesson":"none"}.
503
+ Otherwise reply in 12-280 chars.
504
+
505
+ Reply ONLY valid JSON, no markdown fences: {"lesson":"..."}`;
506
+ }
507
+
466
508
  // ─── Background: LLM Episode Extraction (Tier 2 F) ──────────────────────────
467
509
 
468
510
  export async function handleLLMEpisode() {
@@ -505,6 +547,7 @@ Action: ${e.desc}
505
547
  Error: ${e.isError ? 'yes' : 'no'}
506
548
 
507
549
  JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1,"lesson_learned":"non-obvious insight or 'none' if routine","search_aliases":["alt query 1","alt query 2"]}
550
+ type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y because Z" / rejected an approach (e.g. "Rejected schema migration — single-source module + sync test instead"; "Heterogeneous hook events → heterogeneous context budgets"). bugfix = prior-failing path fixed with a named root cause. feature = new user-visible capability. refactor = behavior unchanged but structure improved. discovery = learned how a system works (read-heavy, no writes). change = routine edit with no new principle (default if unsure and nothing else fits).
508
551
  Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
509
552
  importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
510
553
  lesson_learned: REQUIRED field. State what was learned that isn't obvious from reading the code. Examples: "FTS5 porter stemmer doesn't tokenize CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". If purely routine with nothing learned, write "none" (not null).
@@ -522,6 +565,7 @@ Actions (${episode.entries.length} total):
522
565
  ${actionList}
523
566
 
524
567
  JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"coherent ≤80 char summary","narrative":"what was done, why, and outcome (3-5 sentences)","concepts":["keyword1","keyword2"],"facts":["specific fact 1","specific fact 2"],"importance":1,"lesson_learned":"non-obvious insight or 'none' if routine","search_aliases":["alt query 1","alt query 2"]}
568
+ type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y because Z" / rejected an approach (e.g. "Rejected schema migration — single-source module + sync test instead"; "Heterogeneous hook events → heterogeneous context budgets"). bugfix = prior-failing path fixed with a named root cause. feature = new user-visible capability. refactor = behavior unchanged but structure improved. discovery = learned how a system works (read-heavy, no writes). change = routine edit with no new principle (default if unsure and nothing else fits).
525
569
  Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
526
570
  importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
527
571
  lesson_learned: REQUIRED field. State what was learned that isn't obvious from reading the code. Examples: "FTS5 porter stemmer doesn't tokenize CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". If purely routine with nothing learned, write "none" (not null).
@@ -569,7 +613,31 @@ search_aliases: 2-6 alternative search terms someone might use to find this memo
569
613
  const rawLesson = typeof parsed.lesson_learned === 'string' ? parsed.lesson_learned.trim() : '';
570
614
  const lowSignalLesson = new Set(['none', '', 'n/a', 'null', 'todo', 'tbd', 'na', '-', 'nothing', 'nil']);
571
615
  const isLessonLowSignal = lowSignalLesson.has(rawLesson.toLowerCase()) || rawLesson.length < 12;
572
- const lessonLearned = isLessonLowSignal ? null : rawLesson.slice(0, 500);
616
+ let lessonLearned = isLessonLowSignal ? null : rawLesson.slice(0, 500);
617
+
618
+ // P3: for bugfix/decision, retry once with a lesson-focused prompt.
619
+ // These types have the highest reuse value (~72.7% hit-rate vs change
620
+ // ~16.5%), and Haiku's first pass writes NULL ~70% of the time for
621
+ // curated observations. Retry budget: 1 extra callLLM per bugfix/decision
622
+ // episode. Opt-out: CLAUDE_MEM_NO_LESSON_RETRY=1.
623
+ if (isLessonLowSignal &&
624
+ (parsed.type === 'bugfix' || parsed.type === 'decision') &&
625
+ !process.env.CLAUDE_MEM_NO_LESSON_RETRY) {
626
+ try {
627
+ const retryPrompt = buildLessonRetryPrompt(episode, parsed);
628
+ const retryRaw = callLLM(retryPrompt, 10000);
629
+ if (retryRaw) {
630
+ const retry = parseJsonFromLLM(retryRaw);
631
+ const retryLesson = typeof retry?.lesson === 'string' ? retry.lesson.trim() : '';
632
+ const retryIsLow = lowSignalLesson.has(retryLesson.toLowerCase()) || retryLesson.length < 12;
633
+ if (!retryIsLow) {
634
+ lessonLearned = retryLesson.slice(0, 500);
635
+ debugLog('DEBUG', 'llm-episode', `lesson-retry: recovered ${retryLesson.length}-char lesson for ${parsed.type}`);
636
+ }
637
+ }
638
+ } catch (e) { debugCatch(e, 'lesson-retry'); }
639
+ }
640
+
573
641
  const searchAliases = Array.isArray(parsed.search_aliases)
574
642
  ? parsed.search_aliases.slice(0, 6).join(' ')
575
643
  : null;
package/hook.mjs CHANGED
@@ -42,6 +42,7 @@ import {
42
42
  spawnBackground,
43
43
  } from './hook-shared.mjs';
44
44
  import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation } from './hook-llm.mjs';
45
+ import { extractCitationsFromTranscript, bumpCitationAccess } from './lib/citation-tracker.mjs';
45
46
  import { searchRelevantMemories } from './hook-memory.mjs';
46
47
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, extractUnfinishedSummary } from './hook-handoff.mjs';
47
48
  import { checkForUpdate } from './hook-update.mjs';
@@ -344,12 +345,16 @@ async function handleStop() {
344
345
  // This is the stable CC identifier — the mem plugin's file-based getSessionId()
345
346
  // collides across parallel sessions for the same project (see docs/bug.txt).
346
347
  let ccSessionId = null;
348
+ let transcriptPath = null;
347
349
  try {
348
350
  const raw = await readStdin();
349
351
  const hookData = JSON.parse(raw.text);
350
352
  if (typeof hookData?.session_id === 'string' && hookData.session_id.length > 0) {
351
353
  ccSessionId = hookData.session_id;
352
354
  }
355
+ if (typeof hookData?.transcript_path === 'string' && hookData.transcript_path.length > 0) {
356
+ transcriptPath = hookData.transcript_path;
357
+ }
353
358
  } catch { /* stdin unavailable — fall back to local session id */ }
354
359
 
355
360
  // Capture session info BEFORE cleanup. All DB lookups use the mem-internal id
@@ -448,6 +453,19 @@ async function handleStop() {
448
453
  }
449
454
  }
450
455
  } catch (e) { debugCatch(e, 'handleStop-fast-summary'); }
456
+
457
+ // P4: scan transcript for `#NN` observation citations in assistant text
458
+ // and bump access_count for matched rows. Closes the loop on the "cite #NN"
459
+ // contract — before P4 this was a one-way obligation with no feedback.
460
+ try {
461
+ if (transcriptPath && !process.env.CLAUDE_MEM_NO_CITATION_TRACK) {
462
+ const ids = extractCitationsFromTranscript(transcriptPath);
463
+ if (ids.size > 0) {
464
+ const n = bumpCitationAccess(db, ids, project);
465
+ debugLog('DEBUG', 'handleStop', `citations: ${ids.size} ids scanned, ${n} obs bumped`);
466
+ }
467
+ }
468
+ } catch (e) { debugCatch(e, 'handleStop-citation-track'); }
451
469
  } finally {
452
470
  db.close();
453
471
  }
package/install.mjs CHANGED
@@ -1065,11 +1065,13 @@ async function doctor() {
1065
1065
  }
1066
1066
 
1067
1067
  // Dependencies
1068
- const bsPath = join(INSTALL_DIR, 'node_modules', 'better-sqlite3');
1069
- if (existsSync(bsPath)) {
1070
- ok('better-sqlite3: installed');
1071
- } else {
1072
- 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})`);
1073
1075
  issues++;
1074
1076
  }
1075
1077
 
@@ -1193,6 +1195,26 @@ async function doctor() {
1193
1195
  warn('Update state: failed to read');
1194
1196
  }
1195
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
+
1196
1218
  // Stale temp files
1197
1219
  try {
1198
1220
  const runtimeDir = join(INSTALL_DIR, 'runtime');
@@ -1225,7 +1247,8 @@ async function doctor() {
1225
1247
  const Database = (await import('better-sqlite3')).default;
1226
1248
  const db = new Database(DB_PATH, { readonly: true });
1227
1249
  const obsCount = db.prepare('SELECT COUNT(*) as cnt FROM observations').get()?.cnt || 0;
1228
- 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;
1229
1252
  db.close();
1230
1253
  ok(`DB stats: ${sizeMB}MB, ${obsCount} observations, ${sessCount} sessions`);
1231
1254
  } catch (e) {
@@ -0,0 +1,82 @@
1
+ // Citation tracker (P4): scan Claude Code transcript for `#NN` observation-id
2
+ // citations in assistant text, then bulk-increment access_count for matched rows.
3
+ //
4
+ // Closes the loop on the CLAUDE.md "cite #NN" contract — before P4, citations
5
+ // were a one-way obligation with no measurable feedback. Now each honored
6
+ // citation bumps access_count, making contract compliance observable via
7
+ // mem_stats and preventing cited lessons from decaying into dead memory.
8
+ //
9
+ // FTS5 caveat (project_non_obvious.md): observations_au trigger fires on any
10
+ // column UPDATE including access_count. Per-row UPDATEs wrapped in try-catch
11
+ // to prevent SQLITE_CORRUPT_VTAB cascades from stopping the whole scan.
12
+
13
+ import { readFileSync, existsSync } from 'fs';
14
+ import { debugCatch } from '../utils.mjs';
15
+
16
+ // `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
17
+ // Bounded to 1-7 digits to skip URL fragments, markdown anchors, etc.
18
+ const CITATION_RE = /#(\d{1,7})\b/g;
19
+
20
+ /**
21
+ * Parse a Claude Code transcript .jsonl and extract unique observation IDs
22
+ * cited inside assistant text blocks.
23
+ *
24
+ * @param {string} transcriptPath Path to transcript file (.jsonl)
25
+ * @returns {Set<number>} unique IDs referenced as `#NN` in assistant text
26
+ */
27
+ export function extractCitationsFromTranscript(transcriptPath) {
28
+ const ids = new Set();
29
+ if (!transcriptPath || !existsSync(transcriptPath)) return ids;
30
+ let raw;
31
+ try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return ids; }
32
+ for (const line of raw.split('\n')) {
33
+ if (!line.trim()) continue;
34
+ let entry;
35
+ try { entry = JSON.parse(line); } catch { continue; }
36
+ // Claude Code transcript: one JSON per line with type='assistant' | 'user' | ...
37
+ if (entry.type !== 'assistant' || !entry.message) continue;
38
+ const content = entry.message.content;
39
+ if (!Array.isArray(content)) continue;
40
+ for (const block of content) {
41
+ if (block.type !== 'text' || typeof block.text !== 'string') continue;
42
+ CITATION_RE.lastIndex = 0;
43
+ let m;
44
+ while ((m = CITATION_RE.exec(block.text))) {
45
+ const id = Number(m[1]);
46
+ if (Number.isInteger(id) && id > 0 && id < 1e7) ids.add(id);
47
+ }
48
+ }
49
+ }
50
+ return ids;
51
+ }
52
+
53
+ /**
54
+ * Increment `access_count` (and `last_accessed_at`) for each cited observation
55
+ * that belongs to `project`. Returns the count of successful increments.
56
+ *
57
+ * Per-row UPDATE in try-catch so a single FTS-corrupted row can't abort the
58
+ * scan. Cross-project IDs are silently ignored by the WHERE clause.
59
+ *
60
+ * @param {import('better-sqlite3').Database} db
61
+ * @param {Iterable<number>} ids
62
+ * @param {string} project
63
+ * @returns {number} count of rows incremented
64
+ */
65
+ export function bumpCitationAccess(db, ids, project) {
66
+ if (!db || !ids || !project) return 0;
67
+ const idList = Array.isArray(ids) ? ids : [...ids];
68
+ if (idList.length === 0) return 0;
69
+ const stmt = db.prepare(`
70
+ UPDATE observations SET access_count = access_count + 1, last_accessed_at = ?
71
+ WHERE id = ? AND project = ?
72
+ `);
73
+ const now = Date.now();
74
+ let n = 0;
75
+ for (const id of idList) {
76
+ try {
77
+ const result = stmt.run(now, id, project);
78
+ if (result.changes > 0) n++;
79
+ } catch (e) { debugCatch(e, `bumpCitationAccess-id-${id}`); }
80
+ }
81
+ return n;
82
+ }
@@ -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,139 @@
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
+ }
61
+
62
+ // Cached singleton — isNoiseObservation is called once per observation insert.
63
+ const _LOW_SIG_RE = buildLowSignalRegex();
64
+
65
+ /**
66
+ * Detect narrative that is raw tool-output passthrough, not human/LLM prose (P2).
67
+ *
68
+ * `buildImmediateObservation` constructs narrative as
69
+ * `episode.entries.map(e => e.desc).join('; ')` where each desc is
70
+ * "cmd → stdout/stderr" from `scripts/post-tool-use.sh`. Such narratives
71
+ * have characteristic fingerprints (arrows, stack traces, diffs, test
72
+ * failure banners, absent sentence prose) that Haiku/user-written narratives
73
+ * don't. This check treats passthrough narratives as zero-signal for the
74
+ * purposes of isNoiseObservation.
75
+ *
76
+ * @param {string} narrative
77
+ * @returns {boolean} true = raw tool output, not substantive narrative
78
+ */
79
+ function _isLikelyToolOutputPassthrough(narrative) {
80
+ if (!narrative || narrative.length < 80) return false;
81
+ // post-tool-use.sh formats entries as "cmd → output"; presence of " → " in
82
+ // a long narrative is near-diagnostic of raw entry-desc passthrough.
83
+ if (/ → /.test(narrative)) return true;
84
+ // Stack-trace fingerprints that never appear in curated narratives.
85
+ if (/\n\s+at .+:\d+:\d+/.test(narrative)) return true;
86
+ if (/node:internal\//.test(narrative)) return true;
87
+ // Raw diff output.
88
+ if (/(^|\n)diff --git |(^|\n)@@ -\d/.test(narrative)) return true;
89
+ // Test-runner failure banners.
90
+ if (/(^|\n)\s*FAIL\s+|AssertionError|TypeError: |SyntaxError: /.test(narrative)) return true;
91
+ // Absent sentence prose + multi-"; " is the buildImmediateObservation join signature.
92
+ const hasSentenceBreaks = /\. [A-Z]/.test(narrative);
93
+ const semiJoins = (narrative.match(/; /g) || []).length;
94
+ if (!hasSentenceBreaks && semiJoins >= 2) return true;
95
+ return false;
96
+ }
97
+
98
+ /**
99
+ * Write-side noise filter (P0/P2). Returns true when an observation has a
100
+ * LOW_SIGNAL title AND no recoverable downstream signal — caller should skip
101
+ * insertion.
102
+ *
103
+ * Contract: a low-signal title is kept if ANY of these carry signal:
104
+ * - lesson_learned set and not 'none'
105
+ * - importance >= 2
106
+ * - facts has >=1 non-empty string
107
+ * - narrative >= 40 chars AND not raw stderr / tool-output passthrough (P2)
108
+ *
109
+ * Opt-out: env `CLAUDE_MEM_KEEP_LOW_SIGNAL=1` disables filter (preserves
110
+ * pre-v2.36 behavior — every observation is inserted regardless of signal).
111
+ *
112
+ * @param {object} obs Observation shape: { title, facts, narrative, lessonLearned|lesson_learned, importance }
113
+ * @param {object} [env=process.env] Environment (injected for testability)
114
+ * @returns {boolean} true = noise, caller should drop
115
+ */
116
+ export function isNoiseObservation(obs, env = process.env) {
117
+ if (env && env.CLAUDE_MEM_KEEP_LOW_SIGNAL === '1') return false;
118
+ const title = (obs && obs.title) || '';
119
+ if (!_LOW_SIG_RE.test(title)) return false;
120
+
121
+ const lesson = obs.lessonLearned ?? obs.lesson_learned;
122
+ if (lesson && String(lesson).trim() && String(lesson).trim().toLowerCase() !== 'none') return false;
123
+
124
+ if ((obs.importance ?? 1) >= 2) return false;
125
+
126
+ if (Array.isArray(obs.facts) &&
127
+ obs.facts.filter(f => typeof f === 'string' && f.trim().length > 0).length >= 1) {
128
+ return false;
129
+ }
130
+
131
+ const narrative = (obs.narrative || '').trim();
132
+ if (narrative.length >= 40 &&
133
+ !/^Error[: ]/i.test(narrative) &&
134
+ !_isLikelyToolOutputPassthrough(narrative)) {
135
+ return false;
136
+ }
137
+
138
+ return true;
139
+ }
@@ -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.6",
3
+ "version": "2.36.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -47,6 +47,10 @@
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",
53
+ "lib/citation-tracker.mjs",
50
54
  "registry.mjs",
51
55
  "registry-retriever.mjs",
52
56
  "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.
@@ -149,11 +151,19 @@ try {
149
151
  // fallback — decision/bugfix WITHOUT lesson add context noise to passive Reads
150
152
  // where the agent isn't committed to a change). Edit/Write keep the wider
151
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).
152
162
  const typeFallback = isRead
153
163
  ? 'AND o.lesson_learned IS NOT NULL AND o.lesson_learned != \'\''
154
164
  : `AND (
155
165
  (o.lesson_learned IS NOT NULL AND o.lesson_learned != '')
156
- OR o.type IN ('bugfix', 'decision')
166
+ OR (o.type IN ('bugfix', 'decision') AND ${notLowSignalSql})
157
167
  )`;
158
168
  const obsLimit = isRead ? 1 : 2;
159
169
  const rows = db.prepare(`
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,10 @@ 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',
40
+ 'lib/citation-tracker.mjs',
37
41
  // v2.32 invited-memory: memdir primitives + adopt/unadopt CLI
38
42
  'memdir.mjs',
39
43
  '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;