claude-mem-lite 6.10.1 → 6.10.3

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.1",
12
+ "version": "6.10.3",
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.1",
3
+ "version": "6.10.3",
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,7 +13,7 @@ import {
12
13
  EDIT_TOOLS,
13
14
  isMetaTriggerPrompt,
14
15
  notLowSignalTitleClause,
15
- neutralizeContextDelimiters,
16
+ safeText,
16
17
  } from './utils.mjs';
17
18
  import { scrubRecord, scrubFilePath, scrubFilePaths } from './lib/scrub-record.mjs';
18
19
  import {
@@ -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
 
@@ -414,9 +464,25 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
414
464
  // `api_key: handling` loses `handling` (5 of 6 ordinary developer prompts in a directed
415
465
  // grid lose exactly one term). What is true, and is the actual justification, is that the
416
466
  // term set now AGREES with what the resuming session is shown — `working_on` / `completed`
417
- // / `unfinished` lose the same word through scrubRecord below. No value is scrubbed twice:
418
- // these elements and the columns below are separate derivations from one raw source, each
419
- // scrubbed once, which is the distinction D#46 is open about.
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.
420
486
  const safeFiles = scrubFilePaths([...fileSet]);
421
487
  // The nullish guard mirrors what join() already did with a nullish element. Without it
422
488
  // String(undefined) would put the literal token "undefined" into the term set — a behaviour
@@ -773,54 +839,12 @@ export function renderHandoffInjection(db, project, currentCcSessionId = null) {
773
839
  return renderHandoffFromRow(handoff, db, project);
774
840
  }
775
841
 
776
- // Markdown ATX markers carried by replayed text, at a token boundary.
777
- //
778
- // This block frames itself with `## Working On` / `## Completed` / `## Next steps`, and
779
- // working_on is user prompt text — a prompt that opens with its own outline flattens into
780
- // the block carrying `#` and `##` of its own, on the same line, because working_on joins up
781
- // to five prompts with ` → `. A real injection read
782
- // `## Working On` / `# 自主端到端测试与修复循环 ## 角色与授权 …`, at which point the
783
- // block's structure and the replayed text's structure are indistinguishable to whatever
784
- // reads it next. Same class as the authority-tag defanging one level down: a forged
785
- // SECTION rather than a forged tag.
786
- //
787
- // Only a marker followed by whitespace, at a token boundary, counts — `#42`, `C#` and
788
- // `D#216` are ordinary in this project's prose and survive untouched.
789
- const ATX_HEADING_RE = /(^|\s)#{1,6}\s/g;
790
- const ATX_MAX_PASSES = 32;
791
-
792
- /**
793
- * Strip ATX markers to a FIXPOINT, not in one pass.
794
- *
795
- * The gap is two characters wide and it is the same one format-utils' `defangToFixpoint`
796
- * documents for the tag half: `(^|\s)` CONSUMES the boundary, so after removing the first
797
- * `## ` the regex resumes past the second one and leaves it live. `## ## Key Decisions`
798
- * came out of a single pass as a real `## Key Decisions` section inside the block — the
799
- * precise property this defanging exists to hold, defeated by two extra characters. Found
800
- * by the pre-ship defect lens; reproduced end-to-end before the fix.
801
- *
802
- * TERMINATION: every match contains at least one `#` and the replacement drops all of them,
803
- * so any pass that changes the string removes at least one `#`. Self-bounded by the number
804
- * of `#` in the input, and bounded again by the constant.
805
- *
806
- * INERT AT ANY DEPTH: still changing at the cap (≥32 nested forged layers, not reachable by
807
- * accident) → drop every remaining `#`. Lossier, but the return value then provably carries
808
- * no marker, which is the property callers rely on. Same fail-closed shape as the sibling.
809
- */
810
- function stripAtxToFixpoint(s) {
811
- let text = s;
812
- for (let pass = 0; pass < ATX_MAX_PASSES; pass++) {
813
- const next = text.replace(ATX_HEADING_RE, '$1');
814
- if (next === text) return text;
815
- text = next;
816
- }
817
- return text.replace(/#/g, '');
818
- }
819
-
820
- /** Defang authority tags AND section markers, for any replayed free text. */
821
- function safeText(value) {
822
- return stripAtxToFixpoint(neutralizeContextDelimiters(String(value)));
823
- }
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.
824
848
 
825
849
  // `[bugfix] title` → `title`. Both `completed` and `key_decisions` store the observation
826
850
  // type this way; the bracket run is length-capped so a title that merely opens with a
package/hook-optimize.mjs CHANGED
@@ -1752,6 +1752,17 @@ export async function optimizeRun(
1752
1752
  budget.reenrich,
1753
1753
  findReenrichCandidates(db, budget.reenrich, { scope: 'scopes', project }).length,
1754
1754
  );
1755
+ // ORDER IS LOAD-BEARING: the main scope runs FIRST. The budget half of the
1756
+ // invariant above ("adding a pool cannot starve the lesson enrichment that is
1757
+ // the point of the pass") is enforced by the arithmetic; this statement's
1758
+ // POSITION is the other half. The three pools overlap — a narrow candidate with
1759
+ // a >100-char narrative and a signal-bearing title is also an aliases candidate
1760
+ // and a concepts candidate — and narrow's WHERE requires `search_aliases IS
1761
+ // NULL`, which is the column the aliases pass fills. So serving aliases first
1762
+ // evicts that row from narrow PERMANENTLY: the starvation the comment forbids.
1763
+ // Pinned behaviourally by tests/hook-optimize.test.mjs
1764
+ // "re-enrich pass ordering (main runs before the fill-only passes)". A 2026-09
1765
+ // external review read this block and proposed the swap; it is a regression.
1755
1766
  const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget - conceptsBudget, {
1756
1767
  scope: reenrichScope,
1757
1768
  project,
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
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.10.1",
3
+ "version": "6.10.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.10.1",
9
+ "version": "6.10.3",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
@@ -15,7 +15,7 @@
15
15
  "dependencies": {
16
16
  "@modelcontextprotocol/sdk": "^1.30.0",
17
17
  "better-sqlite3": "^13.0.3",
18
- "zod": "^4.5.4"
18
+ "zod": "^4.6.5"
19
19
  },
20
20
  "bin": {
21
21
  "claude-mem-lite": "cli.mjs"
@@ -25,8 +25,8 @@
25
25
  "@vitest/coverage-v8": "^5.0.0",
26
26
  "acorn": "8.18.0",
27
27
  "eslint": "^10.10.0",
28
- "fast-check": "^4.9.0",
29
- "knip": "^6.35.0",
28
+ "fast-check": "^4.10.0",
29
+ "knip": "^6.35.1",
30
30
  "picomatch": "^4.0.4",
31
31
  "prettier": "3.9.6",
32
32
  "vitest": "^5.0.0"
@@ -2221,9 +2221,9 @@
2221
2221
  }
2222
2222
  },
2223
2223
  "node_modules/fast-check": {
2224
- "version": "4.9.0",
2225
- "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz",
2226
- "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==",
2224
+ "version": "4.10.0",
2225
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz",
2226
+ "integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==",
2227
2227
  "dev": true,
2228
2228
  "funding": [
2229
2229
  {
@@ -2733,9 +2733,9 @@
2733
2733
  }
2734
2734
  },
2735
2735
  "node_modules/knip": {
2736
- "version": "6.35.0",
2737
- "resolved": "https://registry.npmjs.org/knip/-/knip-6.35.0.tgz",
2738
- "integrity": "sha512-eER1hSyi47lJTO9MTnEoJspEscPaVuFtFkHsGbuCmhjDlphzvg2xxcweNg1NCeylo9ERSkb/NzAo7sAcT9gLmA==",
2736
+ "version": "6.35.1",
2737
+ "resolved": "https://registry.npmjs.org/knip/-/knip-6.35.1.tgz",
2738
+ "integrity": "sha512-22wnEnv4do2fvoeJsxpFCG/MBxReNxoCVBccaCl7suUJsG+F0UvxI9ycxZ9YgtLHSSjrZl5tOas0JZ/R1vJ93g==",
2739
2739
  "dev": true,
2740
2740
  "funding": [
2741
2741
  {
@@ -4295,9 +4295,9 @@
4295
4295
  }
4296
4296
  },
4297
4297
  "node_modules/zod": {
4298
- "version": "4.5.4",
4299
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
4300
- "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
4298
+ "version": "4.6.5",
4299
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
4300
+ "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
4301
4301
  "license": "MIT",
4302
4302
  "funding": {
4303
4303
  "url": "https://github.com/sponsors/colinhacks"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.10.1",
3
+ "version": "6.10.3",
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",
@@ -210,7 +210,7 @@
210
210
  "dependencies": {
211
211
  "@modelcontextprotocol/sdk": "^1.30.0",
212
212
  "better-sqlite3": "^13.0.3",
213
- "zod": "^4.5.4"
213
+ "zod": "^4.6.5"
214
214
  },
215
215
  "overrides": {
216
216
  "hono": ">=4.12.31",
@@ -225,8 +225,8 @@
225
225
  "@vitest/coverage-v8": "^5.0.0",
226
226
  "acorn": "8.18.0",
227
227
  "eslint": "^10.10.0",
228
- "fast-check": "^4.9.0",
229
- "knip": "^6.35.0",
228
+ "fast-check": "^4.10.0",
229
+ "knip": "^6.35.1",
230
230
  "picomatch": "^4.0.4",
231
231
  "prettier": "3.9.6",
232
232
  "vitest": "^5.0.0"
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 {