claude-mem-lite 2.29.0 → 2.30.1
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 +225 -32
- package/hook-handoff.mjs +65 -19
- package/hook-memory.mjs +11 -4
- package/hook.mjs +73 -145
- package/mem-cli.mjs +190 -37
- package/package.json +1 -1
- package/schema.mjs +39 -2
- 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,229 @@ 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
|
+
* @param {string|null} [currentCcSessionId=null] Claude Code session id — when provided,
|
|
245
|
+
* the "Working State (from /clear)" block is filtered to handoffs owned by this
|
|
246
|
+
* session, preventing parallel-session bleed (see docs/bug.txt).
|
|
247
|
+
* @returns {string} Joined markdown lines (without <claude-mem-context> wrappers)
|
|
248
|
+
*/
|
|
249
|
+
export function buildSessionContextLines(db, project, now = new Date(), currentCcSessionId = null) {
|
|
250
|
+
// 1. Token-budgeted observation selection
|
|
251
|
+
const selected = selectWithTokenBudget(db, project, 2000);
|
|
252
|
+
const observations = selected.observations;
|
|
253
|
+
|
|
254
|
+
// 2. Fallback: recent across all projects with tiered windows (when local pool is thin)
|
|
255
|
+
let fallbackObs = [];
|
|
256
|
+
if (observations.length < 3) {
|
|
257
|
+
const fbOneDayAgo = now.getTime() - STALE_SESSION_MS;
|
|
258
|
+
const fbSevenDaysAgo = now.getTime() - FALLBACK_OBS_WINDOW_MS;
|
|
259
|
+
fallbackObs = db.prepare(`
|
|
260
|
+
SELECT id, type, title, project, created_at
|
|
261
|
+
FROM observations
|
|
262
|
+
WHERE COALESCE(compressed_into, 0) = 0
|
|
263
|
+
AND (
|
|
264
|
+
(created_at_epoch > ? AND importance >= 1)
|
|
265
|
+
OR (created_at_epoch > ? AND importance >= 2)
|
|
266
|
+
)
|
|
267
|
+
ORDER BY created_at_epoch DESC
|
|
268
|
+
LIMIT 5
|
|
269
|
+
`).all(fbOneDayAgo, fbSevenDaysAgo);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 3. Latest session summary → base summaryLines
|
|
273
|
+
const latestSummary = db.prepare(`
|
|
274
|
+
SELECT request, completed, next_steps, remaining_items, lessons, key_decisions, created_at
|
|
275
|
+
FROM session_summaries
|
|
276
|
+
WHERE project = ?
|
|
277
|
+
ORDER BY created_at_epoch DESC
|
|
278
|
+
LIMIT 1
|
|
279
|
+
`).get(project);
|
|
280
|
+
|
|
281
|
+
const summaryLines = buildSummaryLines(latestSummary);
|
|
282
|
+
|
|
283
|
+
// 4. Key context: top high-importance observations split into File Lessons (actionable)
|
|
284
|
+
// and Key Context (informational). Pushed into summaryLines.
|
|
285
|
+
const keyObs = db.prepare(`
|
|
286
|
+
SELECT o.id, o.type, o.title, o.lesson_learned, o.files_modified FROM observations o
|
|
287
|
+
WHERE o.project = ? AND COALESCE(o.compressed_into, 0) = 0
|
|
288
|
+
AND o.superseded_at IS NULL
|
|
289
|
+
AND COALESCE(o.importance, 1) >= 2
|
|
290
|
+
ORDER BY o.created_at_epoch DESC LIMIT 10
|
|
291
|
+
`).all(project);
|
|
292
|
+
|
|
293
|
+
if (keyObs.length > 0) {
|
|
294
|
+
const fileLessons = [];
|
|
295
|
+
const keyContext = [];
|
|
296
|
+
|
|
297
|
+
for (const o of keyObs) {
|
|
298
|
+
const clean = (o.title || '(untitled)')
|
|
299
|
+
.replace(/ → (?:ERROR: )?\{".*$/, '')
|
|
300
|
+
.replace(/ → (?:ERROR: )?\{[^}]*\.{3}$/, '');
|
|
301
|
+
const hasLesson = o.lesson_learned && o.lesson_learned.trim();
|
|
302
|
+
const hasFiles = o.files_modified && o.files_modified !== '[]';
|
|
303
|
+
|
|
304
|
+
if (hasLesson && hasFiles) {
|
|
305
|
+
try {
|
|
306
|
+
const files = JSON.parse(o.files_modified);
|
|
307
|
+
const fname = basename(Array.isArray(files) && files.length > 0 ? files[0] : '');
|
|
308
|
+
if (fname) {
|
|
309
|
+
fileLessons.push(`- ${fname}: ${truncate(o.lesson_learned, 100)} (#${o.id})`);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
} catch { /* fall through to keyContext */ }
|
|
313
|
+
}
|
|
314
|
+
const lesson = hasLesson ? ` — ${truncate(o.lesson_learned, 60)}` : '';
|
|
315
|
+
keyContext.push(`- [${o.type || 'discovery'}] ${truncate(clean, 80)} (#${o.id})${lesson}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (fileLessons.length > 0) {
|
|
319
|
+
summaryLines.push('### File Lessons');
|
|
320
|
+
summaryLines.push(...fileLessons.slice(0, 5));
|
|
321
|
+
summaryLines.push('');
|
|
322
|
+
}
|
|
323
|
+
if (keyContext.length > 0) {
|
|
324
|
+
summaryLines.push('### Key Context');
|
|
325
|
+
summaryLines.push(...keyContext.slice(0, 5));
|
|
326
|
+
summaryLines.push('');
|
|
327
|
+
}
|
|
328
|
+
} else if (!latestSummary) {
|
|
329
|
+
// Fallback: no summary AND no key observations — show recent activity
|
|
330
|
+
const recentObs = (observations.length >= 3 ? observations : fallbackObs).slice(0, 3);
|
|
331
|
+
if (recentObs.length > 0) {
|
|
332
|
+
summaryLines.push('### Recent Activity');
|
|
333
|
+
for (const o of recentObs) {
|
|
334
|
+
summaryLines.push(`- ${truncate(o.title || '(untitled)', 80)}`);
|
|
335
|
+
}
|
|
336
|
+
summaryLines.push('');
|
|
337
|
+
}
|
|
198
338
|
}
|
|
339
|
+
|
|
340
|
+
// 5. Working state from latest /clear handoff.
|
|
341
|
+
// Session scoping: when currentCcSessionId is provided, restrict to this session's
|
|
342
|
+
// own clear handoff so parallel sessions don't see each other's Working State block.
|
|
343
|
+
const prevClearHandoff = currentCcSessionId
|
|
344
|
+
? db.prepare(`
|
|
345
|
+
SELECT working_on, unfinished, key_files
|
|
346
|
+
FROM session_handoffs
|
|
347
|
+
WHERE project = ? AND type = 'clear' AND session_id = ?
|
|
348
|
+
ORDER BY created_at_epoch DESC LIMIT 1
|
|
349
|
+
`).get(project, currentCcSessionId)
|
|
350
|
+
: db.prepare(`
|
|
351
|
+
SELECT working_on, unfinished, key_files
|
|
352
|
+
FROM session_handoffs
|
|
353
|
+
WHERE project = ? AND type = 'clear'
|
|
354
|
+
ORDER BY created_at_epoch DESC LIMIT 1
|
|
355
|
+
`).get(project);
|
|
356
|
+
|
|
357
|
+
const handoffLines = [];
|
|
358
|
+
if (prevClearHandoff) {
|
|
359
|
+
handoffLines.push('### Working State (from /clear)');
|
|
360
|
+
if (prevClearHandoff.working_on) {
|
|
361
|
+
handoffLines.push(`- Working on: ${truncate(prevClearHandoff.working_on, 200)}`);
|
|
362
|
+
}
|
|
363
|
+
if (prevClearHandoff.unfinished) {
|
|
364
|
+
const pendingSummary = extractUnfinishedSummary(prevClearHandoff.unfinished);
|
|
365
|
+
if (pendingSummary) handoffLines.push(`- Unfinished: ${truncate(pendingSummary, 200)}`);
|
|
366
|
+
}
|
|
367
|
+
if (prevClearHandoff.key_files) {
|
|
368
|
+
try {
|
|
369
|
+
const files = JSON.parse(prevClearHandoff.key_files);
|
|
370
|
+
if (files.length > 0) handoffLines.push(`- Key files: ${files.map(f => basename(f)).join(', ')}`);
|
|
371
|
+
} catch { /* malformed JSON — skip */ }
|
|
372
|
+
}
|
|
373
|
+
handoffLines.push('');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 6. Recent observations table
|
|
377
|
+
const obsLines = [];
|
|
378
|
+
const obsToShow = observations.length >= 3 ? observations : fallbackObs;
|
|
379
|
+
if (obsToShow.length > 0) {
|
|
380
|
+
const today = now.toISOString().slice(0, 10);
|
|
381
|
+
obsLines.push(`### Recent (${today})`);
|
|
382
|
+
obsLines.push('');
|
|
383
|
+
obsLines.push('| ID | Time | T | Title |');
|
|
384
|
+
obsLines.push('|----|------|---|-------|');
|
|
385
|
+
for (const o of obsToShow) {
|
|
386
|
+
const proj = o.project && o.project !== project ? ` (${o.project})` : '';
|
|
387
|
+
obsLines.push(`| #${o.id} | ${fmtTime(o.created_at)} | ${typeIcon(o.type)} | ${truncate(o.title || '(untitled)', 60)}${proj} |`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return [...summaryLines, ...handoffLines, ...obsLines].join('\n');
|
|
199
392
|
}
|
|
200
393
|
|
|
201
394
|
/**
|
package/hook-handoff.mjs
CHANGED
|
@@ -89,12 +89,12 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
89
89
|
const allText = [workingOn, ...completed.map(c => c.title).filter(Boolean), unfinished].join(' ');
|
|
90
90
|
const keywords = extractMatchKeywords(allText, [...fileSet]);
|
|
91
91
|
|
|
92
|
-
// UPSERT
|
|
92
|
+
// UPSERT keyed on (project, type, session_id) — parallel sessions coexist.
|
|
93
|
+
// Same session re-writing its own handoff (e.g. repeated /clear) updates in place.
|
|
93
94
|
db.prepare(`
|
|
94
95
|
INSERT INTO session_handoffs (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch)
|
|
95
96
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
96
|
-
ON CONFLICT(project, type) DO UPDATE SET
|
|
97
|
-
session_id = excluded.session_id,
|
|
97
|
+
ON CONFLICT(project, type, session_id) DO UPDATE SET
|
|
98
98
|
working_on = excluded.working_on,
|
|
99
99
|
completed = excluded.completed,
|
|
100
100
|
unfinished = excluded.unfinished,
|
|
@@ -116,18 +116,41 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
118
|
* Detect if user's prompt indicates continuation of previous work.
|
|
119
|
+
* Stage 0: Non-expired clear handoff + short prompt → auto-continue.
|
|
119
120
|
* Stage 1: Explicit keyword match (zero false positives).
|
|
120
121
|
* Stage 2: FTS5-style term overlap with handoff keywords.
|
|
122
|
+
*
|
|
123
|
+
* Session scoping (currentCcSessionId): when provided, clear handoffs from a
|
|
124
|
+
* DIFFERENT session are excluded from Stage 0 auto-match and from the general
|
|
125
|
+
* pool (prevents cross-session bleed when running parallel sessions for the
|
|
126
|
+
* same project — see docs/bug.txt). When null, legacy behavior is preserved.
|
|
127
|
+
*
|
|
121
128
|
* @param {Database} db Opened main database
|
|
122
129
|
* @param {string} promptText User's prompt text
|
|
123
130
|
* @param {string} project Project identifier
|
|
131
|
+
* @param {string|null} [currentCcSessionId=null] Claude Code session id for scoping
|
|
124
132
|
* @returns {boolean}
|
|
125
133
|
*/
|
|
126
|
-
export function detectContinuationIntent(db, promptText, project) {
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
134
|
+
export function detectContinuationIntent(db, promptText, project, currentCcSessionId = null) {
|
|
135
|
+
// Input guard: empty / whitespace / single-char prompts never trigger auto-injection.
|
|
136
|
+
// The bug was a single-char 'a' + fresh clear handoff → Stage 0 auto-match.
|
|
137
|
+
if (!promptText || typeof promptText !== 'string') return false;
|
|
138
|
+
if (promptText.trim().length < 2) return false;
|
|
139
|
+
|
|
140
|
+
// Stage 0: Non-expired 'clear' handoff — assume continuation unless long unrelated prompt.
|
|
141
|
+
// Session scoping: with currentCcSessionId, only your OWN clear handoff qualifies.
|
|
142
|
+
const clearHandoff = currentCcSessionId
|
|
143
|
+
? db.prepare(`
|
|
144
|
+
SELECT created_at_epoch, match_keywords FROM session_handoffs
|
|
145
|
+
WHERE project = ? AND type = 'clear' AND session_id = ?
|
|
146
|
+
ORDER BY created_at_epoch DESC LIMIT 1
|
|
147
|
+
`).get(project, currentCcSessionId)
|
|
148
|
+
: db.prepare(`
|
|
149
|
+
SELECT created_at_epoch, match_keywords FROM session_handoffs
|
|
150
|
+
WHERE project = ? AND type = 'clear'
|
|
151
|
+
ORDER BY created_at_epoch DESC LIMIT 1
|
|
152
|
+
`).get(project);
|
|
153
|
+
|
|
131
154
|
if (clearHandoff && (Date.now() - clearHandoff.created_at_epoch <= HANDOFF_EXPIRY_CLEAR)) {
|
|
132
155
|
// Short/ambiguous prompts: assume continuation (user may say "ok", "start", etc.)
|
|
133
156
|
if (promptText.length < 40) return true;
|
|
@@ -142,11 +165,20 @@ export function detectContinuationIntent(db, promptText, project) {
|
|
|
142
165
|
// Stage 1: Explicit keyword match — always works, even without handoff
|
|
143
166
|
if (CONTINUE_KEYWORDS.test(promptText)) return true;
|
|
144
167
|
|
|
145
|
-
// Stage 2: FTS5-style term overlap with handoff keywords
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
168
|
+
// Stage 2: FTS5-style term overlap with handoff keywords.
|
|
169
|
+
// Session scoping: exit handoffs from OTHER sessions are still candidates (you may
|
|
170
|
+
// be resuming a previous session), but clear handoffs must be same-session.
|
|
171
|
+
const handoffs = currentCcSessionId
|
|
172
|
+
? db.prepare(`
|
|
173
|
+
SELECT type, match_keywords, created_at_epoch FROM session_handoffs
|
|
174
|
+
WHERE project = ?
|
|
175
|
+
AND ((type = 'clear' AND session_id = ?) OR type = 'exit')
|
|
176
|
+
ORDER BY created_at_epoch DESC
|
|
177
|
+
`).all(project, currentCcSessionId)
|
|
178
|
+
: db.prepare(`
|
|
179
|
+
SELECT type, match_keywords, created_at_epoch FROM session_handoffs
|
|
180
|
+
WHERE project = ? ORDER BY created_at_epoch DESC
|
|
181
|
+
`).all(project);
|
|
150
182
|
if (handoffs.length === 0) return false;
|
|
151
183
|
|
|
152
184
|
// Filter expired handoffs
|
|
@@ -176,18 +208,32 @@ export function detectContinuationIntent(db, promptText, project) {
|
|
|
176
208
|
/**
|
|
177
209
|
* Render handoff injection text for stdout.
|
|
178
210
|
* Reads the most recent handoff + optional session summary.
|
|
211
|
+
*
|
|
212
|
+
* Session scoping (currentCcSessionId): when provided,
|
|
213
|
+
* - clear handoffs: only from the CURRENT session (you continue your own /clear)
|
|
214
|
+
* - exit handoffs: only from OTHER sessions (you resume a previous exit)
|
|
215
|
+
* When null, legacy behavior (most-recent handoff regardless of session).
|
|
216
|
+
*
|
|
179
217
|
* @param {Database} db Opened main database
|
|
180
218
|
* @param {string} project Project identifier
|
|
219
|
+
* @param {string|null} [currentCcSessionId=null] Claude Code session id for scoping
|
|
181
220
|
* @returns {string|null} Injection text or null if no handoff
|
|
182
221
|
*/
|
|
183
|
-
export function renderHandoffInjection(db, project) {
|
|
222
|
+
export function renderHandoffInjection(db, project, currentCcSessionId = null) {
|
|
184
223
|
const now = Date.now();
|
|
185
224
|
// Fetch recent handoffs and find the most recent non-expired one.
|
|
186
|
-
// A newer but expired 'clear' handoff
|
|
187
|
-
const handoffs =
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
225
|
+
// A newer but expired 'clear' handoff must not shadow a still-valid 'exit' handoff.
|
|
226
|
+
const handoffs = currentCcSessionId
|
|
227
|
+
? db.prepare(`
|
|
228
|
+
SELECT * FROM session_handoffs
|
|
229
|
+
WHERE project = ?
|
|
230
|
+
AND ((type = 'clear' AND session_id = ?) OR (type = 'exit' AND session_id != ?))
|
|
231
|
+
ORDER BY created_at_epoch DESC LIMIT 5
|
|
232
|
+
`).all(project, currentCcSessionId, currentCcSessionId)
|
|
233
|
+
: db.prepare(`
|
|
234
|
+
SELECT * FROM session_handoffs
|
|
235
|
+
WHERE project = ? ORDER BY created_at_epoch DESC LIMIT 5
|
|
236
|
+
`).all(project);
|
|
191
237
|
const handoff = handoffs.find(h => {
|
|
192
238
|
const age = now - h.created_at_epoch;
|
|
193
239
|
const maxAge = h.type === 'clear' ? HANDOFF_EXPIRY_CLEAR : HANDOFF_EXPIRY_EXIT;
|
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
|
`);
|