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.
- 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 +139 -65
- package/hook.mjs +7 -4
- package/lib/paused-reader.mjs +10 -2
- package/lib/scrub-record.mjs +55 -16
- 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,9 +13,9 @@ import {
|
|
|
12
13
|
EDIT_TOOLS,
|
|
13
14
|
isMetaTriggerPrompt,
|
|
14
15
|
notLowSignalTitleClause,
|
|
15
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
|
376
|
-
// the secret patterns carry a value class that does not exclude
|
|
377
|
-
// whole-path scrub eats the separator and
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
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
|
|
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/lib/paused-reader.mjs
CHANGED
|
@@ -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
|
|
11
|
-
//
|
|
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
|
package/lib/scrub-record.mjs
CHANGED
|
@@ -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-
|
|
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
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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
|
-
//
|
|
104
|
-
//
|
|
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
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
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
|
|
160
|
+
return s
|
|
122
161
|
.split(/([/\\])/)
|
|
123
162
|
.map((part) => (part === '/' || part === '\\' ? part : scrubSecrets(part)))
|
|
124
163
|
.join('');
|
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 {
|