claude-mem-lite 2.38.0 → 2.39.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.38.0",
13
+ "version": "2.39.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.38.0",
3
+ "version": "2.39.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/hook-context.mjs CHANGED
@@ -369,7 +369,7 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
369
369
  }
370
370
  if (prevClearHandoff.unfinished) {
371
371
  const pendingSummary = extractUnfinishedSummary(prevClearHandoff.unfinished);
372
- if (pendingSummary) handoffLines.push(`- Unfinished: ${truncate(pendingSummary, 200)}`);
372
+ if (pendingSummary) handoffLines.push(`- Recent activity: ${truncate(pendingSummary, 200)}`);
373
373
  }
374
374
  if (prevClearHandoff.key_files) {
375
375
  try {
package/hook-handoff.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // Extracted for testability — hook.mjs has module-level side effects
3
3
 
4
4
  import { basename } from 'path';
5
- import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, LOW_SIGNAL_TITLE } from './utils.mjs';
5
+ import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, LOW_SIGNAL_TITLE, EDIT_TOOLS } from './utils.mjs';
6
6
  import {
7
7
  HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT, HANDOFF_ANCHOR_MAX_AGE,
8
8
  HANDOFF_MATCH_THRESHOLD, CONTINUE_KEYWORDS,
@@ -55,12 +55,16 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
55
55
  ORDER BY created_at_epoch DESC LIMIT 15
56
56
  `).all(sessionId);
57
57
 
58
- // 3. Unfinished — episode snapshot + full session edit history from narratives
58
+ // 3. Recent activity — episode snapshot + full session edit history from narratives.
59
+ // Keep only entries that represent in-flight work (file edits) or outright failures
60
+ // (errors). Successful Bash commands flag isSignificant=true via bash-utils when they
61
+ // match git/test/build/deploy patterns, but a succeeded `git push` is COMPLETED, not
62
+ // pending — including it surfaced release-pipeline commands as "Unfinished" on resume.
59
63
  let unfinished = '';
60
64
  if (episodeSnapshot?.entries) {
61
65
  const seenDescs = new Set();
62
66
  const pendingDescs = episodeSnapshot.entries
63
- .filter(e => e.isSignificant || e.isError)
67
+ .filter(e => e.isError || EDIT_TOOLS.has(e.tool))
64
68
  .map(e => e.desc)
65
69
  .filter(d => { if (seenDescs.has(d)) return false; seenDescs.add(d); return true; });
66
70
  if (pendingDescs.length > 0) unfinished = pendingDescs.join('; ');
@@ -340,10 +344,13 @@ export function renderHandoffInjection(db, project, currentCcSessionId = null) {
340
344
  lines.push('## Completed', ...handoff.completed.split('\n').map(l => `- ${l}`), '');
341
345
  }
342
346
  if (handoff.unfinished) {
343
- // Extract only the pending-work portion (before narrative history separator)
347
+ // Extract only the pending-work portion (before narrative history separator).
348
+ // Header: "Recent activity" rather than "Unfinished" — the list mixes in-flight
349
+ // edits with surfaced errors, and calling a completed edit "unfinished" is a
350
+ // completeness claim the episode buffer can't support.
344
351
  const pending = extractUnfinishedSummary(handoff.unfinished);
345
352
  if (pending) {
346
- lines.push('## Unfinished', ...pending.split('; ').map(l => `- ${l}`), '');
353
+ lines.push('## Recent activity', ...pending.split('; ').map(l => `- ${l}`), '');
347
354
  }
348
355
  }
349
356
  if (handoff.key_files) {
package/hook-memory.mjs CHANGED
@@ -1,7 +1,7 @@
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, notLowSignalTitleClause, noisePenaltyClause } from './utils.mjs';
4
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords } from './utils.mjs';
5
5
 
6
6
  const MAX_MEMORY_INJECTIONS = 3;
7
7
  const MEMORY_LOOKBACK_MS = 60 * 86400000; // 60 days
@@ -16,6 +16,43 @@ const BM25_THRESHOLD = { TINY: 0, SMALL: 1.5, MEDIUM: 2.5, LARGE: 3.5 };
16
16
  // OR fallback max token count — queries with 3+ tokens that fail AND are likely off-topic
17
17
  const OR_FALLBACK_MAX_TOKENS = 2;
18
18
 
19
+ // v27: term-coverage post-filter. Drops high-BM25 candidates whose visible
20
+ // fields (title + lesson_learned) cover <N% of the query's significant terms.
21
+ // Catches the failure mode where FTS tokenization reduces a rich query to a
22
+ // sparse token set and rows sharing only one common word get ranked high.
23
+ // Default 0.4 (≥40% term coverage). Env override `MEM_COVERAGE_THRESHOLD` ∈ [0,1];
24
+ // set to 0 to disable entirely.
25
+ const COVERAGE_MIN_QUERY_TERMS = 2;
26
+ function getCoverageThreshold() {
27
+ const raw = process.env.MEM_COVERAGE_THRESHOLD;
28
+ if (raw === undefined || raw === '') return 0.4;
29
+ const n = parseFloat(raw);
30
+ return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0.4;
31
+ }
32
+ function extractQueryTerms(text) {
33
+ if (!text) return [];
34
+ const ascii = tokenizeHandoff(text).filter(t => !HANDOFF_STOP_WORDS.has(t));
35
+ let cjk = [];
36
+ try { cjk = extractCjkKeywords(text) || []; } catch { /* CJK extraction best-effort */ }
37
+ return [...new Set([...ascii, ...cjk.map(t => String(t).toLowerCase())])];
38
+ }
39
+ function candidateCoverage(row, queryTerms) {
40
+ if (queryTerms.length === 0) return 1.0;
41
+ const hay = `${row.title || ''} ${row.lesson_learned || ''}`.toLowerCase();
42
+ let hits = 0;
43
+ for (const t of queryTerms) {
44
+ if (/[^ -~]/.test(t)) {
45
+ // Non-ASCII (CJK etc): no word boundary semantics, substring match is correct.
46
+ if (hay.includes(t)) hits++;
47
+ } else {
48
+ // ASCII: require word-boundary match so "race" doesn't match "trace".
49
+ const re = new RegExp(`\\b${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
50
+ if (re.test(hay)) hits++;
51
+ }
52
+ }
53
+ return hits / queryTerms.length;
54
+ }
55
+
19
56
  const FILE_RECALL_LOOKBACK_MS = 60 * 86400000; // 60 days
