claude-mem-lite 3.28.0 → 3.28.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/format-utils.mjs +15 -6
- package/hook-llm.mjs +12 -5
- package/hook-optimize.mjs +16 -10
- package/hook.mjs +5 -1
- package/lib/rrf.mjs +7 -0
- package/lib/save-observation.mjs +5 -1
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.28.
|
|
13
|
+
"version": "3.28.2",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.28.
|
|
3
|
+
"version": "3.28.2",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/format-utils.mjs
CHANGED
|
@@ -24,12 +24,21 @@ export function truncate(str, max = 80) {
|
|
|
24
24
|
return str.slice(0, end) + '\u2026';
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
27
|
+
// Two delimiter classes are defanged here:
|
|
28
|
+
// 1. The blocks claude-mem-lite wraps injected context in (claude-mem-context /
|
|
29
|
+
// memory-context / session-handoff). User-derived text containing one LITERALLY
|
|
30
|
+
// would prematurely open/close the block it lands in, spilling the rest as
|
|
31
|
+
// undelimited context.
|
|
32
|
+
// 2. Harness-authority tags the runtime injects (system-reminder / task-notification).
|
|
33
|
+
// Memory replays arbitrary captured text \u2014 file contents, tool output, web pages \u2014
|
|
34
|
+
// so a poisoned observation carrying a literal <system-reminder>\u2026</system-reminder>
|
|
35
|
+
// would smuggle a forged privileged-channel instruction into the model's context.
|
|
36
|
+
// It can't escape its wrapper (class 1 closers are defanged), but a nested forged
|
|
37
|
+
// authority tag is still an indirect-prompt-injection vector; strip the brackets so
|
|
38
|
+
// it reads as inert text. Other tags (<other-tag>) are deliberately left intact.
|
|
39
|
+
// Reachable by editing files that contain these tokens \u2014 e.g. developing claude-mem-lite
|
|
40
|
+
// itself, where source/observations carry the delimiter names.
|
|
41
|
+
const CONTEXT_DELIMITER_RE = /<\/?(?:claude-mem-context|memory-context|session-handoff|system-reminder|task-notification)>/gi;
|
|
33
42
|
|
|
34
43
|
/**
|
|
35
44
|
* Defang the literal context-block delimiter tags in user-derived text. Strips just the
|
package/hook-llm.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { basename } from 'path';
|
|
|
5
5
|
import { existsSync, readFileSync, unlinkSync, readdirSync } from 'fs';
|
|
6
6
|
import {
|
|
7
7
|
jaccardSimilarity, truncate, clampImportance, computeRuleImportance,
|
|
8
|
-
inferProject, parseJsonFromLLM,
|
|
8
|
+
inferProject, parseJsonFromLLM, scrubSecrets,
|
|
9
9
|
computeMinHash, estimateJaccardFromMinHash, cjkBigrams, EDIT_TOOLS, LOW_SIGNAL_TITLE, debugCatch, debugLog, OBS_BM25,
|
|
10
10
|
getCurrentBranch, notLowSignalTitleClause,
|
|
11
11
|
} from './utils.mjs';
|
|
@@ -784,9 +784,13 @@ ${actionList}`;
|
|
|
784
784
|
|
|
785
785
|
obs = {
|
|
786
786
|
type: validTypes.has(parsed.type) ? parsed.type : 'change',
|
|
787
|
-
|
|
787
|
+
// Scrub BEFORE truncate: a secret Haiku regurgitated verbatim (the very
|
|
788
|
+
// case the downstream scrubRecord guards against) could straddle the
|
|
789
|
+
// 120/500-char cut, leaving a head the value-length-gated scrub regex no
|
|
790
|
+
// longer matches. Slicing scrubbed text keeps the boundary leak-free.
|
|
791
|
+
title: truncate(scrubSecrets(parsed.title || ''), 120),
|
|
788
792
|
subtitle: fileList,
|
|
789
|
-
narrative: truncate(parsed.narrative || '', 500),
|
|
793
|
+
narrative: truncate(scrubSecrets(parsed.narrative || ''), 500),
|
|
790
794
|
concepts: Array.isArray(parsed.concepts) ? parsed.concepts.slice(0, 10) : [],
|
|
791
795
|
facts: Array.isArray(parsed.facts) ? parsed.facts.slice(0, 10) : [],
|
|
792
796
|
files: episodeFiles,
|
|
@@ -881,9 +885,12 @@ ${actionList}`;
|
|
|
881
885
|
// INSERT path. type is an enum, importance is numeric, files_read is a
|
|
882
886
|
// JSON array (already scrubbed upstream), minhash_sig is hash bytes.
|
|
883
887
|
const safe = scrubRecord('observations', {
|
|
884
|
-
|
|
888
|
+
// Scrub BEFORE truncate (see first-pass note above): the truncate
|
|
889
|
+
// boundary must land on already-scrubbed text or a straddling secret
|
|
890
|
+
// leaks its head past the value-length-gated regex.
|
|
891
|
+
title: truncate(scrubSecrets(obs.title || ''), 120),
|
|
885
892
|
subtitle: obs.subtitle || '',
|
|
886
|
-
narrative: truncate(obs.narrative || '', 500),
|
|
893
|
+
narrative: truncate(scrubSecrets(obs.narrative || ''), 500),
|
|
887
894
|
concepts: conceptsText,
|
|
888
895
|
facts: factsText,
|
|
889
896
|
text: textField,
|
package/hook-optimize.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { join } from 'path';
|
|
|
7
7
|
import {
|
|
8
8
|
truncate, debugLog, debugCatch, COMPRESSED_AUTO,
|
|
9
9
|
computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity, clampImportance, cjkBigrams,
|
|
10
|
-
notLowSignalTitleClause,
|
|
10
|
+
notLowSignalTitleClause, scrubSecrets,
|
|
11
11
|
} from './utils.mjs';
|
|
12
12
|
import { callModelJSON } from './haiku-client.mjs';
|
|
13
13
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
@@ -152,14 +152,17 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
|
|
|
152
152
|
const facts = Array.isArray(parsed.facts) ? parsed.facts.slice(0, 10) : [];
|
|
153
153
|
const conceptsText = concepts.join(' ');
|
|
154
154
|
const factsText = facts.join(' ');
|
|
155
|
+
// Scrub BEFORE truncate so a secret straddling the cut can't leave a sub-6-char
|
|
156
|
+
// head that scrubSecrets's value-length floor no longer matches (the scrubRecord
|
|
157
|
+
// below would then miss it too). Mirrors the hook-llm save-path fix.
|
|
155
158
|
const lessonLearned = typeof parsed.lesson_learned === 'string'
|
|
156
159
|
&& parsed.lesson_learned.toLowerCase() !== 'none'
|
|
157
160
|
&& parsed.lesson_learned.trim().length > 0
|
|
158
|
-
? parsed.lesson_learned.slice(0, 500) : null;
|
|
161
|
+
? scrubSecrets(parsed.lesson_learned).slice(0, 500) : null;
|
|
159
162
|
const searchAliases = Array.isArray(parsed.search_aliases)
|
|
160
163
|
? parsed.search_aliases.slice(0, 6).join(' ') : null;
|
|
161
|
-
const title = truncate(parsed.title, 120);
|
|
162
|
-
const narrative = truncate(parsed.narrative || cand.narrative || '', 500);
|
|
164
|
+
const title = truncate(scrubSecrets(parsed.title || ''), 120);
|
|
165
|
+
const narrative = truncate(scrubSecrets(parsed.narrative || cand.narrative || ''), 500);
|
|
163
166
|
const importance = clampImportance(parsed.importance);
|
|
164
167
|
|
|
165
168
|
const bigramText = cjkBigrams((title || '') + ' ' + (narrative || ''));
|
|
@@ -446,11 +449,13 @@ Return ONLY valid JSON:
|
|
|
446
449
|
const facts = Array.isArray(parsed.merged_facts) ? parsed.merged_facts.slice(0, 10) : [];
|
|
447
450
|
const conceptsText = concepts.join(' ');
|
|
448
451
|
const factsText = facts.join(' ');
|
|
449
|
-
|
|
450
|
-
|
|
452
|
+
// Scrub BEFORE truncate (see re-enrich note): keep the boundary cut on
|
|
453
|
+
// already-scrubbed text so a straddling secret can't leak a sub-floor head.
|
|
454
|
+
const title = truncate(scrubSecrets(parsed.merged_title || ''), 120);
|
|
455
|
+
const narrative = truncate(scrubSecrets(parsed.merged_narrative || ''), 800);
|
|
451
456
|
const lessonLearned = typeof parsed.merged_lesson === 'string'
|
|
452
457
|
&& parsed.merged_lesson.trim().length > 0
|
|
453
|
-
? parsed.merged_lesson.slice(0, 500) : null;
|
|
458
|
+
? scrubSecrets(parsed.merged_lesson).slice(0, 500) : null;
|
|
454
459
|
|
|
455
460
|
const bigramText = cjkBigrams((title || '') + ' ' + (narrative || ''));
|
|
456
461
|
const textField = [conceptsText, factsText, bigramText].filter(Boolean).join(' ');
|
|
@@ -633,8 +638,9 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
|
|
|
633
638
|
const parsed = await callModelJSON(prompt, 'sonnet', { timeout: 20000, maxTokens: 1000 });
|
|
634
639
|
if (!parsed || !parsed.title) return { compressed: false };
|
|
635
640
|
|
|
636
|
-
|
|
637
|
-
const
|
|
641
|
+
// Scrub BEFORE truncate (see re-enrich note): boundary cut on scrubbed text.
|
|
642
|
+
const title = truncate(scrubSecrets(parsed.title || ''), 120);
|
|
643
|
+
const narrative = truncate(scrubSecrets(parsed.narrative || ''), 800);
|
|
638
644
|
const concepts = Array.isArray(parsed.concepts) ? parsed.concepts.slice(0, 10) : [];
|
|
639
645
|
const facts = Array.isArray(parsed.facts) ? parsed.facts.slice(0, 10) : [];
|
|
640
646
|
const conceptsText = concepts.join(' ');
|
|
@@ -642,7 +648,7 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
|
|
|
642
648
|
const lessonLearned = typeof parsed.lesson_learned === 'string'
|
|
643
649
|
&& parsed.lesson_learned.toLowerCase() !== 'none'
|
|
644
650
|
&& parsed.lesson_learned.trim().length > 0
|
|
645
|
-
? parsed.lesson_learned.slice(0, 500) : null;
|
|
651
|
+
? scrubSecrets(parsed.lesson_learned).slice(0, 500) : null;
|
|
646
652
|
const searchAliases = Array.isArray(parsed.search_aliases)
|
|
647
653
|
? parsed.search_aliases.slice(0, 6).join(' ') : null;
|
|
648
654
|
|
package/hook.mjs
CHANGED
|
@@ -1346,7 +1346,11 @@ async function handleUserPrompt() {
|
|
|
1346
1346
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
1347
1347
|
`).run(
|
|
1348
1348
|
sessionId,
|
|
1349
|
-
|
|
1349
|
+
// Scrub BEFORE the 10k slice: a secret straddling char 10000 would otherwise
|
|
1350
|
+
// be cut to a sub-6-char head that scrubSecrets's value-length floor no longer
|
|
1351
|
+
// matches, persisting a partial secret into prompt_text (later re-emitted at
|
|
1352
|
+
// server.mjs prompt_text + mem-cli recent). Scrubbing full text first is leak-free.
|
|
1353
|
+
scrubSecrets(promptText).slice(0, 10000),
|
|
1350
1354
|
promptNumber,
|
|
1351
1355
|
ccSessionId,
|
|
1352
1356
|
now.toISOString(), now.getTime()
|
package/lib/rrf.mjs
CHANGED
|
@@ -24,8 +24,15 @@ export function rrfAccumulate(rankedLists, k = 60) {
|
|
|
24
24
|
const scores = new Map();
|
|
25
25
|
for (const list of rankedLists) {
|
|
26
26
|
if (!Array.isArray(list)) continue;
|
|
27
|
+
// A list is the accumulation unit: an id repeated within ONE list contributes a
|
|
28
|
+
// single best-rank term, not one per occurrence. Without this, a list carrying a
|
|
29
|
+
// duplicate id (e.g. a future caller that concatenates sub-result sets without
|
|
30
|
+
// dedup) would inflate that id to a fake cross-ranker-consensus score.
|
|
31
|
+
const seenInList = new Set();
|
|
27
32
|
list.forEach((r, i) => {
|
|
28
33
|
if (!r || r.id === undefined || r.id === null) return;
|
|
34
|
+
if (seenInList.has(r.id)) return;
|
|
35
|
+
seenInList.add(r.id);
|
|
29
36
|
const add = 1 / (k + i + 1);
|
|
30
37
|
const prev = scores.get(r.id);
|
|
31
38
|
if (prev) {
|
package/lib/save-observation.mjs
CHANGED
|
@@ -47,7 +47,6 @@ export function saveObservation(db, params) {
|
|
|
47
47
|
if (typeof content !== 'string' || content.trim().length === 0) {
|
|
48
48
|
throw new Error('mem_save: content is empty or whitespace-only');
|
|
49
49
|
}
|
|
50
|
-
const rawTitle = params.title || content.slice(0, 100);
|
|
51
50
|
const importance = params.importance ?? 2;
|
|
52
51
|
const files = Array.isArray(params.files)
|
|
53
52
|
? params.files.filter((f) => typeof f === 'string' && f.length > 0)
|
|
@@ -59,6 +58,11 @@ export function saveObservation(db, params) {
|
|
|
59
58
|
// Scrub secrets BEFORE dedup so the comparison runs on the same form that
|
|
60
59
|
// gets persisted (otherwise a token+placeholder pair could dedup-miss).
|
|
61
60
|
const safeContent = scrubSecrets(content);
|
|
61
|
+
// Derive the title from ALREADY-SCRUBBED content, then scrub again: slicing
|
|
62
|
+
// raw content first could cut a secret value mid-token at the 100-char
|
|
63
|
+
// boundary, leaving a head the value-length-gated scrub regex no longer
|
|
64
|
+
// matches — so the title kept a partial secret while the narrative was clean.
|
|
65
|
+
const rawTitle = params.title || safeContent.slice(0, 100);
|
|
62
66
|
const safeTitle = scrubSecrets(rawTitle);
|
|
63
67
|
const safeLesson = rawLesson ? scrubSecrets(rawLesson) : null;
|
|
64
68
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.28.
|
|
3
|
+
"version": "3.28.2",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|