claude-mem-lite 6.15.0 → 6.16.0

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,10 +9,10 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.15.0",
12
+ "version": "6.16.0",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
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)."
15
+ "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
17
17
  ]
18
18
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.15.0",
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).",
3
+ "version": "6.16.0",
4
+ "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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"
7
7
  },
package/README.md CHANGED
@@ -237,6 +237,22 @@ rm -rf ~/claude-mem-lite/ # pre-v0.5 unhidden (if not auto-moved)
237
237
  repos/ # Shallow-cloned source repos
238
238
  ```
239
239
 
240
+ ## Upgrading to 6.16.0
241
+
242
+ **Three defaults change; one has an off switch.** No schema change and no migration, so
243
+ reverting everything is pinning `claude-mem-lite@6.15.0`.
244
+
245
+ - **Each session gets one of two first lines on a file-recall block.** Half of sessions keep
246
+ "system-injected context, continue your planned action"; the other half get a plain
247
+ statement of where the notes come from, as Claude Code's hooks guide recommends. The two
248
+ are compared by cite-rate before one becomes the default. Off (old line everywhere):
249
+ `CLAUDE_MEM_RECALL_FRAMING=legacy`.
250
+ - **Memory text longer than the host's 10,000-character hook limit is trimmed by whole
251
+ lines**, with a closing line naming the ids left out, instead of the host replacing it with
252
+ a 2,000-character preview. No switch; pin 6.15.0 to revert.
253
+ - **Lessons shown after a failed Bash command now count in citation decay**, like every other
254
+ surface: ones never cited are ranked down over time. No switch; pin 6.15.0 to revert.
255
+
240
256
  ## Upgrading to 6.15.0
241
257
 
242
258
  **Two defaults change; one has an off switch.** No schema change and no migration, so
@@ -1055,6 +1071,7 @@ and names can change between releases.
1055
1071
  |----------|-------------|---------|
1056
1072
  | `CLAUDE_MEM_TASK_IMPERATIVE` | `on`/`1` injects the single most relevant lesson at prompt position under an imperative template. | _(off)_ |
1057
1073
  | `CLAUDE_MEM_SUBAGENT_INJECT` | Dispatch-time memory injection for subagents. | _(off)_ |
1074
+ | `CLAUDE_MEM_RECALL_FRAMING` | First line of a PreToolUse / PostToolUse recall block. `ab` gives each session one of two wordings, the older "system-injected context, continue your planned action" or a plain statement of source, so their cite-rates can be compared in one run (`benchmark/citation-live-replay.mjs --by-framing`); `legacy` / `factual` pin one. | `ab` |
1058
1075
  | `CLAUDE_MEM_SALIENCE` | Selects a comprehension-bridge arm (`bridge`, `bind`); unset = current default behavior. | _(unset)_ |
1059
1076
  | `CLAUDE_MEM_EDGE_DECAY` | Enables decay of file↔observation edges. | _(off)_ |
1060
1077
  | `CLAUDE_MEM_EDGE_DECAY_K` | Edge-decay threshold when the flag above is on (clamped to ≥1). | `3` |
package/README.zh-CN.md CHANGED
@@ -199,6 +199,20 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.16.0
203
+
204
+ **三处默认行为改变,其中一处有开关。** 没有 schema 变更、不需要迁移,全部回退只需固定
205
+ `claude-mem-lite@6.15.0`。
206
+
207
+ - **文件召回块的第一行,每个会话分到两种写法之一。** 一半会话保留原来的 "system-injected context,
208
+ continue your planned action",另一半改成说明这些笔记来自哪里的事实陈述(Claude Code 的 hooks
209
+ 指南建议这样写)。两种写法先比较引用率,再决定默认用哪个。关闭(全部用旧写法):
210
+ `CLAUDE_MEM_RECALL_FRAMING=legacy`。
211
+ - **超过宿主 1 万字符 hook 上限的记忆文本会按整行裁剪**,末尾一行列出被略去的 id;以前宿主会把它换成
212
+ 2,000 字符的预览。没有开关,回退请固定 6.15.0。
213
+ - **Bash 命令失败后展示的教训,现在也计入引用衰减**,和其他注入面一致:一直没被引用的会逐渐排到后面。
214
+ 没有开关,回退请固定 6.15.0。
215
+
202
216
  ## 升级到 6.15.0
203
217
 
204
218
  **两处默认行为改变,其中一处有开关。** 没有 schema 变更、不需要迁移,全部回退只需固定
package/cli/common.mjs CHANGED
@@ -326,6 +326,7 @@ export const KNOWN_CLI_FLAGS = new Set([
326
326
  'benchmark',
327
327
  'body',
328
328
  'branch',
329
+ 'chars',
329
330
  'closes-deferred',
330
331
  'concepts',
331
332
  'confirm',
@@ -427,6 +428,7 @@ export const KNOWN_CLI_FLAGS = new Set([
427
428
  */
428
429
  export const COMMAND_SCOPED_FLAGS = new Map([
429
430
  ['apply', 'verify-apply'],
431
+ ['chars', 'context'],
430
432
  ['digest', 'verify-apply'],
431
433
  ['print-project', 'verify-apply'],
432
434
  ['undo', 'verify-apply'],
@@ -9,6 +9,7 @@ import { buildSessionContextLines } from './hook-context.mjs';
9
9
  import { inferProject, debugCatch, debugLog } from './utils.mjs';
10
10
  import { RUNTIME_DIR } from './hook-shared.mjs';
11
11
  import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
12
+ import { writeCappedHookText } from './lib/hook-text-cap.mjs';
12
13
 
13
14
  /**
14
15
  * Build + emit the memory context block on stdout. Writes the Key Context ids
@@ -27,7 +28,7 @@ export function handlePreCompact({ db, project, sessionId, runtimeDir = RUNTIME_
27
28
  const body = buildSessionContextLines(db, project, new Date(), sessionId || null, collector);
28
29
  const rendered = body && String(body).trim() !== '';
29
30
  if (rendered) {
30
- process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
31
+ writeCappedHookText(`<claude-mem-context>\n${body}\n</claude-mem-context>`);
31
32
  }
32
33
  // Recorded even when NOTHING was re-rendered, matching handleSessionStart — the two
33
34
  // callers must describe the same set (keyctx-marker.mjs header), and the marker is an
package/hook.mjs CHANGED
@@ -99,6 +99,7 @@ import {
99
99
  import { formatHookError } from './lib/native-binding-hint.mjs';
100
100
  import { recordHookError } from './lib/hook-telemetry.mjs';
101
101
  import { queueHookContext, queueHookSystemMessage, flushHookStdout } from './lib/hook-stdout.mjs';
102
+ import { writePlainHookText, resetPlainHookText } from './lib/hook-text-cap.mjs';
102
103
  import { shouldRecallOnFailure } from './lib/tool-refusal.mjs';
103
104
  import {
104
105
  entryInputTags,
@@ -3040,7 +3041,7 @@ function injectHandoffIfEarly(db, { project, promptText, promptNumber, ccSession
3040
3041
  const picked = pickHandoffToInject(db, project, ccSessionId);
3041
3042
  if (picked) {
3042
3043
  const injection = renderHandoffInjection(db, project, ccSessionId);
3043
- if (injection) process.stdout.write(injection + '\n');
3044
+ if (injection) writePlainHookText(injection);
3044
3045
  // Consume ONLY the row we just injected — leave other projects' exit
3045
3046
  // handoffs intact so future sessions can still resume from them.
3046
3047
  // Pre-v2.46 wiped every exit handoff for the project on any continuation
@@ -3251,7 +3252,7 @@ async function injectSemanticMemory(db, { project, promptText, ccSessionId }) {
3251
3252
  const lines = ['<memory-context relevance="high">'];
3252
3253
  for (const m of memories) lines.push(formatMemoryLine(m));
3253
3254
  lines.push('</memory-context>');
3254
- process.stdout.write(lines.join('\n') + '\n');
3255
+ writePlainHookText(lines.join('\n'));
3255
3256
  }
3256
3257
  // HIGH-1 (full audit 2026-07-16): surface FTS-matched events — the canonical
3257
3258
  // store for promoted bugfix/decision/lesson memories that persistHaikuSummary
@@ -3272,7 +3273,7 @@ async function injectSemanticMemory(db, { project, promptText, ccSessionId }) {
3272
3273
  const elines = ['<memory-context relevance="events">'];
3273
3274
  for (const e of events) elines.push(`- ${renderInjectableEvent(e)}`);
3274
3275
  elines.push('</memory-context>');
3275
- process.stdout.write(elines.join('\n') + '\n');
3276
+ writePlainHookText(elines.join('\n'));
3276
3277
  }
3277
3278
  } catch (e) {
3278
3279
  debugCatch(e, 'handleUserPrompt-events');
@@ -3281,7 +3282,7 @@ async function injectSemanticMemory(db, { project, promptText, ccSessionId }) {
3281
3282
  // Guard the write on a non-empty return — formatTaskImperative yields '' for a
3282
3283
  // lesson that strips to empty (e.g. "."), which would otherwise emit a bare line.
3283
3284
  const imperativeLine = formatTaskImperative(imperativePick.lesson_learned, imperativePick.id);
3284
- if (imperativeLine) process.stdout.write(imperativeLine + '\n');
3285
+ if (imperativeLine) writePlainHookText(imperativeLine);
3285
3286
  }
3286
3287
 
3287
3288
  // D#214's ruler, second half: arm B was computed above, before anything was
@@ -3312,6 +3313,7 @@ async function injectSemanticMemory(db, { project, promptText, ccSessionId }) {
3312
3313
  }
3313
3314
 
3314
3315
  async function handleUserPrompt() {
3316
+ resetPlainHookText();
3315
3317
  const input = await readUserPromptInput();
3316
3318
  if (!input) return;
3317
3319
  const { promptText, hookData } = input;
@@ -18,6 +18,7 @@ import { readTranscriptEntries } from './transcript-scan.mjs';
18
18
  // The emitter's own prefix — see SURFACE_MATCHERS.task_imperative. Importing it rather
19
19
  // than re-typing the framing is what keeps emit and extract from becoming two lists.
20
20
  import { TASK_IMPERATIVE_PREFIX } from './task-imperative.mjs';
21
+ import { classifyRecallFraming } from './recall-framing.mjs';
21
22
 
22
23
  import { DAY_MS } from './time-constants.mjs';
23
24
  /**
@@ -743,8 +744,13 @@ const SURFACE_MATCHERS = {
743
744
  // post-tool-use.sh. High-volume surface that NO extractor matched before
744
745
  // v3.47 — error-recall'd obs accrued injection_count but never reached
745
746
  // applyCitationDecay, so they could neither promote nor demote.
747
+ // TWO deliveries: post-tool-use.sh (PostToolUse) and `hook.mjs post-tool-failure`
748
+ // (PostToolUseFailure — where a host-flagged failure goes, not PostToolUse). Keyed on
749
+ // the first alone until 2026-09-27, which kept every failure-path recall out of decay
750
+ // and every cite-rate ruler (C1 denominator count: 158 attachments carrying 389 ids).
746
751
  accepts: ({ command, text }) =>
747
- command.includes('post-tool-use') && text.includes('Related memories found for this error'),
752
+ (command.includes('post-tool-use') || command.includes('post-tool-failure')) &&
753
+ text.includes('Related memories found for this error'),
748
754
  collect: (text, add) => {
749
755
  // Per-line anchored: match only a row that STARTS with `#NN [type]` (after its
