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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.28.0",
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.0",
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
- // The block delimiters claude-mem-lite wraps injected context in. Any user-derived text
28
- // (observation title / lesson, handoff body) that contains one of these LITERALLY would
29
- // prematurely open or close the block it lands in, and the model then reads the rest as
30
- // undelimited context. Reachable by editing files that contain these tokens \u2014 e.g.
31
- // developing claude-mem-lite itself, where source/observations carry the delimiter names.
32
- const CONTEXT_DELIMITER_RE = /<\/?(?:claude-mem-context|memory-context|session-handoff)>/gi;
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
- title: truncate(parsed.title, 120),
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
- title: truncate(obs.title, 120),
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
- const title = truncate(parsed.merged_title, 120);
450
- const narrative = truncate(parsed.merged_narrative || '', 800);
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
- const title = truncate(parsed.title, 120);
637
- const narrative = truncate(parsed.narrative || '', 800);
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
- scrubSecrets(promptText.slice(0, 10000)),
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) {
@@ -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.0",
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",