20
57
  const MAX_FILE_RECALL = 2;
21
58
 
@@ -142,13 +179,27 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
142
179
  const aboveThreshold = scored.filter(r => r.score >= threshold);
143
180
  if (aboveThreshold.length === 0) return [];
144
181
 
182
+ // v27: term-coverage filter — drop candidates whose title+lesson_learned
183
+ // covers <threshold of the query's significant terms. Skipped for
184
+ // single-term queries (coverage signal is meaningless) and when the env
185
+ // override disables it.
186
+ let coverageFiltered = aboveThreshold;
187
+ const coverageThreshold = getCoverageThreshold();
188
+ if (coverageThreshold > 0) {
189
+ const queryTerms = extractQueryTerms(userPrompt);
190
+ if (queryTerms.length >= COVERAGE_MIN_QUERY_TERMS) {
191
+ coverageFiltered = aboveThreshold.filter(r => candidateCoverage(r, queryTerms) >= coverageThreshold);
192
+ if (coverageFiltered.length === 0) return [];
193
+ }
194
+ }
195
+
145
196
  // v26 P0: bump injection_count (NOT access_count) for injected rows.
146
197
  // Before v26 this was bumping access_count, which conflated auto-injection
147
198
  // with real cites/recalls/opens — polluting the noise-ratio signal the
148
199
  // penalty clause now depends on. access_count is reserved for explicit
149
200
  // access (cmdRecall/cmdGet/cmdTimeline/pre-tool-recall/citation-tracker).
150
201
  // Per-row try/catch for FTS trigger safety (project_non_obvious.md).
151
- const result = aboveThreshold.slice(0, MAX_MEMORY_INJECTIONS);
202
+ const result = coverageFiltered.slice(0, MAX_MEMORY_INJECTIONS);
152
203
  const now = Date.now();
153
204
  const bumpStmt = db.prepare(
154
205
  'UPDATE observations SET injection_count = COALESCE(injection_count, 0) + 1, last_injected_at = ? WHERE id = ?'
package/mem-cli.mjs CHANGED
@@ -783,7 +783,32 @@ function cmdTimeline(db, args) {
783
783
  anchorId = nearest.id;
784
784
  anchorNote = `(anchored to #${nearest.id}, closest obs to S#${parsed.id})`;
785
785
  } else {
786
- anchorId = parsed.id;
786
+ // Bare integer (no prefix): try observation first. Fall back to user_prompts
787
+ // then session_summaries so pasted P#/S# IDs still work when the prefix is
788
+ // omitted — matches the prefix-aware routing used by search/probe.
789
+ const obsExists = db.prepare('SELECT 1 FROM observations WHERE id = ?').get(parsed.id);
790
+ if (obsExists) {
791
+ anchorId = parsed.id;
792
+ } else {
793
+ const promptRow = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
794
+ const sessionRow = promptRow ? null : db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
795
+ const hit = promptRow ? { row: promptRow, prefix: 'P', name: 'prompt' }
796
+ : sessionRow ? { row: sessionRow, prefix: 'S', name: 'session' }
797
+ : null;
798
+ if (!hit) {
799
+ fail(`[mem] Observation, prompt, or session with id ${parsed.id} not found`);
800
+ return;
801
+ }
802
+ const proj = project;
803
+ const nearest = db.prepare(`
804
+ SELECT id FROM observations
805
+ WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
806
+ ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
807
+ `).get(...(proj ? [proj, hit.row.created_at_epoch] : [hit.row.created_at_epoch]));
808
+ if (!nearest) { fail(`[mem] No observations near ${hit.prefix}#${parsed.id} (${hit.name})`); return; }
809
+ anchorId = nearest.id;
810
+ anchorNote = `(anchored to #${nearest.id}, closest obs to ${hit.prefix}#${parsed.id})`;
811
+ }
787
812
  }
788
813
  }
789
814
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.38.0",
3
+ "version": "2.39.1",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {