claude-mem-lite 2.29.0 → 2.30.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.29.0",
13
+ "version": "2.30.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.29.0",
3
+ "version": "2.30.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/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'fts-check', 'registry', 'import', 'enrich', 'help']);
2
+ const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'enrich', 'help']);
3
3
  const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'cleanup', 'cleanup-hooks', 'self-update', 'release']);
4
4
 
5
5
  const cmd = process.argv[2];
package/hook-context.mjs CHANGED
@@ -1,9 +1,15 @@
1
1
  // claude-mem-lite CLAUDE.md context injection and token budgeting
2
- // Handles adaptive time windows, token-budgeted selection, and CLAUDE.md persistence
2
+ // Handles adaptive time windows, token-budgeted selection, and legacy CLAUDE.md cleanup.
3
3
 
4
- import { join } from 'path';
4
+ import { basename, join } from 'path';
5
5
  import { readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
6
- import { estimateTokens, truncate, debugLog, debugCatch, DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS } from './utils.mjs';
6
+ import {
7
+ estimateTokens, truncate, typeIcon, fmtTime,
8
+ debugLog, debugCatch,
9
+ DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause,
10
+ } from './utils.mjs';
11
+ import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS } from './hook-shared.mjs';
12
+ import { extractUnfinishedSummary } from './hook-handoff.mjs';
7
13
 
8
14
  /**
9
15
  * Infer the project directory from environment variables or cwd.
@@ -56,11 +62,15 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
56
62
  const tier2Ago = now_ms - windows.tier2;
57
63
  const tier3Ago = now_ms - windows.tier3;
58
64
 
59
- // Candidate pool: tiered time windows by importance (adaptive)
65
+ // Candidate pool: tiered time windows by importance (adaptive).
66
+ // R1/R3: exclude LOW_SIGNAL degraded titles ("Modified X", "Worked on X",
67
+ // "Reviewed N files:", raw error logs) from the Key Context table at
68
+ // session start — they pollute the visible "Recent" table with noise.
60
69
  const obsPool = db.prepare(`
61
70
  SELECT id, type, title, narrative, importance, created_at_epoch, files_modified, lesson_learned
62
71
  FROM observations
63
72
  WHERE project = ? AND COALESCE(compressed_into, 0) = 0
73
+ AND ${notLowSignalTitleClause('')}
64
74
  AND (
65
75
  (created_at_epoch > ? AND importance >= 1)
66
76
  OR (created_at_epoch > ? AND importance >= 2)
@@ -82,9 +92,11 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
82
92
  const selectedSess = [];
83
93
  let totalTokens = 0;
84
94
 
85
- // Type quality multipliers — aligned with scoring-sql.mjs TYPE_QUALITY_CASE
86
- // Demotes bugfix (noisy error logs) and promotes high-signal types
87
- const TYPE_QUALITY = { decision: 1.5, discovery: 1.3, feature: 1.2, refactor: 1.0, change: 0.8, bugfix: 0.35 };
95
+ // Type quality multipliers — aligned with scoring-sql.mjs TYPE_QUALITY_CASE (R2).
96
+ // Weights calibrated from empirical avg access_count per type:
97
+ // decision 6.05, discovery 3.32, bugfix 2.24, feature 2.04, change 0.93, refactor 0.54.
98
+ // Pre-R2 had bugfix=0.35 (inverted vs reality — bugfixes are 2.4× more used than changes).
99
+ const TYPE_QUALITY = { decision: 1.5, discovery: 1.3, bugfix: 1.1, feature: 1.0, refactor: 0.6, change: 0.5 };
88
100
 
89
101
  // Score each candidate: value = recency * type_quality * importance, cost = tokens
90
102
  // Recency uses exponential half-life (consistent with server.mjs BM25 scoring)
@@ -154,48 +166,217 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
154
166
  }
155
167
 
156
168
  /**
157
- * Update the project's CLAUDE.md file with a context block.
158
- * Replaces existing <claude-mem-context> section or appends a new one.
159
- * Uses atomic tmp+rename write to prevent partial writes.
160
- * @param {string} contextBlock Markdown content to inject
169
+ * One-time cleanup of the legacy <claude-mem-context> block from the project's
170
+ * CLAUDE.md file. Pre-v2.30 the hook wrote a slim context snapshot here on every
171
+ * session start, causing constant git noise and stale, one-session-behind content.
172
+ * Context is now delivered exclusively via SessionStart hook stdout.
173
+ *
174
+ * Idempotent: if no legacy block (or no CLAUDE.md) exists, it is a no-op. Also
175
+ * removes the paired hint comment if present, and normalizes residual whitespace
176
+ * at the seam. Uses atomic tmp+rename write.
161
177
  */
