claude-mem-lite 6.10.0 → 6.10.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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.10.0",
12
+ "version": "6.10.2",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
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)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.10.0",
3
+ "version": "6.10.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
@@ -2,6 +2,45 @@ import { DAY_MS } from './lib/time-constants.mjs';
2
2
  // claude-mem-lite: String formatting and display utilities
3
3
  // Extracted from utils.mjs for focused responsibility
4
4
 
5
+ /**
6
+ * Collapse a value to ONE line: newlines to spaces, then trim. Non-strings and nullish
7
+ * become ''.
8
+ *
9
+ * Named and exported because it is a PRECONDITION two other transforms silently relied on
10
+ * `truncate` to provide, and both broke when they were reordered around it:
11
+ *
12
+ * - `scrubSecrets` — three SECRET_PATTERNS arms carry the prose-position lookbehind
13
+ * `(?<![A-Za-z][ \t])`, whose class is HORIZONTAL whitespace on purpose. A credential
14
+ * noun at the start of a line therefore reads as CONFIG position to raw text and as PROSE
15
+ * position to collapsed text, and the config arm redacts any 6+ char value. Scrubbing
16
+ * before truncating (2026-09-21) fed it raw newlines and reinstated a corruption
17
+ * secret-scrub.mjs:45-57 records as deliberately undone: `"Reset the\npassword:
18
+ * instructions are in the onboarding doc"` stored as `password: *** are in…`, irreversibly.
19
+ * Measured 2026-09-22 on a 90-cell grid (6 credential nouns x 5 previous-line endings x 3
20
+ * value shapes): 12 cells over-redact and 0 leak. The COUNT is grid-dependent and is
21
+ * stated here once with its population rather than repeated elsewhere — the pre-ship lens
22
+ * read 9 on a 90-cell grid of the same shape with different value fixtures. What does not
23
+ * move between grids: every affected cell needs the previous line to end in a LETTER, the
24
+ * direction is uniformly over-redaction rather than leakage, and with this normalization
25
+ * restored all 90 cells are byte-identical to the pre-reorder output.
26
+ * - single-line RENDER sites — a `\n` inside an interpolated value starts a new line in the
27
+ * assembled block, so any `## ` after it is a real heading rather than mid-line noise.
28
+ *
29
+ * So: any site that scrubs-then-truncates, or that interpolates free text into one line of a
30
+ * markdown block, normalizes HERE first. `truncate` calls it, which is what kept the two
31
+ * behaviours coupled while nothing named the coupling.
32
+ *
33
+ * @param {*} str Input (any type; coerced)
34
+ * @returns {string} One-line form, trimmed
35
+ */
36
+ export function normalizeInline(str) {
37
+ // Defense-in-depth: a non-string (e.g. an LLM that returned title as an array/number)
38
+ // would throw `str.replace is not a function` and abort the caller. Coerce to '' rather
39
+ // than crash; the real type-guarding happens at the call site.
40
+ if (!str || typeof str !== 'string') return '';
41
+ return str.replace(/\n/g, ' ').trim();
42
+ }
43
+
5
44
  /**
6
45
  * Truncate a string to a maximum length, replacing newlines with spaces.
7
46
  * @param {string} str Input string
@@ -9,12 +48,8 @@ import { DAY_MS } from './lib/time-constants.mjs';
9
48
  * @returns {string} Truncated string with ellipsis if needed
10
49
  */
