claude-mem-lite 2.5.4 → 2.9.2

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/haiku-client.mjs CHANGED
File without changes
package/hook-context.mjs CHANGED
@@ -58,7 +58,7 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
58
58
 
59
59
  // Candidate pool: tiered time windows by importance (adaptive)
60
60
  const obsPool = db.prepare(`
61
- SELECT id, type, title, narrative, importance, created_at_epoch, files_modified
61
+ SELECT id, type, title, narrative, importance, created_at_epoch, files_modified, lesson_learned
62
62
  FROM observations
63
63
  WHERE project = ? AND COALESCE(compressed_into, 0) = 0
64
64
  AND (
@@ -87,7 +87,8 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
87
87
  const ageDays = (now_ms - o.created_at_epoch) / 86400000;
88
88
  const recency = 1 / (1 + ageDays);
89
89
  const impBoost = 0.5 + 0.5 * (o.importance || 1);
90
- const value = recency * impBoost;
90
+ const lessonBoost = o.lesson_learned ? 1.3 : 1.0;
91
+ const value = recency * impBoost * lessonBoost;
91
92
  const cost = estimateTokens((o.title || '') + (o.narrative || ''));
92
93
  return { ...o, value, cost, valueDensity: cost > 0 ? value / Math.sqrt(cost) : 0 };
93
94
  });
@@ -107,10 +108,17 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
107
108
  ].sort((a, b) => b.valueDensity - a.valueDensity);
108
109
 
109
110
  const selectedFiles = new Set();
111
+ const selectedTypes = new Map(); // type → count for diversity constraint
110
112
 
111
113
  for (const c of allCandidates) {
112
114
  if (totalTokens + c.cost > budget) continue;
113
115
 
116
+ // Type diversity: max 3 observations of same type (checked first to avoid file set pollution)
117
+ if (c._kind === 'obs' && c.type) {
118
+ const typeCount = selectedTypes.get(c.type) || 0;
119
+ if (typeCount >= 3) continue;
120
+ }
121
+
114
122
  // Diversity penalty: reduce value for file overlap with already-selected
115
123
  if (c._kind === 'obs' && c.files_modified) {
116
124
  let cFiles;
@@ -119,11 +127,16 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
119
127
  const overlap = cFiles.filter(f => selectedFiles.has(f)).length;
120
128
  const overlapRatio = overlap / cFiles.length;
121
129
  const penalizedValue = c.valueDensity * (1 - 0.3 * overlapRatio);
122
- if (penalizedValue < 0.001) continue; // Skip if too redundant
130
+ if (penalizedValue < 0.001) continue;
123
131
  }
124
132
  for (const f of cFiles) selectedFiles.add(f);
125
133
  }
126
134
 
135
+ // Commit type diversity counter after both gates pass
136
+ if (c._kind === 'obs' && c.type) {
137
+ selectedTypes.set(c.type, (selectedTypes.get(c.type) || 0) + 1);
138
+ }
139
+
127
140
  totalTokens += c.cost;
128
141
  if (c._kind === 'obs') {
129
142
  selectedObs.push({ id: c.id, type: c.type, title: c.title, created_at: new Date(c.created_at_epoch).toISOString() });
@@ -151,11 +164,16 @@ export function updateClaudeMd(contextBlock) {
151
164
  const hintComment = '<!-- claude-mem-lite: auto-updated context. To avoid git noise, add CLAUDE.md to .gitignore -->';
152
165
  const newSection = `${startTag}\n${contextBlock}\n${endTag}`;
153
166
 
154
- const startIdx = content.indexOf(startTag);
155
- const endIdx = content.indexOf(endTag);
167
+ // Use lastIndexOf for both tags — prevents matching documentation references
168
+ // to <claude-mem-context> that appear in code/markdown before the actual context block
169
+ const startIdx = content.lastIndexOf(startTag);
170
+ const endIdx = content.lastIndexOf(endTag);
156
171
 
157
172
  if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
158
- // Replace existing section in-place preserves surrounding content (including hint if present)
173
+ // Skip write if content is unchanged reduces git noise
174
+ const existingSection = content.slice(startIdx, endIdx + endTag.length);
175
+ if (existingSection === newSection) return;
176
+ // Replace from first start to last end — collapses any duplicate sections into one
159
177
  content = content.slice(0, startIdx) + newSection + content.slice(endIdx + endTag.length);
160
178
  } else if (content.length > 0) {
161
179
  // Append to end — never disturb existing CLAUDE.md structure
package/hook-episode.mjs CHANGED
@@ -211,7 +211,7 @@ export function mergePendingEntries(episode) {
211
211
  /**
212
212
  * Check if an episode has significant content worth processing with LLM.
213
213
  * Significant = contains file edits, Bash errors, or a review/research pattern
214
- * (5+ Read/Grep entries indicate investigation worth recording).
214
+ * (8+ Read/Grep entries indicate investigation worth recording).
215
215
  * @param {object} episode The episode to check
216
216
  * @returns {boolean} true if the episode has significant content
217
217
  */
@@ -236,5 +236,5 @@ export function episodeHasSignificantContent(episode) {
236
236
  const readCount = episode.entries.filter(e =>
237
237
  e.tool === 'Read' || e.tool === 'Grep'
238
238
  ).length;
239
- return readCount >= 5;
239
+ return readCount >= 8;
240
240
  }
package/hook-handoff.mjs CHANGED
@@ -25,7 +25,14 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
25
25
  `).all(sessionId);
