claude-mem-lite 6.26.0 → 6.27.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,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.26.0",
12
+ "version": "6.27.0",
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. 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)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.26.0",
3
+ "version": "6.27.0",
4
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"
package/README.md CHANGED
@@ -1141,6 +1141,8 @@ claude-mem-lite.
1141
1141
  | `CLAUDE_MEM_DEBUG` | Enable debug logging (`1` to enable). | _(disabled)_ |
1142
1142
  | `MEM_QUIET_HOOKS` | Low-noise hooks. `1` drops the `File Lessons` / `Key Context` sections from SessionStart injection, the lesson suffix from `[mem] Related memories`, and the `WHEN TO USE` / `Decision rules` blocks from MCP server instructions. IDs and the `Recent` table still surface so `mem_get(ids=[…])` remains reachable. Intended for users running the invited-memory adopt path or who otherwise want minimal auto-injection. **Since v2.82.0 this env no longer gates auto-adopt — use `MEM_NO_AUTO_ADOPT=1` for that.** | _(disabled)_ |
1143
1143
  | `CLAUDE_MEM_SESSION_EVENTS` | `1`/`on` restores the SessionStart `### Key Events` section (recent high-importance rows from the `events` table). **Off by default since v6.13.0**: an audit of 30 events read 2 accurate and 16 wrong. The UserPromptSubmit events block and PreToolUse recall are query-matched and stay on; `mem_search` still reaches every event. | _(off)_ |
1144
+ | `CLAUDE_MEM_STARTUP_RECENT_ROWS` | Rows in the SessionStart `### Recent` table: of the rows the token budget picks, the project's own first and then other projects' (each newest first). `0` hides the table. Was the whole pick before the injection-budget change (7–13 rows per SessionStart table on the corpus it was measured on, mean 9.4). | `5` |
1145
+ | `CLAUDE_MEM_DASHBOARD_EVENTS` | `1` puts the `💡 mem events: N entries` count back on the SessionStart dashboard (dropped in the injection-budget change: the model never ran `activity` from it on the corpus measured). | _(off)_ |
1144
1146
  | `CLAUDE_MEM_SUMMARY_TAIL` | `0`/`off` stops Stop from using the head of the final reply (first 120 characters, code blocks and line-leading markers removed) as a session's Completed line when that reply has no Done / Not done / Failed / Uncertain section; Completed then falls back to observation titles, as before v6.18.0; a line already written gives way to the titles at the session's next turn. A later reply under 400 characters does not replace an earlier one. | _(on)_ |
1145
1147
  | `CLAUDE_MEM_EPISODE_INPUT_FILTER` | What the episode summarizer may learn from. A subagent's tool calls stay out of the episode buffer unless they edit a file inside the project, and mutation probes (mutate → RED run → restore) and the agent's own failing inline scripts (a patch's `anchor not found`) are dropped before a window is saved or summarized — replayed over the 30 audited events, this removes 3 of the 16 that were wrong outright; with `CLAUDE_MEM_LESSON_GROUNDING` none of the 16 lessons is injected. `off` restores the unfiltered input. | _(on)_ |
1146
1148
  | `CLAUDE_MEM_BASH_RECALL` | File recall before a Bash command that views (`cat`, `sed -n`, `head`…) or writes (`sed -i`, `cat > f`, a python patch…) a file, like the Read / Edit recall. A bash prefilter keeps Node from starting for other commands. `off` disables this leg only. | _(on)_ |
@@ -1154,7 +1156,7 @@ claude-mem-lite.
1154
1156
  | Variable | Description | Default |
1155
1157
  |----------|-------------|---------|
1156
1158
  | `CLAUDE_MEM_ALL_TOOLS` | `1` exposes every registered MCP tool in `tools/list` instead of only the core ones (pre-v2.34.0 behavior). The 9 hidden tools stay callable by exact name either way; `mem_search_feedback` is registered only when search telemetry is enabled. | _(9 core)_ |
1157
- | `CLAUDE_MEM_FILE_INTEL` | `0` disables the file-intel block injected before `Read` (past observations about the file you are about to open). | _(on)_ |
1159
+ | `CLAUDE_MEM_FILE_INTEL` | `1` turns on the file-intel line on the first read of a large file — a `Read`, or a Bash `cat` / `sed -n` / `head` — (its approximate token size and a one-line summary). Off by default since the injection-budget change: the line reaches the model next to the read's result, so it cannot shape that read. | _(off)_ |
1158
1160
  | `CLAUDE_MEM_FILE_INTEL_MIN_TOKENS` | Files smaller than this stay silent — file-intel only pays for itself on large files. | `800` |
1159
1161
  | `CLAUDE_MEM_REREAD_GUARD` | `0` disables the warning when the same file is read twice in a session. Never fires on `offset`/`limit` paging. | _(on)_ |
1160
1162
  | `CLAUDE_MEM_REREAD_MIN_TOKENS` | Token floor below which the re-read guard stays silent. | `600` |
@@ -1194,6 +1196,7 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
1194
1196
  | `CLAUDE_MEM_REACH_DISCLOSURE` | `off` suppresses the one-line note that fires when a search's reported `total` exceeds what its pagination can hand back. The candidate pool is sized from `limit` alone and deliberately does not grow with `offset` (D#30 — an offset-scaled pool re-ranks its own prefix under RRF, so pages overlapped and gapped), while `total` is the full match count. Measured on a 128-row corpus: at the default limit of 20 the last non-empty offset is 59, so 60 of 128 rows are unreachable at any offset. The note reports that; it does not change retrieval, ranking, or which rows are returned. It stays **silent** when a filter you asked for (`tier`, or the CJK precision gate on prompts) removed rows after the count was taken — that gap is your filter, not the pool, and raising the limit would not recover it. | _(on)_ |
1195
1197
  | `CLAUDE_MEM_NORMALIZE_CROSS_PROJECT` | `1` restores the pre-fix behaviour where the daily unattended `normalize` runs ONCE over every project's concepts at the same time. That is how one project's stored content could steer synonym groups applied to another project's rows, so the default is now one scoped pass per project (bounded to 8 per run). The cost of the default is that `k8s` in one project and `kubernetes` in another are no longer unified automatically. Note that EVERY unscoped run fans out, including an explicit `optimize --run --task normalize` with no `--project` — this variable is the only route back to the single cross-project pass. A foreground `optimize` run prints a warning when it is set; the daily unattended pass cannot (its worker is spawned with stderr closed), so `claude-mem-lite doctor` reports it as a ⚠ instead. | _(off)_ |
1196
1198
  | `CLAUDE_MEM_AUTO_DEEP_CLI` | `0` disables the same auto-escalation on the CLI path only. | _(auto)_ |
1199
+ | `CLAUDE_MEM_UPS_PROMPT_ECHO` | `0` lets the "Past similar questions" fallback show a stored question identical (or nearly) to the prompt just typed, from an earlier session. The current session's own prompts stay excluded either way: showing the prompt just typed was a bug. | _(on)_ |
1197
1200
  | `CLAUDE_MEM_SCOPE_FILTER` | `1` stops environment-scoped observations from firing on file-triggered recall. They stay reachable via search. **Leave it off**: on the face it gates, `environment` is not the low-relevance class its premise assumes — it cites at least as well as `project` (47.5% vs 44.3%, intervals overlapping), and an earlier measurement left 173 recall groups empty with it on. | _(off)_ |
1198
1201
  | `CLAUDE_MEM_READS_CARRY` | An episode flush collects `reads-<project>.txt` only when it will actually save an observation, so a flush that records nothing no longer discards the Read paths it swept up (42.2% of the paths a flush consumed, measured over 1122 transcripts). `0` restores the pre-v3.83.0 behaviour. | _(on)_ |
1199
1202
 
@@ -1252,6 +1255,8 @@ and names can change between releases.
1252
1255
  | `CLAUDE_MEM_TASK_IMPERATIVE` | `on`/`1` injects the single most relevant lesson at prompt position under an imperative template. | _(off)_ |
1253
1256
  | `CLAUDE_MEM_SUBAGENT_INJECT` | Dispatch-time memory injection for subagents. | _(off)_ |
1254
1257
  | `CLAUDE_MEM_RECALL_FRAMING` | First line of a PreToolUse / PostToolUse recall block. Unset or `legacy` = "system-injected context, continue your planned action"; `factual` = a plain statement of where the notes come from; `ab` = each session gets one of the two, for `benchmark/citation-live-replay.mjs --by-framing`. Any other value is `legacy`. | `legacy` |
1258
+ | `CLAUDE_MEM_RECALL_HEADER_ONCE` | `0` puts the framing line on every PreToolUse recall block. By default a thread (the main session, or one subagent) gets it on its first block only, and again after `/compact`. | _(on)_ |
1259
+ | `CLAUDE_MEM_DIRECTIVE_ONCE` | `0` repeats the full default pre-edit directive on every block. By default a thread gets it in full once (again after `/compact`) and a short form that keeps the `#NN` tag rule after that. The `verdict` / `bind` / `bridge` arms always get their full text. | _(on)_ |
1255
1260
  | `CLAUDE_MEM_SALIENCE` | Selects how the pre-edit lesson line asks for a response: unset = name a lesson's `#NN` only where it changed the edit; `verdict` = the pre-6.17 per-lesson `applied` / `n/a` verdict (the adoption row in CLAUDE.md and the detail doc keep the new wording, and since 6.17.1 the directive says it overrides them); `bind` / `bridge` = comprehension-bridge arms (`bridge` keeps the pre-6.17 verdict wording as its fallback; neither says it overrides the adopted text, so their measured wording stays fixed); `legacy` = no directive. | _(unset)_ |
1256
1261
  | `CLAUDE_MEM_EDGE_DECAY` | Enables decay of file↔observation edges. | _(off)_ |
1257
1262
  | `CLAUDE_MEM_EDGE_DECAY_K` | Edge-decay threshold when the flag above is on (clamped to ≥1). | `3` |
package/haiku-client.mjs CHANGED
@@ -69,16 +69,37 @@ const MODEL_MAP = {
69
69
 
70
70
  // Models that get the STRICT body. Claude Haiku 5.5 answers any non-default `temperature`
71
71
  // with a 400 and thinks unless told not to, so a reply can open with a thinking block, and
72
- // thinking spends the max_tokens these short fixed-schema extractions are sized for. Every
73
- // leg runs it with thinking off: the CLI sets MAX_THINKING_TOKENS=0, OpenRouter gets
74
- // `reasoning: {enabled: false}` (STRICT_BODY_SLUGS), and the direct leg sends no sampling
75
- // field and thinking disabled. With thinking off it can answer a bare one-word query with a
72
+ // thinking spends the max_tokens these short fixed-schema extractions are sized for. The
73
+ // keyed legs run it with thinking off: OpenRouter gets `reasoning: {enabled: false}`
74
+ // (STRICT_BODY_SLUGS), and the direct leg sends no sampling field and thinking disabled.
75
+ // The CLI leg does NOT: Claude Code 2.1.301 sends `thinking: {type: "adaptive"}` and effort
76
+ // medium for `--model haiku` whether or not MAX_THINKING_TOKENS=0 is set (request bodies
77
+ // captured at a stub base URL, 2026-10-15; D#334). Its max_tokens is Claude Code's own, so
78
+ // nothing is cut off there. With thinking off it can answer a bare one-word query with a
76
79
  // prose question instead of JSON, so a caller's `jsonSchema` becomes a structured output on
77
80
  // these models (rewrite replays 2026-10-15: 7/72 below two variants without it, 0/72 with
78
- // it; docs/audits/20261015-v6.26.0-replays.md). Keyed by the ID actually sent, so an
79
- // override naming another model keeps the old body.
81
+ // it; docs/audits/20261015-v6.26.0-replays.md). Keyed by the ID actually sent, read through
82
+ // strictBodyKey, so an override naming another model keeps the old body.
80
83
  const STRICT_BODY_MODELS = new Set(['claude-haiku-5-5']);
81
84
 
85
+ /**
86
+ * The model's own name inside the routing decoration around it, for the STRICT_BODY_*
87
+ * lookups: an OpenRouter variant suffix (`anthropic/claude-haiku-5.5:nitro`, `:floor`) and
88
+ * the Bedrock form a gateway may take (`anthropic.claude-haiku-5-5`, with or without a
89
+ * cross-region prefix such as `us.`, `global.` or GovCloud's `us-gov.`). Haiku 5.5 named
90
+ * either way was sent the old body: thinking on, no structured output and, on the direct
91
+ * leg, temperature 0, which it answers with a 400 (OpenRouter drops the field in transit).
92
+ * A gateway deployment name says nothing about the model behind it and is not guessed at.
93
+ * (D#321)
94
+ * @param {string} id model ID or OpenRouter slug as sent
95
+ * @returns {string}
96
+ */
97
+ function strictBodyKey(id) {
98
+ return String(id)
99
+ .replace(/:[a-z0-9-]+$/, '')
100
+ .replace(/^(?:[a-z]{2,6}(?:-[a-z]+)?\.)?anthropic\./, '');
101
+ }
102
+
82
103
  // Claude Code's tier-alias override vars, honored on the direct-API leg too:
83
104
  // gateway deployments route on the DEPLOYMENT name (Azure Foundry rejects the
84
105
  // Anthropic model ID when it differs), and one env set then covers both the
@@ -582,7 +603,7 @@ async function callModelAPI(
582
603
  // "`temperature` is deprecated for this model."). Keep sending it until a
583
604
  // model refuses, then drop it for that model only - the retry at the bottom
584
605
  // pays for itself once. A model known to refuse it never gets it.
585
- if (STRICT_BODY_MODELS.has(modelId)) {
606
+ if (STRICT_BODY_MODELS.has(strictBodyKey(modelId))) {
586
607
  body.thinking = { type: 'disabled' };
587
608
  if (jsonSchema) body.output_config = { format: { type: 'json_schema', schema: jsonSchema.schema } };
588
609
  } else if (!_temperatureDeprecated.has(modelId)) body.temperature = temperature;
@@ -680,10 +701,22 @@ async function callModelAPI(
680
701
  debugLog('WARN', `${model}-api`, `refusal (${data.stop_details?.category ?? 'no category'})`);
681
702
  return null;
682
703
  }
683
- // By content, not position: a thinking block (no `text`) can come first.
704
+ // The callers parse a fixed-schema JSON answer out of this text (the opt-in lesson bridge
705
+ // keeps its first line), so a reply cut off at max_tokens is a failed call too: the CLI
706
+ // fallback has no such cap. A whole JSON answer followed by cut-off prose is discarded with
707
+ // it, although parseJsonFromLLM would have recovered it (D#322; D#338).
708
+ if (data.stop_reason === 'max_tokens') {
709
+ debugLog('WARN', `${model}-api`, 'reply cut off at max_tokens');
710
+ return null;
711
+ }
712
+ // By content, not position: a thinking block (no `text`) can come first. Every text
713
+ // block, joined, so an empty one cannot hide the answer after it (D#324).
684
714
  const text = Array.isArray(data.content)
685
- ? data.content.find((b) => typeof b?.text === 'string')?.text
686
- : undefined;
715
+ ? data.content
716
+ .filter((b) => typeof b?.text === 'string')
717
+ .map((b) => b.text)
718
+ .join('')
719
+ : '';
687
720
  return text ? { text } : null;
688
721
  } finally {
689
722
  clearTimeout(timer);
@@ -757,7 +790,10 @@ function claudeArgs(modelName) {
757
790
  /**
758
791
  * The environment every headless spawn runs with. MAX_THINKING_TOKENS=0: these are
759
792
  * structured extraction calls, and the thinking the CLI enables by default was most of
760
- * their output tokens and latency (measured above).
793
+ * their output tokens and latency (measured above, 2026-09-29; the model the CLI ran then was
794
+ * not recorded). Claude Code 2.1.301 does not change its request for Claude Haiku 5.5 on this
795
+ * variable: the body carries adaptive thinking either way (D#334). Earlier versions were not
796
+ * checked.
761
797
  */
762
798
  function cliSpawnEnv() {
763
799
  return {
@@ -1095,8 +1131,15 @@ async function callOpenRouterAPI(
1095
1131
  // Optional OpenRouter attribution headers (ignored by the API if absent).
1096
1132
  'X-Title': 'claude-mem-lite',
1097
1133
  };
1098
- const payload = { model, max_tokens: maxTokens, temperature, messages };
1099
- if (STRICT_BODY_SLUGS.has(model)) {
1134
+ // A strict slug gets no temperature, as on the direct leg. No OpenRouter endpoint serving
1135
+ // Claude Haiku 5.5 lists it (endpoints API, 2026-10-15), so it was dropped in transit,
1136
+ // and it made `provider.require_parameters` match no endpoint at all (72/72 HTTP 404 on
1137
+ // the rewrite prompts, 0/72 without it; docs/audits/20261015-d330-openrouter-routing.md).
1138
+ const strict = STRICT_BODY_SLUGS.has(strictBodyKey(model));
1139
+ const payload = strict
1140
+ ? { model, max_tokens: maxTokens, messages }
1141
+ : { model, max_tokens: maxTokens, temperature, messages };
1142
+ if (strict) {
1100
1143
  payload.reasoning = { enabled: false };
1101
1144
  if (jsonSchema) {
1102
1145
  payload.response_format = {
@@ -1119,7 +1162,27 @@ async function callOpenRouterAPI(
1119
1162
  }
1120
1163
 
1121
1164
  const data = await res.json();
1122
- const text = data.choices?.[0]?.message?.content;
1165
+ const choice = data.choices?.[0];
1166
+ // OpenRouter normalises finish_reason to stop / length / content_filter / tool_calls /
1167
+ // error and passes the provider's own value as native_finish_reason. A cut-off, filtered
1168
+ // or refused reply can still carry part of an answer; none is one. (D#322, D#323)
1169
+ const finish = choice?.finish_reason;
1170
+ const native = choice?.native_finish_reason;
1171
+ if (
1172
+ finish === 'length' ||
1173
+ finish === 'content_filter' ||
1174
+ finish === 'error' ||
1175
+ native === 'refusal' ||
1176
+ choice?.error
1177
+ ) {
1178
+ debugLog(
1179
+ 'WARN',
1180
+ `${tier}-openrouter`,
1181
+ `no answer (finish_reason ${finish}, native ${native ?? 'none'})`,
1182
+ );
1183
+ return null;
1184
+ }
1185
+ const text = choice?.message?.content;
1123
1186
  return text ? { text } : null;
1124
1187
  } finally {
1125
1188
  clearTimeout(timer);
package/hook-context.mjs CHANGED
@@ -46,6 +46,8 @@ import { inferProjectDir } from './project-utils.mjs';
46
46
  import { TYPE_QUALITY, TYPE_QUALITY_DEFAULT } from './scoring-sql.mjs';
47
47
 
48
48
  import { DAY_MS } from './lib/time-constants.mjs';
49
+ import { capRecentRows, recentTableRowCap } from './lib/recent-table.mjs';
50
+
49
51
  /**
50
52
  * Compute adaptive recall time windows based on project activity velocity.
51
53
  * High activity -> shorter windows (recent data more relevant).
@@ -532,14 +534,16 @@ const MIN_LOCAL_OBS = 3;
532
534
  * MIN_LOCAL_OBS, so the table gains at most two rows.
533
535
  *
534
536
  * The COUNT never shrinks; the composition can, and that is a ranking decision rather than
535
- * an accident. At the uncapped table site this only adds. At the `.slice(0, MIN_LOCAL_OBS)`
537
+ * an accident. At the table site, before its 5-row cap, this only added. At the `.slice(0, MIN_LOCAL_OBS)`
536
538
  * site the selected rows are PREPENDED, so with 1 local row and 3+ fallback rows the third
537
539
  * fallback title is evicted — still three rows, one of them now local. Exhaustive
538
540
  * enumeration over 42,436 ordered input pairs: 0 cases render fewer rows, 3,000 evict a
539
541
  * fallback row that way, max gain 2. Preferring the project's own rows over cross-project
540
542
  * ones inside a fixed window is the intended order, and saying so is the point: a new
541
543
  * population entering a limit-bounded window without a stated ranking is this repo's
542
- * recorded failure shape.
544
+ * recorded failure shape. The table site has been capped since the injection-budget change
545
+ * (5 rows), and its cut keeps that order too — capRecentRows ranks own rows first; a newest-5
546
+ * cut over the union evicted them (pre-ship defect review P2-1).
543
547
  */
544
548
  function withFallbackTopUp(observations, fallbackObs) {
545
549
  if (observations.length >= MIN_LOCAL_OBS) return observations;
@@ -914,8 +918,15 @@ export function buildSessionContextLines(
914
918
  // already chosen, so the token budget and the row set are untouched (pinned by a case in
915
919
  // tests/hook-context.test.mjs). Tiebroken on id for the same reason D#9 gives — an
916
920
  // untiebroken tie flips direction, and two saves in one millisecond are common.
921
+ //
922
+ // Injection budget C4: the display is then cut to `recentTableRowCap()` rows (5), the project's
923
+ // own rows ranked before the cross-project top-up (lib/recent-table.mjs). The knapsack still
924
+ // picks as before.
917
925
  const obsLines = [];
918
- const obsToShow = [...withFallbackTopUp(observations, fallbackObs)].sort(
926
+ const obsToShow = capRecentRows(
927
+ withFallbackTopUp(observations, fallbackObs),
928
+ project,
929
+ recentTableRowCap(),
919
930
  (a, b) => Date.parse(b.created_at) - Date.parse(a.created_at) || b.id - a.id,
920
931
  );
921
932
  if (obsToShow.length > 0) {
package/hook.mjs CHANGED
@@ -128,8 +128,7 @@ import {
128
128
  import { writePlainHookText, resetPlainHookText, idsShownWhole } from './lib/hook-text-cap.mjs';
129
129
  import { shouldRecallOnFailure } from './lib/tool-refusal.mjs';
130
130
  import {
131
- entryInputTags,
132
- extractDiagnosis,
131
+ workerEntryFields,
133
132
  filterSummaryInput,
134
133
  episodeInputFilterEnabled,
135
134
  } from './lib/episode-input-filter.mjs';
@@ -176,6 +175,7 @@ import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
176
175
  import { detectMemOverride } from './lib/mem-override.mjs';
177
176
  import { injectedIdsFileName, keyContextIdsFileName, readInjectedMarker } from './lib/injected-ids.mjs';
178
177
  import { recordKeyContextInjection, touchKeyContextMarker } from './lib/keyctx-marker.mjs';
178
+ import { RECALL_SHOWN_PREFIX, clearRecallShown } from './lib/recall-shown.mjs';
179
179
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
180
180
  import { selectErrorRecall } from './lib/error-recall-core.mjs';
181
181
  import { errorRecallSuppression } from './lib/error-recall-gate.mjs';
@@ -781,28 +781,16 @@ async function handlePostToolUse() {
781
781
  // Tier 1 B: Detect significant Bash commands
782
782
  const bashSig = tool_name === 'Bash' ? detectBashSignificance(toolInput, resp) : null;
783
783
 
784
- // D#69: tags and diagnosis lines need the FULL command and output, which the entry
785
- // does not keep, so they are computed here once. The output is windowed head + tail:
786
- // a runner prints its failures at the END, and a multi-megabyte stdout should not be
787
- // regex-scanned on every PostToolUse.
788
- const respWindow = resp.length > 65536 ? resp.slice(0, 32768) + '\n' + resp.slice(-32768) : resp;
789
-
790
- // `diagOut` = the diagnosis lines read from tool OUTPUT: a lesson quoting one stays
791
- // under every injection floor (D#100(3)). Always present on a Bash entry, [] included, so
792
- // the worker can tell a new entry with no output lines from one buffered before the field.
793
- const diagnosis = extractDiagnosis(tool_name, toolInput, respWindow, {
794
- isError: bashSig?.isError || false,
795
- writesFiles: tool_name === 'Bash' && bashWrites.length > 0,
796
- scrub: scrubSecrets,
797
- });
798
-
799
- // Build episode entry
784
+ // Build episode entry. The worker fields (input tags, DIAGNOSIS lines, Bash's diagOut)
785
+ // need the full command and output, which the entry does not keep (D#69).
800
786
  const entry = {
801
787
  tool: tool_name,
802
788
  desc: scrubSecrets(makeEntryDesc(tool_name, toolInput, resp, bashSig)),
803
- inputTags: entryInputTags(tool_name, toolInput, respWindow),
804
- diag: diagnosis.lines,
805
- ...(tool_name === 'Bash' ? { diagOut: diagnosis.output } : {}),
789
+ ...workerEntryFields(tool_name, toolInput, resp, {
790
+ isError: bashSig?.isError || false,
791
+ writesFiles: tool_name === 'Bash' && bashWrites.length > 0,
792
+ scrub: scrubSecrets,
793
+ }),
806
794
  files,
807
795
  ...(tool_name === 'Bash' && bashWrites.length ? { bashWrites } : {}),
808
796
  ts: Date.now(),
@@ -1884,7 +1872,8 @@ function gcStalePreRecallCooldowns() {
1884
1872
  const isInjectedMarker =
1885
1873
  name.startsWith('.claude-mem-injected-') ||
1886
1874
  name.startsWith('.claude-mem-keyctx-') || // D#123 Key Context marker — same per-session growth, same 24h GC
1887
- name.startsWith(BUGFIX_NUDGE_MARKER_PREFIX); // §9-B once-per-session nudge claim — same shape
1875
+ name.startsWith(BUGFIX_NUDGE_MARKER_PREFIX) || // §9-B once-per-session nudge claim — same shape
1876
+ name.startsWith(RECALL_SHOWN_PREFIX); // injection budget C1/C3 per-thread marker — same shape
1888
1877
  if (!isCooldown && !isInjectedMarker) continue;
1889
1878
  try {
1890
1879
  const p = join(RUNTIME_DIR, name);
@@ -3115,6 +3104,14 @@ async function handleSessionStart() {
3115
3104
  } catch {
3116
3105
  /* stdin unavailable — legacy behavior */
3117
3106
  }
3107
+ // Injection budget C1/C3 (lib/recall-shown.mjs): the recall header and full directive are
3108
+ // shown once per thread because the thread still holds the first copy. Compaction is when it
3109
+ // stops holding it, so the main thread starts over; so does any fresh context (startup, or a
3110
+ // /clear, which also rotates the session id). `resume` keeps the marker, on the ASSUMPTION that
3111
+ // a resumed transcript still carries the earlier copies — not verified (pre-ship delta review);
3112
+ // hooks/hooks.json does not even match `resume`, so this branch only sees it from a direct
3113
+ // call. Clearing errs safe: the worst case is one more header.
3114
+ if (ccSessionId && startSource !== 'resume') clearRecallShown(RUNTIME_DIR, ccSessionId);
3118
3115
 
3119
3116
  // Snapshot episode BEFORE flush for handoff extraction
3120
3117
  const episodeSnapshot = readEpisodeRaw();
@@ -386,6 +386,33 @@ export function extractDiagnosis(
386
386
  return { lines: [...clean(lines), ...out], output: out };
387
387
  }
388
388
 
389
+ /**
390
+ * The fields an episode entry carries for the llm-episode worker: the input filter's tags,
391
+ * the prompt's DIAGNOSIS lines and, on Bash, `diagOut`, the lines read from tool OUTPUT (a
392
+ * lesson quoting one stays under every injection floor, D#100(3); always present on a Bash
393
+ * entry, [] included, so the worker can tell a new entry with no output lines from one
394
+ * buffered before the field). They need the FULL command and output, which the entry does
395
+ * not keep, so hook.mjs computes them once per PostToolUse. The output is windowed head +
396
+ * tail: a runner prints its failures at the END, and a multi-megabyte stdout should not be
397
+ * regex-scanned on every call (D#69). benchmark/episode-flush-replay.mjs calls this too, so
398
+ * the model A/B it feeds sees the entries production builds (D#326).
399
+ * @param {string} tool
400
+ * @param {object} input tool_input
401
+ * @param {string} resp normalized tool response
402
+ * @param {{isError?: boolean, writesFiles?: boolean, scrub?: (s: string) => string}} [opts]
403
+ * as extractDiagnosis
404
+ * @returns {{inputTags: string[], diag: string[], diagOut?: string[]}}
405
+ */
406
+ export function workerEntryFields(tool, input, resp, opts) {
407
+ const respWindow = resp.length > 65536 ? resp.slice(0, 32768) + '\n' + resp.slice(-32768) : resp;
408
+ const diagnosis = extractDiagnosis(tool, input, respWindow, opts);
409
+ return {
410
+ inputTags: entryInputTags(tool, input, respWindow),
411
+ diag: diagnosis.lines,
412
+ ...(tool === 'Bash' ? { diagOut: diagnosis.output } : {}),
413
+ };
414
+ }
415
+
389
416
  // ─── Grounding check ────────────────────────────────────────────────────────
390
417
 
391
418
  const GROUND_NGRAM = 4;
@@ -17,6 +17,17 @@ const SUMMARY_MAX = 80;
17
17
  const DEFAULT_MIN_TOKENS = 800;
18
18
  const DEFAULT_MAX_READ_BYTES = 24 * 1024;
19
19
 
20
+ /**
21
+ * Whether the hook injects the line at all. Opt-in since the injection-budget change
22
+ * (docs/audits/20261016-injection-budget-baseline.md): CLAUDE_MEM_FILE_INTEL=1/on/true/yes.
23
+ * Exported so benchmark/injection-budget.mjs replays the shipped predicate, not a copy.
24
+ * @param {Record<string, string|undefined>} [env]
25
+ * @returns {boolean}
26
+ */
27
+ export function fileIntelEnabled(env = process.env) {
28
+ return ['1', 'on', 'true', 'yes'].includes(String(env.CLAUDE_MEM_FILE_INTEL || '').toLowerCase());
29
+ }
30
+
20
31
  // Mirror of utils.estimateTokens (ASCII ~4 chars/token, CJK ~1.5). Kept local so
21
32
  // the standalone hook stays lean — see file header + the mirror test.
22
33
  export function estimateContentTokens(text) {
@@ -0,0 +1,116 @@
1
+ // lib/recall-shown.mjs — what one THREAD has already been shown once: the recall framing header
2
+ // and the full cite directive (injection budget C1 / C3, docs/audits/20261016-injection-budget-baseline.md).
3
+ //
4
+ // Every PreToolUse recall block opened with the framing line — the legacy wording, or under the
5
+ // `factual` arm (lib/recall-framing.mjs) a line naming the file — and every directive line carried
6
+ // the same ACK text. On the frozen 52-session corpus the header was 2.78 KB/session over 1,546
7
+ // blocks (220 distinct strings: 1,033 legacy at 82 B, 513 factual naming a file) and the
8
+ // Read→Edit nudge carrying the full directive 2.03 KB/session over 340 — while a thread needs each
9
+ // once to know what the `[mem]` blocks are and how to tag a lesson. (PostToolUse recall carries a
10
+ // framing line only under the `bind` arm, and this module does not govern it.) So the first block of a
11
+ // thread carries both in full, later blocks drop the header and carry a short directive that
12
+ // keeps the tag rule. Lesson rows are never touched here: every `#NN` that was injected still
13
+ // is, so the (session, id) pairs the cite-rate counts do not move.
14
+ //
15
+ // The marker is keyed like the cooldown (lib/cooldown-path.mjs threadKey): a subagent thread
16
+ // shares no context with its parent, so it gets its own header. SessionStart clears the main
17
+ // thread's marker on every source but `resume` — compaction, /clear and a fresh start are when the
18
+ // earlier copy leaves (or was never in) the context. A subagent's marker goes only with the 24 h GC.
19
+ //
20
+ // Leaf on purpose: scripts/pre-tool-recall.js is a hot standalone hook and imports this.
21
+ //
22
+ // Switches (each restores the old behaviour on its own):
23
+ // CLAUDE_MEM_RECALL_HEADER_ONCE=0 header on every block
24
+ // CLAUDE_MEM_DIRECTIVE_ONCE=0 full directive on every block
25
+
26
+ import { readFileSync, unlinkSync } from 'fs';
27
+ import { join } from 'path';
28
+ import { atomicWriteFileSync } from './atomic-write.mjs';
29
+ import { cooldownSessionKey } from './cooldown-path.mjs';
30
+
31
+ /** Filename prefix, exported so the SessionStart GC can match the family. */
32
+ export const RECALL_SHOWN_PREFIX = 'recall-shown-';
33
+
34
+ // The default salience arm's directive (D#98). Moved here from scripts/pre-tool-recall.js, byte
35
+ // for byte, so benchmark/injection-budget.mjs can recognise it in recorded blocks.
36
+ export const ACK_DIRECTIVE =
37
+ 'apply each lesson to this edit or rule it out. If one changes the edit, add its bare tag (#NN) once at the end of the sentence describing that change; a lesson that did not apply needs no mention, and memory ids get no other mention in your reply.';
38
+ // What later blocks of the same thread carry. It keeps the one rule a cite depends on (tag only
39
+ // a lesson that changed the edit) and points back at the full text, which this thread has
40
+ // already been shown — the marker is cleared whenever the context starts over (see above) precisely
41
+ // so that stays true.
42
+ export const ACK_DIRECTIVE_SHORT =
43
+ 'apply each lesson to this edit — tag rule as before: (#NN) only where one changes it.';
44
+
45
+ const OFF = ['0', 'off', 'false', 'no'];
46
+ const isOn = (v) => !OFF.includes(String(v ?? '').toLowerCase());
47
+
48
+ const EMPTY = Object.freeze({ header: false, directive: false });
49
+
50
+ /**
51
+ * Decide what one recall block shows, given what the thread was already shown.
52
+ *
53
+ * @param {{header: boolean, directive: boolean}} shown
54
+ * @param {{header: boolean, directive: boolean}} wants what the block would carry today
55
+ * @param {Record<string, string|undefined>} [env]
56
+ * @returns {{header: boolean, directive: 'full'|'short'|null, next: {header: boolean, directive: boolean}}}
57
+ */
58
+ export function planRecallBlock(shown, wants, env = process.env) {
59
+ const s = shown || EMPTY;
60
+ const header = Boolean(wants?.header) && (!isOn(env.CLAUDE_MEM_RECALL_HEADER_ONCE) || !s.header);
61
+ let directive = null;
62
+ if (wants?.directive) directive = isOn(env.CLAUDE_MEM_DIRECTIVE_ONCE) && s.directive ? 'short' : 'full';
63
+ return {
64
+ header,
65
+ directive,
66
+ next: { header: Boolean(s.header || header), directive: Boolean(s.directive || directive === 'full') },
67
+ };
68
+ }
69
+
70
+ /**
71
+ * @param {string} runtimeDir
72
+ * @param {string|null|undefined} thread threadKey(sessionId, agentId); null without a session
73
+ * @returns {string|null}
74
+ */
75
+ export function recallShownPath(runtimeDir, thread) {
76
+ if (!thread) return null;
77
+ return join(runtimeDir, `${RECALL_SHOWN_PREFIX}${cooldownSessionKey(thread)}.json`);
78
+ }
79
+
80
+ /** @returns {{header: boolean, directive: boolean}} empty when absent, unreadable or no path */
81
+ export function readRecallShown(path) {
82
+ if (!path) return { ...EMPTY };
83
+ try {
84
+ const v = JSON.parse(readFileSync(path, 'utf8'));
85
+ return { header: v?.header === true, directive: v?.directive === true };
86
+ } catch {
87
+ return { ...EMPTY };
88
+ }
89
+ }
90
+
91
+ /** Best-effort write; a lost write only means the next block shows the line again. */
92
+ export function markRecallShown(path, shown) {
93
+ if (!path) return;
94
+ try {
95
+ atomicWriteFileSync(
96
+ path,
97
+ JSON.stringify({ header: Boolean(shown?.header), directive: Boolean(shown?.directive) }),
98
+ );
99
+ } catch {
100
+ /* silent — never block a tool call over a marker */
101
+ }
102
+ }
103
+
104
+ /**
105
+ * SessionStart (startup / clear / compact — every source but resume): the main thread starts
106
+ * from a context without the earlier copies.
107
+ */
108
+ export function clearRecallShown(runtimeDir, sessionId) {
109
+ const p = recallShownPath(runtimeDir, sessionId);
110
+ if (!p) return;
111
+ try {
112
+ unlinkSync(p);
113
+ } catch {
114
+ /* absent is fine */
115
+ }
116
+ }
@@ -0,0 +1,49 @@
1
+ // lib/recent-table.mjs — how many rows the SessionStart `### Recent` table shows, and which
2
+ // (injection budget C4, docs/audits/20261016-injection-budget-baseline.md).
3
+ //
4
+ // The table used to render the whole token-budget pick: 7–13 rows per table (60 SessionStart
5
+ // tables) on the frozen 52-session corpus, 1.02 KB/session, ids cited 2 times in 498 (session, id) pairs. It now shows
6
+ // `recentTableRowCap()` rows (5).
7
+ //
8
+ // WHICH five matters on a thin project: below MIN_LOCAL_OBS own rows, hook-context tops the pick up
9
+ // with other projects' rows, which are often newer. A newest-5 cut over that union evicted the
10
+ // project's OWN rows (pre-ship defect review P2-1: 5 of the 60 corpus tables, 10 own rows), so
11
+ // the cut ranks own rows first and only then fills with foreign ones.
12
+ //
13
+ // Leaf (env-number only): benchmark/injection-budget.mjs replays these two functions, and
14
+ // importing hook-context.mjs would create the data dir's runtime directory as a side effect.
15
+
16
+ import { envNumber } from './env-number.mjs';
17
+
18
+ /**
19
+ * Rows the table shows. Read per call so the CLI `context` command and tests see the current env.
20
+ * 0 hides the table.
21
+ * @param {Record<string, string|undefined>} [env]
22
+ * @returns {number}
23
+ */
24
+ export function recentTableRowCap(env = process.env) {
25
+ return envNumber(env.CLAUDE_MEM_STARTUP_RECENT_ROWS, {
26
+ name: 'CLAUDE_MEM_STARTUP_RECENT_ROWS',
27
+ defaultValue: 5,
28
+ min: 0,
29
+ integer: true,
30
+ });
31
+ }
32
+
33
+ /**
34
+ * The rows the table keeps, in display order: the project's own rows first (a row with no
35
+ * `project`, or this project), then other projects' rows, each newest first by `newer`; cut to
36
+ * `cap`; then sorted by `newer` for display. Copies — the caller's array is not reordered.
37
+ *
38
+ * @template {{project?: string|null}} T
39
+ * @param {T[]} rows
40
+ * @param {string} project
41
+ * @param {number} cap
42
+ * @param {(a: T, b: T) => number} newer the display comparator (newest first)
43
+ * @returns {T[]}
44
+ */
45
+ export function capRecentRows(rows, project, cap, newer) {
46
+ const own = (o) => !o.project || o.project === project;
47
+ const ranked = [...rows.filter(own).sort(newer), ...rows.filter((o) => !own(o)).sort(newer)];
48
+ return ranked.slice(0, Math.max(0, cap)).sort(newer);
49
+ }
@@ -1,9 +1,10 @@
1
- // lib/startup-dashboard.mjs — aggregates git/tasks/plans/handoff/events stats
2
- // into a single SessionStart injection line (T10c v2.31).
1
+ // lib/startup-dashboard.mjs — aggregates git/tasks/plans/handoff state
2
+ // into a single SessionStart injection line (T10c v2.31; the events count left in the
3
+ // injection-budget change).
3
4
  //
4
5
  // Pure function (with injectable stubs for test determinism). Never throws:
5
6
  // every external reader is try/catch'd so a broken git repo, missing tasks dir,
6
- // or absent events table cannot break SessionStart.
7
+ // or absent table cannot break SessionStart.
7
8
 
8
9
  import { readGitState } from './git-state.mjs';
9
10
  import { readProjectTasks } from './task-reader.mjs';
@@ -47,7 +48,7 @@ function readRecentHandoff(db, project) {
47
48
  *
48
49
  * @param {object} options
49
50
  * @param {object} options.db - better-sqlite3 handle. May be null.
50
- * @param {string} options.project - project identifier used for handoff/events queries.
51
+ * @param {string} options.project - project identifier used for the handoff (and opt-in events) query.
51
52
  * @param {string} [options.projectPath=process.cwd()] - filesystem root for git + tasks.
52
53
  * @param {object} [options.stubs] - per-field test injectors:
53
54
  * {git?, tasks?, plans?, handoff?}. handoff: pass explicit null to skip;
@@ -61,13 +62,6 @@ export function buildDashboard({ db, project, projectPath = process.cwd(), stubs
61
62
  // stubs.handoff === null means "test asserted no handoff"; undefined means "query DB".
62
63
  const handoff = stubs && 'handoff' in stubs ? stubs.handoff : readRecentHandoff(db, project);
63
64
 
64
- let eventCount = 0;
65
- try {
66
- eventCount = db?.prepare(`SELECT COUNT(*) c FROM events WHERE project = ?`).get(project)?.c ?? 0;
67
- } catch {
68
- /* events table may not exist on very old DBs */
69
- }
70
-
71
65
  const parts = [];
72
66
 
73
67
  if ((git?.changed?.length ?? 0) > 0 || (git?.stashes?.length ?? 0) > 0) {
@@ -110,8 +104,23 @@ export function buildDashboard({ db, project, projectPath = process.cwd(), stubs
110
104
  );
111
105
  }
112
106
 
113
- if (eventCount > 0) {
114
- parts.push(`💡 mem events: ${eventCount} entries (\`claude-mem-lite activity recent\`)`);
107
+ // Injection budget C6: a `💡 mem events: N entries (claude-mem-lite activity recent)` line
108
+ // used to follow. It sat in all 60 SessionStart blocks of the frozen 52-session corpus and
109
+ // `activity` was run in none of the 52 sessions (docs/audits/20261016-injection-budget-
110
+ // baseline.md; a first count said 56 and 4, corrected in the pre-ship claims review); the
111
+ // count tells the model nothing it acts on. `claude-mem-lite help` still lists `activity recent`.
112
+ // CLAUDE_MEM_DASHBOARD_EVENTS=1 brings the line back (pre-ship defect review P2-2).
113
+ if (
114
+ ['1', 'on', 'true', 'yes'].includes(String(process.env.CLAUDE_MEM_DASHBOARD_EVENTS || '').toLowerCase())
115
+ ) {
116
+ let eventCount = 0;
117
+ try {
118
+ eventCount = db?.prepare(`SELECT COUNT(*) c FROM events WHERE project = ?`).get(project)?.c ?? 0;
119
+ } catch {
120
+ /* events table may not exist on very old DBs */
121
+ }
122
+ if (eventCount > 0)
123
+ parts.push(`💡 mem events: ${eventCount} entries (\`claude-mem-lite activity recent\`)`);
115
124
  }
116
125
 
117
126
  // Invited-memory hint (Phase F): persists every SessionStart until the user
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.26.0",
3
+ "version": "6.27.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.26.0",
9
+ "version": "6.27.0",
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.26.0",
3
+ "version": "6.27.0",
4
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",
@@ -123,6 +123,8 @@
123
123
  "lib/hook-stdout.mjs",
124
124
  "lib/hook-text-cap.mjs",
125
125
  "lib/recall-framing.mjs",
126
+ "lib/recall-shown.mjs",
127
+ "lib/recent-table.mjs",
126
128
  "lib/proc-lock.mjs",
127
129
  "lib/atomic-write.mjs",
128
130
  "lib/platform-gate.mjs",
@@ -29,12 +29,21 @@ import {
29
29
  toolEditPath,
30
30
  } from '../lib/file-edge-match.mjs';
31
31
  import { scrubFilePath } from '../lib/scrub-record.mjs';
32
- import { fileIntelFor } from '../lib/file-intel.mjs';
32
+ import { fileIntelFor, fileIntelEnabled } from '../lib/file-intel.mjs';
33
33
  import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
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
37
  import { recallFramingLine } from '../lib/recall-framing.mjs';
38
+ // Leaf (fs + path): the header and the full ACK directive once per thread (injection budget C1/C3).
39
+ import {
40
+ ACK_DIRECTIVE,
41
+ ACK_DIRECTIVE_SHORT,
42
+ markRecallShown,
43
+ planRecallBlock,
44
+ readRecallShown,
45
+ recallShownPath,
46
+ } from '../lib/recall-shown.mjs';
38
47
  // D#154: the one stdout writer. This script has THREE emit sites (Read→Edit ack,
39
48
  // repeated-read guard, lesson block) and they stay one document because each branch
40
49
  // process.exit()s before reaching the next.
@@ -158,8 +167,9 @@ const SALIENCE_BRIDGE = process.env.CLAUDE_MEM_SALIENCE === 'bridge';
158
167
  // would make readings before and after this change incomparable (pre-ship review P2-2).
159
168
  const VERDICT_DIRECTIVE =
160
169
  "apply each lesson to this edit or rule it out — state '#NN applied' or '#NN n/a — <reason>' in your next user-facing message.";
161
- const ACK_DIRECTIVE =
162
- 'apply each lesson to this edit or rule it out. If one changes the edit, add its bare tag (#NN) once at the end of the sentence describing that change; a lesson that did not apply needs no mention, and memory ids get no other mention in your reply.';
170
+ // ACK_DIRECTIVE (the default) lives in lib/recall-shown.mjs with its short form: a thread gets the
171
+ // full text once and the short one after (injection budget C3). Only the default arm shortens —
172
+ // the other arms' text must stay the one their readings were taken with (see below).
163
173
  // v-bind salience forcing-function (#8771 audit: ack ≠ act). Instead of a cheap
164
174
  // '#NN applied / n/a' verdict, demand the model bind the lesson to the concrete
165
175
  // line it's editing and quote the satisfying edit line. Selected by
@@ -182,13 +192,20 @@ const ACTIVE_DIRECTIVE = SALIENCE_BIND
182
192
  : ACK_DIRECTIVE;
183
193
  const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
184
194
  // Feature ① (file intelligence): on the first Read of a file each session, inject
185
- // its approximate token size + a one-line summary so the agent can decide to read
186
- // fully, slice, or grep. Read-only (Edit/Write already commit to the file). Default
187
- // ON; CLAUDE_MEM_FILE_INTEL=0 disables. Files below the token floor stay silent so
188
- // small reads carry no noise. Env names mirror schema.mjs CLAUDE_MEM_* convention (#8447).
189
- const FILE_INTEL_OFF = ['0', 'off', 'false', 'no'].includes(
190
- String(process.env.CLAUDE_MEM_FILE_INTEL || '').toLowerCase(),
191
- );
195
+ // its approximate token size + a one-line summary. Read-only (Edit/Write already commit
196
+ // to the file). Files below the token floor stay silent. Env names mirror schema.mjs
197
+ // CLAUDE_MEM_* convention (#8447).
198
+ //
199
+ // OPT-IN since the injection-budget change (docs/audits/20261016-injection-budget-baseline.md).
200
+ // It was meant to let the agent "decide to read fully, slice, or grep", but PreToolUse
201
+ // additionalContext is delivered next to the tool RESULT (Claude Code hooks reference, "Add
202
+ // context for Claude"), so it can never change the call it rides on. On the frozen 52-session
203
+ // corpus 89.9% of its 1,102 lines rode a call that was already a partial read (`sed -n`, grep,
204
+ // Read with offset/limit), after a full Read the same file was sliced later in the turn LESS
205
+ // often (34.9%, 22/63) than after a partial Bash read (69.1%), and no reply named a size (0/1,102) —
206
+ // at 1.57 KB/session, plus the framing header of the 426 blocks it was the only content of.
207
+ // CLAUDE_MEM_FILE_INTEL=1 turns it back on.
208
+ const FILE_INTEL_ON = fileIntelEnabled();
192
209
  // P2 (D#78): edge-level decay ENFORCEMENT — opt-in (default OFF, shadow-first).
193
210
  // When on, a (obs,file) edge whose miss_streak reached K consecutive uncited
194
211
  // injections stops firing on this surface; the lesson stays reachable via
@@ -541,6 +558,33 @@ try {
541
558
  const isSessionScoped = Boolean(sessionId);
542
559
  const cooldown = readCooldown(cooldownPath);
543
560
  const now = Date.now();
561
+ // C1/C3 (lib/recall-shown.mjs): the framing header and the full ACK directive once per thread.
562
+ // Same thread key as the cooldown; null without a session, which shows both every time.
563
+ const shownPath = recallShownPath(RUNTIME_DIR, dedupKey);
564
+ // Prepends the header when this thread has not had one, fills the directive slot, and records
565
+ // what was shown. `body` is everything but the header; `directiveAt(text)` builds the line
566
+ // that carries the directive, or is null when the block has none.
567
+ // `name` is the file the factual framing arm names; the lesson block passes its scrubbed `fname`.
568
+ const emitRecallBlock = (body, directiveAt = null, name = basename(filePath)) => {
569
+ const shown = readRecallShown(shownPath);
570
+ const plan = planRecallBlock(shown, { header: true, directive: Boolean(directiveAt) });
571
+ const lines = [...body];
572
+ if (directiveAt) {
573
+ lines.push(
574
+ directiveAt(
575
+ plan.directive === 'short' && ACTIVE_DIRECTIVE === ACK_DIRECTIVE
576
+ ? ACK_DIRECTIVE_SHORT
577
+ : ACTIVE_DIRECTIVE,
578
+ ),
579
+ );
580
+ }
581
+ if (plan.header) lines.unshift(recallFramingLine('PreToolUse', { sessionId, fname: name }));
582
+ queueHookContext('PreToolUse', lines.join('\n'));
583
+ flushHookStdout();
584
+ if (plan.next.header !== shown.header || plan.next.directive !== shown.directive) {
585
+ markRecallShown(shownPath, plan.next);
586
+ }
587
+ };
544
588
  if (isSessionScoped) {
545
589
  const entry = cooldown[filePath];
546
590
  if (entry) {
@@ -556,14 +600,11 @@ try {
556
600
  if (!isRead && wasReadMode && seenIds.length > 0 && !SALIENCE_LEGACY) {
557
601
  // Namespaced per table: a bare `#N` for an event id names a different memory.
558
602
  const idList = lessonIdTokens(seenIds, entry.obsIds).join(', ');
559
- queueHookContext(
560
- 'PreToolUse',
561
- [
562
- recallFramingLine('PreToolUse', { sessionId, fname: basename(filePath) }),
563
- `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
564
- ].join('\n'),
603
+ emitRecallBlock(
604
+ [],
605
+ (directive) =>
606
+ `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${directive}`,
565
607
  );
566
- flushHookStdout();
567
608
  cooldown[filePath] = { ...entry, mode: 'edit' };
568
609
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
569
610
  } else if (isRead && !REREAD_GUARD_OFF && typeof entry === 'object' && entry.reread) {
@@ -571,14 +612,7 @@ try {
571
612
  // nudge to reuse what's already in context. Read-only; never throws.
572
613
  const meta = readFileMeta(filePath);
573
614
  if (shouldWarnReread(entry.reread, meta ? meta.mtimeMs : null, isFullRead, REREAD_MIN_TOKENS)) {
574
- queueHookContext(
575
- 'PreToolUse',
576
- [
577
- recallFramingLine('PreToolUse', { sessionId, fname: basename(filePath) }),
578
- buildRereadWarning(basename(filePath), entry.reread.tokens),
579
- ].join('\n'),
580
- );
581
- flushHookStdout();
615
+ emitRecallBlock([buildRereadWarning(basename(filePath), entry.reread.tokens)]);
582
616
  recordMetric(DATA_DIR, { event: 'reread_warn' }); // tier-1 firing counter (②)
583
617
  }
584
618
  }
@@ -824,10 +858,10 @@ try {
824
858
  // reliably renders across CC variants (sdscc drops plain-text stdout from PreToolUse).
825
859
  // suppressOutput:true hides it from transcript mode per CC hook docs.
826
860
  // Feature ①: file intelligence (size + summary) for the first Read of this
827
- // file this session. Read-only; opt out via CLAUDE_MEM_FILE_INTEL=0. Never
861
+ // file this session. Read-only; opt-in via CLAUDE_MEM_FILE_INTEL=1. Never
828
862
  // throws — fileIntelFor returns null on unreadable/below-threshold files.
829
863
  let fileIntelLine = null;
830
- if (isRead && !FILE_INTEL_OFF) {
864
+ if (isRead && FILE_INTEL_ON) {
831
865
  try {
832
866
  fileIntelLine = fileIntelFor(filePath, { minTokens: FILE_INTEL_MIN_TOKENS });
833
867
  } catch {}
@@ -860,15 +894,14 @@ try {
860
894
  via: viaBash ? 'bash' : 'tool',
861
895
  });
862
896
  }
863
- const showFraming =
864
- hasLessons || Boolean(fileIntelLine) || (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1');
865
- if (showFraming) {
866
- // Framing line mirrors #7758 handoff-injection fix: without an explicit
867
- // "this is context, the call continues" line, observed turn-end after Edit+reminder
868
- // when the model misreads passive lesson context as a closing note. Two wordings
869
- // run side by side per session — lib/recall-framing.mjs.
870
- lines.push(recallFramingLine('PreToolUse', { sessionId, fname }));
871
- }
897
+ // Framing line mirrors #7758 handoff-injection fix: without an explicit
898
+ // "this is context, the call continues" line, observed turn-end after Edit+reminder
899
+ // when the model misreads passive lesson context as a closing note. Wording:
900
+ // lib/recall-framing.mjs. emitRecallBlock prepends it to the FIRST block of a thread
901
+ // only (C1, lib/recall-shown.mjs): every later block still opens with its own `[mem]`
902
+ // line, and the thread has already been told what those are.
903
+ // The directive slot, filled by emitRecallBlock (full text once per thread, short after).
904
+ let directiveAt = null;
872
905
  // MED-1 (full audit 2026-07-16): defang the injection-block delimiters in
873
906
  // all DB/file-derived text before it enters additionalContext (which CC wraps
874
907
  // in <system-reminder>). Stored text is raw — a lesson/title/summary carrying
@@ -925,7 +958,7 @@ try {
925
958
  lines.push(
926
959
  `[mem] ⚠ #${bridged.id} → this edit must: ${neutralizeContextDelimiters(bridged.check)}. Confirm your new code satisfies it.`,
927
960
  );
928
- else lines.push(`[mem] ⚠ Before this edit: ${ACTIVE_DIRECTIVE}`);
961
+ else directiveAt = (directive) => `[mem] ⚠ Before this edit: ${directive}`;
929
962
  }
930
963
  } else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
931
964
  // R-4: Edit/Write empty → short backfill reminder. OPT-IN (default off) as
@@ -939,16 +972,12 @@ try {
939
972
  //
940
973
  // Read never emitted this (passive). The cooldown write below still runs on
941
974
  // every branch, so Read→Edit dedup + cite-back lessonId tracking are intact.
942
- // (Framing line already pushed above via showFraming.)
943
975
  lines.push(
944
976
  `[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: /lesson --file ${fname} "<root cause + fix>"`,
945
977
  );
946
978
  }
947
979
 
948
- if (lines.length > 0) {
949
- queueHookContext('PreToolUse', lines.join('\n'));
950
- flushHookStdout();
951
- }
980
+ if (lines.length > 0) emitRecallBlock(lines, directiveAt, fname);
952
981
  // Cooldown applies on ALL branches (including silent-Read) so subsequent
953
982
  // calls on the same file in the same session don't re-query — preserving
954
983
  // the per-filePath invariant that underpins Read→Edit dedup.
@@ -275,3 +275,26 @@ export function extractDeferredRefs(text) {
275
275
  }
276
276
  return out;
277
277
  }
278
+
279
+ /**
280
+ * Whether a stored prompt is an echo of the current one: the same text after whitespace and
281
+ * case are folded, or one a prefix of the other covering at least 80% of the longer (a re-paste
282
+ * with a line added). Injection budget C5: the past-question fallback handed the model its own
283
+ * prompt back in 8 of 52 rows on the frozen corpus (docs/audits/20261016-injection-budget-baseline.md).
284
+ * @param {string} stored
285
+ * @param {string} current
286
+ * @returns {boolean}
287
+ */
288
+ export function isPromptEcho(stored, current) {
289
+ const norm = (s) =>
290
+ String(s ?? '')
291
+ .replace(/\s+/g, ' ')
292
+ .trim()
293
+ .toLowerCase();
294
+ const a = norm(stored);
295
+ const b = norm(current);
296
+ if (!a || !b) return false;
297
+ if (a === b) return true;
298
+ const [short, long] = a.length <= b.length ? [a, b] : [b, a];
299
+ return short.length >= 0.8 * long.length && long.startsWith(short);
300
+ }
@@ -40,6 +40,7 @@ import {
40
40
  extractDeferredRefs,
41
41
  DEDUP_STALE_MS,
42
42
  detectMemOverride,
43
+ isPromptEcho,
43
44
  } from './prompt-search-utils.mjs';
44
45
  import { injectedIdsFileName, mergeInjectedMarker } from '../lib/injected-ids.mjs';
45
46
  import { getDeferredByIds } from '../lib/deferred-work.mjs';
@@ -566,7 +567,13 @@ function searchByFile(db, files, project, limit) {
566
567
  // ranking with no scoring multipliers and no top-|rel| gate (prompts are
567
568
  // sparser and more surface-form than observations; the gate would rarely
568
569
  // fire and mostly kill real hits).
569
- function searchByUserPrompts(db, queryText, project, limit) {
570
+ //
571
+ // Injection budget C5: a prompt from THIS Claude Code session is already in the context, and the
572
+ // current prompt itself can be in the table (hook.mjs user-prompt writes it in parallel with this
573
+ // search) — so rows of the current session are excluded in SQL, and a row that is an echo of the
574
+ // current prompt (isPromptEcho) in JS. Rows written before cc_session_id existed (NULL) stay
575
+ // eligible.
576
+ function searchByUserPrompts(db, queryText, project, limit, ccSessionId = null) {
570
577
  const ftsQuery = upsFtsQuery(queryText);
571
578
  if (!ftsQuery) return [];
572
579
 
@@ -584,6 +591,7 @@ function searchByUserPrompts(db, queryText, project, limit) {
584
591
  AND s.project = ?
585
592
  AND up.created_at_epoch > ?
586
593
  AND up.prompt_text NOT LIKE '<task-notification>%'
594
+ AND (? IS NULL OR up.cc_session_id IS NULL OR up.cc_session_id != ?)
587
595
  ORDER BY relevance
588
596
  LIMIT ?
589
597
  `;
@@ -596,13 +604,23 @@ function searchByUserPrompts(db, queryText, project, limit) {
596
604
  // filter, then take `limit` — same ORDER BY, so the old result is a prefix of this
597
605
  // one and a row can only be added, never displaced by something worse.
598
606
  const poolLimit = Math.min(limit * PROMPT_FALLBACK_POOL_FACTOR, PROMPT_FALLBACK_POOL_MAX);
599
- let rows = db.prepare(sql).all(ftsQuery, project, cutoff, poolLimit);
607
+ const session = typeof ccSessionId === 'string' && ccSessionId ? ccSessionId : null;
608
+ // The echo filter runs BEFORE the OR relaxation decides: the current prompt matches every one
609
+ // of its own AND terms, so an echo is often the AND query's only row — filtering it after
610
+ // would leave nothing and never try the OR pool (the LIMIT-upstream-of-a-JS-filter shape).
611
+ // CLAUDE_MEM_UPS_PROMPT_ECHO=0 keeps echoes from EARLIER sessions (the pre-change behaviour);
612
+ // the current session's rows stay excluded in SQL either way — that half is the bug fix.
613
+ const echoFilterOn = !['0', 'off', 'false', 'no'].includes(
614
+ String(process.env.CLAUDE_MEM_UPS_PROMPT_ECHO || '').toLowerCase(),
615
+ );
616
+ const notEcho = (rs) => (echoFilterOn ? rs.filter((r) => !isPromptEcho(r.prompt_text, queryText)) : rs);
617
+ let rows = notEcho(db.prepare(sql).all(ftsQuery, project, cutoff, session, session, poolLimit));
600
618
 
601
619
  if (rows.length === 0) {
602
620
  const orQuery = relaxFtsQueryToOr(ftsQuery);
603
621
  if (orQuery) {
604
622
  try {
605
- rows = db.prepare(sql).all(orQuery, project, cutoff, poolLimit);
623
+ rows = notEcho(db.prepare(sql).all(orQuery, project, cutoff, session, session, poolLimit));
606
624
  } catch {}
607
625
  }
608
626
  }
@@ -1092,7 +1110,7 @@ async function main() {
1092
1110
  // single highest-noise UPS path. Restored when REQUIRE_SIGNAL=0.
1093
1111
  let promptRows = [];
1094
1112
  if (rows.length === 0 && (!REQUIRE_EXPLICIT_SIGNAL || signalPresent)) {
1095
- promptRows = searchByUserPrompts(db, promptText, project, PROMPT_FALLBACK_LIMIT);
1113
+ promptRows = searchByUserPrompts(db, promptText, project, PROMPT_FALLBACK_LIMIT, hookData.session_id);
1096
1114
  }
1097
1115
 
1098
1116
  const candidateIds = rows.length > 0 ? rows.map((r) => r.id) : promptRows.map((r) => `P${r.id}`);
package/source-files.mjs CHANGED
@@ -157,6 +157,8 @@ export const SOURCE_FILES = [
157
157
  'lib/hook-stdout.mjs',
158
158
  'lib/hook-text-cap.mjs',
159
159
  'lib/recall-framing.mjs',
160
+ 'lib/recall-shown.mjs',
161
+ 'lib/recent-table.mjs',
160
162
  // audit P0/P1: inter-process install lock + atomic config writes — imported by
161
163
  // install.mjs (settings.json + install lock) and hook-update.mjs (.claude.json
162
164
  // + auto-update lock). Must ship or a partial install/update skips them.