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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/cli.mjs +1 -1
- package/commands/mem.md +2 -1
- package/commands/memory.md +2 -1
- package/commands/tools.md +2 -1
- package/commands/update.md +2 -1
- package/haiku-client.mjs +103 -0
- package/hook-context.mjs +213 -32
- package/hook-memory.mjs +40 -17
- package/hook.mjs +36 -134
- package/install.mjs +1 -1
- package/mem-cli.mjs +248 -34
- package/nlp.mjs +26 -0
- package/package.json +1 -5
- package/project-utils.mjs +14 -1
- package/schema.mjs +2 -1
- package/scoring-sql.mjs +46 -6
- package/scripts/pre-tool-recall.js +35 -12
- package/scripts/prompt-search-utils.mjs +39 -14
- package/scripts/user-prompt-search.js +10 -1
- package/server.mjs +123 -30
- package/skill.md +13 -26
- package/synonyms.mjs +79 -1
- package/tool-schemas.mjs +11 -0
- package/utils.mjs +9 -3
- package/commands/recall.md +0 -9
- package/commands/recent.md +0 -7
- package/commands/search.md +0 -9
- package/commands/timeline.md +0 -7
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 {
|
|
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)))
|
|
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(
|
|
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
|
-
//
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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 (
|
|
964
|
-
if (jsonOutput) { out(JSON.stringify({
|
|
965
|
-
else { out(
|
|
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(
|
|
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.
|
|
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
|
-
//
|
|
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 =
|
|
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 —
|
|
45
|
-
*
|
|
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 '
|
|
53
|
-
WHEN '
|
|
54
|
-
WHEN '
|
|
55
|
-
WHEN '
|
|
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
|
+
}
|