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