11
50
  export function truncate(str, max = 80) {
51
+ str = normalizeInline(str);
12
52
  if (!str) return '';
13
- // Defense-in-depth: a non-string (e.g. an LLM that returned title as an array/number)
14
- // would throw `str.replace is not a function` and abort the caller. Coerce to '' rather
15
- // than crash; the real type-guarding happens at the call site.
16
- if (typeof str !== 'string') return '';
17
- str = str.replace(/\n/g, ' ').trim();
18
53
  if (str.length <= max) return str;
19
54
  // Never split a UTF-16 surrogate pair: slicing between the high and low half emits a
20
55
  // lone surrogate (invalid UTF-16) that then gets persisted to the DB. If the last kept
@@ -134,6 +169,68 @@ export function neutralizeContextDelimiters(s) {
134
169
  return defangToFixpoint(s, CONTEXT_DELIMITER_RE);
135
170
  }
136
171
 
172
+ // Markdown ATX markers carried by replayed text, at a token boundary.
173
+ //
174
+ // The injected blocks frame THEMSELVES with `## Working On` / `### Working State` / `###
175
+ // Recent`, and the text they replay is user prompt text — a prompt that opens with its own
176
+ // outline flattens into the block carrying `#` and `##` of its own, on the same line,
177
+ // because working_on joins up to five prompts with ` → `. A real injection read
178
+ // `## Working On` / `# 自主端到端测试与修复循环 ## 角色与授权 …`, at which point the
179
+ // block's structure and the replayed text's structure are indistinguishable to whatever
180
+ // reads it next. Same class as the authority-tag defanging above: a forged SECTION rather
181
+ // than a forged tag.
182
+ //
183
+ // Only a marker followed by whitespace, at a token boundary, counts — `#42`, `C#` and
184
+ // `D#216` are ordinary in this project's prose and survive untouched.
185
+ const ATX_HEADING_RE = /(^|\s)#{1,6}\s/g;
186
+ const ATX_MAX_PASSES = 32;
187
+
188
+ /**
189
+ * Strip ATX markers to a FIXPOINT, not in one pass.
190
+ *
191
+ * The gap is two characters wide and it is the same one `defangToFixpoint` documents for
192
+ * the tag half: `(^|\s)` CONSUMES the boundary, so after removing the first `## ` the regex
193
+ * resumes past the second one and leaves it live. `## ## Key Decisions` came out of a single
194
+ * pass as a real `## Key Decisions` section inside the block — the precise property this
195
+ * defanging exists to hold, defeated by two extra characters.
196
+ *
197
+ * TERMINATION: every match contains at least one `#` and the replacement drops all of them,
198
+ * so any pass that changes the string removes at least one `#`. Self-bounded by the number
199
+ * of `#` in the input, and bounded again by the constant.
200
+ *
201
+ * INERT AT ANY DEPTH: still changing at the cap (≥32 nested forged layers, not reachable by
202
+ * accident) → drop every remaining `#`. Lossier, but the return value then provably carries
203
+ * no marker, which is the property callers rely on. Same fail-closed shape as the sibling.
204
+ */
205
+ function stripAtxToFixpoint(s) {
206
+ let text = s;
207
+ for (let pass = 0; pass < ATX_MAX_PASSES; pass++) {
208
+ const next = text.replace(ATX_HEADING_RE, '$1');
209
+ if (next === text) return text;
210
+ text = next;
211
+ }
212
+ return text.replace(/#/g, '');
213
+ }
214
+
215
+ /**
216
+ * Defang authority tags AND section markers, for any replayed free text.
217
+ *
218
+ * Lives here rather than in one renderer because the SAME session_handoffs columns are
219
+ * replayed by two surfaces — `hook-handoff.mjs`'s `<session-handoff>` block and
220
+ * `hook-context.mjs`'s `### Working State (from /clear)` — and until 2026-09-21 only the first
221
+ * one called it. One home, so they cannot drift apart again.
222
+ *
223
+ * Apply it PER FIELD, never to an assembled block: both callers structure themselves with
224
+ * ATX headers, so a whole-string pass would delete their own sectioning along with the
225
+ * forged one.
226
+ *
227
+ * @param {*} value Replayed free text (any type; coerced)
228
+ * @returns {string} Text with delimiter tags and section markers defanged
229
+ */
230
+ export function safeText(value) {
231
+ return stripAtxToFixpoint(neutralizeContextDelimiters(String(value)));
232
+ }
233
+
137
234
  // <skill-loaded> is deliberately NOT in CONTEXT_DELIMITER_RE above: mem_use's legitimate
138
235
  // load path has to emit a REAL one, and that result goes through the same handler-wide
139
236
  // defang, which would strip it. So the tag is neutralized here instead — per call site,
package/hook-context.mjs CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  inferProject,
17
17
  debugLog,
18
18
  neutralizeContextDelimiters,
19
+ safeText,
20
+ normalizeInline,
19
21
  DECAY_HALF_LIFE_BY_TYPE,
20
22
  DEFAULT_DECAY_HALF_LIFE_MS,
21
23
  notLowSignalTitleClause,
@@ -647,7 +649,19 @@ export function buildSessionContextLines(
647
649
  const files = JSON.parse(o.files_modified);
648
650
  const fname = basename(Array.isArray(files) && files.length > 0 ? files[0] : '');
649
651
  if (fname) {
650
- fileLessons.push({ id: o.id, line: `- ${fname}: ${truncate(o.lesson_learned, 100)} (#${o.id})` });
652
+ // `fname` is the ONE value in this block interpolated with neither `truncate` nor
653
+ // a defang, so a newline inside a stored path put everything after it on its own
654
+ // line and any `## ` there became a real heading. Reproduced end to end from
655
+ // `files_modified = ["notes.mjs\n## Forged"]`, which reaches the column because
656
+ // lib/save-observation.mjs filters `files` on `typeof f === 'string'` and length
657
+ // only — and `mem_save` is agent-callable, which is the threat model this
658
+ // defanging exists for. Same mechanism and same source column as the
659
+ // `- Key files:` line below; found by the pre-ship defect lens after the commit
660
+ // that fixed that one declared this family closed.
661
+ fileLessons.push({
662
+ id: o.id,
663
+ line: `- ${safeText(normalizeInline(fname))}: ${truncate(o.lesson_learned, 100)} (#${o.id})`,
664
+ });
651
665
  continue;
652
666
  }
653
667
  } catch {
@@ -772,17 +786,46 @@ export function buildSessionContextLines(
772
786
  const handoffLines = [];
773
787
  if (prevClearHandoff) {
774
788
  handoffLines.push('### Working State (from /clear)');
789
+ // `safeText`, per field, on all three: these are the same session_handoffs columns
790
+ // hook-handoff.mjs's `<session-handoff>` block replays, and that surface has defanged
791
+ // them since a real injection arrived carrying `## ` of its own. This one applied only
792
+ // the TAG half — the `neutralizeContextDelimiters` at the bottom of this function covers
793
+ // every line in the block — and no ATX marker at all, so a forged SECTION replayed live.
794
+ // `working_on` is the worst of the three: it is raw user prompt text.
795
+ //
796
+ // Defang BEFORE truncate, the same order the handoff builder's own persistence comment
797
+ // prescribes, so the 200-char cut cannot leave a half-processed marker at the boundary.
798
+ //
799
+ // Per FIELD, never over the assembled block: `### Working State` / `### Recent` /
800
+ // `### Last Session` are this block's OWN sectioning and a whole-string ATX pass would
801
+ // delete it. That is also why this is not simply folded into the return below.
775
802
  if (prevClearHandoff.working_on) {
776
- handoffLines.push(`- Working on: ${truncate(prevClearHandoff.working_on, 200)}`);
803
+ handoffLines.push(`- Working on: ${truncate(safeText(prevClearHandoff.working_on), 200)}`);
777
804
  }
778
805
  if (prevClearHandoff.unfinished) {
779
806
  const pendingSummary = extractUnfinishedSummary(prevClearHandoff.unfinished);
780
- if (pendingSummary) handoffLines.push(`- Recent activity: ${truncate(pendingSummary, 200)}`);
807
+ if (pendingSummary) {
808
+ handoffLines.push(`- Recent activity: ${truncate(safeText(pendingSummary), 200)}`);
809
+ }
781
810
  }
782
811
  if (prevClearHandoff.key_files) {
783
812
  try {
784
813
  const files = JSON.parse(prevClearHandoff.key_files);
785
- if (files.length > 0) handoffLines.push(`- Key files: ${files.map((f) => basename(f)).join(', ')}`);
814
+ if (files.length > 0) {
815
+ // `normalizeInline` as well as `safeText`, and this line is the reason the pair is
816
+ // needed rather than either alone. Of the three fields here it is the only one with
817
+ // no `truncate`, so it is the only one where a stored newline can start a line —
818
+ // which is what turns a marker from mid-line noise into a real heading. It also
819
+ // closes a composition gap `safeText` cannot see: `#<>#` is neither an ATX marker
820
+ // nor a delimiter tag, so safeText leaves it, and the block-level defang at the end
821
+ // of this function strips every `<`/`>` when it fails closed, collapsing it to `##`
822
+ // with no ATX pass left to run. Collapsed to one line, that `##` can only land
823
+ // mid-line. (The fail-closed trigger itself is reachable through the untruncated
824
+ // `Lessons:` / `Decisions:` lines in buildSummaryLines — still open, see below.)
825
+ handoffLines.push(
826
+ `- Key files: ${safeText(normalizeInline(files.map((f) => basename(f)).join(', ')))}`,
827
+ );
828
+ }
786
829
  } catch {
787
830
  /* malformed JSON — skip */
788
831
  }
package/hook-handoff.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  import { basename } from 'path';
5
5
  import {
6
6
  truncate,
7
+ normalizeInline,
7
8
  extractMatchKeywords,
8
9
  tokenizeHandoff,
9
10
  isSpecificTerm,
@@ -12,9 +13,9 @@ import {
12
13
  EDIT_TOOLS,
13
14
  isMetaTriggerPrompt,
14
15
  notLowSignalTitleClause,
15
- neutralizeContextDelimiters,
16
+ safeText,
16
17
  } from './utils.mjs';
17
- import { scrubRecord, scrubFilePath } from './lib/scrub-record.mjs';
18
+ import { scrubRecord, scrubFilePath, scrubFilePaths } from './lib/scrub-record.mjs';
18
19
  import {
19
20
  HANDOFF_EXPIRY_CLEAR,
20
21
  HANDOFF_EXPIRY_EXIT,
@@ -101,14 +102,60 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
101
102
  const subjectPrompts = prompts.filter((p) => !isMetaTriggerPrompt(p.prompt_text));
102
103
  const sourcePrompts = subjectPrompts.length > 0 ? subjectPrompts : prompts;
103
104
 
105
+ // Scrub BEFORE truncate. A secret straddling the 200-char cut is shortened below the
106
+ // length floor its own pattern requires, stops matching entirely, and the retained head is
107
+ // then stored verbatim — the exact failure the persistence-boundary comment below
108
+ // prescribes against, in the three call sites that sit above it.
109
+ //
110
+ // Measured 2026-09-22 on this machine, one fixture set for every number below (the first
111
+ // draft used two probes with two different `xoxb-` tokens, so its denominator and its
112
+ // table described different fixtures): five credential families, the cut walked through
113
+ // the token one character at a time, 244 cut points. Under truncate-then-scrub **38** cut
114
+ // points leak ≥12 characters (33 leak ≥13); under this order, 0. FIXED-LENGTH families are
115
+ // the worst case, because a short read matches nothing at all rather than matching less —
116
+ // longest head the old order still stored, minus the prefix:
117
+ //
118
+ // ghp_ + 36 29 of 36 entropy chars
119
+ // AKIA + 16 15 of 16
120
+ // xoxb- (12-12-24) 9 of 50
121
+ // password= + 32 4 of 32 (the appended `…` counts toward the `{6,}` value class)
122
+ // sk-ant-api03- + 80 1 of 80 (the `ant` alternation lets `api03-AA` satisfy `{8,}`)
123
+ //
124
+ // The variable-length row is the contrast case and it is 1, not 0. The destination is not
125
+ // a log line: this column is persisted and replayed into a later session's prompt by both
126
+ // renderers.
127
+ //
128
+ // `working_on` is consequently the one column scrubbed TWICE — here, and again at the
129
+ // persistence boundary via scrubRecord, which stays as-is because `completed` and
130
+ // `unfinished` do NOT come from prompts: `completed` is a stored-row query (:218), and
131
+ // `unfinished` is the in-memory episode snapshot or the on-disk task list under
132
+ // `~/.claude/tasks/`, with observation narrative appended (:246-:289). An earlier draft of
133
+ // this sentence said both "reach that call from STORED ROWS", which is the load-bearing
134
+ // half of why those two columns were left alone, and it was wrong about `unfinished`.
135
+ // scrubSecrets is a fixpoint on its own output for every family that reaches this path;
136
+ // that property is pinned in tests/handoff-working-on-scrub-order.test.mjs rather than
137
+ // assumed here, since D#46 is open on idempotence by CONTRACT.
138
+ // `normalizeInline` FIRST, and it is not cosmetic. `truncate` used to run before
139
+ // `scrubSecrets` and collapsed newlines on the way; moving the scrub earlier handed it raw
140
+ // newlines, which flips every line-start credential noun from the scrubber's prose arm to
141
+ // its config arm and irreversibly redacts ordinary English. That corruption is recorded at
142
+ // secret-scrub.mjs:45-57 as one a prior pre-tag review already undone once. See
143
+ // normalizeInline's own docblock for the measured grid. The scrubber now sees exactly the
144
+ // one-line shape it saw before the reorder; only the LENGTH cut moved.
145
+ //
146
+ // Dedup keys on the SCRUBBED line, deliberately, and this is a behaviour change from the
147
+ // pre-reorder code: two prompts differing only in their credential both render as
148
+ // `deploy with key ***`, and keying on the raw text would replay that identical sentence
149
+ // twice. The key is what the resuming session is actually shown. Pinned by a case.
104
150
  const seen = new Set();
105
- const uniquePrompts = sourcePrompts.filter((p) => {
106
- const t = truncate(p.prompt_text, 200);
107
- if (seen.has(t)) return false;
108
- seen.add(t);
109
- return true;
110
- });
111
- let workingOn = uniquePrompts.map((p) => truncate(p.prompt_text, 200)).join(' → ');
151
+ const safePromptLines = [];
152
+ for (const p of sourcePrompts) {
153
+ const line = truncate(scrubSecrets(normalizeInline(p.prompt_text)), 200);
154
+ if (seen.has(line)) continue;
155
+ seen.add(line);
156
+ safePromptLines.push(line);
157
+ }
158
+ let workingOn = safePromptLines.join(' → ');
112
159
 
113
160
  if (subjectPrompts.length === 0) {
114
161
  const fallback = db
@@ -123,7 +170,10 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
123
170
  )
124
171
  .get(project);
125
172
  if (fallback?.title) {
126
- workingOn = `(carry-forward subject) ${truncate(fallback.title, 180)}`;
173
+ // Same order as the prompt arm above. Titles are scrubbed on write TODAY, so this is
174
+ // defense-in-depth for rows that predate that — which is not hypothetical: D#49 still
175
+ // has three bare credential-shaped values backfilled in a sibling column.
176
+ workingOn = `(carry-forward subject) ${truncate(scrubSecrets(normalizeInline(fallback.title)), 180)}`;
127
177
  }
128
178
  }
129
179
 
@@ -372,9 +422,10 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
372
422
  // rewrite the serialized form risks breaking the downstream JSON.parse. Same rule
373
423
  // key_files follows below.
374
424
  nextSteps = JSON.stringify({
375
- // scrubFilePath, not scrubSecrets: this field is a filesystem PATH, and eight of
376
- // the secret patterns carry a value class that does not exclude `/`, so a
377
- // whole-path scrub eats the separator and destroys the filename. That is the named
425
+ // scrubFilePath, not scrubSecrets: this field is a filesystem PATH, and many of
426
+ // the secret patterns carry a value class that does not exclude `/` (count and
427
+ // population: lib/scrub-record.mjs), so a whole-path scrub eats the separator and
428
+ // destroys the filename. That is the named
378
429
  // mechanism this repo grew for exactly this shape; the prose fields below are prose
379
430
  // and correctly take the plain scrub.
380
431
  file: scrubFilePath(String(note.file)),
@@ -386,9 +437,60 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
386
437
  /* best-effort, like the task reader above — never block the handoff */
387
438
  }
388
439
 
389
- // 6. Match keywords
390
- const allText = [workingOn, ...completed.map((c) => c.title).filter(Boolean), unfinished].join(' ');
391
- const keywords = extractMatchKeywords(allText, [...fileSet]);
440
+ // 6. Match keywords.
441
+ //
442
+ // Scrubbed at the DERIVATION, and the scrubbed file array is derived ONCE and feeds both
443
+ // sinks (here and key_files below) — the shape hook-llm.mjs uses where one path array
444
+ // reaches two columns. lib/scrub-record.mjs excludes match_keywords from scrubRecord, and
445
+ // the reason it recorded — "built from tokenizeHandoff() output (alphanumeric tokens
446
+ // only), so secrets cannot survive the upstream tokenizer" — does not hold on either arm:
447
+ // the FILE arm never reaches the tokenizer (it takes basename-minus-extension straight off
448
+ // this set, which holds RAW paths), and the tokenizer SPLITS a secret from its keyword
449
+ // rather than removing it, so `token=ghp_…` contributes `ghp_…` as a term of its own.
450
+ //
451
+ // Exposure, measured rather than asserted: nothing renders this column and no export face
452
+ // reads the table — `EXPORT_COLUMNS` is observations-only and `session_handoffs` has zero
453
+ // occurrences in server.mjs and the CLI. So this is local-DB-at-rest, with no egress path.
454
+ // A credential in a stored column is still worth removing; it is not a disclosure. (The
455
+ // sentence this replaces claimed egress through `export` and was false — a replacement
456
+ // justification written while retracting another one, unverified, which is this repo's
457
+ // signature recurrence. Pre-ship claims lens.)
458
+ //
459
+ // Per ELEMENT, then join — never scrub the concatenation. A credential noun ending one
460
+ // element and a `=`/`:` opening the next form a match that exists in NEITHER, and the
461
+ // derived term set then loses a word both columns keep (measured: `zebrafish`). Same rule
462
+ // next_steps and key_files already follow, and the same rule the truncation note below
463
+ // states. NOT identity on ordinary prose, which an earlier draft of this comment claimed:
464
+ // `api_key: handling` loses `handling` (5 of 6 ordinary developer prompts in a directed
465
+ // grid lose exactly one term). What is true, and is the actual justification, is that the
466
+ // term set now AGREES with what the resuming session is shown — `working_on` / `completed`
467
+ // / `unfinished` lose the same word through scrubRecord below.
468
+ //
469
+ // SUPERSEDED 2026-09-21, and the correction is load-bearing rather than cosmetic. This
470
+ // block used to end "No value is scrubbed twice: these elements and the columns below are
471
+ // separate derivations from one raw source, each scrubbed once, which is the distinction
472
+ // D#46 is open about." That still holds for `completed` and `unfinished`. It is now FALSE
473
+ // for `workingOn`, which arrives here ALREADY scrubbed, because the prompt arm above had
474
+ // to scrub before truncating to stop a boundary-straddling secret from being stored as a
475
+ // verbatim head.
476
+ //
477
+ // The chain depth is TWO, on each of the two derivations, and it is worth spelling out
478
+ // because a draft of this block said "twice on this derivation and a third time through
479
+ // scrubRecord" — which counts one value as three and no value is:
480
+ // match_keywords : prompt arm -> the `allText` map below = 2
481
+ // working_on : prompt arm -> scrubRecord at the INSERT = 2
482
+ // Idempotence is therefore a property this file now DEPENDS on rather than merely
483
+ // tolerates — measured and pinned in tests/handoff-working-on-scrub-order.test.mjs, not
484
+ // asserted here. D#46 stays open on idempotence by CONTRACT; what is closed is
485
+ // idempotence for the eleven families that test pins.
486
+ const safeFiles = scrubFilePaths([...fileSet]);
487
+ // The nullish guard mirrors what join() already did with a nullish element. Without it
488
+ // String(undefined) would put the literal token "undefined" into the term set — a behaviour
489
+ // change smuggled in by the per-element rewrite rather than chosen.
490
+ const allText = [workingOn, ...completed.map((c) => c.title).filter(Boolean), unfinished]
491
+ .map((t) => (t === null || t === undefined ? '' : scrubSecrets(String(t))))
492
+ .join(' ');
493
+ const keywords = extractMatchKeywords(allText, safeFiles);
392
494
 
393
495
  // T10d: capture HEAD sha so detectContinuationIntent can anchor on it later.
394
496
  // Best-effort — failures (non-git dir, missing binary, timeout) yield null.
@@ -427,7 +529,21 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
427
529
  key_decisions: decisions.map((d) => `[${d.type}] ${d.title}`).join('\n'),
428
530
  match_keywords: keywords,
429
531
  });
430
- const safeKeyFiles = JSON.stringify([...fileSet].slice(0, 20).map((f) => scrubSecrets(String(f))));
532
+ // scrubFilePath, not scrubSecrets — the same correction next_steps.file took above, and
533
+ // key_files was the last of the six path columns still taking the whole-string form. It
534
+ // was already element-wise, which is what made it look compliant with this module's
535
+ // prescription; the function was the wrong one. Many SECRET_PATTERNS have a value class
536
+ // that does not exclude `/` (count and population: lib/scrub-record.mjs), so a whole-path
537
+ // match eats the separator and the filename
538
+ // with it, and the renderer below emits `basename(f)` — `## Key Files` read `password=***`
539
+ // where the file was `notes.mjs`. Worse than a wrong name: fileSet is keyed on the RAW
540
+ // path, so two files under one credential-bearing directory survive dedup and then
541
+ // collapse onto the identical stored string.
542
+ // `safeFiles` was derived at the keywords block above, so both sinks see one scrubbed
543
+ // array rather than two independent scrubs of the same paths. Slicing after the map is
544
+ // equivalent to mapping after the slice (per-element, order-preserving) and keeps the
545
+ // keyword arm on the FULL set, which is what it read before.
546
+ const safeKeyFiles = JSON.stringify(safeFiles.slice(0, 20));
431
547
  // The UPSERT below resets `consumed_at`. Rewriting a handoff makes it fresh again, so it
432
548
  // must become injectable again: the DELETE that consumeHandoff replaced did this
433
549
  // implicitly (row gone, next build INSERTed a new one), while the UPSERT reuses the row.
@@ -723,54 +839,12 @@ export function renderHandoffInjection(db, project, currentCcSessionId = null) {
723
839
  return renderHandoffFromRow(handoff, db, project);
724
840
  }
725
841
 
726
- // Markdown ATX markers carried by replayed text, at a token boundary.
727
- //
728
- // This block frames itself with `## Working On` / `## Completed` / `## Next steps`, and
729
- // working_on is user prompt text — a prompt that opens with its own outline flattens into
730
- // the block carrying `#` and `##` of its own, on the same line, because working_on joins up
731
- // to five prompts with ` → `. A real injection read
732
- // `## Working On` / `# 自主端到端测试与修复循环 ## 角色与授权 …`, at which point the
733
- // block's structure and the replayed text's structure are indistinguishable to whatever
734
- // reads it next. Same class as the authority-tag defanging one level down: a forged
735
- // SECTION rather than a forged tag.
736
- //
737
- // Only a marker followed by whitespace, at a token boundary, counts — `#42`, `C#` and
738
- // `D#216` are ordinary in this project's prose and survive untouched.
739
- const ATX_HEADING_RE = /(^|\s)#{1,6}\s/g;
740
- const ATX_MAX_PASSES = 32;
741
-
742
- /**
743
- * Strip ATX markers to a FIXPOINT, not in one pass.
744
- *
745
- * The gap is two characters wide and it is the same one format-utils' `defangToFixpoint`
746
- * documents for the tag half: `(^|\s)` CONSUMES the boundary, so after removing the first
747
- * `## ` the regex resumes past the second one and leaves it live. `## ## Key Decisions`
748
- * came out of a single pass as a real `## Key Decisions` section inside the block — the
749
- * precise property this defanging exists to hold, defeated by two extra characters. Found
750
- * by the pre-ship defect lens; reproduced end-to-end before the fix.
751
- *
752
- * TERMINATION: every match contains at least one `#` and the replacement drops all of them,
753
- * so any pass that changes the string removes at least one `#`. Self-bounded by the number
754
- * of `#` in the input, and bounded again by the constant.
755
- *
756
- * INERT AT ANY DEPTH: still changing at the cap (≥32 nested forged layers, not reachable by
757
- * accident) → drop every remaining `#`. Lossier, but the return value then provably carries
758
- * no marker, which is the property callers rely on. Same fail-closed shape as the sibling.
759
- */
760
- function stripAtxToFixpoint(s) {
761
- let text = s;
762
- for (let pass = 0; pass < ATX_MAX_PASSES; pass++) {
763
- const next = text.replace(ATX_HEADING_RE, '$1');
764
- if (next === text) return text;
765
- text = next;
766
- }
767
- return text.replace(/#/g, '');
768
- }
769
-
770
- /** Defang authority tags AND section markers, for any replayed free text. */
771
- function safeText(value) {
772
- return stripAtxToFixpoint(neutralizeContextDelimiters(String(value)));
773
- }
842
+ // `safeText` — the ATX-marker + authority-tag defang this renderer has applied since a real
843
+ // injection came back carrying `## ` of its own — moved to format-utils.mjs 2026-09-21. It
844
+ // was private here while hook-context's `### Working State (from /clear)` replayed the SAME
845
+ // three session_handoffs columns with only the tag half of the treatment, so the two surfaces
846
+ // had drifted apart by a whole defence. One home now; see the docblock there for why it must
847
+ // be applied per FIELD and never to an assembled block.
774
848
 
775
849
  // `[bugfix] title` → `title`. Both `completed` and `key_decisions` store the observation
776
850
  // type this way; the bracket run is length-capped so a title that merely opens with a
package/hook.mjs CHANGED
@@ -2114,13 +2114,16 @@ function saveHandoffAndFastSummary(
2114
2114
  debugCatch(e, 'session-start-handoff');
2115
2115
  }
2116
2116
 
2117
- // Read the just-saved handoff for downstream consumers (fast summary remaining, working state).
2117
+ // Read the just-saved handoff for the ONE downstream consumer here: the fast summary's
2118
+ // "remaining" line, below. It used to select `working_on, unfinished, key_files` and
2119
+ // read only `unfinished` — the other two were dead, because the `### Working State`
2120
+ // block that renders them is built by hook-context.mjs from its own query, not from
2121
+ // this row. Selected columns are cheap; a reader looking for who consumes `key_files`
2122
+ // is not, and this site answered that question wrongly.
2118
2123
  // Session-scoped read to avoid picking up a parallel session's clear handoff.
2119
2124
  try {
2120
2125
  prevClearHandoff = db
2121
- .prepare(
2122
- 'SELECT working_on, unfinished, key_files FROM session_handoffs WHERE project = ? AND type = ? AND session_id = ?',
2123
- )
2126
+ .prepare('SELECT unfinished FROM session_handoffs WHERE project = ? AND type = ? AND session_id = ?')
2124
2127
  .get(prevProject || project, 'clear', handoffScopeId);
2125
2128
  } catch {}
2126
2129
 
@@ -7,8 +7,16 @@
7
7
  // while the answer sat in the repo in prose.
8
8
  //
9
9
  // It is also why the next step is taken from a FILE rather than from a model summary:
10
- // session_summaries.next_steps is non-empty in 11 of 310 rows (3.5%), so that route has
11
- // already been measured and does not work.
10
+ // session_summaries.next_steps is thin, but quote BOTH numbers, because the lifetime
11
+ // average hides a trend and a single average was the original justification: 15 of 318 rows
12
+ // non-empty over the corpus lifetime (4.7%), and 4 of the newest 20 (20%). So the honest
13
+ // form is "unreliable", not "does not work" — the recent regime is four times the average.
14
+ // The reason to read a FILE instead is not the rate anyway: a paused note is written by a
15
+ // human on purpose and names its own verify command, which is a different kind of signal
16
+ // from a field an LLM fills in when it happens to.
17
+ //
18
+ // The pre-ship claims lens raised this, reporting 4 of the newest 5 non-empty; that specific
19
+ // figure did not reproduce (measured 1 of 5, 4 of 20). The caveat stood, its number did not.
12
20
  //
13
21
  // A leaf on purpose — `fs`/`path` only, no package imports and no edge back into the hook
14
22
  // layer, so importing it costs nothing at load time (same reason lib/data-paths.mjs is a
@@ -17,6 +17,13 @@
17
17
  // .files_modified / .files_read, observation_files.filename and events
18
18
  // .file_paths all stored raw paths while the title DERIVED FROM THE SAME PATH
19
19
  // was scrubbed. A prescription in a comment is not a mechanism; the helper is.
20
+ //
21
+ // Second axis, found after D#44 closed: that one compliant call site was compliant
22
+ // in SHAPE only. It mapped `scrubSecrets` over the elements — element-wise, as
23
+ // prescribed — and so read as the model the other five were measured against, while
24
+ // taking the whole-string function this module exists to keep off a path. "Follows the
25
+ // rule" and "calls the helper" are different claims, and only the second is checkable.
26
+ // Which is why the prescription above names `scrubFilePaths` rather than describing it.
20
27
 
21
28
  import { scrubSecrets } from '../secret-scrub.mjs';
22
29
 
@@ -52,7 +59,12 @@ export const TEXT_FIELDS_BY_TABLE = {
52
59
  'completed',
53
60
  'unfinished',
54
61
  // Excluded:
55
- // key_files — JSON.stringify(array); pre-scrub elements at call site
62
+ // key_files — JSON.stringify(array); pre-scrubbed at the call site via
63
+ // `scrubFilePaths`, NOT a bare per-element scrubSecrets. It held
64
+ // filesystem paths and took the whole-string function through
65
+ // v6.10.0, which is the defect scrubFilePath's own docblock
66
+ // describes; the renderer emits basename(), so the destroyed
67
+ // filename was what the resuming session was shown.
56
68
  // next_steps — same shape, same reason: JSON.stringify({file,title,items}),
57
69
  // pre-scrubbed element-wise in buildAndSaveHandoff. Listing it
58
70
  // here would let scrubSecrets rewrite the SERIALIZED JSON, and the
@@ -60,12 +72,20 @@ export const TEXT_FIELDS_BY_TABLE = {
60
72
  // Next steps section would disappear silently rather than fail.
61
73
  // Named here because the pre-ship review found the column had been
62
74
  // added without updating this block, which is the file's contract.
63
- // match_keywords — currently a space-joined plain string; keeping it
64
- // here would scrub safely, but the value is built from
65
- // tokenizeHandoff() output (alphanumeric tokens only),
66
- // so secrets cannot survive the upstream tokenizer.
67
- // Excluded to avoid double-work + future-proof against
68
- // a refactor that switches to JSON.stringify.
75
+ // match_keywords — currently a space-joined plain string. Excluded because the
76
+ // value is scrubbed at its DERIVATION in buildAndSaveHandoff
77
+ // (`scrubSecrets(allText)` + the shared `safeFiles` array), which
78
+ // also future-proofs against a refactor to JSON.stringify.
79
+ //
80
+ // RETRACTED, measured: the reason recorded here until v6.10.0 was
81
+ // "built from tokenizeHandoff() output (alphanumeric tokens only),
82
+ // so secrets cannot survive the upstream tokenizer." Neither half
83
+ // holds. extractMatchKeywords has TWO arms and the FILE arm never
84
+ // reaches the tokenizer at all — it takes basename-minus-extension
85
+ // off the raw path set. And the tokenizer splits a secret from its
86
+ // keyword rather than removing it: `token=ghp_…` yields `ghp_…` as
87
+ // a term of its own. A no-op justification is worse than no
88
+ // justification, because it retires the question.
69
89
  // key_decisions is kept: call site uses '\n'.join (plain string), and
70
90
  // decision titles can carry secrets verbatim (LLM output).
71
91
  'key_decisions',
@@ -100,8 +120,16 @@ export const TEXT_FIELDS_BY_TABLE = {
100
120
  */
101
121
  export function scrubFilePath(p) {
102
122
  // SEGMENT-WISE, and that is the whole point rather than a micro-optimisation.
103
- // Eight SECRET_PATTERNS carry a value class that does not exclude `/`
104
- // (secret-scrub.mjs:33/74/78/83/98/109/259/260). On prose that is correct; run
123
+ // Many SECRET_PATTERNS carry a value class that does not exclude `/`. This is the one place
124
+ // that number is stated, with its population, because it is GRID-DEPENDENT and five copies of
125
+ // a bare "eight" is how it went wrong: measured here, 11 of the 40 patterns match text
126
+ // spanning `/` on a 15-shape credential grid; the two v6.10.1 pre-ship lenses measured 12
127
+ // (1130 path probes) and 15 (26-shape grid), and 21 on a structural reading of the value
128
+ // classes. "Eight", carried since v6.8.2 with the enumeration
129
+ // secret-scrub.mjs:33/74/78/83/98/109/259/260, is an UNDER-count on every one of those
130
+ // populations — it omits at least the `Authorization:`, `AccountKey=`, `DATABASE_URL`/
131
+ // `SUPABASE_KEY` and db-connection-URL arms. The mechanism does not depend on the count.
132
+ // On prose the wide value class is correct; run
105
133
  // whole-path, the match eats the separator and everything after it, so
106
134
  // `/repo/token=<secret>/notes.mjs` became `/repo/token=***` — the filename
107
135
  // destroyed at WRITE time and unrecoverable, and every file under such a
@@ -109,16 +137,27 @@ export function scrubFilePath(p) {
109
137
  // recalled another file's observations). Splitting first bounds every pattern to
110
138
  // the segment it matched in.
111
139
  //
112
- // The trade, stated rather than glossed: a credential whose own syntax spans a
113
- // separator is no longer caught here — the Slack webhook path and the
114
- // `scheme://user:pass@host` arms both need their `/` characters. Those are URL
115
- // shapes, and these columns hold filesystem paths; `scrubSecrets` still runs
116
- // whole-string on every prose field, which is where a URL actually lands.
117
- // Preserving path structure wins because the path IS the recall key.
140
+ // A credential whose own syntax SPANS a separator — `scheme://user:pass@host`, the Slack
141
+ // webhook path, a `postgres://` connection string — needs its `/` characters to match at all,
142
+ // so segment-wise scrubbing cannot see it. That was accepted here until v6.10.1 on the stated
143
+ // reason "these columns hold filesystem paths", and the pre-ship review measured that premise
144
+ // FALSE: `lib/save-observation.mjs` filters `params.files` on `typeof f === 'string'` and
145
+ // nothing else, and `extractFilePaths` returns a URL verbatim from a `{path}` / `{filePath}`
146
+ // tool input under a `PostToolUse: *` matcher. A URL reaches these columns. Measured: four URL
147
+ // shapes v6.10.0 redacted were being stored verbatim.
118
148
  //
149
+ // So the value decides, not the column: when it carries `://` it is scrubbed whole-string, the
150
+ // only way those patterns match. NAMED COST, pinned in tests/secret-scrub-coverage.test.mjs: a
151
+ // URL whose credential sits in a path SEGMENT now loses its filename to the greedy value class,
152
+ // which is the thing segment-wise scrubbing exists to prevent, traded back on this one shape.
153
+ // Redacting a live credential wins over preserving a filename that is not a recall key. The
154
+ // guard is `scrubSecrets`-decided, not scheme-decided — a credential-free URL comes back
155
+ // byte-identical, so ordinary `https://` / `s3://` / `file://` values are untouched.
156
+ const s = String(p ?? '');
157
+ if (s.includes('://')) return scrubSecrets(s);
119
158
  // The capture group keeps the separators in the split output, so join()
120
159
  // reconstructs the original byte-for-byte when nothing matches.
121
- return String(p ?? '')
160
+ return s
122
161
  .split(/([/\\])/)
123
162
  .map((part) => (part === '/' || part === '\\' ? part : scrubSecrets(part)))
124
163
  .join('');
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.10.0",
3
+ "version": "6.10.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.10.0",
9
+ "version": "6.10.2",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.10.0",
3
+ "version": "6.10.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",
package/utils.mjs CHANGED
@@ -44,6 +44,8 @@ export {
44
44
  isoWeekKey,
45
45
  formatErrorRecallHints,
46
46
  neutralizeContextDelimiters,
47
+ safeText,
48
+ normalizeInline,
47
49
  } from './format-utils.mjs';
48
50
  export { computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from './hash-utils.mjs';
49
51
  export {