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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.29.0",
13
+ "version": "2.30.1",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.29.0",
3
+ "version": "2.30.1",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
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 persistence
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 { estimateTokens, truncate, debugLog, debugCatch, DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS } from './utils.mjs';
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
- // Demotes bugfix (noisy error logs) and promotes high-signal types
87
- const TYPE_QUALITY = { decision: 1.5, discovery: 1.3, feature: 1.2, refactor: 1.0, change: 0.8, bugfix: 0.35 };
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
- * Update the project's CLAUDE.md file with a context block.
158
- * Replaces existing <claude-mem-context> section or appends a new one.
159
- * Uses atomic tmp+rename write to prevent partial writes.
160
- * @param {string} contextBlock Markdown content to inject
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 updateClaudeMd(contextBlock) {
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 for both tags prevents matching documentation references
173
- // to <claude-mem-context> that appear in code/markdown before the actual context block
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
- if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
178
- // Skip write if content is unchanged — reduces git noise
179
- const existingSection = content.slice(startIdx, endIdx + endTag.length);
180
- if (existingSection === newSection) return;
181
- // Replace from first start to last end collapses any duplicate sections into one
182
- content = content.slice(0, startIdx) + newSection + content.slice(endIdx + endTag.length);
183
- } else if (content.length > 0) {
184
- // Append to end never disturb existing CLAUDE.md structure
185
- const hint = content.includes(hintComment) ? '' : hintComment + '\n';
186
- content = content.trimEnd() + '\n\n' + hint + newSection + '\n';
187
- } else {
188
- content = hintComment + '\n' + newSection + '\n';
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, content);
220
+ writeFileSync(tmp, normalized);
194
221
  renameSync(tmp, claudeMdPath);
195
222
  } catch (e) {
196
223
  try { unlinkSync(tmp); } catch {}
197
- debugLog('ERROR', 'updateClaudeMd', `CLAUDE.md write failed: ${e.message}`);
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
- // Stage 0: Non-expired 'clear' handoff assume continuation unless long unrelated prompt
128
- const clearHandoff = db.prepare(`
129
- SELECT created_at_epoch, match_keywords FROM session_handoffs WHERE project = ? AND type = 'clear'
130
- `).get(project);
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
- const handoffs = db.prepare(`
147
- SELECT type, match_keywords, created_at_epoch FROM session_handoffs
148
- WHERE project = ? ORDER BY created_at_epoch DESC
149
- `).all(project);
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 (1h) must not shadow a still-valid 'exit' handoff (7d).
187
- const handoffs = db.prepare(`
188
- SELECT * FROM session_handoffs
189
- WHERE project = ? ORDER BY created_at_epoch DESC LIMIT 5
190
- `).all(project);
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: high-signal types > noisy types
9
- // Bugfix raised from 0.5→0.75 to match scoring-sql.mjs; lesson_learned boost (1.5×) stacks
10
- const MEMORY_TYPE_BOOST = { decision: 1.5, discovery: 1.3, feature: 1.2, refactor: 1.0, change: 0.8, bugfix: 0.75 };
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
  `);