26
26
  if (prompts.length === 0) return; // Empty session — nothing to hand off
27
27
 
28
- const workingOn = prompts.map(p => truncate(p.prompt_text, 200)).join(' → ');
28
+ const seen = new Set();
29
+ const uniquePrompts = prompts.filter(p => {
30
+ const t = truncate(p.prompt_text, 200);
31
+ if (seen.has(t)) return false;
32
+ seen.add(t);
33
+ return true;
34
+ });
35
+ const workingOn = uniquePrompts.map(p => truncate(p.prompt_text, 200)).join(' → ');
29
36
 
30
37
  // 2. Completed — from observations (include narrative for richer handoff)
31
38
  const completed = db.prepare(`
@@ -37,45 +44,47 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
37
44
  // 3. Unfinished — episode snapshot + full session edit history from narratives
38
45
  let unfinished = '';
39
46
  if (episodeSnapshot?.entries) {
47
+ const seenDescs = new Set();
40
48
  const pendingDescs = episodeSnapshot.entries
41
49
  .filter(e => e.isSignificant || e.isError)
42
- .map(e => e.desc);
50
+ .map(e => e.desc)
51
+ .filter(d => { if (seenDescs.has(d)) return false; seenDescs.add(d); return true; });
43
52
  if (pendingDescs.length > 0) unfinished = pendingDescs.join('; ');
44
53
  }
45
- // Only the most recent bugfix is an "unfinished" signal (earlier ones are likely resolved)
46
- if (!unfinished) {
47
- const lastBugfix = completed.find(o => o.type === 'bugfix');
48
- if (lastBugfix) unfinished = lastBugfix.title;
49
- }
50
54
  // Enrich unfinished with full session edit history from observation narratives.
51
55
  // Since handoff is UPSERT (max 2 rows per project), storing more data is free.
56
+ // Always use \n---\n separator so extractUnfinishedSummary can distinguish
57
+ // pending work (before separator) from narrative history (after separator).
52
58
  const narratives = completed
53
59
  .filter(c => c.narrative)
54
60
  .map(c => c.narrative);
55
61
  if (narratives.length > 0) {
56
62
  const editHistory = narratives.join('\n');
57
- unfinished = [unfinished, editHistory].filter(Boolean).join('\n---\n');
63
+ unfinished += '\n---\n' + editHistory;
58
64
  }
59
65
 
60
66
  // 4. Key files — from episode snapshot + observations
61
67
  const fileSet = new Set();
62
- if (episodeSnapshot?.files) episodeSnapshot.files.forEach(f => fileSet.add(f));
68
+ const isValidFile = f => f && f.length > 2 && f.includes('/') && f.indexOf('/', 1) !== -1
69
+ && !f.startsWith('/dev/') && !f.startsWith('/proc/') && !f.startsWith('/tmp/');
70
+ if (episodeSnapshot?.files) episodeSnapshot.files.filter(isValidFile).forEach(f => fileSet.add(f));
63
71
  const obsFiles = db.prepare(`
64
72
  SELECT files_modified FROM observations
65
73
  WHERE memory_session_id = ? AND files_modified IS NOT NULL
66
74
  ORDER BY created_at_epoch DESC LIMIT 10
67
75
  `).all(sessionId);
68
76
  for (const row of obsFiles) {
69
- try { JSON.parse(row.files_modified).forEach(f => fileSet.add(f)); } catch {}
77
+ try { JSON.parse(row.files_modified).filter(isValidFile).forEach(f => fileSet.add(f)); } catch {}
70
78
  }
71
79
 
72
- // 5. Key decisions — high importance observations
80
+ // 5. Key decisions — high importance observations (skip low-signal degraded titles)
81
+ const LOW_SIGNAL_TITLE = /^(Error (while working|in)|Modified |Worked on |Reviewed \d+ files:)/;
73
82
  const decisions = db.prepare(`
74
83
  SELECT title FROM observations
75
84
  WHERE memory_session_id = ? AND COALESCE(importance, 1) >= 2
76
85
  AND COALESCE(compressed_into, 0) = 0
77
- ORDER BY created_at_epoch DESC LIMIT 5
78
- `).all(sessionId);
86
+ ORDER BY created_at_epoch DESC LIMIT 10
87
+ `).all(sessionId).filter(d => d.title && !LOW_SIGNAL_TITLE.test(d.title)).slice(0, 5);
79
88
 