750
756
  // indent), NOT every such token in the block. The inlined lesson body (v3.16.x)
@@ -857,6 +863,32 @@ export function countInjectedBySurface(transcriptPath, opts = {}) {
857
863
  return out;
858
864
  }
859
865
 
866
+ /**
867
+ * Which recall framing arm(s) this transcript's PreToolUse blocks carried (A1 A/B).
868
+ *
869
+ * Read from the injected text, not recomputed from the session id: a session that ran
870
+ * before the A/B shipped saw the legacy line whatever its id hashes to, and only the text
871
+ * knows. Same walk and same `pretool` matcher as every other face.
872
+ *
873
+ * @param {string|null|undefined} transcriptPath
874
+ * @param {{mainOnly?: boolean}} [opts]
875
+ * @returns {'legacy'|'factual'|'mixed'|null} null when no PreToolUse block carried a framing line.
876
+ */
877
+ export function pretoolFramingOf(transcriptPath, opts = {}) {
878
+ const seen = new Set();
879
+ eachHookAttachment(
880
+ transcriptPath,
881
+ (ctx) => {
882
+ if (!SURFACE_MATCHERS.pretool.accepts(ctx)) return;
883
+ const arm = classifyRecallFraming(ctx.text);
884
+ if (arm) seen.add(arm);
885
+ },
886
+ opts,
887
+ );
888
+ if (seen.size === 0) return null;
889
+ return seen.size > 1 ? 'mixed' : [...seen][0];
890
+ }
891
+
860
892
  // Per-face extractors: thin wrappers over the shared table, kept as named
