claude-mem-lite 6.10.1 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/format-utils.mjs +102 -5
- package/hook-context.mjs +47 -4
- package/hook-handoff.mjs +84 -60
- package/hook.mjs +7 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/utils.mjs +2 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.10.
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
seen.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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.
|
|
418
|
-
//
|
|
419
|
-
//
|
|
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
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
//
|
|
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.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
|
|
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
|
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.10.
|
|
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.
|
|
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.
|
|
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 {
|