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