162
- export function updateClaudeMd(contextBlock) {
178
+ export function cleanupClaudeMdLegacyBlock() {
163
179
  const claudeMdPath = join(inferProjectDir(), 'CLAUDE.md');
164
- let content = '';
165
- try { content = readFileSync(claudeMdPath, 'utf8'); } catch {}
180
+ let content;
181
+ try { content = readFileSync(claudeMdPath, 'utf8'); } catch { return; }
166
182
 
167
183
  const startTag = '<claude-mem-context>';
168
184
  const endTag = '</claude-mem-context>';
169
- const hintComment = '<!-- claude-mem-lite: auto-updated context. To avoid git noise, add CLAUDE.md to .gitignore -->';
170
- const newSection = `${startTag}\n${contextBlock}\n${endTag}`;
171
185
 
172
- // Use lastIndexOf for both tags prevents matching documentation references
173
- // to <claude-mem-context> that appear in code/markdown before the actual context block
186
+ // Use lastIndexOf so documentation references to the tag earlier in the file
187
+ // (e.g. inside a code block in architecture notes) are not accidentally swept.
174
188
  const startIdx = content.lastIndexOf(startTag);
175
189
  const endIdx = content.lastIndexOf(endTag);
190
+ if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) return;
176
191
 
177
- if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
178
- // Skip write if content is unchanged — reduces git noise
179
- const existingSection = content.slice(startIdx, endIdx + endTag.length);
180
- if (existingSection === newSection) return;
181
- // Replace from first start to last end collapses any duplicate sections into one
182
- content = content.slice(0, startIdx) + newSection + content.slice(endIdx + endTag.length);
183
- } else if (content.length > 0) {
184
- // Append to end never disturb existing CLAUDE.md structure
185
- const hint = content.includes(hintComment) ? '' : hintComment + '\n';
186
- content = content.trimEnd() + '\n\n' + hint + newSection + '\n';
187
- } else {
188
- content = hintComment + '\n' + newSection + '\n';
192
+ // Extend forward to swallow a trailing newline so we don't leave a stranded blank line.
193
+ let removeEnd = endIdx + endTag.length;
194
+ if (content[removeEnd] === '\n') removeEnd += 1;
195
+
196
+ // Extend backward if the paired hint comment sits on the line immediately before
197
+ // the start tag. The hint is the exact string the old updateClaudeMd emitted.
198
+ let removeStart = startIdx;
199
+ const hintPattern = '<!-- claude-mem-lite: auto-updated context';
200
+ const leadingSlice = content.slice(0, startIdx);
201
+ const hintIdx = leadingSlice.lastIndexOf(hintPattern);
202
+ if (hintIdx !== -1) {
203
+ const between = content.slice(hintIdx, startIdx);
204
+ if (/^<!-- claude-mem-lite: [^\n]*-->\s*$/.test(between)) {
205
+ removeStart = hintIdx;
206
+ }
189
207
  }
190
208
 
209
+ // Swallow a single preceding newline to avoid leaving a blank-line gap behind.
210
+ if (removeStart > 0 && content[removeStart - 1] === '\n') removeStart -= 1;
211
+
212
+ const cleaned = content.slice(0, removeStart) + content.slice(removeEnd);
213
+ // Collapse any ≥3 consecutive newlines to two, then ensure exactly one trailing newline.
214
+ const normalized = cleaned.replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n');
215
+
216
+ if (normalized === content) return;
217
+
191
218
  const tmp = claudeMdPath + '.mem-tmp';
192
219
  try {
193
- writeFileSync(tmp, content);
220
+ writeFileSync(tmp, normalized);
194
221
  renameSync(tmp, claudeMdPath);
195
222
  } catch (e) {
196
223
  try { unlinkSync(tmp); } catch {}
197
- debugLog('ERROR', 'updateClaudeMd', `CLAUDE.md write failed: ${e.message}`);
224
+ debugLog('ERROR', 'cleanupClaudeMdLegacyBlock', `CLAUDE.md write failed: ${e.message}`);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Assemble the full markdown body that goes inside the <claude-mem-context>
230
+ * block emitted at session start. Same shape as the inline builder hook.mjs
231
+ * used to compose directly; extracted so both the SessionStart hook AND the
232
+ * `claude-mem-lite context` CLI can read live context from the DB.
233
+ *
234
+ * Sections (in order):
235
+ * 1. Last Session (from session_summaries.latest)
236
+ * 2. File Lessons / Key Context (top importance≥2 observations)
237
+ * 3. Recent Activity fallback (when no summary and no key obs)
238
+ * 4. Working State (from latest clear handoff)
239
+ * 5. Recent (N) table (observations via selectWithTokenBudget + fallback)
240
+ *
241
+ * @param {import('better-sqlite3').Database} db Opened main DB
242
+ * @param {string} project Canonical project name (from inferProject())
243
+ * @param {Date} [now=new Date()] Clock reference for time windows and table header
244
+ * @returns {string} Joined markdown lines (without <claude-mem-context> wrappers)
245
+ */
246
+ export function buildSessionContextLines(db, project, now = new Date()) {
247
+ // 1. Token-budgeted observation selection
248
+ const selected = selectWithTokenBudget(db, project, 2000);
249
+ const observations = selected.observations;
250
+
251
+ // 2. Fallback: recent across all projects with tiered windows (when local pool is thin)
252
+ let fallbackObs = [];
253
+ if (observations.length < 3) {
254
+ const fbOneDayAgo = now.getTime() - STALE_SESSION_MS;
255
+ const fbSevenDaysAgo = now.getTime() - FALLBACK_OBS_WINDOW_MS;
256
+ fallbackObs = db.prepare(`
257
+ SELECT id, type, title, project, created_at
258
+ FROM observations
259
+ WHERE COALESCE(compressed_into, 0) = 0
260
+ AND (
261
+ (created_at_epoch > ? AND importance >= 1)
262
+ OR (created_at_epoch > ? AND importance >= 2)
263
+ )
264
+ ORDER BY created_at_epoch DESC
265
+ LIMIT 5
266
+ `).all(fbOneDayAgo, fbSevenDaysAgo);
267
+ }
268
+
269
+ // 3. Latest session summary → base summaryLines
270
+ const latestSummary = db.prepare(`
271
+ SELECT request, completed, next_steps, remaining_items, lessons, key_decisions, created_at
272
+ FROM session_summaries
273
+ WHERE project = ?
274
+ ORDER BY created_at_epoch DESC
275
+ LIMIT 1
276
+ `).get(project);
277
+
278
+ const summaryLines = buildSummaryLines(latestSummary);
279
+
280
+ // 4. Key context: top high-importance observations split into File Lessons (actionable)
281
+ // and Key Context (informational). Pushed into summaryLines.
282
+ const keyObs = db.prepare(`
283
+ SELECT o.id, o.type, o.title, o.lesson_learned, o.files_modified FROM observations o
284
+ WHERE o.project = ? AND COALESCE(o.compressed_into, 0) = 0
285
+ AND o.superseded_at IS NULL
286
+ AND COALESCE(o.importance, 1) >= 2
287
+ ORDER BY o.created_at_epoch DESC LIMIT 10
288
+ `).all(project);
289
+
290
+ if (keyObs.length > 0) {
291
+ const fileLessons = [];
292
+ const keyContext = [];
293
+
294
+ for (const o of keyObs) {
295
+ const clean = (o.title || '(untitled)')
296
+ .replace(/ → (?:ERROR: )?\{".*$/, '')
297
+ .replace(/ → (?:ERROR: )?\{[^}]*\.{3}$/, '');
298
+ const hasLesson = o.lesson_learned && o.lesson_learned.trim();
299
+ const hasFiles = o.files_modified && o.files_modified !== '[]';
300
+
301
+ if (hasLesson && hasFiles) {
302
+ try {
303
+ const files = JSON.parse(o.files_modified);
304
+ const fname = basename(Array.isArray(files) && files.length > 0 ? files[0] : '');
305
+ if (fname) {
306
+ fileLessons.push(`- ${fname}: ${truncate(o.lesson_learned, 100)} (#${o.id})`);
307
+ continue;
308
+ }
309
+ } catch { /* fall through to keyContext */ }
310
+ }
311
+ const lesson = hasLesson ? ` — ${truncate(o.lesson_learned, 60)}` : '';
312
+ keyContext.push(`- [${o.type || 'discovery'}] ${truncate(clean, 80)} (#${o.id})${lesson}`);
313
+ }
314
+
315
+ if (fileLessons.length > 0) {
316
+ summaryLines.push('### File Lessons');
317
+ summaryLines.push(...fileLessons.slice(0, 5));
318
+ summaryLines.push('');
319
+ }
320
+ if (keyContext.length > 0) {
321
+ summaryLines.push('### Key Context');
322
+ summaryLines.push(...keyContext.slice(0, 5));
323
+ summaryLines.push('');
324
+ }
325
+ } else if (!latestSummary) {
326
+ // Fallback: no summary AND no key observations — show recent activity
327
+ const recentObs = (observations.length >= 3 ? observations : fallbackObs).slice(0, 3);
328
+ if (recentObs.length > 0) {
329
+ summaryLines.push('### Recent Activity');
330
+ for (const o of recentObs) {
331
+ summaryLines.push(`- ${truncate(o.title || '(untitled)', 80)}`);
332
+ }
333
+ summaryLines.push('');
334
+ }
198
335
  }
336
+
337
+ // 5. Working state from latest /clear handoff
338
+ const prevClearHandoff = db.prepare(`
339
+ SELECT working_on, unfinished, key_files
340
+ FROM session_handoffs
341
+ WHERE project = ? AND type = 'clear'
342
+ ORDER BY created_at_epoch DESC LIMIT 1
343
+ `).get(project);
344
+
345
+ const handoffLines = [];
346
+ if (prevClearHandoff) {
347
+ handoffLines.push('### Working State (from /clear)');
348
+ if (prevClearHandoff.working_on) {
349
+ handoffLines.push(`- Working on: ${truncate(prevClearHandoff.working_on, 200)}`);
350
+ }
351
+ if (prevClearHandoff.unfinished) {
352
+ const pendingSummary = extractUnfinishedSummary(prevClearHandoff.unfinished);
353
+ if (pendingSummary) handoffLines.push(`- Unfinished: ${truncate(pendingSummary, 200)}`);
354
+ }
355
+ if (prevClearHandoff.key_files) {
356
+ try {
357
+ const files = JSON.parse(prevClearHandoff.key_files);
358
+ if (files.length > 0) handoffLines.push(`- Key files: ${files.map(f => basename(f)).join(', ')}`);
359
+ } catch { /* malformed JSON — skip */ }
360
+ }
361
+ handoffLines.push('');
362
+ }
363
+
364
+ // 6. Recent observations table
365
+ const obsLines = [];
366
+ const obsToShow = observations.length >= 3 ? observations : fallbackObs;
367
+ if (obsToShow.length > 0) {
368
+ const today = now.toISOString().slice(0, 10);
369
+ obsLines.push(`### Recent (${today})`);
370
+ obsLines.push('');
371
+ obsLines.push('| ID | Time | T | Title |');
372
+ obsLines.push('|----|------|---|-------|');
373
+ for (const o of obsToShow) {
374
+ const proj = o.project && o.project !== project ? ` (${o.project})` : '';
375
+ obsLines.push(`| #${o.id} | ${fmtTime(o.created_at)} | ${typeIcon(o.type)} | ${truncate(o.title || '(untitled)', 60)}${proj} |`);
376
+ }
377
+ }
378
+
379
+ return [...summaryLines, ...handoffLines, ...obsLines].join('\n');
199
380
  }
200
381
 
201
382
  /**
package/hook-memory.mjs CHANGED
@@ -1,13 +1,15 @@
1
1
  // claude-mem-lite — Semantic Memory Injection
2
2
  // Search past observations for relevant memories to inject as context at user-prompt time.
3
3
 
4
- import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, OBS_BM25 } from './utils.mjs';
4
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, OBS_BM25, notLowSignalTitleClause } from './utils.mjs';
5
5
 
6
6
  const MAX_MEMORY_INJECTIONS = 3;
7
7
  const MEMORY_LOOKBACK_MS = 60 * 86400000; // 60 days
8
- // Aligned with TYPE_QUALITY_CASE: high-signal types > noisy types
9
- // Bugfix raised from 0.5→0.75 to match scoring-sql.mjs; lesson_learned boost (1.5×) stacks
10
- const MEMORY_TYPE_BOOST = { decision: 1.5, discovery: 1.3, feature: 1.2, refactor: 1.0, change: 0.8, bugfix: 0.75 };
8
+ // Aligned with TYPE_QUALITY_CASE in scoring-sql.mjs (R2 rebalance).
9
+ // Weights calibrated to empirical avg access_count:
10
+ // decision 6.05, discovery 3.32, bugfix 2.24, feature 2.04, change 0.93, refactor 0.54.
11
+ // lesson_learned boost (1.5×) stacks for entries with a real takeaway.
12
+ const MEMORY_TYPE_BOOST = { decision: 1.5, discovery: 1.3, bugfix: 1.1, feature: 1.0, refactor: 0.6, change: 0.5 };
11
13
  // Adaptive BM25 thresholds — scale with corpus size to filter noise.
12
14
  // Larger corpora produce more weak matches from common words.
13
15
  const BM25_THRESHOLD = { TINY: 0, SMALL: 1.5, MEDIUM: 2.5, LARGE: 3.5 };
@@ -37,6 +39,9 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
37
39
  const excludeSet = new Set(excludeIds);
38
40
 
39
41
  // Phase 1: Same-project search (highest priority)
42
+ // R1: notLowSignalTitleClause() excludes hook-llm fallback titles
43
+ // ("Modified X", "Worked on X", "Reviewed N files:", raw error logs, etc.)
44
+ // that almost never get referenced (3.3% access rate) but compete for BM25 rank.
40
45
  const selectStmt = db.prepare(`
41
46
  SELECT o.id, o.type, o.title, o.importance, o.lesson_learned, o.project,
42
47
  ${OBS_BM25} as relevance
@@ -48,6 +53,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
48
53
  AND o.created_at_epoch > ?
49
54
  AND COALESCE(o.compressed_into, 0) = 0
50
55
  AND o.superseded_at IS NULL
56
+ AND ${notLowSignalTitleClause('o')}
51
57
  ORDER BY ${OBS_BM25}
52
58
  LIMIT 10
53
59
  `);
@@ -84,6 +90,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
84
90
  AND o.created_at_epoch > ?
85
91
  AND COALESCE(o.compressed_into, 0) = 0
86
92
  AND o.superseded_at IS NULL
93
+ AND ${notLowSignalTitleClause('o')}
87
94
  ORDER BY ${OBS_BM25}
88
95
  LIMIT 5
89
96
  `);
package/hook.mjs CHANGED
@@ -5,13 +5,13 @@
5
5
  // Background workers (slow): llm-episode, llm-summary
6
6
 
7
7
  import { randomUUID } from 'crypto';
8
- import { join, basename } from 'path';
8
+ import { join } from 'path';
9
9
  import { readFileSync, writeFileSync, unlinkSync, readdirSync, renameSync, statSync } from 'fs';
10
10
  import { homedir } from 'os';
11
11
  import {
12
- truncate, typeIcon, inferProject, detectBashSignificance,
12
+ truncate, inferProject, detectBashSignificance,
13
13
  extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
14
- makeEntryDesc, scrubSecrets, EDIT_TOOLS, debugCatch, debugLog, fmtTime,
14
+ makeEntryDesc, scrubSecrets, EDIT_TOOLS, debugCatch, debugLog,
15
15
  COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, isoWeekKey, OBS_BM25,
16
16
  } from './utils.mjs';
17
17
  import {
@@ -20,10 +20,10 @@ import {
20
20
  createEpisode, addFileToEpisode,
21
21
  writePendingEntry, mergePendingEntries, episodeHasSignificantContent,
22
22
  } from './hook-episode.mjs';
23
- import { selectWithTokenBudget, updateClaudeMd, buildSummaryLines } from './hook-context.mjs';
23
+ import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-context.mjs';
24
24
  import {
25
25
  RUNTIME_DIR, EPISODE_BUFFER_SIZE, EPISODE_TIME_GAP_MS,
26
- SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS, FALLBACK_OBS_WINDOW_MS,
26
+ SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS,
27
27
  sessionFile, getSessionId, createSessionId, openDb,
28
28
  spawnBackground,
29
29
  } from './hook-shared.mjs';
@@ -555,7 +555,7 @@ async function handleSessionStart() {
555
555
  // Mark maintenance as done (24h gate) — even though compression runs in background
556
556
  writeFileSync(maintainFile, JSON.stringify({ epoch: Date.now() }));
557
557
  // Weekly summary grouping runs in background to avoid blocking SessionStart
558
- spawnBackground('auto-compress');
558
+ if (!process.env.CLAUDE_MEM_SKIP_COMPRESS) spawnBackground('auto-compress');
559
559
  if (!process.env.CLAUDE_MEM_SKIP_OPTIMIZE) spawnBackground('llm-optimize');
560
560
  } catch (e) { debugCatch(e, 'auto-maintain'); }
561
561
  }
@@ -645,28 +645,6 @@ async function handleSessionStart() {
645
645
  }
646
646
  } catch {}
647
647
 
648
- // Token-budgeted observation selection (replaces flat LIMIT 15)
649
- const selected = selectWithTokenBudget(db, project, 2000);
650
- const observations = selected.observations;
651
-
652
- // Fallback: recent across all projects with tiered windows (M7: local variable for clarity)
653
- let fallbackObs = [];
654
- if (observations.length < 3) {
655
- const fbOneDayAgo = Date.now() - STALE_SESSION_MS;
656
- const fbSevenDaysAgo = Date.now() - FALLBACK_OBS_WINDOW_MS;
657
- fallbackObs = db.prepare(`
658
- SELECT id, type, title, project, created_at
659
- FROM observations
660
- WHERE COALESCE(compressed_into, 0) = 0
661
- AND (
662
- (created_at_epoch > ? AND importance >= 1)
663
- OR (created_at_epoch > ? AND importance >= 2)
664
- )
665
- ORDER BY created_at_epoch DESC
666
- LIMIT 5
667
- `).all(fbOneDayAgo, fbSevenDaysAgo);
668
- }
669
-
670
648
  // Fallback fast summary: if a recently completed session has no summary yet
671
649
  // (e.g. /exit → fast restart before Haiku finishes), build one synchronously.
672
650
  // Skipped when prevSessionId is set (already handled above).
@@ -708,114 +686,19 @@ async function handleSessionStart() {
708
686
  } catch (e) { debugCatch(e, 'session-start-exit-fast-summary'); }
709
687
  }
710
688
 
711
- // Latest session summary
712
- const latestSummary = db.prepare(`
713
- SELECT request, completed, next_steps, remaining_items, lessons, key_decisions, created_at
714
- FROM session_summaries
715
- WHERE project = ?
716
- ORDER BY created_at_epoch DESC
717
- LIMIT 1
718
- `).get(project);
719
-
720
- // Build summary lines (shared by stdout and CLAUDE.md)
721
- const summaryLines = buildSummaryLines(latestSummary);
722
-
723
- // Key context: top high-importance observations for CLAUDE.md persistence
724
- // Split into "File Lessons" (actionable, has lesson + file) and "Key Context" (informational)
725
- const keyObs = db.prepare(`
726
- SELECT o.id, o.type, o.title, o.lesson_learned, o.files_modified FROM observations o
727
- WHERE o.project = ? AND COALESCE(o.compressed_into, 0) = 0
728
- AND o.superseded_at IS NULL
729
- AND COALESCE(o.importance, 1) >= 2
730
- ORDER BY o.created_at_epoch DESC LIMIT 10
731
- `).all(project);
732
-
733
- if (keyObs.length > 0) {
734
- const fileLessons = [];
735
- const keyContext = [];
736
-
737
- for (const o of keyObs) {
738
- const clean = (o.title || '(untitled)')
739
- .replace(/ → (?:ERROR: )?\{".*$/, '')
740
- .replace(/ → (?:ERROR: )?\{[^}]*\.{3}$/, '');
741
- const hasLesson = o.lesson_learned && o.lesson_learned.trim();
742
- const hasFiles = o.files_modified && o.files_modified !== '[]';
743
-
744
- if (hasLesson && hasFiles) {
745
- try {
746
- const files = JSON.parse(o.files_modified);
747
- const fname = basename(Array.isArray(files) && files.length > 0 ? files[0] : '');
748
- if (fname) {
749
- fileLessons.push(`- ${fname}: ${truncate(o.lesson_learned, 100)} (#${o.id})`);
750
- continue;
751
- }
752
- } catch {}
753
- }
754
- const lesson = hasLesson ? ` — ${truncate(o.lesson_learned, 60)}` : '';
755
- keyContext.push(`- [${o.type || 'discovery'}] ${truncate(clean, 80)} (#${o.id})${lesson}`);
756
- }
757
-
758
- if (fileLessons.length > 0) {
759
- summaryLines.push('### File Lessons');
760
- summaryLines.push(...fileLessons.slice(0, 5));
761
- summaryLines.push('');
762
- }
763
- if (keyContext.length > 0) {
764
- summaryLines.push('### Key Context');
765
- summaryLines.push(...keyContext.slice(0, 5));
766
- summaryLines.push('');
767
- }
768
- } else if (!latestSummary) {
769
- // Fallback: no summary AND no key observations — show recent activity
770
- const recentObs = (observations.length >= 3 ? observations : fallbackObs).slice(0, 3);
771
- if (recentObs.length > 0) {
772
- summaryLines.push('### Recent Activity');
773
- for (const o of recentObs) {
774
- summaryLines.push(`- ${truncate(o.title || '(untitled)', 80)}`);
775
- }
776
- summaryLines.push('');
777
- }
778
- }
779
-
780
- // Working state from /clear handoff (persisted to both stdout and CLAUDE.md)
781
- const handoffLines = [];
782
- if (prevClearHandoff) {
783
- handoffLines.push('### Working State (from /clear)');
784
- if (prevClearHandoff.working_on) handoffLines.push(`- Working on: ${truncate(prevClearHandoff.working_on, 200)}`);
785
- if (prevClearHandoff.unfinished) {
786
- const pendingSummary = extractUnfinishedSummary(prevClearHandoff.unfinished);
787
- if (pendingSummary) handoffLines.push(`- Unfinished: ${truncate(pendingSummary, 200)}`);
788
- }
789
- if (prevClearHandoff.key_files) {
790
- try {
791
- const files = JSON.parse(prevClearHandoff.key_files);
792
- if (files.length > 0) handoffLines.push(`- Key files: ${files.map(f => basename(f)).join(', ')}`);
793
- } catch {}
794
- }
795
- handoffLines.push('');
796
- }
797
-
798
- // Build observations table (stdout only — not persisted to CLAUDE.md)
799
- const obsLines = [];
800
- const obsToShow = observations.length >= 3 ? observations : fallbackObs;
801
- if (obsToShow.length > 0) {
802
- const today = now.toISOString().slice(0, 10);
803
- obsLines.push(`### Recent (${today})`);
804
- obsLines.push('');
805
- obsLines.push('| ID | Time | T | Title |');
806
- obsLines.push('|----|------|---|-------|');
807
- for (const o of obsToShow) {
808
- const proj = o.project ? ` (${o.project})` : '';
809
- obsLines.push(`| #${o.id} | ${fmtTime(o.created_at)} | ${typeIcon(o.type)} | ${truncate(o.title || '(untitled)', 60)}${proj} |`);
810
- }
811
- }
689
+ // Build the full context body via shared helper (also used by `mem-cli context`).
690
+ // Queries session_summaries, key observations, clear handoff, and the
691
+ // token-budgeted observation pool directly from the DB.
692
+ const fullContext = buildSessionContextLines(db, project, now);
812
693
 
813
- // Stdout: full context (summary + handoff state + observations table)
814
- const fullContext = [...summaryLines, ...handoffLines, ...obsLines].join('\n');
694
+ // Stdout is the sole context-delivery channel. The SessionStart hook output
695
+ // is injected as a <system-reminder> at session start, giving Claude the
696
+ // full summary + handoff state + observations table fresh from the DB.
815
697
  process.stdout.write(`<claude-mem-context>\n${fullContext}\n</claude-mem-context>\n`);
816
698
 
817
- // CLAUDE.md: slim (summary + handoff state observations already in stdout)
818
- updateClaudeMd([...summaryLines, ...handoffLines].join('\n'));
699
+ // One-time migration: remove any stale <claude-mem-context> block left in
700
+ // CLAUDE.md by pre-v2.30 installs. Idempotent no-op afterwards.
701
+ cleanupClaudeMdLegacyBlock();
819
702
 
820
703
  // Pre-load TF-IDF vocabulary cache for this session (from DB, ~1ms)
821
704
  try { getVocabulary(db); } catch (e) { debugCatch(e, 'session-start-vocab'); }
package/mem-cli.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { homedir } from 'os';
6
6
  import { ensureDb, DB_PATH, REGISTRY_DB_PATH, checkFTSIntegrity, rebuildFTS } from './schema.mjs';
7
- import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, jaccardSimilarity, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, isoWeekKey, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, DEFAULT_DECAY_HALF_LIFE_MS, getCurrentBranch } from './utils.mjs';
7
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, jaccardSimilarity, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, isoWeekKey, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, DEFAULT_DECAY_HALF_LIFE_MS, getCurrentBranch, notLowSignalTitleClause, LOW_SIGNAL_TITLE } from './utils.mjs';
8
8
  import { extractCjkLikePatterns } from './nlp.mjs';
9
9
  import { resolveProject } from './project-utils.mjs';
10
10
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
@@ -13,7 +13,8 @@ import { autoBoostIfNeeded, reRankWithContext, markSuperseded, extractPRFTerms,
13
13
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
14
14
  import { searchResources } from './registry-retriever.mjs';
15
15
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
16
- import { basename, join } from 'path';
16
+ import { buildSessionContextLines } from './hook-context.mjs';
17
+ import { basename } from 'path';
17
18
  import { readFileSync } from 'fs';
18
19
 
19
20
  // OBS_BM25, TYPE_DECAY_CASE imported from utils.mjs
@@ -81,7 +82,7 @@ function cmdSearch(db, args) {
81
82
  const { positional, flags } = parseArgs(args);
82
83
  const query = positional.join(' ');
83
84
  if (!query) {
84
- fail('[mem] Usage: mem search <query> [--type TYPE] [--source SOURCE] [--limit N] [--project P] [--from DATE] [--to DATE] [--importance N] [--branch B] [--offset N] [--sort relevance|time|importance]');
85
+ fail('[mem] Usage: mem search <query> [--type TYPE] [--source SOURCE] [--limit N] [--project P] [--from DATE] [--to DATE] [--importance N] [--branch B] [--offset N] [--sort relevance|time|importance] [--include-noise]');
85
86
  return;
86
87
  }
87
88
 
@@ -118,6 +119,10 @@ function cmdSearch(db, args) {
118
119
  return;
119
120
  }
120
121
  const useOr = flags.or === true || flags.or === 'true';
122
+ // R-1: opt-in flag to surface hook-llm fallback titles ("Modified X", "Worked on X", raw
123
+ // error logs, etc.) which are otherwise filtered from default search. Use for auditing or
124
+ // when explicitly searching for a file/command that produced a degraded title.
125
+ const includeNoise = flags['include-noise'] === true || flags['include-noise'] === 'true';
121
126
 
122
127
  if (source && !['observations', 'sessions', 'prompts'].includes(source)) {
123
128
  fail(`[mem] Invalid --source "${source}". Use: observations, sessions, prompts`);
@@ -144,11 +149,11 @@ function cmdSearch(db, args) {
144
149
 
145
150
  // Search observations
146
151
  if (!effectiveSource || effectiveSource === 'observations') {
147
- let obsRows = searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minImportance, branch, offset: effectiveSource ? offset : 0 });
152
+ let obsRows = searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minImportance, branch, includeNoise, offset: effectiveSource ? offset : 0 });
148
153
  if (obsRows.length === 0) {
149
154
  const orQuery = relaxFtsQueryToOr(ftsQuery);
150
155
  if (orQuery) {
151
- try { obsRows = searchFts(db, orQuery, { type, project, limit, dateFrom, dateTo, minImportance, branch, offset: effectiveSource ? offset : 0 }); } catch {}
156
+ try { obsRows = searchFts(db, orQuery, { type, project, limit, dateFrom, dateTo, minImportance, branch, includeNoise, offset: effectiveSource ? offset : 0 }); } catch {}
152
157
  }
153
158
  }
154
159
  // Type-list fallback
@@ -179,7 +184,7 @@ function cmdSearch(db, args) {
179
184
  if (expanded.length > 0) {
180
185
  const expansionFts = expanded.map(c => `"${c.replace(/"/g, '""')}"`).join(' OR ');
181
186
  try {
182
- const expRows = searchFts(db, expansionFts, { type, project, limit, dateFrom, dateTo, minImportance, branch, offset: 0 });
187
+ const expRows = searchFts(db, expansionFts, { type, project, limit, dateFrom, dateTo, minImportance, branch, includeNoise, offset: 0 });
183
188
  for (const r of expRows) {
184
189
  if (!existingIds.has(r.id)) {
185
190
  existingIds.add(r.id);
@@ -202,7 +207,7 @@ function cmdSearch(db, args) {
202
207
  if (prfTerms.length > 0) {
203
208
  const prfFts = prfTerms.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
204
209
  try {
205
- const prfRows = searchFts(db, prfFts, { type, project, limit, dateFrom, dateTo, minImportance, branch, offset: 0 });
210
+ const prfRows = searchFts(db, prfFts, { type, project, limit, dateFrom, dateTo, minImportance, branch, includeNoise, offset: 0 });
206
211
  for (const r of prfRows) {
207
212
  if (!existingIds.has(r.id)) {
208
213
  existingIds.add(r.id);
@@ -378,7 +383,7 @@ function cmdSearch(db, args) {
378
383
  }
379
384
  }
380
385
 
381
- function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minImportance, branch, offset }) {
386
+ function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minImportance, branch, includeNoise, offset }) {
382
387
  const now = Date.now();
383
388
  // Current project for boost (2× when no explicit project filter)
384
389
  const currentProject = !project ? inferProject() : null;
@@ -396,12 +401,18 @@ function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minIm
396
401
  if (dateTo) { wheres.push('o.created_at_epoch <= ?'); whereParams.push(dateTo); }
397
402
  if (minImportance) { wheres.push('COALESCE(o.importance, 1) >= ?'); whereParams.push(minImportance); }
398
403
  if (branch) { wheres.push('o.branch = ?'); whereParams.push(branch); }
404
+ // R-1: exclude hook-llm fallback titles ("Modified X", "Worked on X", raw error logs)
405
+ // from default search. They compete for BM25 rank but have ~3% access rate. Mirrors the
406
+ // filter already applied in hook-memory.mjs, hook-context.mjs, and user-prompt-search.js.
407
+ // Use --include-noise to audit them.
408
+ if (!includeNoise) wheres.push(notLowSignalTitleClause('o'));
399
409
 
400
410
  // Param order: SELECT scoring (now, proj, proj) → WHERE (ftsQuery, filters...) → ORDER BY scoring (now, proj, proj) → LIMIT/OFFSET
401
411
  const scoreParams = [now, currentProject, currentProject];
402
412
  const params = [...scoreParams, ...whereParams, ...scoreParams, limit, offset || 0];
403
413
 
404
- // Scoring aligned with server.mjs: BM25 × type-decay × type-quality × project_boost × importance × access_bonus
414
+ // Scoring aligned with server.mjs: BM25 × type-decay × type-quality × project_boost × importance × access_bonus × lesson-boost
415
+ // R-3: lesson_learned presence adds a +0.3 multiplier (empirical: +6.3pp hit-rate lift on bugfix).
405
416
  const ftsRows = db.prepare(`
406
417
  SELECT o.id, o.type, o.title, o.subtitle, o.created_at, o.created_at_epoch, o.lesson_learned,
407
418
  o.files_modified, o.importance,
@@ -410,7 +421,8 @@ function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minIm
410
421
  * ${TYPE_QUALITY_CASE}
411
422
  * (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
412
423
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
413
- * (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0))) as score
424
+ * (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))
425
+ * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL)) as score
414
426
  FROM observations_fts
415
427
  JOIN observations o ON observations_fts.rowid = o.id
416
428
  WHERE ${wheres.join(' AND ')}
@@ -420,6 +432,7 @@ function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minIm
420
432
  * (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
421
433
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
422
434
  * (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))
435
+ * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))
423
436
  LIMIT ? OFFSET ?
424
437
  `).all(...params);
425
438
 
@@ -447,6 +460,9 @@ function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minIm
447
460
  if (dateTo && obs.created_at_epoch > dateTo) continue;
448
461
  if (minImportance && (obs.importance ?? 1) < minImportance) continue;
449
462
  if (branch && obs.branch !== branch) continue;
463
+ // R-1: LOW_SIGNAL filter also applies to vector-side additions (the SQL
464
+ // clause only filtered the FTS5 side) so RRF can't re-admit noise.
465
+ if (!includeNoise && obs.title && LOW_SIGNAL_TITLE.test(obs.title)) continue;
450
466
  rowMap.set(vr.id, obs);
451
467
  }
452
468
  }
@@ -464,6 +480,7 @@ function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minIm
464
480
  if (dateTo && obs.created_at_epoch > dateTo) return false;
465
481
  if (minImportance && (obs.importance ?? 1) < minImportance) return false;
466
482
  if (branch && obs.branch !== branch) return false;
483
+ if (!includeNoise && obs.title && LOW_SIGNAL_TITLE.test(obs.title)) return false;
467
484
  return true;
468
485
  })
469
486
  .slice(0, limit);
@@ -855,10 +872,153 @@ function cmdSave(db, args) {
855
872
  out(`[mem] Saved #${result.lastInsertRowid} [${type}] "${truncate(safeTitle, 80)}" (project: ${project})`);
856
873
  }
857
874
 
875
+ // N-1: Quality-focused stats for R-2 A/B baseline.
876
+ //
877
+ // Shows the five numbers that will tell us whether the Haiku prompt change is
878
+ // working: lesson_learned rate, LOW_SIGNAL title rate, per-type hit% and lesson%,
879
+ // and current-vs-target deltas. Designed to be eyeballed once a day during the
880
+ // A/B rollout. All metrics respect --project and --days filters.
881
+ //
882
+ // Targets (aspirational, not enforced):
883
+ // - Lesson rate ≥ 15% (current baseline ~4.4%)
884
+ // - LOW_SIGNAL rate ≤ 30% (current baseline ~49.4%)
885
+ function renderQualityReport(db, { project, days }) {
886
+ const projectFilter = project ? 'AND project = ?' : '';
887
+ const baseParams = project ? [project] : [];
888
+ const now = Date.now();
889
+ const cutoff = now - days * 86400000;
890
+
891
+ // LOW_SIGNAL is the inverse of notLowSignalTitleClause() — inline a SUM(CASE)
892
+ // that flips the sign so we count titles that DO match the LOW_SIGNAL regex.
893
+ const lowSignalIsMatchExpr = `NOT ${notLowSignalTitleClause('')}`;
894
+
895
+ // Unresolved-bugfix detection: narrative-text proxies for "investigation in progress,
896
+ // never reached a fix". Heuristic — false positives possible (e.g. a real lesson noting
897
+ // "the bug persists in legacy clients"), but the directional signal is what we care about.
898
+ // R-7 micro-experiment surfaced this pollution: ~3/5 of randomly-sampled bugfix narratives
899
+ // explicitly ended with "root cause not yet identified".
900
+ const unresolvedNarrativeExpr = `(
901
+ LOWER(COALESCE(narrative,'')) LIKE '%not yet identified%'
902
+ OR LOWER(COALESCE(narrative,'')) LIKE '%not yet resolved%'
903
+ OR LOWER(COALESCE(narrative,'')) LIKE '%not yet fixed%'
904
+ OR LOWER(COALESCE(narrative,'')) LIKE '%root cause not%'
905
+ OR LOWER(COALESCE(narrative,'')) LIKE '%still fail%'
906
+ OR LOWER(COALESCE(narrative,'')) LIKE '%errors persisted%'
907
+ OR LOWER(COALESCE(narrative,'')) LIKE '%persisted on retry%'
908
+ )`;
909
+
910
+ // In-window aggregates
911
+ const windowRow = db.prepare(`
912
+ SELECT
913
+ COUNT(*) as total,
914
+ SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson,
915
+ SUM(CASE WHEN ${lowSignalIsMatchExpr} THEN 1 ELSE 0 END) as low_signal,
916
+ SUM(CASE WHEN type = 'bugfix' THEN 1 ELSE 0 END) as bugfix_total,
917
+ SUM(CASE WHEN type = 'bugfix' AND ${unresolvedNarrativeExpr} THEN 1 ELSE 0 END) as bugfix_unresolved
918
+ FROM observations
919
+ WHERE created_at_epoch >= ? ${projectFilter}
920
+ `).get(cutoff, ...baseParams);
921
+
922
+ // All-time aggregates (context for recent numbers)
923
+ const allTimeRow = db.prepare(`
924
+ SELECT
925
+ COUNT(*) as total,
926
+ SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson,
927
+ SUM(CASE WHEN ${lowSignalIsMatchExpr} THEN 1 ELSE 0 END) as low_signal
928
+ FROM observations
929
+ WHERE 1=1 ${projectFilter}
930
+ `).get(...baseParams);
931
+
932
+ // Per-type: count, hit rate (access_count > 0), lesson rate
933
+ const typeRows = db.prepare(`
934
+ SELECT
935
+ type,
936
+ COUNT(*) as total,
937
+ SUM(CASE WHEN COALESCE(access_count, 0) > 0 THEN 1 ELSE 0 END) as accessed,
938
+ SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END) as with_lesson
939
+ FROM observations
940
+ WHERE created_at_epoch >= ? ${projectFilter}
941
+ GROUP BY type
942
+ ORDER BY total DESC
943
+ `).all(cutoff, ...baseParams);
944
+
945
+ // Top-5 most-accessed lessons (all-time, this project scope)
946
+ const topLessons = db.prepare(`
947
+ SELECT id, type, title, lesson_learned, COALESCE(access_count, 0) as ac
948
+ FROM observations
949
+ WHERE lesson_learned IS NOT NULL AND lesson_learned != ''
950
+ AND COALESCE(access_count, 0) > 0
951
+ AND COALESCE(compressed_into, 0) = 0
952
+ ${projectFilter}
953
+ ORDER BY ac DESC
954
+ LIMIT 5
955
+ `).all(...baseParams);
956
+
957
+ const pct = (n, d) => d > 0 ? (100 * n / d).toFixed(1) : '0.0';
958
+ const scope = project ? ` — ${project}` : '';
959
+ out(`[mem] Quality snapshot${scope} — window: ${days}d`);
960
+ out('────────────────────────────────────────────────────');
961
+ out(` Writes (${days}d): ${windowRow.total} observations`);
962
+
963
+ const lessonPct = pct(windowRow.with_lesson, windowRow.total);
964
+ const allLessonPct = pct(allTimeRow.with_lesson, allTimeRow.total);
965
+ out(` Lesson rate: ${windowRow.with_lesson} / ${windowRow.total} (${lessonPct}%) [all-time: ${allTimeRow.with_lesson} / ${allTimeRow.total} = ${allLessonPct}%]`);
966
+
967
+ const noisePct = pct(windowRow.low_signal, windowRow.total);
968
+ const allNoisePct = pct(allTimeRow.low_signal, allTimeRow.total);
969
+ out(` LOW_SIGNAL: ${windowRow.low_signal} / ${windowRow.total} (${noisePct}%) [all-time: ${allTimeRow.low_signal} / ${allTimeRow.total} = ${allNoisePct}%]`);
970
+
971
+ if (windowRow.bugfix_total > 0) {
972
+ const unresolvedPct = pct(windowRow.bugfix_unresolved, windowRow.bugfix_total);
973
+ out(` Unresolved bugfix: ${windowRow.bugfix_unresolved} / ${windowRow.bugfix_total} (${unresolvedPct}%) [investigation-only narratives — should trend ↓ with R-6 manual-save contract]`);
974
+ }
975
+ out('');
976
+
977
+ if (typeRows.length > 0) {
978
+ out(` Type breakdown (${days}d):`);
979
+ for (const r of typeRows) {
980
+ const hit = pct(r.accessed, r.total);
981
+ const lp = pct(r.with_lesson, r.total);
982
+ const typeLabel = r.type.padEnd(10);
983
+ // padStart(5) on count so rows align up to 5-digit totals (99999).
984
+ out(` ${typeLabel}${String(r.total).padStart(5)} hit ${hit.padStart(5)}% lesson ${lp.padStart(5)}%`);
985
+ }
986
+ out('');
987
+ }
988
+
989
+ if (topLessons.length > 0) {
990
+ out(' Top accessed lessons (all-time):');
991
+ for (const l of topLessons) {
992
+ const t = truncate(l.lesson_learned, 80);
993
+ out(` #${l.id} [${l.type}] (${l.ac}x) ${t}`);
994
+ }
995
+ out('');
996
+ }
997
+
998
+ // R-2 watchdog — explicit targets make progress legible.
999
+ const lessonNum = parseFloat(lessonPct);
1000
+ const noiseNum = parseFloat(noisePct);
1001
+ const lessonGap = (lessonNum - 15).toFixed(1);
1002
+ const noiseGap = (noiseNum - 30).toFixed(1);
1003
+ const lessonStatus = lessonNum >= 15 ? '✅' : '🔴';
1004
+ const noiseStatus = noiseNum <= 30 ? '✅' : '🔴';
1005
+ out(' Targets (R-2 watchdog):');
1006
+ out(` ${lessonStatus} Lesson rate ≥ 15% → currently ${lessonPct}% (gap ${lessonGap >= 0 ? '+' : ''}${lessonGap}pp)`);
1007
+ out(` ${noiseStatus} LOW_SIGNAL ≤ 30% → currently ${noisePct}% (gap ${noiseGap >= 0 ? '+' : ''}${noiseGap}pp)`);
1008
+ }
1009
+
858
1010
  function cmdStats(db, args) {
859
1011
  const { flags } = parseArgs(args);
860
1012
  const project = flags.project ? resolveProject(db, flags.project) : null;
861
1013
  const days = parseInt(flags.days, 10) || 30;
1014
+ // N-1: --quality routes to a separate quality-focused report (lesson rate,
1015
+ // LOW_SIGNAL rate, per-type hit+lesson %, R-2 watchdog targets). Intended as
1016
+ // the baseline metric dashboard for the future Haiku prompt A/B test.
1017
+ const quality = flags.quality === true || flags.quality === 'true';
1018
+ if (quality) {
1019
+ renderQualityReport(db, { project, days });
1020
+ return;
1021
+ }
862
1022
 
863
1023
  const projectFilter = project ? 'AND project = ?' : '';
864
1024
  const baseParams = project ? [project] : [];
@@ -965,36 +1125,22 @@ function cmdStats(db, args) {
965
1125
  out(` 🔴 Working: ${tierMap.working ?? 0} | 🟡 Active: ${tierMap.active ?? 0} | 🔵 Archive: ${tierMap.archive ?? 0}`);
966
1126
  }
967
1127
 
968
- function cmdContext(_db, args) {
1128
+ function cmdContext(db, args) {
969
1129
  const { flags } = parseArgs(args);
970
1130
  const jsonOutput = flags.json === true || flags.json === 'true' || flags.format === 'json';
971
1131
 
972
- // Read the project's CLAUDE.md and extract the context block
973
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
974
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
975
-
976
- let content;
977
- try {
978
- content = readFileSync(claudeMdPath, 'utf8');
979
- } catch {
980
- if (jsonOutput) { out(JSON.stringify({ error: 'No CLAUDE.md found' })); }
981
- else { out(`[mem] No CLAUDE.md found at ${claudeMdPath}`); }
982
- return;
983
- }
984
-
985
- const startTag = '<claude-mem-context>';
986
- const endTag = '</claude-mem-context>';
987
- const startIdx = content.lastIndexOf(startTag);
988
- const endIdx = content.lastIndexOf(endTag);
1132
+ // Generate context live from DB same builder the SessionStart hook uses.
1133
+ // Pre-v2.30 this command parsed a snapshot out of CLAUDE.md, but the hook no
1134
+ // longer writes there; DB is now the single source of truth.
1135
+ const project = flags.project ? resolveProject(db, flags.project) : inferProject();
1136
+ const block = buildSessionContextLines(db, project).trim();
989
1137
 
990
- if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
991
- if (jsonOutput) { out(JSON.stringify({ error: 'No context block found' })); }
992
- else { out('[mem] No claude-mem-context block found in CLAUDE.md'); }
1138
+ if (!block) {
1139
+ if (jsonOutput) { out(JSON.stringify({ raw: '', sections: {} })); }
1140
+ else { out(`[mem] No context yet for project "${project}"`); }
993
1141
  return;
994
1142
  }
995
1143
 
996
- const block = content.slice(startIdx + startTag.length, endIdx).trim();
997
-
998
1144
  if (jsonOutput) {
999
1145
  // Parse markdown sections into structured JSON
1000
1146
  const result = { raw: block, sections: {} };
@@ -1012,7 +1158,7 @@ function cmdContext(_db, args) {
1012
1158
  }
1013
1159
  out(JSON.stringify(result, null, 2));
1014
1160
  } else {
1015
- out(`[mem] Current context:\n${block}`);
1161
+ out(`<claude-mem-context>\n${block}\n</claude-mem-context>`);
1016
1162
  }
1017
1163
  }
1018
1164
 
@@ -1883,6 +2029,8 @@ Commands:
1883
2029
  stats Show memory statistics
1884
2030
  --project P Filter by project
1885
2031
  --days N Lookback window (default 30)
2032
+ --quality Quality dashboard: lesson rate, LOW_SIGNAL rate, per-type
2033
+ hit/lesson %, top-accessed lessons, R-2 watchdog targets
1886
2034
 
1887
2035
  context Show current CLAUDE.md context block
1888
2036
  --json Output as structured JSON
@@ -2014,22 +2162,27 @@ async function cmdOptimize(db, args) {
2014
2162
  const tasks = taskIdx >= 0 && args[taskIdx + 1] ? [args[taskIdx + 1]] : undefined;
2015
2163
  const maxIdx = args.indexOf('--max');
2016
2164
  const maxItems = maxIdx >= 0 ? parseInt(args[maxIdx + 1], 10) || 15 : 15;
2165
+ // R-7 micro: --scope wide targets bugfix/refactor/feature/decision with narrative but no
2166
+ // lesson_learned (the "Haiku judged 'none'" cases). Default 'narrow' preserves old behavior.
2167
+ const scopeIdx = args.indexOf('--scope');
2168
+ const reenrichScope = scopeIdx >= 0 && args[scopeIdx + 1] === 'wide' ? 'wide' : 'narrow';
2017
2169
 
2018
2170
  if (!run && !runAll) {
2019
2171
  const preview = optimizePreview(db);
2020
2172
  out('[mem] 🔍 LLM Optimization Preview:');
2021
- out(` Re-enrich candidates: ${preview.reenrich}`);
2173
+ out(` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}`);
2022
2174
  out(` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`);
2023
2175
  out(` Cluster-merge: ${preview.clusterMerge} clusters`);
2024
2176
  out(` Smart-compress: ${preview.smartCompress} clusters`);
2025
2177
  out(` Total: ${preview.total} items`);
2026
2178
  out('');
2027
2179
  out('Run with --run to execute, --run-all to bypass gates.');
2180
+ out('For R-7 backfill: --run --task re-enrich --scope wide --max N');
2028
2181
  return;
2029
2182
  }
2030
2183
 
2031
- out('[mem] Running LLM optimization...');
2032
- const results = await optimizeRun(db, { tasks, maxItems, force: runAll });
2184
+ out(`[mem] Running LLM optimization${reenrichScope === 'wide' ? ' (scope: wide)' : ''}...`);
2185
+ const results = await optimizeRun(db, { tasks, maxItems, force: runAll, reenrichScope });
2033
2186
 
2034
2187
  if (results.reenrich) out(` Re-enrich: ${results.reenrich.processed || 0} processed, ${results.reenrich.skipped || 0} skipped`);
2035
2188
  if (results.normalize) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.29.0",
3
+ "version": "2.30.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
package/scoring-sql.mjs CHANGED
@@ -42,18 +42,57 @@ export const TYPE_DECAY_CASE = `(
42
42
 
43
43
  /**
44
44
  * Type quality multiplier — promotes high-signal types (decisions, discoveries).
45
- * Bugfix raised from 0.35→0.75: lesson_learned bugfixes are valuable during
46
- * active debugging; raw error logs are filtered by importance, not type penalty.
45
+ * Weights calibrated from empirical avg access_count per type in production data:
46
+ * decision 6.05, discovery 3.32, bugfix 2.24, feature 2.04, change 0.93, refactor 0.54.
47
+ * The old (pre-R2) table had bugfix=0.75 < change=0.8, inverted vs reality.
47
48
  * Applied as: BM25 × time_decay × TYPE_QUALITY × project_boost × importance
48
49
  */
49
50
  export const TYPE_QUALITY_CASE = `(
50
51
  CASE o.type
51
52
  WHEN 'decision' THEN 1.5
52
53
  WHEN 'discovery' THEN 1.3
53
- WHEN 'feature' THEN 1.2
54
- WHEN 'refactor' THEN 1.0
55
- WHEN 'change' THEN 0.8
56
- WHEN 'bugfix' THEN 0.75
54
+ WHEN 'bugfix' THEN 1.1
55
+ WHEN 'feature' THEN 1.0
56
+ WHEN 'refactor' THEN 0.6
57
+ WHEN 'change' THEN 0.5
57
58
  ELSE 1.0
58
59
  END
59
60
  )`;
61
+
62
+ /**
63
+ * SQL WHERE clause fragment excluding LOW_SIGNAL degraded titles — the fallback
64
+ * titles hook-llm.mjs writes when Haiku summarization is unavailable or skipped
65
+ * (e.g. "Modified X", "Worked on X", "Reviewed N files:", raw "Error: ..." logs).
66
+ *
67
+ * Empirical data: 544 such entries in production, 18 ever accessed (3.3% rate).
68
+ * They are capped at importance=1 on write, but that alone doesn't keep them out
69
+ * of FTS5 injection when BM25 scores are competitive. This clause removes them
70
+ * from the candidate pool at the SQL level so real bugfixes/discoveries dominate.
71
+ *
72
+ * Mirrors LOW_SIGNAL_TITLE regex in utils.mjs — keep in sync.
73
+ *
74
+ * @param {string} [alias='o'] Table alias for the observations row. Use '' for unqualified.
75
+ * @returns {string} SQL boolean expression (already parenthesized; safe to combine with AND/OR)
76
+ */
77
+ 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
+ )`;
98
+ }
@@ -7,8 +7,10 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
7
7
  import { basename, join } from 'path';
8
8
  import { homedir } from 'os';
9
9
 
10
- const DB_PATH = join(homedir(), '.claude-mem-lite', 'claude-mem-lite.db');
11
- const RUNTIME_DIR = join(homedir(), '.claude-mem-lite', 'runtime');
10
+ // CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR env overrides allow tests and debug tools to
11
+ // point the hook at an isolated DB + cooldown dir without touching the user's real state.
12
+ const DB_PATH = process.env.CLAUDE_MEM_DB_PATH || join(homedir(), '.claude-mem-lite', 'claude-mem-lite.db');
13
+ const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(homedir(), '.claude-mem-lite', 'runtime');
12
14
  const COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
13
15
  const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
14
16
  const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold
@@ -127,10 +129,16 @@ try {
127
129
  console.log(` #${r.id} [${r.type}] ${title}`);
128
130
  }
129
131
  }
130
- // Update cooldown
131
- cooldown[filePath] = now;
132
- writeCooldown(cooldown);
132
+ } else {
133
+ // R-4: emit a short backfill reminder instead of staying silent.
134
+ // Two goals: (1) Claude sees that the system actually ran, (2) Claude is
135
+ // nudged to mem_save a lesson when solving a non-obvious bug. The reminder
136
+ // is one line to minimize per-Edit context cost.
137
+ console.log(`[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: claude-mem-lite save --type bugfix --lesson "<one-line root cause + fix>"`);
133
138
  }
