claude-mem-lite 2.38.0 → 2.39.0
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-memory.mjs +53 -2
- package/mem-cli.mjs +26 -1
- package/package.json +1 -1
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
|
|