80
89
  // 6. Match keywords
81
90
  const allText = [workingOn, ...completed.map(c => c.title).filter(Boolean), unfinished].join(' ');
@@ -98,7 +107,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
98
107
  project, type, sessionId,
99
108
  truncate(workingOn, 1000),
100
109
  completed.map(c => `[${c.type}] ${c.title}`).join('\n'),
101
- truncate(unfinished, 3000),
110
+ unfinished.length > 3000 ? unfinished.slice(0, 2999) + '…' : unfinished,
102
111
  JSON.stringify([...fileSet].slice(0, 20)),
103
112
  decisions.map(d => d.title).join('\n'),
104
113
  keywords,
@@ -116,12 +125,19 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
116
125
  * @returns {boolean}
117
126
  */
118
127
  export function detectContinuationIntent(db, promptText, project) {
119
- // Stage 0: Non-expired 'clear' handoff = always continue (/clear means user is resuming)
128
+ // Stage 0: Non-expired 'clear' handoff assume continuation unless long unrelated prompt
120
129
  const clearHandoff = db.prepare(`
121
- SELECT created_at_epoch FROM session_handoffs WHERE project = ? AND type = 'clear'
130
+ SELECT created_at_epoch, match_keywords FROM session_handoffs WHERE project = ? AND type = 'clear'
122
131
  `).get(project);
123
132
  if (clearHandoff && (Date.now() - clearHandoff.created_at_epoch <= HANDOFF_EXPIRY_CLEAR)) {
124
- return true;
133
+ // Short/ambiguous prompts: assume continuation (user may say "ok", "start", etc.)
134
+ if (promptText.length < 40) return true;
135
+ // Long prompts: check keyword overlap to confirm same-task intent
136
+ if (!clearHandoff.match_keywords) return true; // no keywords stored, can't verify
137
+ const clearPromptTokens = tokenizeHandoff(promptText);
138
+ const clearHandoffTokens = new Set(tokenizeHandoff(clearHandoff.match_keywords));
139
+ if (clearPromptTokens.some(t => clearHandoffTokens.has(t))) return true;
140
+ // Long prompt with zero keyword overlap → likely new task, fall through
125
141
  }
126
142
 
127
143
  // Stage 1: Explicit keyword match — always works, even without handoff
@@ -195,7 +211,11 @@ export function renderHandoffInjection(db, project) {
195
211
  lines.push('## Completed', ...handoff.completed.split('\n').map(l => `- ${l}`), '');
196
212
  }
197
213
  if (handoff.unfinished) {
198
- lines.push('## Unfinished', ...handoff.unfinished.split('; ').map(l => `- ${l}`), '');
214
+ // Extract only the pending-work portion (before narrative history separator)
215
+ const pending = extractUnfinishedSummary(handoff.unfinished);
216
+ if (pending) {
217
+ lines.push('## Unfinished', ...pending.split('; ').map(l => `- ${l}`), '');
218
+ }
199
219
  }
200
220
  if (handoff.key_files) {
201
221
  try {
package/hook-llm.mjs CHANGED
@@ -39,7 +39,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
39
39
  VALUES (?, ?, ?, ?, ?, 'active')
40
40
  `).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
41
41
 
42
- // Two-tier dedup
42
+ // Three-tier dedup
43
43
  // Tier 1 (fast): 5-min Jaccard on titles
44
44
  const fiveMinAgo = now.getTime() - DEDUP_WINDOW_MS;
45
45
  const recent = db.prepare(`
@@ -52,6 +52,21 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
52
52
  return null;
53
53
  }
54
54
 
55
+ // Tier 1.5: Extended title dedup for low-signal degraded titles (1-day window)
56
+ // "Error in X", "Modified X" titles are low-specificity → use longer dedup window
57
+ const LOW_SIGNAL = /^(Error (while working|in)|Modified |Worked on |Reviewed \d+ files:)/;
58
+ if (obs.title && LOW_SIGNAL.test(obs.title)) {
59
+ const oneDayAgo = now.getTime() - 86400000;
60
+ const extRecent = db.prepare(`
61
+ SELECT title FROM observations
62
+ WHERE project = ? AND created_at_epoch > ? AND created_at_epoch <= ?
63
+ ORDER BY created_at_epoch DESC LIMIT 30
64
+ `).all(project, oneDayAgo, fiveMinAgo);
65
+ if (extRecent.some(r => jaccardSimilarity(r.title, obs.title) > 0.85)) {
66
+ return null;
67
+ }
68
+ }
69
+
55
70
  // Tier 2 (slow): MinHash cross-session dedup (7-day window)
56
71
  const minhashSig = computeMinHash((obs.title || '') + ' ' + (obs.narrative || ''));
57
72
  if (minhashSig) {
@@ -174,10 +189,31 @@ export function buildDegradedTitle(episode) {
174
189
  const hasError = episode.entries.some(e => e.isError);
175
190
  const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
176
191
 
192
+ // Extract a short error hint from the first error entry's desc
193
+ let errorHint = '';
194
+ if (hasError) {
195
+ const errEntry = episode.entries.find(e => e.isError);
196
+ if (errEntry?.desc) {
197
+ // Extract meaningful error text from "cmd → ERROR: ..." format
198
+ const errMatch = errEntry.desc.match(/→ ERROR: (.{3,80})/);
199
+ if (errMatch) {
200
+ // Clean JSON/noise from the error snippet
201
+ const cleaned = errMatch[1].replace(/[{"[\]]/g, '').replace(/\\n/g, ' ').trim();
202
+ if (cleaned.length >= 4) errorHint = `: ${truncate(cleaned, 50)}`;
203
+ }
204
+ }
205
+ }
206
+
177
207
  if (files.length > 0) {
178
208
  const names = files.map(f => basename(f)).slice(0, 3).join(', ');
179
209
  const suffix = files.length > 3 ? ` +${files.length - 3} more` : '';
180
- if (hasError) return `Error while working on ${names}${suffix}`;
210
+ if (hasError) {
211
+ // Include the triggering command for richer context: "Error: dispatch.mjs — npm test failed"
212
+ const errEntry = episode.entries.find(e => e.isError);
213
+ const cmd = errEntry?.desc?.match(/^(.{3,30}?) →/)?.[1]?.trim();
214
+ const cmdHint = cmd ? ` — ${cmd}` : '';
215
+ return `Error: ${names}${suffix}${errorHint || cmdHint}`;
216
+ }
181
217
  if (hasEdit) return `Modified ${names}${suffix}`;
182
218
  return `Worked on ${names}${suffix}`;
183
219
  }
@@ -214,6 +250,20 @@ export function buildImmediateObservation(episode) {
214
250
  title = truncate(buildDegradedTitle(episode), 120);
215
251
  }
216
252
 
253
+ const ruleImportance = computeRuleImportance(episode);
254
+ // Low-signal degraded titles ("Error in...", "Modified...") should not inflate importance.
255
+ // Cap at 1 unless rule-based signals indicate genuine importance (error-in-test → 3, config → 2).
256
+ const LOW_SIGNAL = /^(Error (while working|in)|Modified |Worked on |Reviewed \d+ files:)/;
257
+ const isLowSignal = LOW_SIGNAL.test(title);
258
+ let importance;
259
+ if (isReviewPattern) {
260
+ importance = Math.max(2, ruleImportance);
261
+ } else if (isLowSignal && ruleImportance <= 2) {
262
+ importance = 1; // Degraded titles stay low unless rule signals critical (imp=3)
263
+ } else {
264
+ importance = ruleImportance;
265
+ }
266
+
217
267
  return {
218
268
  type: inferredType,
219
269
  title,
@@ -223,7 +273,7 @@ export function buildImmediateObservation(episode) {
223
273
  facts: [],
224
274
  files: episode.files,
225
275
  filesRead: episode.filesRead || [],
226
- importance: isReviewPattern ? Math.max(2, computeRuleImportance(episode)) : computeRuleImportance(episode),
276
+ importance,
227
277
  };
228
278
  }
229
279
 
@@ -268,10 +318,10 @@ File: ${episode.files.join(', ') || 'unknown'}
268
318
  Action: ${e.desc}
269
319
  Error: ${e.isError ? 'yes' : 'no'}
270
320
 
271
- JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1,"lesson_learned":"non-obvious insight or null if routine","search_aliases":["alt query 1","alt query 2"]}
321
+ JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1,"lesson_learned":"non-obvious insight or 'none' if routine","search_aliases":["alt query 1","alt query 2"]}
272
322
  Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
273
- importance: 0=not worth saving (pure browsing, trivial query, no learning value), 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)
274
- lesson_learned: If this episode revealed something NON-OBVIOUS (a debugging insight, a gotcha, a design reason), capture it as a reusable lesson. null if routine.
323
+ importance: Be strict default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
324
+ lesson_learned: REQUIRED field. State what was learned that isn't obvious from reading the code. Examples: "FTS5 porter stemmer doesn't tokenize CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". If purely routine with nothing learned, write "none" (not null).
275
325
  search_aliases: 2-6 alternative search terms someone might use to find this memory later (include CJK if project uses Chinese)`;
276
326
  } else {
277
327
  const actionList = episode.entries.map((e, i) =>
@@ -285,10 +335,10 @@ Files: ${fileList}
285
335
  Actions (${episode.entries.length} total):
286
336
  ${actionList}
287
337
 
288
- JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"coherent ≤80 char summary","narrative":"what was done, why, and outcome (3-5 sentences)","concepts":["keyword1","keyword2"],"facts":["specific fact 1","specific fact 2"],"importance":1,"lesson_learned":"non-obvious insight or null if routine","search_aliases":["alt query 1","alt query 2"]}
338
+ JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"coherent ≤80 char summary","narrative":"what was done, why, and outcome (3-5 sentences)","concepts":["keyword1","keyword2"],"facts":["specific fact 1","specific fact 2"],"importance":1,"lesson_learned":"non-obvious insight or 'none' if routine","search_aliases":["alt query 1","alt query 2"]}
289
339
  Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
290
- importance: 0=not worth saving (pure browsing, trivial query, no learning value), 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)
291
- lesson_learned: If this episode revealed something NON-OBVIOUS (a debugging insight, a gotcha, a design reason), capture it as a reusable lesson. null if routine.
340
+ importance: Be strict default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
341
+ lesson_learned: REQUIRED field. State what was learned that isn't obvious from reading the code. Examples: "FTS5 porter stemmer doesn't tokenize CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". If purely routine with nothing learned, write "none" (not null).
292
342
  search_aliases: 2-6 alternative search terms someone might use to find this memory later (include CJK if project uses Chinese)`;
293
343
  }
294
344
 
@@ -323,7 +373,10 @@ search_aliases: 2-6 alternative search terms someone might use to find this memo
323
373
  return;
324
374
  }
325
375
 
326
- const lessonLearned = typeof parsed.lesson_learned === 'string' ? parsed.lesson_learned.slice(0, 500) : null;
376
+ const lessonLearned = typeof parsed.lesson_learned === 'string'
377
+ && parsed.lesson_learned.toLowerCase() !== 'none'
378
+ && parsed.lesson_learned.trim().length > 0
379
+ ? parsed.lesson_learned.slice(0, 500) : null;
327
380
  const searchAliases = Array.isArray(parsed.search_aliases)
328
381
  ? parsed.search_aliases.slice(0, 6).join(' ')
329
382
  : null;
@@ -471,17 +524,41 @@ key_decisions: Only decisions with lasting impact (library choices, architecture
471
524
  ? JSON.stringify(llmParsed.lessons) : null;
472
525
  const decisionsJson = Array.isArray(llmParsed.key_decisions) && llmParsed.key_decisions.length > 0
473
526
  ? JSON.stringify(llmParsed.key_decisions) : null;
474
- db.prepare(`
475
- INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, lessons, key_decisions, created_at, created_at_epoch)
476
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?, ?, ?)
477
- `).run(
478
- sessionId, project,
479
- llmParsed.request || '', llmParsed.investigated || '', llmParsed.learned || '',
480
- llmParsed.completed || '', llmParsed.next_steps || '',
481
- llmParsed.remaining_items || '',
482
- lessonsJson, decisionsJson,
483
- now.toISOString(), now.getTime()
484
- );
527
+
528
+ // Upgrade existing fast summary instead of creating a duplicate
529
+ const existingFast = db.prepare(`
530
+ SELECT id FROM session_summaries
531
+ WHERE memory_session_id = ? AND notes = 'fast'
532
+ LIMIT 1
533
+ `).get(sessionId);
534
+
535
+ if (existingFast) {
536
+ db.prepare(`
537
+ UPDATE session_summaries
538
+ SET request=?, investigated=?, learned=?, completed=?, next_steps=?, remaining_items=?,
539
+ lessons=?, key_decisions=?, notes='llm', created_at=?, created_at_epoch=?
540
+ WHERE id = ?
541
+ `).run(
542
+ llmParsed.request || '', llmParsed.investigated || '', llmParsed.learned || '',
543
+ llmParsed.completed || '', llmParsed.next_steps || '',
544
+ llmParsed.remaining_items || '',
545
+ lessonsJson, decisionsJson,
546
+ now.toISOString(), now.getTime(),
547
+ existingFast.id
548
+ );
549
+ } else {
550
+ db.prepare(`
551
+ INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, lessons, key_decisions, created_at, created_at_epoch)
552
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?, ?, ?)
553
+ `).run(
554
+ sessionId, project,
555
+ llmParsed.request || '', llmParsed.investigated || '', llmParsed.learned || '',
556
+ llmParsed.completed || '', llmParsed.next_steps || '',
557
+ llmParsed.remaining_items || '',
558
+ lessonsJson, decisionsJson,
559
+ now.toISOString(), now.getTime()
560
+ );
561
+ }
485
562
  }
486
563
  } finally {
487
564
  db.close();
package/hook-memory.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { sanitizeFtsQuery, debugCatch } from './utils.mjs';
5
5
 
6
6
  const MAX_MEMORY_INJECTIONS = 3;
7
- const MEMORY_LOOKBACK_MS = 30 * 86400000; // 30 days
7
+ const MEMORY_LOOKBACK_MS = 60 * 86400000; // 60 days
8
8
  const MEMORY_TYPE_BOOST = { bugfix: 1.5, decision: 1.3, discovery: 1.0, feature: 0.8, change: 0.5, refactor: 0.5 };
9
9
 
10
10
  const FILE_RECALL_LOOKBACK_MS = 60 * 86400000; // 60 days
@@ -12,7 +12,7 @@ const MAX_FILE_RECALL = 2;
12
12
 
13
13
  /**
14
14
  * Search for relevant past observations to inject as memory context.
15
- * Strict quality gates: importance>=2, type-boosted, lesson-boosted, BM25-thresholded.
15
+ * Quality gates: importance>=1 (with 0.6x penalty), type-boosted, lesson-boosted, BM25-thresholded (>=1.5).
16
16
  * @param {import('better-sqlite3').Database} db Memory database
17
17
  * @param {string} userPrompt User's prompt text
18
18
  * @param {string} project Current project
@@ -29,14 +29,15 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
29
29
  const cutoff = Date.now() - MEMORY_LOOKBACK_MS;
30
30
  const excludeSet = new Set(excludeIds);
31
31
 
32
+ // Phase 1: Same-project search (highest priority)
32
33
  const selectStmt = db.prepare(`
33
- SELECT o.id, o.type, o.title, o.importance, o.lesson_learned,
34
+ SELECT o.id, o.type, o.title, o.importance, o.lesson_learned, o.project,
34
35
  bm25(observations_fts, 10, 5, 5, 3, 3, 2) as relevance
35
36
  FROM observations_fts
36
37
  JOIN observations o ON o.id = observations_fts.rowid
37
38
  WHERE observations_fts MATCH ?
38
39
  AND o.project = ?
39
- AND o.importance >= 2
40
+ AND o.importance >= 1
40
41
  AND o.created_at_epoch > ?
41
42
  AND COALESCE(o.compressed_into, 0) = 0
42
43
  ORDER BY bm25(observations_fts, 10, 5, 5, 3, 3, 2)
@@ -44,17 +45,45 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
44
45
  `);
45
46
  const rows = selectStmt.all(ftsQuery, project, cutoff);
46
47
 
47
- // Score: BM25 × type boost × lesson boost, filter by threshold, exclude Key Context IDs
48
- const scored = rows
48
+ // Phase 2: Cross-project search for high-value decisions/discoveries
49
+ // These are transferable insights (debugging patterns, architectural reasons, gotchas)
50
+ let crossRows = [];
51
+ try {
52
+ crossRows = db.prepare(`
53
+ SELECT o.id, o.type, o.title, o.importance, o.lesson_learned, o.project,
54
+ bm25(observations_fts, 10, 5, 5, 3, 3, 2) as relevance
55
+ FROM observations_fts
56
+ JOIN observations o ON o.id = observations_fts.rowid
57
+ WHERE observations_fts MATCH ?
58
+ AND o.project != ?
59
+ AND o.type IN ('decision', 'discovery')
60
+ AND o.importance >= 2
61
+ AND o.created_at_epoch > ?
62
+ AND COALESCE(o.compressed_into, 0) = 0
63
+ ORDER BY bm25(observations_fts, 10, 5, 5, 3, 3, 2)
64
+ LIMIT 5
65
+ `).all(ftsQuery, project, cutoff);
66
+ } catch (e) { debugCatch(e, 'crossProjectSearch'); }
67
+
68
+ // Merge and score: same-project full weight, cross-project 0.7x
69
+ const allRows = [...rows, ...crossRows];
70
+ const scored = allRows
49
71
  .filter(r => !excludeSet.has(r.id))
50
- .map(r => ({
51
- ...r,
52
- score: Math.abs(r.relevance) * (MEMORY_TYPE_BOOST[r.type] || 1.0) * (r.lesson_learned ? 1.5 : 1.0),
53
- }))
72
+ .map(r => {
73
+ const crossProjectPenalty = r.project === project ? 1.0 : 0.7;
74
+ return {
75
+ ...r,
76
+ score: Math.abs(r.relevance)
77
+ * (MEMORY_TYPE_BOOST[r.type] || 1.0)
78
+ * (r.lesson_learned ? 1.5 : 1.0)
79
+ * (r.importance >= 2 ? 1.0 : 0.6)
80
+ * crossProjectPenalty,
81
+ };
82
+ })
54
83
  .sort((a, b) => b.score - a.score);
55
84
 
56
- // Strict threshold: only inject if best match has meaningful score
57
- if (scored.length === 0 || scored[0].score < 1.0) return [];
85
+ // Strict threshold: raised from 1.0 to 1.5 to compensate for wider pool
86
+ if (scored.length === 0 || scored[0].score < 1.5) return [];
58
87
 
59
88
  // Update access_count for injected memories
60
89
  const result = scored.slice(0, MAX_MEMORY_INJECTIONS);
@@ -83,7 +112,10 @@ export function recallForFile(db, filePath, project) {
83
112
  try {
84
113
  const basename = filePath.split('/').pop();
85
114
  const cutoff = Date.now() - FILE_RECALL_LOOKBACK_MS;
86
- const likePattern = `%"${basename}"%`;
115
+ // Match both full paths (/path/to/file.mjs) and basename-only entries ("file.mjs")
116
+ // Two patterns avoid false positives: %/file.mjs"% won't match /webapp.mjs
117
+ const pathPattern = `%/${basename}"%`;
118
+ const namePattern = `%"${basename}"%`;
87
119
  const rows = db.prepare(`
88
120
  SELECT id, type, title, importance, lesson_learned
89
121
  FROM observations
@@ -91,10 +123,10 @@ export function recallForFile(db, filePath, project) {
91
123
  AND importance >= 2
92
124
  AND COALESCE(compressed_into, 0) = 0
93
125
  AND created_at_epoch > ?
94
- AND files_modified LIKE ?
126
+ AND (files_modified LIKE ? OR files_modified LIKE ?)
95
127
  ORDER BY created_at_epoch DESC
96
128
  LIMIT ?
97
- `).all(project, cutoff, likePattern, MAX_FILE_RECALL);
129
+ `).all(project, cutoff, pathPattern, namePattern, MAX_FILE_RECALL);
98
130
  const updateStmt = db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1 WHERE id = ?');
99
131
  for (const r of rows) updateStmt.run(r.id);
100
132
  return rows;
File without changes
package/hook-shared.mjs CHANGED
@@ -140,6 +140,27 @@ export function incrementInjection() { _injectionCount++; }
140
140
  export function resetInjectionBudget() { _injectionCount = 0; }
141
141
  export function hasInjectionBudget() { return _injectionCount < MAX_INJECTIONS_PER_SESSION; }
142
142
 
143
+ // ─── Previous Session Context (for user-prompt dispatch enrichment) ──────────
144
+ // Session-start caches next_steps; first user-prompt reads+clears for richer dispatch.
145
+
146
+ export function prevContextFile() {
147
+ return join(RUNTIME_DIR, `prev-context-${inferProject()}`);
148
+ }
149
+
150
+ export function cachePrevContext(nextSteps) {
151
+ try { writeFileSync(prevContextFile(), JSON.stringify({ nextSteps, ts: Date.now() })); } catch {}
152
+ }
153
+
154
+ export function readAndClearPrevContext() {
155
+ const file = prevContextFile();
156
+ try {
157
+ const data = JSON.parse(readFileSync(file, 'utf8'));
158
+ try { unlinkSync(file); } catch {}
159
+ if (Date.now() - data.ts > 12 * 3600000) return null; // 12h expiry
160
+ return data.nextSteps || null;
161
+ } catch { return null; }
162
+ }
163
+
143
164
  // ─── Tool Event Tracking (for dispatch feedback) ────────────────────────────
144
165
  // PostToolUse appends feedback-relevant tool events (Skill, Task, Edit, Write, Bash errors).
145
166
  // Stop handler reads them and passes to collectFeedback for adoption/outcome detection.