139
+ // Cooldown applies to BOTH branches so the reminder doesn't spam every Edit.
140
+ cooldown[filePath] = now;
141
+ writeCooldown(cooldown);
134
142
  } catch {
135
143
  // Silent failure — never block editing
136
144
  } finally {
@@ -4,7 +4,7 @@
4
4
  // Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
5
5
 
6
6
  import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
7
- import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, isPathConfined } from '../utils.mjs';
7
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, isPathConfined, notLowSignalTitleClause } from '../utils.mjs';
8
8
  import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { homedir } from 'os';
@@ -27,6 +27,8 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
27
27
 
28
28
  const typeClause = typeFilter ? 'AND o.type = ?' : '';
29
29
  const now = Date.now();
30
+ // R1: notLowSignalTitleClause() excludes hook-llm degraded titles
31
+ // ("Modified X", "Worked on X", "Reviewed N files:", raw error logs).
30
32
  const sql = `
31
33
  SELECT o.id, o.type, o.title, o.lesson_learned,
32
34
  ${OBS_BM25}
@@ -40,6 +42,7 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
40
42
  AND o.importance >= 1
41
43
  AND o.created_at_epoch > ?
42
44
  AND COALESCE(o.compressed_into, 0) = 0
45
+ AND ${notLowSignalTitleClause('o')}
43
46
  ${typeClause}
44
47
  ORDER BY relevance
45
48
  LIMIT ?
@@ -75,6 +78,7 @@ function searchByFile(db, files, project, limit) {
75
78
  const escaped = basename.replace(/%/g, '\\%').replace(/_/g, '\\_');
76
79
  const likePattern = `%${escaped}`;
77
80
 
81
+ // R1: exclude LOW_SIGNAL degraded titles from file-level recall.
78
82
  const rows = db.prepare(`
