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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-context.mjs +1 -1
- package/hook-handoff.mjs +12 -5
- package/hook-memory.mjs +53 -2
- package/mem-cli.mjs +26 -1
- package/package.json +1 -1
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(`-
|
|
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.
|
|
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.
|
|
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('##
|
|
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 =
|
|
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
|
-
|
|
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
|
|