claude-mem-lite 2.28.2 → 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.
package/mem-cli.mjs CHANGED
@@ -4,14 +4,17 @@
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
+ import { extractCjkLikePatterns } from './nlp.mjs';
8
9
  import { resolveProject } from './project-utils.mjs';
9
10
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
10
11
  import { getVocabulary, computeVector, vectorSearch, rrfMerge, VECTOR_SCAN_LIMIT, rebuildVocabulary, _resetVocabCache } from './tfidf.mjs';
11
12
  import { autoBoostIfNeeded, reRankWithContext, markSuperseded, extractPRFTerms, expandQueryByConcepts } from './server-internals.mjs';
12
13
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
13
14
  import { searchResources } from './registry-retriever.mjs';
14
- import { basename, join } from 'path';
15
+ import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
16
+ import { buildSessionContextLines } from './hook-context.mjs';
17
+ import { basename } from 'path';
15
18
  import { readFileSync } from 'fs';
16
19
 
17
20
  // OBS_BM25, TYPE_DECAY_CASE imported from utils.mjs
@@ -79,7 +82,7 @@ function cmdSearch(db, args) {
79
82
  const { positional, flags } = parseArgs(args);
80
83
  const query = positional.join(' ');
81
84
  if (!query) {
82
- 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]');
83
86
  return;
84
87
  }
85
88
 
@@ -116,6 +119,10 @@ function cmdSearch(db, args) {
116
119
  return;
117
120
  }
118
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';
119
126
 