79
83
  SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned
80
84
  FROM observations o
@@ -84,6 +88,7 @@ function searchByFile(db, files, project, limit) {
84
88
  AND COALESCE(o.compressed_into, 0) = 0
85
89
  AND o.created_at_epoch > ?
86
90
  AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
91
+ AND ${notLowSignalTitleClause('o')}
87
92
  ORDER BY o.created_at_epoch DESC
88
93
  LIMIT ?
89
94
  `).all(project, cutoff, file, likePattern, limit);
@@ -102,6 +107,9 @@ function searchByFile(db, files, project, limit) {
102
107
 
103
108
  function searchRecent(db, project, limit) {
104
109
  const cutoff = Date.now() - LOOKBACK_MS;
110
+ // R1: exclude LOW_SIGNAL degraded titles from "recent" recall intent
111
+ // (e.g. when user asks "what did I do earlier"). Unqualified alias because
112
+ // this query selects directly from observations with no join.
105
113
  return db.prepare(`
106
114
  SELECT id, type, title, lesson_learned
107
115
  FROM observations
@@ -109,6 +117,7 @@ function searchRecent(db, project, limit) {
109
117
  AND importance >= 1
110
118
  AND COALESCE(compressed_into, 0) = 0
111
119
  AND created_at_epoch > ?
120
+ AND ${notLowSignalTitleClause('')}
112
121
  ORDER BY created_at_epoch DESC
113
122
  LIMIT ?
114
123
  `).all(project, cutoff, limit);
package/server.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
- import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, getCurrentBranch, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined } from './utils.mjs';
7
+ import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, getCurrentBranch, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined, notLowSignalTitleClause, LOW_SIGNAL_TITLE } from './utils.mjs';
8
8
  import { extractCjkLikePatterns } from './nlp.mjs';
9
9
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
10
10
  import { ensureDb, DB_PATH, REGISTRY_DB_PATH, checkFTSIntegrity, rebuildFTS } from './schema.mjs';
@@ -155,26 +155,32 @@ function safeHandler(fn) {
155
155
 
156
156
  // Score expression variants for FTS5 queries (see Scoring Model Constants above)
157
157
  // TYPE_QUALITY_CASE demotes bugfix (×0.6) and promotes decision/discovery (×1.5/1.3)
158
+ // R-3: lesson_learned presence adds ×1.3 boost — empirical +6.3pp hit-rate lift on bugfix.
158
159
  const FULL_SCORE = `${OBS_BM25}
159
160
  * (1.0 + EXP(-0.693 * (? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
160
161
  * ${TYPE_QUALITY_CASE}
161
162
  * (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
162
163
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
163
- * (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))`;
164
+ * (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))
165
+ * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
164
166
 
165
167
  const SIMPLE_SCORE = `${OBS_BM25}
166
168
  * (1.0 + EXP(-0.693 * (? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
167
169
  * ${TYPE_QUALITY_CASE}
168
- * (0.5 + 0.5 * COALESCE(o.importance, 1))`;
170
+ * (0.5 + 0.5 * COALESCE(o.importance, 1))
171
+ * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
169
172
 
170
173
  /**
171
174
  * Build an FTS5 observation search query.
172
175
  * @param {'full'|'simple'} scoring - full includes project boost + access bonus
173
- * @param {object} opts - { multiplier, withSnippet, withOffset }
176
+ * @param {object} opts - { multiplier, withSnippet, withOffset, includeNoise }
177
+ * includeNoise=true keeps hook-llm fallback titles ("Modified X", "Worked on X", etc.);
178
+ * default false mirrors the filter already applied in hook-memory.mjs / user-prompt-search.js.
174
179
  */
175
- function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset } = {}) {
180
+ function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset, includeNoise } = {}) {
176
181
  const scoreExpr = scoring === 'full' ? FULL_SCORE : SIMPLE_SCORE;
177
182
  const mult = multiplier ? ` * ${multiplier}` : '';
183
+ const lowSignalClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
178
184
  return `
179
185
  SELECT o.id, o.type, o.title, o.subtitle, o.project, o.created_at, o.importance,
180
186
  o.files_modified,
@@ -191,6 +197,7 @@ function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset } = {})
191
197
  AND (? IS NULL OR o.created_at_epoch <= ?)
192
198
  AND (? IS NULL OR COALESCE(o.importance, 1) >= ?)
193
199
  AND (? IS NULL OR o.branch = ?)
200
+ ${lowSignalClause}
194
201
  ORDER BY score
195
202
  LIMIT ?${withOffset ? ' OFFSET ?' : ''}`;
196
203
  }
@@ -225,12 +232,14 @@ function ftsRowToResult(r, { scoreMultiplier, snippet } = {}) {
225
232
  function searchObservations(ctx) {
226
233
  const { ftsQuery, args, epochFrom, epochTo, perSourceLimit, perSourceOffset, currentProject, limit } = ctx;
227
234
  const results = [];
235
+ // R-1: hide hook-llm fallback titles unless caller explicitly opts in via include_noise=true.
236
+ const includeNoise = args.include_noise === true;
228
237
 
229
238
  if (ftsQuery) {
230
239
  const now = Date.now();
231
240
  const projectBoost = args.project ? null : currentProject;
232
241
 
233
- const rows = db.prepare(buildObsFtsQuery('full', { withSnippet: true, withOffset: true }))
242
+ const rows = db.prepare(buildObsFtsQuery('full', { withSnippet: true, withOffset: true, includeNoise }))
234
243
  .all(...buildObsFtsParams({ now, projectBoost, ftsQuery, args, epochFrom, epochTo, limit: perSourceLimit, offset: perSourceOffset }));
235
244
  for (const r of rows) results.push(ftsRowToResult(r, { snippet: true }));
236
245
 
@@ -239,7 +248,7 @@ function searchObservations(ctx) {
239
248
  const orQuery = relaxFtsQueryToOr(ftsQuery);
240
249
  if (orQuery) {
241
250
  try {
242
- const orRows = db.prepare(buildObsFtsQuery('full', { multiplier: 0.5, withSnippet: true, withOffset: true }))
251
+ const orRows = db.prepare(buildObsFtsQuery('full', { multiplier: 0.5, withSnippet: true, withOffset: true, includeNoise }))
243
252
  .all(...buildObsFtsParams({ now, projectBoost, ftsQuery: orQuery, args, epochFrom, epochTo, limit: perSourceLimit, offset: perSourceOffset }));
244
253
  for (const r of orRows) results.push(ftsRowToResult(r, { snippet: true }));
245
254
  } catch (e) { debugCatch(e, 'searchObservations-or-fallback'); }
@@ -249,8 +258,8 @@ function searchObservations(ctx) {
249
258
  // Two-phase query expansion for sparse results (only when well below limit)
250
259
  if (rows.length > 0 && results.length < Math.ceil(limit / 2)) {
251
260
  const existingIds = new Set(results.map(r => r.id));
252
- expandObsByConceptCo(ctx, now, existingIds, results);
253
- expandObsByPRF(ctx, now, rows.length, existingIds, results);
261
+ expandObsByConceptCo(ctx, now, existingIds, results, includeNoise);
262
+ expandObsByPRF(ctx, now, rows.length, existingIds, results, includeNoise);
254
263
  }
255
264
 
256
265
  // Vector search + RRF hybrid merge
@@ -279,6 +288,9 @@ function searchObservations(ctx) {
279
288
  if (epochTo !== null && obs.created_at_epoch > epochTo) continue;
280
289
  if (args.importance && (obs.importance ?? 1) < args.importance) continue;
281
290
  if (args.branch && obs.branch !== args.branch) continue;
291
+ // R-1: parity with FTS5 WHERE — vector path must also reject LOW_SIGNAL titles
292
+ // so RRF cannot re-admit what the SQL clause excluded.
293
+ if (!includeNoise && obs.title && LOW_SIGNAL_TITLE.test(obs.title)) continue;
282
294
  resultMap.set(vr.id, { source: 'obs', id: obs.id, type: obs.type, title: obs.title, subtitle: obs.subtitle, project: obs.project, date: obs.created_at, importance: obs.importance, files_modified: obs.files_modified, snippet: '' });
283
295
  }
284
296
  }
@@ -298,6 +310,7 @@ function searchObservations(ctx) {
298
310
  if (epochTo !== null && obs.created_at_epoch > epochTo) continue;
299
311
  if (args.importance && (obs.importance ?? 1) < args.importance) continue;
300
312
  if (args.branch && obs.branch !== args.branch) continue;
313
+ if (!includeNoise && obs.title && LOW_SIGNAL_TITLE.test(obs.title)) continue;
301
314
  results.push({ source: 'obs', id: obs.id, type: obs.type, title: obs.title, subtitle: obs.subtitle, project: obs.project, date: obs.created_at, importance: obs.importance, files_modified: obs.files_modified, score: -vr.similarity, snippet: '' });
302
315
  }
303
316
  }
@@ -329,14 +342,14 @@ function searchObservations(ctx) {
329
342
  return results;
330
343
  }
331
344
 
332
- function expandObsByConceptCo(ctx, now, existingIds, results) {
345
+ function expandObsByConceptCo(ctx, now, existingIds, results, includeNoise = false) {
333
346
  const { ftsQuery, args, epochFrom, epochTo, limit } = ctx;
334
347
  if (results.length >= Math.ceil(limit / 2)) return;
335
348
  const expanded = expandQueryByConcepts(db, ftsQuery, args.project);
336
349
  if (expanded.length === 0) return;
337
350
  const expansionFts = expanded.map(c => `"${c.replace(/"/g, '""')}"`).join(' OR ');
338
351
  try {
339
- const expRows = db.prepare(buildObsFtsQuery('simple'))
352
+ const expRows = db.prepare(buildObsFtsQuery('simple', { includeNoise }))
340
353
  .all(...buildObsFtsParams({ now, ftsQuery: expansionFts, args, epochFrom, epochTo, limit }));
341
354
  for (const r of expRows) {
342
355
  if (!existingIds.has(r.id)) {
@@ -347,7 +360,7 @@ function expandObsByConceptCo(ctx, now, existingIds, results) {
347
360
  } catch (e) { debugLog('WARN', 'mem_search', `concept expansion error: ${e.message}`); }
348
361
  }
349
362
 
350
- function expandObsByPRF(ctx, now, primaryCount, existingIds, results) {
363
+ function expandObsByPRF(ctx, now, primaryCount, existingIds, results, includeNoise = false) {
351
364
  const { ftsQuery, args, epochFrom, epochTo, limit } = ctx;
352
365
  if (primaryCount < 3) return;
353
366
  const topResults = db.prepare(`
@@ -362,7 +375,7 @@ function expandObsByPRF(ctx, now, primaryCount, existingIds, results) {
362
375
  if (prfTerms.length === 0) return;
363
376
  const prfFts = prfTerms.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
364
377
  try {
365
- const prfRows = db.prepare(buildObsFtsQuery('simple'))
378
+ const prfRows = db.prepare(buildObsFtsQuery('simple', { includeNoise }))
366
379
  .all(...buildObsFtsParams({ now, ftsQuery: prfFts, args, epochFrom, epochTo, limit }));
367
380
  for (const r of prfRows) {
368
381
  if (!existingIds.has(r.id)) {
package/tool-schemas.mjs CHANGED
@@ -41,6 +41,7 @@ export const memSearchSchema = {
41
41
  limit: coerceInt.pipe(z.number().int().min(1).max(100)).optional().describe('Max results (default 20)'),
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
+ 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'),
44
45
  };
45
46
 
46
47
  export const memRecentSchema = {
package/utils.mjs CHANGED
@@ -8,7 +8,7 @@ import { execSync } from 'child_process';
8
8
  // ─── Re-exports from extracted modules ──────────────────────────────────────
9
9
  // Backward compatibility: all consumers import from utils.mjs
10
10
 
11
- export { DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, OBS_FTS_COLUMNS } from './scoring-sql.mjs';
11
+ export { DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, OBS_FTS_COLUMNS, notLowSignalTitleClause } from './scoring-sql.mjs';
12
12
  export { cjkBigrams, extractCjkSynonymTokens, extractCjkKeywords, extractCjkLikePatterns, SYNONYM_MAP, expandToken, sanitizeFtsQuery, relaxFtsQueryToOr, FTS_STOP_WORDS, CJK_COMPOUNDS } from './nlp.mjs';
13
13
  export { resolveProject, _resetProjectCache } from './project-utils.mjs';
14
14
  export { scrubSecrets, SECRET_PATTERNS } from './secret-scrub.mjs';
@@ -89,7 +89,13 @@ export function clampImportance(val) {
89
89
  export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']);
90
90
 
91
91
  // Low-signal degraded title patterns — shared by hook-llm.mjs (dedup + importance cap) and hook-handoff.mjs (decision filter)
92
- export const LOW_SIGNAL_TITLE = /^(Error (while working|in)|Error: |Modified |Worked on |Reviewed \d+ files:|# |node |npm |npx |\(no description\)|\(error\)$)/;
92
+ // Two top-level alternatives:
93
+ // 1. ^(prefix1|prefix2|...) — title starts with one of the hook-llm fallback prefixes
94
+ // 2. \(error\)$ — title ends with '(error)' (Bug #2 fix: previously this was
95
+ // inside the prefix group with a meaningless $, so only the exact title '(error)' matched.
96
+ // 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\)$/;
93
99
 
94
100
  export function computeRuleImportance(episode) {
95
101
  let importance = 1;