861
893
  // exports because callers and tests address individual faces.
862
894
  function extractOneSurface(face, transcriptPath, opts) {
@@ -89,6 +89,8 @@
89
89
  // about ONE channel. Before believing it for an event, find which field that event's
90
90
  // runner actually reads.
91
91
 
92
+ import { capHookText } from './hook-text-cap.mjs';
93
+
92
94
  let parts = [];
93
95
  let queuedEvent = null;
94
96
  let systemParts = [];
@@ -226,14 +228,15 @@ export function flushHookStdout(deps = {}) {
226
228
  if (!hasContext && !hasInput && !hasSystem) return false;
227
229
  const write = deps.write || ((s) => process.stdout.write(s));
228
230
  const envelope = { suppressOutput: true };
229
- if (hasSystem) envelope.systemMessage = systemParts.join('\n');
231
+ // Each field is capped on its own — the host measures them separately (hook-text-cap.mjs).
232
+ if (hasSystem) envelope.systemMessage = capHookText(systemParts.join('\n'));
230
233
  // Omitted entirely when there is nothing addressed to the host's per-event block:
231
234
  // Stop's schema REJECTS a hookSpecificOutput block, and an envelope carrying only a
232
235
  // user notice must not invent an event name to hang one on.
233
236
  if (hasContext || hasInput) {
234
237
  envelope.hookSpecificOutput = { hookEventName: queuedEvent };
235
238
  if (hasInput) envelope.hookSpecificOutput.updatedInput = queuedInput;
236
- if (hasContext) envelope.hookSpecificOutput.additionalContext = parts.join('\n\n');
239
+ if (hasContext) envelope.hookSpecificOutput.additionalContext = capHookText(parts.join('\n\n'));
237
240
  }
238
241
  parts = [];
239
242
  queuedEvent = null;
@@ -0,0 +1,191 @@
1
+ // lib/hook-text-cap.mjs — keep every string the host injects under its 10,000-character cap.
2
+ //
3
+ // Claude Code's hooks reference (code.claude.com/docs/en/hooks, fetched 2026-09-27):
4
+ //
5
+ // "A hook's `additionalContext`, `systemMessage`, and `initialUserMessage` strings, and its
6
+ // plain stdout, are capped at 10,000 characters … For JSON output, each field is measured
7
+ // separately; plain stdout is measured whole. Over the limit: Claude Code saves the output
8
+ // to a file in the session directory and replaces it with the file path and a preview of
9
+ // up to the first 2,000 characters … Claude Code doesn't ask Claude to read the file."
10
+ //
11
+ // So an over-long injection is not cut at 10,000 — it collapses to a 2,000-character preview
12
+ // and the model is never told to fetch the rest. Trimming here, by whole lines, keeps ~9,500
13
+ // characters in context instead of 2,000, and says which ids were dropped.
14
+ //
15
+ // Two entry points, one per delivery channel:
16
+ // • capHookText(text) — a single string (an envelope field, PreCompact's one write);
17
+ // • writePlainHookText(text) — plain stdout written in several chunks by one process
18
+ // (UserPromptSubmit), measured whole by the host, so the
19
+ // budget is per HANDLER, not per call.
20
+
21
+ /** The host's documented per-field / whole-plain-stdout cap. */
22
+ export const HOOK_TEXT_CAP = 10_000;
23
+
24
+ // Content stops RESERVE characters short of the cap so the omission footer and any closing
25
+ // tags re-appended for blocks cut open fit. Sized, not proven: MAX_FOOTER_IDS bounds the id
26
+ // COUNT, not their length, and nothing bounds the tag nesting. For what the surfaces emit
27
+ // (ids of a few digits, one or two nested blocks) the tail is under ~250 characters; a longer
28
+ // one is still held under the cap by the final safeSlice, at the cost of the re-closed tags.
29
+ const RESERVE = 500;
30
+ const MAX_FOOTER_IDS = 12;
31
+
32
+ // Ids as every injection surface renders them: `#12`, `E#34`, `D#5`, `P#820`.
33
+ const ID_RE = /(?<![\w#])((?:[A-Z])?#\d+)\b/g;
34
+ const OPEN_TAG_RE = /^<([a-z][\w-]*)(?:\s[^>]*)?>$/;
35
+ const CLOSE_TAG_RE = /^<\/([a-z][\w-]*)>$/;
36
+
37
+ function idsIn(lines) {
38
+ const seen = new Set();
39
+ for (const line of lines) for (const m of line.matchAll(ID_RE)) seen.add(m[1]);
40
+ return [...seen];
41
+ }
42
+
43
+ /**
44
+ * @param {string[]} droppedLines whole lines not shown
45
+ * @param {string} [cutRest] the tail of a line that was cut short, when one was
46
+ */
47
+ function omissionFooter(droppedLines, cutRest = '') {
48
+ const ids = idsIn(cutRest ? [cutRest, ...droppedLines] : droppedLines);
49
+ const shown = ids.slice(0, MAX_FOOTER_IDS).join(', ');
50
+ const more = ids.length > MAX_FOOTER_IDS ? ` +${ids.length - MAX_FOOTER_IDS} more` : '';
51
+ const idPart = ids.length ? ` (ids: ${shown}${more})` : '';
52
+ const what = [
53
+ cutRest ? 'a line was cut short' : '',
54
+ droppedLines.length ? `${droppedLines.length} more line(s) not shown` : '',
55
+ ]
56
+ .filter(Boolean)
57
+ .join(', ');
58
+ return `[claude-mem-lite] ${what} — hook output limit${idPart}`;
59
+ }
60
+
61
+ /** `s` cut to at most `n` UTF-16 units without leaving half of a surrogate pair. */
62
+ function safeSlice(s, n) {
63
+ if (n <= 0) return '';
64
+ if (s.length <= n) return s;
65
+ const c = s.charCodeAt(n - 1);
66
+ return s.slice(0, c >= 0xd800 && c <= 0xdbff ? n - 1 : n);
67
+ }
68
+
69
+ /**
70
+ * Trim `text` to at most `cap` characters by dropping whole lines from the end.
71
+ *
72
+ * Lines are kept from the top (every surface renders its highest-ranked rows first), a
73
+ * footer names what was dropped, and any `<tag>` block whose closing line was dropped is
74
+ * closed again so the kept text still parses as the block it claims to be. A text already
75
+ * within `cap` is returned unchanged.
76
+ *
77
+ * @param {string} text
78
+ * @param {number} [cap] Hard ceiling for the returned string.
79
+ * @returns {string}
80
+ */
81
+ export function capHookText(text, cap = HOOK_TEXT_CAP) {
82
+ const s = String(text ?? '');
83
+ if (s.length <= cap) return s;
84
+ const budget = Math.max(0, cap - RESERVE);
85
+ const lines = s.split('\n');
86
+ const kept = [];
87
+ const open = [];
88
+ let used = 0;
89
+ let i = 0;
90
+ for (; i < lines.length; i++) {
91
+ const add = lines[i].length + (kept.length ? 1 : 0);
92
+ if (used + add > budget) break;
93
+ kept.push(lines[i]);
94
+ used += add;
95
+ const o = lines[i].match(OPEN_TAG_RE);
96
+ const c = lines[i].match(CLOSE_TAG_RE);
97
+ if (o) open.push(o[1]);
98
+ else if (c && open[open.length - 1] === c[1]) open.pop();
99
+ }
100
+ // A first line longer than the whole budget would otherwise leave nothing at all. It is cut
101
+ // with a visible ellipsis and the footer says so: a lesson or an instruction cut mid-sentence
102
+ // with no marker reads as complete (pre-ship review P3-1).
103
+ let cutRest = '';
104
+ if (kept.length === 0 && lines.length > 0) {
105
+ let head = safeSlice(lines[0], budget - 1);
106
+ // Back off to a word boundary when the cut lands inside a token, so an id is never shown
107
+ // half (`#424…`) and the footer, which reads ids from the cut part, gets it whole.
108
+ if (/[\w#]$/.test(head) && /^[\w#]/.test(lines[0].slice(head.length))) {
109
+ const sp = head.search(/\s\S*$/);
110
+ if (sp > 0) head = head.slice(0, sp + 1);
111
+ }
112
+ kept.push(`${head}…`);
113
+ cutRest = lines[0].slice(head.length);
114
+ i = 1;
115
+ }
116
+ // A closing line re-appended below is not "not shown"; count it out of the footer.
117
+ const reclosed = new Map();
118
+ for (const t of open) reclosed.set(t, (reclosed.get(t) || 0) + 1);
119
+ const dropped = lines.slice(i).filter((l) => {
120
+ if (l.trim() === '') return false;
121
+ const c = l.match(CLOSE_TAG_RE);
122
+ if (c && reclosed.get(c[1]) > 0) {
123
+ reclosed.set(c[1], reclosed.get(c[1]) - 1);
124
+ return false;
125
+ }
126
+ return true;
127
+ });
128
+ const tail = [];
129
+ if (dropped.length || cutRest) tail.push(omissionFooter(dropped, cutRest));
130
+ for (let k = open.length - 1; k >= 0; k--) tail.push(`</${open[k]}>`);
131
+ const out = [...kept, ...tail].join('\n');
132
+ // "Never over cap" holds regardless; the slice fires only for a tail RESERVE did not cover
133
+ // (see RESERVE) or a caller passing a cap smaller than RESERVE.
134
+ return out.length <= cap ? out : safeSlice(out, cap);
135
+ }
136
+
137
+ /**
138
+ * Write ONE capped string as a hook's whole plain stdout (PreCompact's single block).
139
+ *
140
+ * @param {string} text
141
+ * @param {{write?: (s: string) => void}} [deps]
142
+ * @returns {void}
143
+ */
144
+ export function writeCappedHookText(text, deps = {}) {
145
+ const write = deps.write || ((s) => process.stdout.write(s));
146
+ write(`${capHookText(text, HOOK_TEXT_CAP - 1)}\n`);
147
+ }
148
+
149
+ // ── plain stdout, several chunks per handler ─────────────────────────────────────────
150
+
151
+ let plainUsed = 0;
152
+
153
+ /**
154
+ * Write a chunk of plain hook stdout, keeping the HANDLER's total under the cap.
155
+ *
156
+ * The host measures plain stdout whole, so two blocks that each fit can still overflow
157
+ * together. A chunk that no longer fits is trimmed by capHookText to what is left; once the
158
+ * budget is spent, later chunks are reduced to a one-line omission note while room for one
159
+ * remains, and dropped after that.
160
+ *
161
+ * @param {string} text Chunk to write; a trailing newline is added.
162
+ * @param {{write?: (s: string) => void, cap?: number}} [deps]
163
+ * @returns {void}
164
+ */
165
+ export function writePlainHookText(text, deps = {}) {
166
+ const write = deps.write || ((s) => process.stdout.write(s));
167
+ const cap = deps.cap ?? HOOK_TEXT_CAP;
168
+ const body = String(text ?? '');
169
+ const remaining = cap - plainUsed;
170
+ if (remaining > RESERVE) {
171
+ // capHookText returns `body` unchanged when it fits, so this is also the common path.
172
+ const out = `${capHookText(body, remaining - 1)}\n`;
173
+ write(out);
174
+ plainUsed += out.length;
175
+ return;
176
+ }
177
+ const note = `${omissionFooter(body.split('\n').filter((l) => l.trim() !== ''))}\n`;
178
+ if (note.length <= remaining) {
179
+ write(note);
180
+ plainUsed += note.length;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Start a new plain-stdout budget. Called at the top of each handler that writes through
186
+ * writePlainHookText: one handler invocation is one hook output, and in-process callers
187
+ * (tests, the dispatcher) may run several.
188
+ */
189
+ export function resetPlainHookText() {
190
+ plainUsed = 0;
191
+ }
@@ -0,0 +1,86 @@
1
+ // lib/recall-framing.mjs — the first line of a PreToolUse / PostToolUse recall block, in two arms.
2
+ //
3
+ // The legacy line announces itself as "system-injected context, continue your planned
4
+ // action". It was added in v2.40.0, modelled on the #7758 fix (a handoff injection misread
5
+ // as a user message): without a "this is context, not a new request" signal the model
6
+ // sometimes ended its turn after an Edit + reminder. Claude Code's hooks reference
7
+ // (code.claude.com/docs/en/hooks, fetched 2026-09-27) now warns against exactly this shape:
8
+ //
9
+ // "Write the text as factual statements rather than imperative system instructions …
10
+ // Text framed as out-of-band system commands can trigger Claude's prompt-injection
11
+ // defenses, which causes Claude to surface the text to you instead of treating it as
12
+ // context."
13
+ //
14
+ // PreToolUse is the best-cited face on record (56.2% over 7 days, 2026-09-27), so the wording
15
+ // is not swapped on the documentation's say-so. The factual arm keeps that line's point as a
16
+ // statement ("the tool call proceeds as planned") and the two arms run side by side, assigned
17
+ // per SESSION, so one walk over one corpus compares them (doctrine rule 2: never diff two runs
18
+ // taken at different times). benchmark/citation-live-replay.mjs `--by-framing` reads the arm
19
+ // back from the injected text itself, which stays correct across the deploy boundary.
20
+ //
21
+ // CLAUDE_MEM_RECALL_FRAMING = ab (default) | legacy | factual.
22
+
23
+ const LEGACY_TAIL = 'system-injected context, continue your planned action:';
24
+ // The substring classifyRecallFraming keys on. Part of the factual line only.
25
+ const FACTUAL_MARK = 'notes recorded by claude-mem-lite';
26
+ // The shape both arms' first line shares: `[mem] PreToolUse recall — …` / `[mem] PostToolUse recall — …`.
27
+ const FRAMING_LINE_RE = /^\[mem\] (?:PreToolUse|PostToolUse) recall — /;
28
+
29
+ /**
30
+ * Which arm this session gets.
31
+ *
32
+ * `ab` splits on a stable hash of the session id, so every recall block of one session uses
33
+ * one wording (a per-call coin would mix both into every session and leave nothing to
34
+ * compare). No session id → legacy: the arm must be attributable, and a block with no
35
+ * session cannot be.
36
+ *
37
+ * @param {string|null|undefined} sessionId
38
+ * @param {Record<string, string|undefined>} [env]
39
+ * @returns {'legacy'|'factual'}
40
+ */
41
+ export function recallFramingArm(sessionId, env = process.env) {
42
+ const mode = String(env.CLAUDE_MEM_RECALL_FRAMING || 'ab')
43
+ .trim()
44
+ .toLowerCase();
45
+ if (mode === 'legacy' || mode === 'factual') return mode;
46
+ if (!sessionId) return 'legacy';
47
+ // FNV-1a, 32-bit: stable across processes and Node versions, unlike anything keyed on
48
+ // object identity or Math.random.
49
+ let h = 0x811c9dc5;
50
+ for (const ch of String(sessionId)) {
51
+ h ^= ch.codePointAt(0);
52
+ h = Math.imul(h, 0x01000193) >>> 0;
53
+ }
54
+ return h % 2 === 0 ? 'legacy' : 'factual';
55
+ }
56
+
57
+ /**
58
+ * The framing line for one recall block.
59
+ *
60
+ * @param {'PreToolUse'|'PostToolUse'} face
61
+ * @param {{sessionId?: string|null, fname?: string, env?: Record<string, string|undefined>}} [opts]
62
+ * @returns {string}
63
+ */
64
+ export function recallFramingLine(face, { sessionId = null, fname = '', env = process.env } = {}) {
65
+ if (recallFramingArm(sessionId, env) === 'legacy') return `[mem] ${face} recall — ${LEGACY_TAIL}`;
66
+ const subject = fname ? ` about ${fname}` : '';
67
+ return `[mem] ${face} recall — ${FACTUAL_MARK}${subject}; the tool call proceeds as planned:`;
68
+ }
69
+
70
+ /**
71
+ * Read the arm back from injected text (the A/B ruler's side).
72
+ *
73
+ * @param {string} text One hook attachment's text.
74
+ * @returns {'legacy'|'factual'|null} null when the text carries neither framing line.
75
+ */
76
+ export function classifyRecallFraming(text) {
77
+ // Only the framing line itself counts. The block also carries lesson bodies, and a lesson
78
+ // that quotes either wording (one about this very A/B, say) must not relabel the session
79
+ // (pre-ship review P3-8).
80
+ for (const line of String(text ?? '').split('\n')) {
81
+ if (!FRAMING_LINE_RE.test(line)) continue;
82
+ if (line.endsWith(LEGACY_TAIL)) return 'legacy';
83
+ if (line.includes(FACTUAL_MARK)) return 'factual';
84
+ }
85
+ return null;
86
+ }
@@ -36,3 +36,12 @@ export const DAY_MS = 24 * HOUR_MS;
36
36
  * printing "✓ Removed" — and `doctor` recommends running `cleanup`.
37
37
  */
38
38
  export const ORPHAN_EPISODE_AGE_MS = HOUR_MS;
39
+
40
+ /**
41
+ * PreToolUse recall's hard age cut: observations and events created longer ago than this
42
+ * are never surfaced by file, whatever their later use. Set without a measurement, and it
43
+ * first bites on 2026-11-04 on the maintainer DB (oldest live row 2026-09-05). Shared with
44
+ * benchmark/cutoff-reach-probe.mjs, which counts the rows it removes, so the probe reads the
45
+ * shipped window rather than a copy of it.
46
+ */
47
+ export const PRETOOL_LOOKBACK_MS = 60 * DAY_MS;
package/mem-cli.mjs CHANGED
@@ -1825,7 +1825,14 @@ function cmdContext(db, args) {
1825
1825
  // produce (the CLI twin of why <skill-loaded> is excluded from CONTEXT_DELIMITER_RE).
1826
1826
  // The untrusted half is already neutralized one layer up: buildSessionContextLines
1827
1827
  // defangs every row it renders, so only the trusted wrapper is written raw here.
1828
- outVerbatim(`<claude-mem-context>\n${block}\n</claude-mem-context>`);
1828
+ const wrapped = `<claude-mem-context>\n${block}\n</claude-mem-context>`;
1829
+ outVerbatim(wrapped);
1830
+ if (flags.chars) {
1831
+ // stderr, so stdout stays byte-for-byte the block. This is the <claude-mem-context>
1832
+ // part only: SessionStart also prepends the startup dashboard to the same field, and
1833
+ // lib/hook-text-cap.mjs trims the whole field at the cap.
1834
+ process.stderr.write(`[mem] context: ${wrapped.length} characters (hook cap ${HOOK_TEXT_CAP})\n`);
1835
+ }
1829
1836
  }
1830
1837
  }
1831
1838
 
@@ -3273,7 +3280,7 @@ Commands:
3273
3280
  daily_activity,data_health,tier_distribution})
3274
3281
  or quality shape when --quality --json combined
3275
3282
 
3276
- context Show current CLAUDE.md context block
3283
+ context Show the SessionStart context block (--chars: its size vs the hook cap, on stderr)
3277
3284
  --json Output as structured JSON
3278
3285
 
3279
3286
  browse Tier-grouped memory dashboard
@@ -3623,6 +3630,7 @@ import { cmdActivity } from './cli/activity.mjs';
3623
3630
  import { cmdVerifyApply } from './cli/verify-apply.mjs';
3624
3631
 
3625
3632
  import { DAY_MS } from './lib/time-constants.mjs';
3633
+ import { HOOK_TEXT_CAP } from './lib/hook-text-cap.mjs';
3626
3634
  // ─── Main Entry Point ────────────────────────────────────────────────────────
3627
3635
 
3628
3636
  /**
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.15.0",
3
+ "version": "6.16.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.15.0",
9
+ "version": "6.16.0",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.15.0",
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).",
3
+ "version": "6.16.0",
4
+ "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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",
7
7
  "engines": {
@@ -116,6 +116,8 @@
116
116
  "lib/hook-stdin.mjs",
117
117
  "lib/plugin-key.mjs",
118
118
  "lib/hook-stdout.mjs",
119
+ "lib/hook-text-cap.mjs",
120
+ "lib/recall-framing.mjs",
119
121
  "lib/proc-lock.mjs",
120
122
  "lib/atomic-write.mjs",
121
123
  "lib/platform-gate.mjs",
@@ -31,6 +31,7 @@ import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
31
31
  import { readHookStdin, TOOL_INPUT_FILE_MAX_BYTES } from '../lib/hook-stdin.mjs';
32
32
  import { cooldownPathFor as sharedCooldownPathFor } from '../lib/cooldown-path.mjs';
33
33
  import { toolEditPath } from '../lib/file-edge-match.mjs';
34
+ import { recallFramingLine } from '../lib/recall-framing.mjs';
34
35
 
35
36
  const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
36
37
 
@@ -95,7 +96,7 @@ async function main() {
95
96
  }
96
97
  if (!dropped.length) return;
97
98
 
98
- const lines = ['[mem] PostToolUse recall — system-injected context, continue your planned action:'];
99
+ const lines = [recallFramingLine('PostToolUse', { sessionId, fname: basename(filePath) })];
99
100
  for (const d of dropped.slice(0, 3)) {
100
101
  lines.push(
101
102
  `[mem] ⚠ your edit to ${basename(filePath)} dropped \`${d.token}\` flagged by #${d.obsId} — if intentional say so, else re-check before moving on.`,
@@ -34,6 +34,7 @@ import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/rerea
34
34
  import { recordMetric } from '../lib/metrics.mjs';
35
35
  import { presentIdents } from '../lib/lesson-idents.mjs';
36
36
  import { neutralizeContextDelimiters } from '../format-utils.mjs';
37
+ import { recallFramingLine } from '../lib/recall-framing.mjs';
37
38
  // D#154: the one stdout writer. This script has THREE emit sites (Read→Edit ack,
38
39
  // repeated-read guard, lesson block) and they stay one document because each branch
39
40
  // process.exit()s before reaching the next.
@@ -66,7 +67,7 @@ import { readHookStdin, TOOL_INPUT_FILE_MAX_BYTES, salvageTruncatedHookEvent } f
66
67
  // only — cheaper than several imports this script already carries.
67
68
  import { inferProject, inferProjectDir } from '../project-utils.mjs';
68
69
 
69
- import { DAY_MS } from '../lib/time-constants.mjs';
70
+ import { PRETOOL_LOOKBACK_MS } from '../lib/time-constants.mjs';
70
71
  // CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
71
72
  // whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
72
73
  // per-component overrides for tests that mix isolated + real paths.
@@ -525,7 +526,7 @@ try {
525
526
  queueHookContext(
526
527
  'PreToolUse',
527
528
  [
528
- '[mem] PreToolUse recall — system-injected context, continue your planned action:',
529
+ recallFramingLine('PreToolUse', { sessionId, fname: basename(filePath) }),
529
530
  `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
530
531
  ].join('\n'),
531
532
  );
@@ -540,7 +541,7 @@ try {
540
541
  queueHookContext(
541
542
  'PreToolUse',
542
543
  [
543
- '[mem] PreToolUse recall — system-injected context, continue your planned action:',
544
+ recallFramingLine('PreToolUse', { sessionId, fname: basename(filePath) }),
544
545
  buildRereadWarning(basename(filePath), entry.reread.tokens),
545
546
  ].join('\n'),
546
547
  );
@@ -613,8 +614,9 @@ try {
613
614
  // Stop-side edge attribution so trigger and resolver can never drift.
614
615
  const fileMatch = fileMatchClause('of2');
615
616
  const fileParams = fileMatchParams(filePath);
616
- // 60-day lookback to avoid surfacing ancient observations
617
- const cutoff = Date.now() - 60 * DAY_MS;
617
+ // 60-day lookback to avoid surfacing ancient observations (PRETOOL_LOOKBACK_MS;
618
+ // benchmark/cutoff-reach-probe.mjs counts what it removes)
619
+ const cutoff = Date.now() - PRETOOL_LOOKBACK_MS;
618
620
 
619
621
  // Surface actionable lessons first, then high-importance bugfix/decision observations.
620
622
  // Priority: 1) observations with lesson_learned (most actionable for preventing repeat bugs)
@@ -829,9 +831,10 @@ try {
829
831
  hasLessons || Boolean(fileIntelLine) || (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1');
830
832
  if (showFraming) {
831
833
  // Framing line mirrors #7758 handoff-injection fix: without an explicit
832
- // "system-injected, continue" disclaimer, observed turn-end after Edit+reminder
833
- // when the model misreads passive lesson context as a closing note.
834
- lines.push(`[mem] PreToolUse recall — system-injected context, continue your planned action:`);
834
+ // "this is context, the call continues" line, observed turn-end after Edit+reminder
835
+ // when the model misreads passive lesson context as a closing note. Two wordings
836
+ // run side by side per session — lib/recall-framing.mjs.
837
+ lines.push(recallFramingLine('PreToolUse', { sessionId, fname }));
835
838
  }
836
839
  // MED-1 (full audit 2026-07-16): defang the injection-block delimiters in
837
840
  // all DB/file-derived text before it enters additionalContext (which CC wraps
@@ -48,6 +48,7 @@ import { isSchemaSkewError, schemaSkewFromError, shouldRecordSkew } from '../lib
48
48
 
49
49
  import { DAY_MS } from '../lib/time-constants.mjs';
50
50
  import { envNumber } from '../lib/env-number.mjs';
51
+ import { writePlainHookText, resetPlainHookText } from '../lib/hook-text-cap.mjs';
51
52
  // ─── Constants ──────────────────────────────────────────────────────────────
52
53
 
53
54
  // Telemetry sink (lib/hook-telemetry.mjs contract): env override for tests, else
@@ -697,6 +698,7 @@ function formatPromptResults(rows) {
697
698
  // ─── Main ───────────────────────────────────────────────────────────────────
698
699
 
699
700
  async function main() {
701
+ resetPlainHookText();
700
702
  // Prevent recursion from background claude -p calls
701
703
  if (process.env.CLAUDE_MEM_HOOK_RUNNING) return;
702
704
 
@@ -786,7 +788,7 @@ async function main() {
786
788
  for (const dl of neutralizeContextDelimiters(r.detail).split('\n')) lines.push(` ${dl}`);
787
789
  }
788
790
  }
789
- process.stdout.write(lines.join('\n') + '\n');
791
+ writePlainHookText(lines.join('\n'));
790
792
  // Merge into the dedup file so a re-referencing prompt within the stale
791
793
  // window skips re-injection. A later FTS-path write replaces ids wholesale
792
794
  // (accepted: worst case is one cheap re-injection after an obs-emitting
@@ -1078,7 +1080,7 @@ async function main() {
1078
1080
  : formatPromptResults(promptRows)
1079
1081
  : null;
1080
1082
  if (output) {
1081
- process.stdout.write(output + '\n');
1083
+ writePlainHookText(output);
1082
1084
  // Write injected IDs for dedup with hook.mjs handleUserPrompt + self-dedup
1083
1085
  // replace, NOT union: this leg writes the prompt's own result set wholesale, and it
1084
1086
  // is the ONE writer that puts raw observation numbers (mixed with `P<id>` strings)
package/source-files.mjs CHANGED
@@ -150,6 +150,8 @@ export const SOURCE_FILES = [
150
150
  'lib/hook-stdin.mjs',
151
151
  'lib/plugin-key.mjs',
152
152
  'lib/hook-stdout.mjs',
153
+ 'lib/hook-text-cap.mjs',
154
+ 'lib/recall-framing.mjs',
153
155
  // audit P0/P1: inter-process install lock + atomic config writes — imported by
154
156
  // install.mjs (settings.json + install lock) and hook-update.mjs (.claude.json
155
157
  // + auto-update lock). Must ship or a partial install/update skips them.