120
127
  if (source && !['observations', 'sessions', 'prompts'].includes(source)) {
121
128
  fail(`[mem] Invalid --source "${source}". Use: observations, sessions, prompts`);
@@ -142,11 +149,11 @@ function cmdSearch(db, args) {
142
149
 
143
150
  // Search observations
144
151
  if (!effectiveSource || effectiveSource === 'observations') {
145
- 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 });
146
153
  if (obsRows.length === 0) {
147
154
  const orQuery = relaxFtsQueryToOr(ftsQuery);
148
155
  if (orQuery) {
149
- 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 {}
150
157
  }
151
158
  }
152
159
  // Type-list fallback
@@ -177,7 +184,7 @@ function cmdSearch(db, args) {
177
184
  if (expanded.length > 0) {
178
185
  const expansionFts = expanded.map(c => `"${c.replace(/"/g, '""')}"`).join(' OR ');
179
186
  try {
180
- 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 });
181
188
  for (const r of expRows) {
182
189
  if (!existingIds.has(r.id)) {
183
190
  existingIds.add(r.id);
@@ -200,7 +207,7 @@ function cmdSearch(db, args) {
200
207
  if (prfTerms.length > 0) {
201
208
  const prfFts = prfTerms.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
202
209
  try {
203
- 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 });
204
211
  for (const r of prfRows) {
205
212
  if (!existingIds.has(r.id)) {
206
213
  existingIds.add(r.id);
@@ -280,6 +287,31 @@ function cmdSearch(db, args) {
280
287
  LIMIT ? OFFSET ?
281
288
  `).all(...promptParams);
282
289
  for (const r of promptRows) results.push({ ...r, _source: 'prompt' });
290
+ // CJK LIKE fallback: FTS5 unicode61 can't tokenize CJK substrings in prompts
291
+ if (promptRows.length === 0) {
292
+ const cjkPatterns = extractCjkLikePatterns(query);
293
+ if (cjkPatterns.length > 0) {
294
+ const likeConds = cjkPatterns.map(() => 'p.prompt_text LIKE ?');
295
+ const likeParams = cjkPatterns.map(p => `%${p}%`);
296
+ if (project) likeParams.push(project);
297
+ if (dateFrom) likeParams.push(dateFrom);
298
+ if (dateTo) likeParams.push(dateTo);
299
+ likeParams.push(effectiveSource ? limit : limit, effectiveSource ? offset : 0);
300
+ const fallbackRows = db.prepare(`
301
+ SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
302
+ FROM user_prompts p
303
+ JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
304
+ WHERE (${likeConds.join(' OR ')})
305
+ AND p.prompt_text NOT LIKE '<task-notification>%'
306
+ ${project ? 'AND s.project = ?' : ''}
307
+ ${dateFrom ? 'AND p.created_at_epoch >= ?' : ''}
308
+ ${dateTo ? 'AND p.created_at_epoch <= ?' : ''}
309
+ ORDER BY p.created_at_epoch DESC
310
+ LIMIT ? OFFSET ?
311
+ `).all(...likeParams);
312
+ for (const r of fallbackRows) results.push({ ...r, _source: 'prompt', score: 0 });
313
+ }
314
+ }
283
315
  } catch { /* prompt FTS may not exist in older DBs */ }
284
316
  }
285
317
 
@@ -351,7 +383,7 @@ function cmdSearch(db, args) {
351
383
  }
352
384
  }
353
385
 
354
- 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 }) {
355
387
  const now = Date.now();
356
388
  // Current project for boost (2× when no explicit project filter)
357
389
  const currentProject = !project ? inferProject() : null;
@@ -369,12 +401,18 @@ function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minIm
369
401
  if (dateTo) { wheres.push('o.created_at_epoch <= ?'); whereParams.push(dateTo); }
370
402
  if (minImportance) { wheres.push('COALESCE(o.importance, 1) >= ?'); whereParams.push(minImportance); }
371
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'));
372
409
 
373
410
  // Param order: SELECT scoring (now, proj, proj) → WHERE (ftsQuery, filters...) → ORDER BY scoring (now, proj, proj) → LIMIT/OFFSET
374
411
  const scoreParams = [now, currentProject, currentProject];
375
412
  const params = [...scoreParams, ...whereParams, ...scoreParams, limit, offset || 0];
376
413
 
377
- // 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).
378
416
  const ftsRows = db.prepare(`
379
417
  SELECT o.id, o.type, o.title, o.subtitle, o.created_at, o.created_at_epoch, o.lesson_learned,
380
418
  o.files_modified, o.importance,
@@ -383,7 +421,8 @@ function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minIm
383
421
  * ${TYPE_QUALITY_CASE}
384
422
  * (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
385
423
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
386
- * (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
387
426
  FROM observations_fts
388
427
  JOIN observations o ON observations_fts.rowid = o.id
389
428
  WHERE ${wheres.join(' AND ')}
@@ -393,6 +432,7 @@ function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minIm
393
432
  * (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
394
433
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
395
434
  * (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))
435
+ * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))
396
436
  LIMIT ? OFFSET ?
397
437
  `).all(...params);
398
438
 
@@ -420,6 +460,9 @@ function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minIm
420
460
  if (dateTo && obs.created_at_epoch > dateTo) continue;
421
461
  if (minImportance && (obs.importance ?? 1) < minImportance) continue;
422
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;
423
466
  rowMap.set(vr.id, obs);
424
467
  }
425
468
  }
@@ -437,6 +480,7 @@ function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minIm
437
480
  if (dateTo && obs.created_at_epoch > dateTo) return false;
438
481
  if (minImportance && (obs.importance ?? 1) < minImportance) return false;
439
482
  if (branch && obs.branch !== branch) return false;
483
+ if (!includeNoise && obs.title && LOW_SIGNAL_TITLE.test(obs.title)) return false;
440
484
  return true;
441
485
  })
442
486
  .slice(0, limit);
@@ -828,10 +872,153 @@ function cmdSave(db, args) {
828
872
  out(`[mem] Saved #${result.lastInsertRowid} [${type}] "${truncate(safeTitle, 80)}" (project: ${project})`);
829
873
  }
830
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
+
831
1010
  function cmdStats(db, args) {
832
1011
  const { flags } = parseArgs(args);
833
1012
  const project = flags.project ? resolveProject(db, flags.project) : null;
834
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
+ }
835
1022
 
836
1023
  const projectFilter = project ? 'AND project = ?' : '';
837
1024
  const baseParams = project ? [project] : [];
@@ -938,36 +1125,22 @@ function cmdStats(db, args) {
938
1125
  out(` 🔴 Working: ${tierMap.working ?? 0} | 🟡 Active: ${tierMap.active ?? 0} | 🔵 Archive: ${tierMap.archive ?? 0}`);
939
1126
  }
940
1127
 
941
- function cmdContext(_db, args) {
1128
+ function cmdContext(db, args) {
942
1129
  const { flags } = parseArgs(args);
943
1130
  const jsonOutput = flags.json === true || flags.json === 'true' || flags.format === 'json';
944
1131
 
945
- // Read the project's CLAUDE.md and extract the context block
946
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
947
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
948
-
949
- let content;
950
- try {
951
- content = readFileSync(claudeMdPath, 'utf8');
952
- } catch {
953
- if (jsonOutput) { out(JSON.stringify({ error: 'No CLAUDE.md found' })); }
954
- else { out(`[mem] No CLAUDE.md found at ${claudeMdPath}`); }
955
- return;
956
- }
957
-
958
- const startTag = '<claude-mem-context>';
959
- const endTag = '</claude-mem-context>';
960
- const startIdx = content.lastIndexOf(startTag);
961
- 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();
962
1137
 
963
- if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
964
- if (jsonOutput) { out(JSON.stringify({ error: 'No context block found' })); }
965
- 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}"`); }
966
1141
  return;
967
1142
  }
968
1143
 
969
- const block = content.slice(startIdx + startTag.length, endIdx).trim();
970
-
971
1144
  if (jsonOutput) {
972
1145
  // Parse markdown sections into structured JSON
973
1146
  const result = { raw: block, sections: {} };
@@ -985,7 +1158,7 @@ function cmdContext(_db, args) {
985
1158
  }
986
1159
  out(JSON.stringify(result, null, 2));
987
1160
  } else {
988
- out(`[mem] Current context:\n${block}`);
1161
+ out(`<claude-mem-context>\n${block}\n</claude-mem-context>`);
989
1162
  }
990
1163
  }
991
1164
 
@@ -1856,6 +2029,8 @@ Commands:
1856
2029
  stats Show memory statistics
1857
2030
  --project P Filter by project
1858
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
1859
2034
 
1860
2035
  context Show current CLAUDE.md context block
1861
2036
  --json Output as structured JSON
@@ -1980,6 +2155,44 @@ async function cmdEnrich(argv) {
1980
2155
  }
1981
2156
  }
1982
2157
 
2158
+ async function cmdOptimize(db, args) {
2159
+ const run = args.includes('--run');
2160
+ const runAll = args.includes('--run-all');
2161
+ const taskIdx = args.indexOf('--task');
2162
+ const tasks = taskIdx >= 0 && args[taskIdx + 1] ? [args[taskIdx + 1]] : undefined;
2163
+ const maxIdx = args.indexOf('--max');
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';
2169
+
2170
+ if (!run && !runAll) {
2171
+ const preview = optimizePreview(db);
2172
+ out('[mem] 🔍 LLM Optimization Preview:');
2173
+ out(` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}`);
2174
+ out(` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`);
2175
+ out(` Cluster-merge: ${preview.clusterMerge} clusters`);
2176
+ out(` Smart-compress: ${preview.smartCompress} clusters`);
2177
+ out(` Total: ${preview.total} items`);
2178
+ out('');
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');
2181
+ return;
2182
+ }
2183
+
2184
+ out(`[mem] Running LLM optimization${reenrichScope === 'wide' ? ' (scope: wide)' : ''}...`);
2185
+ const results = await optimizeRun(db, { tasks, maxItems, force: runAll, reenrichScope });
2186
+
2187
+ if (results.reenrich) out(` Re-enrich: ${results.reenrich.processed || 0} processed, ${results.reenrich.skipped || 0} skipped`);
2188
+ if (results.normalize) {
2189
+ if (results.normalize.skipped) out(` Normalize: skipped (${results.normalize.reason})`);
2190
+ else out(` Normalize: ${results.normalize.processed || 0} updated, ${results.normalize.groups || 0} synonym groups`);
2191
+ }
2192
+ if (results.clusterMerge) out(` Cluster-merge: ${results.clusterMerge.merged || 0} merged of ${results.clusterMerge.processed || 0} clusters`);
2193
+ if (results.smartCompress) out(` Smart-compress: ${results.smartCompress.compressed || 0} compressed of ${results.smartCompress.processed || 0} clusters`);
2194
+ }
2195
+
1983
2196
  // ─── Main Entry Point ────────────────────────────────────────────────────────
1984
2197
 
1985
2198
  export async function run(argv) {
@@ -2020,6 +2233,7 @@ export async function run(argv) {
2020
2233
  case 'export': cmdExport(db, cmdArgs); break;
2021
2234
  case 'compress': cmdCompress(db, cmdArgs); break;
2022
2235
  case 'maintain': cmdMaintain(db, cmdArgs); break;
2236
+ case 'optimize': await cmdOptimize(db, cmdArgs); break;
2023
2237
  case 'fts-check': cmdFtsCheck(db, cmdArgs); break;
2024
2238
  case 'stats': cmdStats(db, cmdArgs); break;
2025
2239
  case 'context': cmdContext(db, cmdArgs); break;
package/nlp.mjs CHANGED
@@ -108,6 +108,22 @@ export function extractCjkKeywords(text) {
108
108
  return found;
109
109
  }
110
110
 
111
+ /**
112
+ * Extract CJK patterns suitable for SQL LIKE fallback when FTS5 fails on CJK text.
113
+ * Uses dictionary extraction + bigram fallback for unmatched portions.
114
+ * @param {string} query Raw query text
115
+ * @returns {string[]} CJK patterns (≥2 chars each), empty if no CJK content
116
+ */
117
+ export function extractCjkLikePatterns(query) {
118
+ if (!query || !/[\u4e00-\u9fff\u3400-\u4dbf]{2,}/.test(query)) return [];
119
+ const keywords = extractCjkKeywords(query);
120
+ // Bigrams for unmatched CJK portions
121
+ let remainder = query;
122
+ for (const w of keywords) remainder = remainder.split(w).join(' ');
123
+ const bigrams = cjkBigrams(remainder).split(' ').filter(Boolean);
124
+ return [...new Set([...keywords, ...bigrams])];
125
+ }
126
+
111
127
  // ─── FTS5 Token Formatting ──────────────────────────────────────────────────
112
128
 
113
129
  // Format a term for FTS5: quote if it contains spaces, hyphens, or special chars
@@ -166,6 +182,16 @@ export function sanitizeFtsQuery(query) {
166
182
  if (cjkWords.length > 0) {
167
183
  expandedTokens.push(...cjkWords);
168
184
  cjkExtracted = true;
185
+ // Preserve unmatched CJK portions as bigrams (don't silently drop them)
186
+ const matched = new Set(cjkWords);
187
+ let remainder = t;
188
+ for (const w of matched) remainder = remainder.split(w).join(' ');
189
+ const gapBigrams = cjkBigrams(remainder);
190
+ if (gapBigrams) {
191
+ for (const bg of gapBigrams.split(' ')) {
192
+ if (bg && !CJK_STOP_WORDS.has(bg) && !matched.has(bg)) expandedTokens.push(bg);
193
+ }
194
+ }
169
195
  continue;
170
196
  }
171
197
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.28.2",
3
+ "version": "2.30.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -64,10 +64,6 @@
64
64
  "skill.md",
65
65
  "commands/mem.md",
66
66
  "commands/memory.md",
67
- "commands/search.md",
68
- "commands/recall.md",
69
- "commands/recent.md",
70
- "commands/timeline.md",
71
67
  "commands/update.md",
72
68
  "commands/tools.md",
73
69
  "hooks/hooks.json",
package/project-utils.mjs CHANGED
@@ -20,12 +20,25 @@ export function resolveProject(db, name) {
20
20
 
21
21
  // Short name: prefer the canonical "parent--name" form (from inferProject())
22
22
  // which typically has far more data than manually-saved short names.
23
+ // 1) Exact suffix match: "mem" → "projects--mem"
23
24
  const suffixed = db.prepare(
24
25
  'SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1'
25
26
  ).get(`%--${name}`);
26
27
  if (suffixed) { _cache.set(name, suffixed.project); return suffixed.project; }
27
28
 
28
- // Fallback: synthesize canonical form from current directory
29
+ // 2) Prefix-in-suffix match: "code-graph" "projects--code-graph-mcp"
30
+ const prefixed = db.prepare(
31
+ 'SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1'
32
+ ).get(`%--${name}%`);
33
+ if (prefixed) { _cache.set(name, prefixed.project); return prefixed.project; }
34
+
35
+ // 3) Substring match: broader fallback for partial names
36
+ const substr = db.prepare(
37
+ 'SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1'
38
+ ).get(`%${name}%`);
39
+ if (substr) { _cache.set(name, substr.project); return substr.project; }
40
+
41
+ // 4) Fallback: synthesize canonical form from current directory
29
42
  const inferred = inferProject();
30
43
  if (inferred.endsWith(`--${name}`)) { _cache.set(name, inferred); return inferred; }
31
44
 
package/schema.mjs CHANGED
@@ -13,7 +13,7 @@ export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
13
13
  export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
14
14
 
15
15
  // Increment when schema changes (tables, columns, indexes, FTS, migrations)
16
- export const CURRENT_SCHEMA_VERSION = 20;
16
+ export const CURRENT_SCHEMA_VERSION = 21;
17
17
 
18
18
  const CORE_SCHEMA = `
19
19
  CREATE TABLE IF NOT EXISTS sdk_sessions (
@@ -111,6 +111,7 @@ const MIGRATIONS = [
111
111
  'ALTER TABLE observations ADD COLUMN superseded_at INTEGER DEFAULT NULL',
112
112
  'ALTER TABLE observations ADD COLUMN superseded_by INTEGER DEFAULT NULL',
113
113
  'ALTER TABLE observations ADD COLUMN last_accessed_at INTEGER DEFAULT NULL',
114
+ 'ALTER TABLE observations ADD COLUMN optimized_at INTEGER DEFAULT NULL',
114
115
  ];
115
116
 
116
117
  /**
package/scoring-sql.mjs CHANGED
@@ -41,18 +41,58 @@ export const TYPE_DECAY_CASE = `(
41
41
  )`;
42
42
 
43
43
  /**
44
- * Type quality multiplier — demotes noisy types (bugfix error logs)
45
- * and promotes high-signal types (decisions, discoveries).
44
+ * Type quality multiplier — promotes high-signal types (decisions, discoveries).
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.
46
48
  * Applied as: BM25 × time_decay × TYPE_QUALITY × project_boost × importance
47
49
  */
48
50
  export const TYPE_QUALITY_CASE = `(
49
51
  CASE o.type
50
52
  WHEN 'decision' THEN 1.5
51
53
  WHEN 'discovery' THEN 1.3
52
- WHEN 'feature' THEN 1.2
53
- WHEN 'refactor' THEN 1.0
54
- WHEN 'change' THEN 0.8
55
- WHEN 'bugfix' THEN 0.35
54
+ WHEN 'bugfix' THEN 1.1
55
+ WHEN 'feature' THEN 1.0
56
+ WHEN 'refactor' THEN 0.6
57
+ WHEN 'change' THEN 0.5
56
58
  ELSE 1.0
57
59
  END
58
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
+ }