claude-mem-lite 6.17.1 → 6.19.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.17.1",
12
+ "version": "6.19.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.17.1",
3
+ "version": "6.19.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
@@ -81,7 +81,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
81
81
  - **Episode batching** -- Groups related file operations into coherent episodes before LLM encoding
82
82
  - **Error-triggered recall** -- Automatically searches memory when Bash errors occur, surfacing relevant past fixes
83
83
  - **Proactive file history** -- When editing a file, automatically shows relevant past observations for that file
84
- - **Session summaries** -- written at every Stop (the assistant's final report when it has Done / Not done sections, else the first prompt and recent observation titles), then upgraded by a background model summary when the session has observations
84
+ - **Session summaries** -- written at every Stop (the assistant's final report when it has Done / Not done sections, else — when the final reply has no report section at all — its first 120 characters, else the first prompt and recent observation titles), then upgraded by a background model summary when the session has observations
85
85
  - **Project-scoped context** -- Injects recent memory into `CLAUDE.md` and session startup for immediate context
86
86
  - **Observation types** -- Categorized as `decision`, `bugfix`, `feature`, `refactor`, `discovery`, or `change`
87
87
  - **Importance grading** -- LLM assigns 1-3 importance levels (routine / notable / critical) to each observation
@@ -237,6 +237,36 @@ 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.19.0
241
+
242
+ **Search output changes; no switch.** No schema change and no migration, so reverting is
243
+ pinning `claude-mem-lite@6.18.0`.
244
+
245
+ - **`search` / `mem_search` mark machine-written observations with `🤖`**, and `get` /
246
+ `mem_get` name them in the header. Hook-captured, imported and compressed rows carry it;
247
+ explicit saves and events do not. The result line explains the mark whenever a shown row
248
+ has it, and `search --json` adds `auto` to observation rows.
249
+ - **`mem_search` shows a snippet line only when it adds to the title.**
250
+ - Fixes: a lone search match in a new per-project store no longer sorts last; a Bash
251
+ step's stored description keeps the end of its output; the secret scrubber catches values
252
+ behind markdown labels and stays linear-time on crafted input.
253
+
254
+ ## Upgrading to 6.18.0
255
+
256
+ **One default changes, with a switch.** No schema change and no migration. Pinning
257
+ `claude-mem-lite@6.17.1` stops new lines; a line already written stays until that session's
258
+ next observation titles replace it, and 6.17.1 keeps its unrecognised tag as text in the
259
+ row's `notes`.
260
+
261
+ - **Last Session shows how the previous session ended even without a report.** When the
262
+ final reply has no Done / Not done / Failed / Uncertain section, `Completed:` is its first
263
+ 120 characters (code blocks and line-leading markers removed) where it used to be
264
+ observation titles, which are usually empty; the /clear handoff labels it
265
+ `<session-summary source="last-reply">`. A report or a model summary still takes
266
+ precedence. A later reply under 400 characters ("You're welcome!") does not replace an
267
+ earlier one, and a reply in which the secret scrubber finds anything writes no line at all.
268
+ `CLAUDE_MEM_SUMMARY_TAIL=0` restores the titles from the session's next turn on.
269
+
240
270
  ## Upgrading to 6.17.0
241
271
 
242
272
  **Two defaults change; one has a switch.** No schema change and no migration, so reverting
@@ -985,6 +1015,7 @@ claude-mem-lite.
985
1015
  | `CLAUDE_MEM_DEBUG` | Enable debug logging (`1` to enable). | _(disabled)_ |
986
1016
  | `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)_ |
987
1017
  | `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)_ |
1018
+ | `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)_ |
988
1019
  | `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)_ |
989
1020
  | `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)_ |
990
1021
  | `CLAUDE_MEM_LESSON_GROUNDING` | An auto-captured event keeps its lesson only when the lesson quotes the window's own diagnosis (a failing output line, a comment the edit added, or the commit message); otherwise the row is kept without it at importance 1, below every injection face. `off` keeps unquoted lessons. | _(on)_ |
package/README.zh-CN.md CHANGED
@@ -60,7 +60,7 @@
60
60
  - **Episode 批处理** -- 将相关文件操作分组为连贯的 episode,再进行 LLM 编码
61
61
  - **错误触发回忆** -- Bash 出错时自动搜索记忆,浮现相关的历史修复方案
62
62
  - **主动文件历史** -- 编辑文件时,自动显示该文件相关的历史观察记录
63
- - **会话摘要** -- 每次 Stop 时写入(助手最终回复带 Done / Not done 段落时取其内容,否则取首条提示和最近的 observation 标题),会话有 observation 时再由后台模型摘要升级
63
+ - **会话摘要** -- 每次 Stop 时写入(助手最终回复带 Done / Not done 段落时取其内容,否则——最终回复里没有任何报告段落时——取其前 120 个字符,再否则取首条提示和最近的 observation 标题),会话有 observation 时再由后台模型摘要升级
64
64
  - **项目作用域上下文** -- 将最近的记忆注入 `CLAUDE.md` 和会话启动上下文
65
65
  - **观察类型** -- 分类为 `decision`、`bugfix`、`feature`、`refactor`、`discovery` 或 `change`
66
66
  - **重要度分级** -- LLM 为每条观察分配 1-3 级重要度(日常/关注/关键)
@@ -199,6 +199,30 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.19.0
203
+
204
+ **搜索输出有变化,没有开关。** 没有 schema 变更、不需要迁移,回退只需固定 `claude-mem-lite@6.18.0`。
205
+
206
+ - **`search` / `mem_search` 用 `🤖` 标出机器写入的 observation**,`get` / `mem_get` 在标题行注明。
207
+ hook 采集、导入和压缩生成的行带这个标记;显式保存的和 events 不带。只要显示的行里有带标记的,
208
+ 结果行就会解释它的含义;`search --json` 的 observation 行多一个 `auto` 字段。
209
+ - **`mem_search` 只在摘录比标题多出信息时才显示摘录行。**
210
+ - 修复:新的按项目存储里,单条搜索命中不再排在最后;Bash 步骤存下的描述会保留输出的结尾;
211
+ 密钥擦除器能识别 markdown 标签后面的值,并且在构造输入下保持线性时间。
212
+
213
+ ## 升级到 6.18.0
214
+
215
+ **一处默认行为改变,有开关。** 没有 schema 变更、不需要迁移。固定 `claude-mem-lite@6.17.1`
216
+ 后不再写新的这一行;已经写入的那一行会保留到该会话下一次有 observation 标题时被替换,6.17.1
217
+ 会把它不认识的标签当作普通文本留在该行的 `notes` 里。
218
+
219
+ - **没有报告时,Last Session 也能说明上次会话是怎么结束的。** 最终回复里没有 Done / Not done /
220
+ Failed / Uncertain 段落时,`Completed:` 取该回复的前 120 个字符(去掉代码块和行首标记),原先这里
221
+ 是 observation 标题,而它通常是空的;/clear 交接里标为 `<session-summary source="last-reply">`。
222
+ 有报告或模型摘要时仍以它们为准。之后不足 400 字符的回复(如“不客气!”)不会替换之前那一条。
223
+ 设 `CLAUDE_MEM_SUMMARY_TAIL=0` 恢复为 observation 标题,进行中的会话从下一轮起生效。
224
+ 回复里只要被密钥擦除器检出任何内容,就不写这一行。
225
+
202
226
  ## 升级到 6.17.0
203
227
 
204
228
  **两处默认行为改变,其中一处有开关。** 没有 schema 变更、不需要迁移,全部回退只需固定
package/format-utils.mjs CHANGED
@@ -92,7 +92,9 @@ export function queryLabel(query) {
92
92
 
93
93
  // Two delimiter classes are defanged here:
94
94
  // 1. The blocks claude-mem-lite wraps injected context in (claude-mem-context /
95
- // memory-context / session-handoff). User-derived text containing one LITERALLY
95
+ // memory-context / session-handoff / the handoff's inner session-summary — D#129: a
96
+ // summary carrying `</session-summary><session-summary source="report">` closed the
97
+ // real block and forged its provenance). User-derived text containing one LITERALLY
96
98
  // would prematurely open/close the block it lands in, spilling the rest as
97
99
  // undelimited context.
98
100
  // 2. Harness-authority + tool-call tags the runtime injects (system-reminder /
@@ -113,7 +115,7 @@ export function queryLabel(query) {
113
115
  // Reachable by editing files that contain these tokens \u2014 e.g. developing claude-mem-lite
114
116
  // itself, where source/observations carry the delimiter names.
115
117
  const CONTEXT_DELIMITER_RE =
116
- /<\/?(?:claude-mem-context|memory-context|session-handoff|system-reminder|task-notification|(?:antml:)?function_calls|(?:antml:)?function_results|(?:antml:)?invoke|(?:antml:)?parameter)(?:\s[^>]*)?>/gi;
118
+ /<\/?(?:claude-mem-context|memory-context|session-handoff|session-summary|system-reminder|task-notification|(?:antml:)?function_calls|(?:antml:)?function_results|(?:antml:)?invoke|(?:antml:)?parameter)(?:\s[^>]*)?>/gi;
117
119
 
118
120
  // Pass cap for the fixpoint loop below. 32 nested layers of a forged delimiter is far past
119
121
  // anything prose produces; the cap exists only to bound the ADVERSARIAL cost (an unbounded
package/hook-llm.mjs CHANGED
@@ -991,6 +991,7 @@ export async function handleLLMEpisode() {
991
991
  // events; treating them as a separate role + boundary marker reduces the
992
992
  // attack surface for memory poisoning via crafted file content.
993
993
  const SHARED_OBS_SCHEMA_TAIL = `${MEMORY_INPUT_GUARD}
994
+ Grounding: state only outcomes the user message shows. An action description may be cut short ("…"); if its result is not visible, say what was run, not how it turned out. Never write that something passed, failed, was verified or confirmed unless that result appears in the user message.
994
995
  type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y because Z" / rejected an approach (e.g. "Rejected schema migration — single-source module + sync test instead"; "Heterogeneous hook events → heterogeneous context budgets"). bugfix = prior-failing path fixed with a named root cause. feature = new user-visible capability. refactor = behavior unchanged but structure improved. discovery = learned how a system works (read-heavy, no writes). change = routine edit with no new principle (default if unsure and nothing else fits).
995
996
  Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
996
997
  importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
package/hook.mjs CHANGED
@@ -1246,8 +1246,9 @@ function writeFastSummaryBaseline(db, { sessionId, project, transcriptPath }) {
1246
1246
  // Stop fires once per assistant TURN and the mem session survives it (R10-P1-1), so this
1247
1247
  // runs on every turn of a session. The first turn with anything to say INSERTs the row
1248
1248
  // (T4-P2-B's guard: never a second row); every later turn REFRESHES that row from its tail's
1249
- // report, or, without one, from the current observation titles where the row's Done is
1250
- // still titles (lib/fast-summary.mjs writeStopSummary). The guard alone used to stop there, so the
1249
+ // report, or, without one, from the head of the final reply (D#121) or the current
1250
+ // observation titles where the row's Done is still that grade (lib/fast-summary.mjs
1251
+ // writeStopSummary). The guard alone used to stop there, so the
1251
1252
  // row kept the FIRST turn's report: over 7 days of this machine's transcripts (09:40Z), 20
1252
1253
  // of the 31 sessions that wrote §10 markers had a first-turn extract different from their
1253
1254
  // last report. Uses the mem-internal sessionId as the WHERE key per the top-of-file
@@ -1262,8 +1263,11 @@ function writeFastSummaryBaseline(db, { sessionId, project, transcriptPath }) {
1262
1263
  let structuredCompleted = '';
1263
1264
  let structuredNotDone = '';
1264
1265
  let structuredNotes = '';
1266
+ // The raw final reply rides along: with no Done in it, its head is the row's Done floor
1267
+ // (D#121, lib/fast-summary.mjs writeStopSummary).
1268
+ let tail = null;
1265
1269
  try {
1266
- const tail = transcriptPath ? extractTailAssistantText(transcriptPath) : null;
1270
+ tail = transcriptPath ? extractTailAssistantText(transcriptPath) : null;
1267
1271
  if (tail) {
1268
1272
  const s = extractStructuredSummary(tail);
1269
1273
  structuredCompleted = s.done;
@@ -1280,7 +1284,7 @@ function writeFastSummaryBaseline(db, { sessionId, project, transcriptPath }) {
1280
1284
  writeStopSummary(db, {
1281
1285
  sessionId,
1282
1286
  project,
1283
- report: { done: structuredCompleted, notDone: structuredNotDone, lines: structuredNotes },
1287
+ report: { done: structuredCompleted, notDone: structuredNotDone, lines: structuredNotes, tail },
1284
1288
  source: readFastSummarySource(db, sessionId),
1285
1289
  now: new Date(),
1286
1290
  limits: FAST_SUMMARY_LIMITS.stop,
@@ -1757,8 +1761,9 @@ async function handleStop() {
1757
1761
  // D#95 made this opt-in in 34a65cd and was reverted before release: its premise ("Last
1758
1762
  // Session already comes from the Stop report") holds only when the assistant's final reply
1759
1763
  // carries Done / Not done sections (lib/summary-extractor.mjs). Without them the Stop row is
1760
- // the first prompt + recent observation titles, and this worker's model summary is the only
1761
- // prose summary such a user gets.
1764
+ // the first prompt + the head of the final reply (D#121, since v6.18.0; observation titles
1765
+ // before), and this worker's model summary is the only multi-field summary (Next / Lessons /
1766
+ // Decisions) such a user gets — when observations exist for it to read.
1762
1767
  if (!process.env.CLAUDE_MEM_SKIP_SUMMARY)
1763
1768
  spawnBackground('llm-summary', sessionId, project, String(stopEpoch));
1764
1769
 
@@ -20,7 +20,8 @@
20
20
  //
21
21
  // Which writer wins a field depends on where that field came from, recorded per field at
22
22
  // the head of `notes` (parseSummaryNotes): the assistant's own report beats the model's
23
- // summary, which beats the last observation titles.
23
+ // summary, which beats the head of the final reply (D#121), which beats the last
24
+ // observation titles.
24
25
  //
25
26
  // The model's write lives here too (`mergeModelSummary`): its precedence against the other
26
27
  // two writers is the point of this module, and keeping the three in one place is what lets
@@ -50,6 +51,83 @@ export const FAST_SUMMARY_LIMITS = {
50
51
  exitRestart: { request: 200, completed: 300, remaining: 200, notes: 400 },
51
52
  };
52
53
 
54
+ /**
55
+ * D#121 — the final reply as a Done floor. A session whose last assistant message carries
56
+ * no report (in 30 headless sessions without that convention, Stop found one in none:
57
+ * docs/audits/20260927-d114-default-user-corpus.md) had its opening prompt and observation
58
+ * titles, usually empty, as Last Session. The first TAIL_FLOOR_CHARS of that message
59
+ * is the measured length and position (blind labels: mean 1.73 of 2, none wrong; the LAST
60
+ * 120 chars scored 0.03 — 29 of 30 uninformative: offers, questions, trailing detail).
61
+ * Longer heads were not labelled, so the floor stores exactly this much. The labelled text
62
+ * also had inline markup stripped; the shipped one keeps it (see replyLines).
63
+ */
64
+ export const TAIL_FLOOR_CHARS = 120;
65
+ /**
66
+ * A later reply shorter than this does not replace an existing tail floor. In the
67
+ * supplementary corpus (docs/audits/20260927-d114-default-user-corpus.md) closing replies to
68
+ * "thanks" / "好的,谢谢" ran 15–187 chars and task replies 801+; 400 sits near the geometric
69
+ * middle, about 2× clear of both. Replacing with a closing reply loses the floor; keeping an
70
+ * older floor over a short real answer only ages it — so the cut leans high. A short reply
71
+ * may still create the first floor, which beats having none.
72
+ */
73
+ export const TAIL_MIN_REPLACE = 400;
74
+
75
+ /** Opt-out: CLAUDE_MEM_SUMMARY_TAIL=0 restores the 6.17 titles fallback. Read per call. */
76
+ function tailFloorEnabled() {
77
+ return !['0', 'off', 'false', 'no'].includes(
78
+ String(process.env.CLAUDE_MEM_SUMMARY_TAIL || '').toLowerCase(),
79
+ );
80
+ }
81
+
82
+ /**
83
+ * How much of the reply the floor examines. Only its head is stored, and the secret check
84
+ * scrubs what it examines: scrubbing a whole 200k-char reply reached two pre-existing
85
+ * quadratic scrub patterns and ran Stop past its 5 s timeout (v6.18.0 security review P2-1).
86
+ * A PEM block can be longer than that; see PEM_HEADER_RE.
87
+ */
88
+ const TAIL_EXAMINE_CHARS = 8000;
89
+ /**
90
+ * A PEM header anywhere in the examined head writes no floor. The scrubber matches a PEM block
91
+ * by its BEGIN and END lines; a key body longer than the window leaves the END outside it,
92
+ * and ~70 chars of key material after the header would be stored (v6.18.0 re-check P3-a).
93
+ */
94
+ const PEM_HEADER_RE = /-----BEGIN [A-Z0-9 ]+-----/;
95
+
96
+ /**
97
+ * The reply's lines with fenced code blocks turned into a space and line-leading markdown
98
+ * markers (#, >, list bullets, numbered items) removed — nothing inside a token is rewritten.
99
+ * Two review rounds found that every in-token rewrite (stripping backticks, `**`, `*`, `|`)
100
+ * either ate identifiers such as `*.mjs` or moved a secret out of a scrub pattern's shape.
101
+ * The stored floor is this text with its whitespace collapsed.
102
+ */
103
+ function replyLines(text) {
104
+ return String(text || '')
105
+ .replace(/```[\s\S]*?(?:```|$)/g, ' ')
106
+ .split('\n')
107
+ .map((l) => l.replace(/^\s*(?:#{1,6}\s+|>\s?|[-*+]\s+|\d+[.)]\s+)/, ''))
108
+ .join('\n');
109
+ }
110
+
111
+ /**
112
+ * Whether the scrubber finds a secret in `text` as written OR with inline markup (`, |, *)
113
+ * removed. Markup can hide a secret from the scrubber — a key split by a backtick or `*`,
114
+ * `gh|p_…` are missed as written and caught stripped (a markup-wrapped LABEL such as
115
+ * `**password**=…` is caught as written since D#128) — and storing the
116
+ * stripped text instead would leave a split key's tail behind. The caller asks it about the
117
+ * exact text it stores, not the reply as written: a list or quote marker between a label and
118
+ * its value hid the pair from a check on the raw reply, and removing the marker for storage
119
+ * brought them together (security review P1-1). Both views are checked: the lines-kept one
120
+ * is required (without it the re-check fuzz stored 164 of 10,829 cases the raw reply scrubs,
121
+ * e.g. `creds\npassword: <v>\nDone`); the joined one caught nothing the first missed and is
122
+ * kept for a pattern that stops at a newline. Likewise the as-written arm: no current pattern
123
+ * needs it (mutation-checked), one with a markup character would.
124
+ */
125
+ function carriesSecret(raw) {
126
+ const scrub = (v) => scrubRecord('session_summaries', { completed: v }).completed;
127
+ const stripped = raw.replace(/[`|*]/g, '');
128
+ return scrub(raw) !== raw || scrub(stripped) !== stripped;
129
+ }
130
+
53
131
  /**
54
132
  * The two reads every fast summary is built from: the session's opening prompt, and the
55
133
  * titles of its most recent observations.
@@ -125,14 +203,15 @@ export function insertFastSummary(db, { sessionId, project, values, limits, now
125
203
  /**
126
204
  * Where a row's Done and Not done came from, stored as the head of `notes`:
127
205
  *
128
- * `done<report|model|titles> left<report|other>[ <Failed / Uncertain lines>]`
206
+ * `done<report|model|tail|titles> left<report|other>[ <Failed / Uncertain lines>]`
129
207
  *
130
- * - done: `report` = the assistant's own Done; `model` = the LLM summary; `titles` = the last
131
- * observation titles, a fallback.
208
+ * - done: `report` = the assistant's own Done; `model` = the LLM summary; `tail` = the head
209
+ * of the final reply when it carried no report (D#121); `titles` = the last observation
210
+ * titles, a fallback.
132
211
  * - left: `report` = the assistant's own Not done, where '' means "nothing left"; `other` =
133
212
  * the model's inference or the handoff's unfinished list, which only fill a gap.
134
213
  *
135
- * Precedence per field: report > model > titles / other. It is recorded per FIELD because one
214
+ * Precedence per field: report > model > tail > titles / other. It is recorded per FIELD because one
136
215
  * report can carry a Not done and no Done, or only Failed lines; a single per-row tag read
137
216
  * those as a full report and froze stale titles as its Done (v6.13.5 delta review P2-1,
138
217
  * P2-2). Rows written before this carry older values: `fast` and bare Failed / Uncertain
@@ -144,7 +223,7 @@ export function insertFastSummary(db, { sessionId, project, values, limits, now
144
223
  */
145
224
  export function parseSummaryNotes(notes) {
146
225
  const text = typeof notes === 'string' ? notes : '';
147
- const m = /^done(report|model|titles) left(report|other)(?: ([\s\S]*))?$/.exec(text);
226
+ const m = /^done(report|model|tail|titles) left(report|other)(?: ([\s\S]*))?$/.exec(text);
148
227
  if (m) return { done: m[1], left: m[2], lines: m[3] || '' };
149
228
  if (text === '' || text === 'llm') return { done: 'model', left: 'other', lines: '' };
150
229
  if (text === 'fast') return { done: 'titles', left: 'other', lines: '' };
@@ -154,13 +233,14 @@ export function parseSummaryNotes(notes) {
154
233
  /**
155
234
  * The `source` attribute the /clear handoff puts on `<session-summary>`: who wrote the row's
156
235
  * Done text — `haiku` (the model worker), `report` (the assistant's own final report,
157
- * extracted at Stop) or `titles` (observation titles, the fast fallback).
236
+ * extracted at Stop), `last-reply` (the head of the final reply, D#121) or `titles`
237
+ * (observation titles, the fast fallback).
158
238
  * @param {string|null|undefined} notes session_summaries.notes
159
- * @returns {'haiku'|'report'|'titles'}
239
+ * @returns {'haiku'|'report'|'last-reply'|'titles'}
160
240
  */
161
241
  export function summarySourceLabel(notes) {
162
242
  const { done } = parseSummaryNotes(notes);
163
- return done === 'model' ? 'haiku' : done;
243
+ return done === 'model' ? 'haiku' : done === 'tail' ? 'last-reply' : done;
164
244
  }
165
245
 
166
246
  /** Inverse of parseSummaryNotes. `lines` must already be scrubbed. */
@@ -200,10 +280,15 @@ function clean(field, value, max) {
200
280
 
201
281
  /**
202
282
  * Stop's write, on every turn. `report` is this turn's tail extract ({done, notDone, lines}
203
- * — lines are its Failed / Uncertain lines, raw); `source` is readFastSummarySource's output.
283
+ * — lines are its Failed / Uncertain lines, raw — plus `tail`, the raw final reply);
284
+ * `source` is readFastSummarySource's output.
204
285
  * The session's first write INSERTs; every later one updates that row:
205
- * - a Done in the report replaces `completed` (done = report); without one, a `titles` row
206
- * takes the current titles;
286
+ * - a Done in the report replaces `completed` (done = report). A reply with no report
287
+ * section at all (no Done, Not done, Failed or Uncertain) and no secret gives its head,
288
+ * which replaces a `titles` or empty Done, or a `tail` one when the reply is at least
289
+ * TAIL_MIN_REPLACE chars as written (done = tail); with the floor switched off, a `tail`
290
+ * Done gives way to the titles, empty or not. Otherwise a `titles` row takes the current
291
+ * titles;
207
292
  * - any Done or Not done makes `remaining_items` this report's Not done, '' included
208
293
  * (left = report) — a Done with no Not done says nothing is left;
209
294
  * - Failed / Uncertain lines replace the previous report's whenever the tail carries a
@@ -217,10 +302,26 @@ export function writeStopSummary(db, { sessionId, project, report, source, now,
217
302
  const lines = scrubRecord('session_summaries', { notes: report.lines || '' }).notes;
218
303
  const titles = clean('completed', source.completed, limits.completed);
219
304
  const hasReport = Boolean(done || notDone);
305
+ // The floor is taken only from a reply with no report at all: a Not done, Failed or
306
+ // Uncertain section alone is still a report, and its head would repeat that section as a
307
+ // Done (v6.18.0 pre-tag review). A reply whose examined head the scrubber finds a secret in
308
+ // writes no floor (carriesSecret, on the stored text); clean() scrubs again before the cut.
309
+ const tailOn = tailFloorEnabled();
310
+ const rawTail = !hasReport && !lines && tailOn ? String(report.tail || '').trim() : '';
311
+ const lined = rawTail ? replyLines(rawTail).slice(0, TAIL_EXAMINE_CHARS) : '';
312
+ const flat = lined.replace(/\s+/g, ' ').trim();
313
+ const safe =
314
+ flat && !PEM_HEADER_RE.test(lined) && !carriesSecret(lined) && !carriesSecret(flat) ? flat : '';
315
+ // The cut is checked as well: dropping the tail of a line can complete a shape the scrubber
316
+ // flags (`password=<value>(x)` → `password=<value>…`), and the stored text should be one a
317
+ // later re-scrub leaves alone.
318
+ const cut = safe ? clean('completed', safe, Math.min(TAIL_FLOOR_CHARS, limits.completed)) : '';
319
+ const tail = cut && !carriesSecret(cut) ? cut : '';
320
+ const tailReplaces = rawTail.length >= TAIL_MIN_REPLACE;
220
321
  db.transaction(() => {
221
322
  const id = newestSummaryId(db, sessionId);
222
323
  if (id === null) {
223
- const completed = done || titles;
324
+ const completed = done || tail || titles;
224
325
  if (!(source.request || completed || notDone)) return;
225
326
  db.prepare(INSERT_SQL).run(
226
327
  sessionId,
@@ -229,7 +330,11 @@ export function writeStopSummary(db, { sessionId, project, report, source, now,
229
330
  completed,
230
331
  notDone,
231
332
  formatSummaryNotes(
232
- { done: done ? 'report' : 'titles', left: hasReport ? 'report' : 'other', lines },
333
+ {
334
+ done: done ? 'report' : tail ? 'tail' : 'titles',
335
+ left: hasReport ? 'report' : 'other',
336
+ lines,
337
+ },
233
338
  limits.notes,
234
339
  ),
235
340
  now.toISOString(),
@@ -246,6 +351,18 @@ export function writeStopSummary(db, { sessionId, project, report, source, now,
246
351
  if (done) {
247
352
  completed = done;
248
353
  prov.done = 'report';
354
+ } else if (
355
+ tail &&
356
+ (prov.done === 'titles' || !nonEmpty(row.completed) || (prov.done === 'tail' && tailReplaces))
357
+ ) {
358
+ completed = tail;
359
+ prov.done = 'tail';
360
+ } else if (prov.done === 'tail' && !tailOn) {
361
+ // With the floor switched off, a floor written earlier gives way to the titles even
362
+ // when they are empty — the usual case for a user without observations — so the
363
+ // opt-out takes effect from the next turn.
364
+ completed = titles;
365
+ prov.done = 'titles';
249
366
  } else if (titles && (prov.done === 'titles' || !nonEmpty(row.completed))) {
250
367
  // A titles Done follows the current titles, and an EMPTY Done takes them whatever its
251
368
  // tag: a model-created row without a Done, or a legacy '' / NULL notes row (third
@@ -0,0 +1,32 @@
1
+ // Which writer produced an observation, read back from its memory_session_id. saveObservation
2
+ // writes every explicit save under MANUAL_SESSION_ID_PREFIX. The hook capture (`hook-`),
3
+ // transcript import (`import-`), compression summaries (`compress-`) and rows imported from
4
+ // older stores (a bare session uuid) are machine-written.
5
+ //
6
+ // Search and get mark the machine-written side, because explicit saves are the large majority
7
+ // of a typical store and a mark on nearly every line would carry no information. An unknown id
8
+ // renders unmarked, as every row did before the mark existed.
9
+
10
+ export const MANUAL_SESSION_ID_PREFIX = 'manual-';
11
+
12
+ const AUTO_MARK = '🤖';
13
+ const AUTO_TEXT = 'auto-written, not an explicit save';
14
+
15
+ /** @param {string|null|undefined} memorySessionId */
16
+ export function isAutoWritten(memorySessionId) {
17
+ return (
18
+ typeof memorySessionId === 'string' &&
19
+ memorySessionId !== '' &&
20
+ !memorySessionId.startsWith(MANUAL_SESSION_ID_PREFIX)
21
+ );
22
+ }
23
+
24
+ /** Search row tag; `auto` is set on obs rows by attachBodyTokens. */
25
+ export const autoTag = (r) => (r.auto ? ` ${AUTO_MARK}` : '');
26
+
27
+ /** Search result-line legend, shown only when a rendered row carries the tag. */
28
+ export const autoLegend = (rows) => (rows.some((r) => r.auto) ? ` · ${AUTO_MARK} = ${AUTO_TEXT}` : '');
29
+
30
+ /** Get header note for a full observation row. */
31
+ export const autoHeaderNote = (row) =>
32
+ isAutoWritten(row.memory_session_id) ? ` · ${AUTO_MARK} ${AUTO_TEXT}` : '';
@@ -22,6 +22,7 @@ import { liveObsFilterSql } from './inject-search-core.mjs';
22
22
  // Imported, not injected: `allowStatuses` below is the POLICY this function exists to hold,
23
23
  // and a caller free to pass its own resolver could reinstate the one-way gate D#195 closed.
24
24
  import { resolveDeferredIds, closeDeferredItems } from './deferred-work.mjs';
25
+ import { MANUAL_SESSION_ID_PREFIX } from './provenance.mjs';
25
26
 
26
27
  const DEDUP_WINDOW_MS = 5 * 60 * 1000;
27
28
  const DEDUP_RECENT_LIMIT = 50;
@@ -197,7 +198,7 @@ export function saveObservation(db, params) {
197
198
  const safeTitle = scrubSecrets(rawTitle);
198
199
  const safeLesson = rawLesson ? scrubSecrets(rawLesson) : null;
199
200
 
200
- const sessionId = `manual-${project}`;
201
+ const sessionId = `${MANUAL_SESSION_ID_PREFIX}${project}`;
201
202
 
202
203
  // Ensure session exists (FK constraint). INSERT OR IGNORE makes this safe
203
204
  // under concurrent calls.
@@ -409,6 +409,20 @@ const SINGLE_MATCH_BANDS = [
409
409
  [0, -0.25],
410
410
  ];
411
411
 
412
+ // #36: the ratio above assumes the lone hit's magnitude says how well it matched. It does not
413
+ // when FTS5 clamped the IDF: a term in at least half of a table's rows gets idf <= 0, which
414
+ // FTS5 replaces with 1e-6, so the raw bm25 is at most 1e-6 × (k1+1 = 2.2) per phrase whatever
415
+ // the match. A new per-project store reaches it on every query (2 observations, term in 1:
416
+ // idf = ln(1.5/1.5) = 0). Such a lone row is scored the way a multi-row source's best already
417
+ // is (-1): of two clamped rows the better was normalized to -1; a single one was sunk to -0.25.
418
+ // The test reads the RAW bm25 on the obs leg (rawScore): FULL_SCORE's multipliers go down to
419
+ // 0.5 × 0.5 × 0.2 × 0.4 = 0.02, so a demoted row with an informative IDF (0.18) scored 1.35e-4
420
+ // and was lifted from last to first when the final score was tested (v6.19.0 pre-tag review
421
+ // P3-3). Session and event multipliers are >= 1 and prompts carry raw bm25, so their final
422
+ // score is tested. An obs row without rawScore keeps the bands. A near-zero IDF that FTS5 did not
423
+ // clamp (a term in very nearly half the rows) can land below the scale too, and says as little.
424
+ const CLAMPED_IDF_SCALE = 1e-4;
425
+
412
426
  /**
413
427
  * Normalize each source's BM25 scores to [-1, 0] before cross-source merge.
414
428
  * Prevents observations (BM25 can reach -40) from systematically outranking
@@ -437,6 +451,11 @@ export function normalizeCrossSourceScores(results, sourceKey) {
437
451
  );
438
452
  if (srcResults.length === 0) continue;
439
453
  if (srcResults.length === 1) {
454
+ const magnitude = src === 'obs' ? srcResults[0].rawScore : srcResults[0].score;
455
+ if (typeof magnitude === 'number' && Math.abs(magnitude) < CLAMPED_IDF_SCALE) {
456
+ srcResults[0].score = -1;
457
+ continue;
458
+ }
440
459
  const ratio = globalMaxAbs > 0 ? Math.abs(srcResults[0].score) / globalMaxAbs : 0;
441
460
  srcResults[0].score = SINGLE_MATCH_BANDS.find(([floor]) => ratio >= floor)[1];
442
461
  continue;
package/mem-cli.mjs CHANGED
@@ -25,6 +25,7 @@ import { resolveProject } from './project-utils.mjs';
25
25
  import { resolveCliProject as cliProject } from './lib/cli-project.mjs';
26
26
  import { reRankWithContext } from './search-scoring.mjs';
27
27
  import { searchObservationsHybrid } from './search-engine.mjs';
28
+ import { autoHeaderNote, autoLegend, autoTag } from './lib/provenance.mjs';
28
29
  import {
29
30
  fetchObsDetail,
30
31
  fetchPromptDetail,
@@ -565,6 +566,8 @@ async function cmdSearch(db, args, { llm } = {}) {
565
566
  importance: r.importance ?? null,
566
567
  files_modified: r.files_modified || null,
567
568
  body_tokens: r.bodyTokens ?? null,
569
+ // Events carry no session id, so only observations can say who wrote them.
570
+ ...(r.source === 'obs' ? { auto: r.auto === true } : {}),
568
571
  };
569
572
  });
570
573
  out(
@@ -588,7 +591,7 @@ async function cmdSearch(db, args, { llm } = {}) {
588
591
  // Pluralize on total — "Found 1 of 44 result" reads wrong; the population (44) drives
589
592
  // grammatical number, not the page slice (1).
590
593
  out(
591
- `[mem] Found ${countLabel} result${total !== 1 ? 's' : ''} for "${queryLabel(query)}"${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt, E# event)' : ''}`,
594
+ `[mem] Found ${countLabel} result${total !== 1 ? 's' : ''} for "${queryLabel(query)}"${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt, E# event)' : ''}${autoLegend(paged)}`,
592
595
  );
593
596
  // `~Nt` = est. tokens to fetch this row's full body via mem_get (attachBodyTokens, paired with
594
597
  // MCP). Conditional so a row that skipped enrichment renders cleanly, not "~undefinedt".
@@ -614,7 +617,7 @@ async function cmdSearch(db, args, { llm } = {}) {
614
617
  } else {
615
618
  const date = fmtDateShort(r.created_at);
616
619
  const title = truncate(r.title || r.subtitle || '(untitled)', 80);
617
- out(`#${r.id} ${typeIcon(r.type)} ${date}${timeStr} ${title}${tok(r)}`);
620
+ out(`#${r.id} ${typeIcon(r.type)}${autoTag(r)} ${date}${timeStr} ${title}${tok(r)}`);
618
621
  if (r.lesson_learned) {
619
622
  out(` -> ${truncate(r.lesson_learned, 80)}`);
620
623
  }
@@ -793,7 +796,7 @@ function renderObsRows(db, ids, requestedFields) {
793
796
  const fields = requestedFields || OBS_FIELDS;
794
797
  const parts = [];
795
798
  for (const r of rows) {
796
- const lines = [`#${r.id} [${r.type}] ${fmtDateShort(r.created_at)}`];
799
+ const lines = [`#${r.id} [${r.type}] ${fmtDateShort(r.created_at)}${autoHeaderNote(r)}`];
797
800
  // Retraction first (shared with mem_get via get-core) — see supersededNotice.
798
801
  const retracted = supersededNotice(r);
799
802
  if (retracted) lines.push(retracted);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.17.1",
3
+ "version": "6.19.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.17.1",
9
+ "version": "6.19.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.17.1",
3
+ "version": "6.19.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",
@@ -82,6 +82,7 @@
82
82
  "lib/stats-quality.mjs",
83
83
  "lib/low-signal-patterns.mjs",
84
84
  "lib/private-strip.mjs",
85
+ "lib/provenance.mjs",
85
86
  "lib/citation-tracker.mjs",
86
87
  "lib/edge-attribution.mjs",
87
88
  "lib/file-edge-match.mjs",
package/search-engine.mjs CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  import { citeFactorClause } from './scoring-sql.mjs';
22
22
  import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
23
23
  import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
24
+ import { isAutoWritten } from './lib/provenance.mjs';
24
25
 
25
26
  // Scoring expressions — full adds project boost + access bonus; simple is for
26
27
  // expansion paths where boost would over-amplify already-loose matches.
@@ -69,7 +70,8 @@ export function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset,
69
70
  SELECT o.id, o.type, o.title, o.subtitle, o.project, o.created_at, o.created_at_epoch, o.importance,
70
71
  o.files_modified, o.lesson_learned,
71
72
  ${withSnippet ? "snippet(observations_fts, 2, '»', '«', '…', 10) as match_snippet," : ''}
72
- ${scoreExpr}${mult} as score
73
+ ${scoreExpr}${mult} as score,
74
+ ${OBS_BM25} as raw_bm25
73
75
  FROM observations_fts
74
76
  JOIN observations o ON observations_fts.rowid = o.id
75
77
  WHERE observations_fts MATCH ?
@@ -330,6 +332,19 @@ export function countSearchTotal(
330
332
  return total;
331
333
  }
332
334
 
335
+ /**
336
+ * True when an FTS excerpt says something the title does not. snippet() wraps matches in »« and
337
+ * cuts with …, and a save's title is the start of its narrative, so an excerpt that is only a
338
+ * piece of the title repeats it once markers and ellipses are stripped.
339
+ * @param {string|null|undefined} snippet
340
+ * @param {string|null|undefined} title
341
+ */
342
+ export function snippetAddsInfo(snippet, title) {
343
+ if (typeof snippet !== 'string' || snippet.length <= 10) return false;
344
+ const bare = snippet.replace(/[»«]/g, '').replace(/^…|…$/g, '').trim();
345
+ return !(title || '').includes(bare);
346
+ }
347
+
333
348
  export function ftsRowToResult(r, { scoreMultiplier, snippet } = {}) {
334
349
  return {
335
350
  source: 'obs',
@@ -346,6 +361,9 @@ export function ftsRowToResult(r, { scoreMultiplier, snippet } = {}) {
346
361
  created_at: r.created_at,
347
362
  created_at_epoch: r.created_at_epoch,
348
363
  score: scoreMultiplier ? r.score * scoreMultiplier : r.score,
364
+ // bm25 before FULL_SCORE's multipliers, which can shrink it 50x: only this can say whether
365
+ // FTS5 clamped the IDF (normalizeCrossSourceScores, CLAMPED_IDF_SCALE).
366
+ rawScore: r.raw_bm25,
349
367
  files_modified: r.files_modified,
350
368
  importance: r.importance,
351
369
  lesson_learned: r.lesson_learned,
@@ -362,7 +380,8 @@ export function ftsRowToResult(r, { scoreMultiplier, snippet } = {}) {
362
380
  // heavy obs fields are batch-fetched by id HERE rather than carried on every result. The source
363
381
  // key is read as `source || _source` because the two render paths disagree (#8654): MCP sets
364
382
  // `source`+`text`, CLI sets `_source`+`prompt_text`. estimateTokens floors at 1, so a missing row
365
- // or empty body yields 1 — never 0/NaN.
383
+ // or empty body yields 1 — never 0/NaN. The same by-id read sets `auto` (lib/provenance.mjs) on
384
+ // the rendered page only, so no result producer has to carry memory_session_id.
366
385
  export function attachBodyTokens(db, results) {
367
386
  if (!Array.isArray(results) || results.length === 0) return results;
368
387
  const obsIds = results
@@ -373,7 +392,7 @@ export function attachBodyTokens(db, results) {
373
392
  try {
374
393
  const ph = obsIds.map(() => '?').join(',');
375
394
  const rows = db
376
- .prepare(`SELECT id, narrative, facts, text FROM observations WHERE id IN (${ph})`)
395
+ .prepare(`SELECT id, narrative, facts, text, memory_session_id FROM observations WHERE id IN (${ph})`)
377
396
  .all(...obsIds);
378
397
  for (const row of rows) bodyById.set(row.id, row);
379
398
  } catch (e) {
@@ -385,6 +404,7 @@ export function attachBodyTokens(db, results) {
385
404
  let parts;
386
405
  if (src === 'obs') {
387
406
  const row = bodyById.get(r.id) || {};
407
+ r.auto = isAutoWritten(row.memory_session_id);
388
408
  parts = [r.title, r.subtitle, r.lesson_learned, row.narrative, row.facts, row.text];
389
409
  } else if (src === 'session') {
390
410
  parts = [r.request, r.completed, r.working_on];
package/secret-scrub.mjs CHANGED
@@ -28,9 +28,41 @@ export const SECRET_PATTERNS = [
28
28
  // keyword. Allowing a leading `_` catches those while the prose lookbehind still
29
29
  // excludes "Marker token: …". `secret` added so a bare SECRET=… with a mixed-alnum
30
30
  // value is covered (the hex-only assignment pattern below misses non-hex values).
31
+ //
32
+ // MARKDOWN AROUND A LABEL (D#128). `- **Password**: \`<v>\`` put `**` between the noun and
33
+ // its separator, so no label pattern matched and the value was stored. Models write labels
34
+ // that way all the time. So a label may carry up to three markup characters after the noun
35
+ // (`[*_~\`]`), and up to two of `*_~` between the separator and the value
36
+ // (`**Password:** <v>`). Three rules keep this from widening what counts as a value:
37
+ // - no backtick after the separator: `` `password:` <next word> `` names the label and
38
+ // then goes on in prose, and the next word (or a CJK run with no spaces) was scrubbed;
39
+ // - at most two characters there, so the scrubber's own `***` is never taken for markup
40
+ // (`password=*** token=abc` would scrub `token=abc` on the second pass);
41
+ // - the prose check looks through emphasis (`the **password**: …` is prose) but not a
42
+ // backtick, since `` word `token: <v>` `` is code, not prose. A label wrapped in code
43
+ // (`` `GH_TOKEN`: <v> ``) has its own branch, whose prose check looks past the opening
44
+ // backtick. That branch starts with a cheap lookahead: without it the 40-char lookbehind
45
+ // ran at every position, and a 500k-char input went from 407 ms to 3,387 ms.
46
+ // Measured 2026-09-27 over 831,328 unique lines (this repo's tracked text plus local
47
+ // transcripts: prompts, replies, tool output), old and new run back to back: 28 lines
48
+ // differ. All the catches are fixtures quoted from the D#128 audit; the rest are
49
+ // `- **token**:assistant …` and `` `PGPASSWORD`=PG+password ``, which the same text with
50
+ // no markup scrubs too, plus a JSON-escaped `\n` taken as a value. Idempotence failures: 0
51
+ // and 0. A 13,770-case ground-truth fuzz (labels × 8 wrappings × separators × values ×
52
+ // positions): leaks 12,580 → 421, none newly opened. All 421 are `<word> token|secret|bearer:`
53
+ // in prose, left open by design (below).
54
+ //
55
+ // ACCEPTED GAPS (D#131). This scrubber stops ACCIDENTAL persistence; an author who wants a
56
+ // secret stored can always encode it. So shapes only an adversary writes stay open:
57
+ // zero-width characters inside a label, fullwidth letters. Shapes that occur naturally but
58
+ // cannot be told from prose stay open too: `password is <v>` and `<word> token: <v>` (the
59
+ // guard below). A pattern that caught them would also rewrite ordinary sentences, and
60
+ // v3.61.0 already had to undo exactly that. `| password | <v> |` table rows stay open for a
61
+ // different reason: those 831k lines held 1 such row and none with a credential-shaped
62
+ // value, so a table pattern's false-positive rate cannot be measured here.
31
63
  // 1a. `=` assignment → ALWAYS scrub (config syntax, never prose):
32
64
  [
33
- /((?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*=\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi,
65
+ /((?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)(?:[*_~`]{1,3})?\s*=(?:[*_~]{1,2}(?=\s))?\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi,
34
66
  '$1***',
35
67
  ],
36
68
  // 1b. `:` separator, PASSWORD nouns. Position decides how permissive the value
@@ -71,16 +103,16 @@ export const SECRET_PATTERNS = [
71
103
  // Both arms emit `***` (3 chars, under the {6,} floor), so they cannot
72
104
  // double-apply.
73
105
  [
74
- /((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|passphrase)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi,
106
+ /((?:(?<![A-Za-z][ \t][*_~]{0,3})(?:\b|_)(?:password|passwd|passphrase)(?:[*_~]{1,3})?|(?=_?(?:password|passwd|passphrase)`)(?<![A-Za-z][ \t]`[\w-]{0,40})(?<=`[\w-]{0,40})(?:\b|_)(?:password|passwd|passphrase)`)\s*[::](?:[*_~]{1,2}(?=\s))?\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi,
75
107
  '$1***',
76
108
  ],
77
109
  [
78
- /((?:\b|_)(?:password|passwd|passphrase)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)(?![A-Za-z]{1,15}(?=[\s,;'"}\]]|$))[^\s,;'"}\]]{6,}/gi,
110
+ /((?:\b|_)(?:password|passwd|passphrase)(?:[*_~`]{1,3})?\s*[::](?:[*_~]{1,2}(?=\s))?\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)(?![A-Za-z]{1,15}(?=[`*~]*(?:[\s,;'"}\]]|$)))[^\s,;'"}\]]{6,}/gi,
79
111
  '$1***',
80
112
  ],
81
113
  // 1c. `:` separator, prose-ambiguous nouns → keep the lookbehind ("the token: alicebob"):
82
114
  [
83
- /((?<![A-Za-z][ \t])(?:\b|_)(?:token|bearer|secret)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi,
115
+ /((?:(?<![A-Za-z][ \t][*_~]{0,3})(?:\b|_)(?:token|bearer|secret)(?:[*_~]{1,3})?|(?=_?(?:token|bearer|secret)`)(?<![A-Za-z][ \t]`[\w-]{0,40})(?<=`[\w-]{0,40})(?:\b|_)(?:token|bearer|secret)`)\s*[::](?:[*_~]{1,2}(?=\s))?\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi,
84
116
  '$1***',
85
117
  ],
86
118
  // access_token / refresh_token are the canonical OAuth2 field names — they were
@@ -95,7 +127,7 @@ export const SECRET_PATTERNS = [
95
127
  // low-FP decision that `topsecret=` / `access_token_count:` are non-credentials
96
128
  // (#8283 + utils.test.mjs:1089-1100); bare `pwd` is omitted so `PWD=` (a path) survives.
97
129
  [
98
- /((?:\b|_)(?:api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token|pgpassword|pgpass|mysql_pwd)\s*[=:]\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi,
130
+ /((?:\b|_)(?:api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token|pgpassword|pgpass|mysql_pwd)(?:[*_~`]{1,3})?\s*[=::](?:[*_~]{1,2}(?=\s))?\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi,
99
131
  '$1***',
100
132
  ],
101
133
  // Space-separated credential CLI flag: `--password <value>` (long-form). The KV
@@ -118,19 +150,28 @@ export const SECRET_PATTERNS = [
118
150
  // (a) bare credential nouns: `=` always scrubs; `:` keeps the prose lookbehind
119
151
  // (mirrors the unquoted 1a/1b split — a quoted value doesn't turn `:` prose
120
152
  // into config, but `<word> password="x"` is still a leak):
121
- [/((?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*=\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
122
- [/((?:\b|_)(?:password|passwd|passphrase)\s*:\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
123
- [/((?<![A-Za-z][ \t])(?:\b|_)(?:token|bearer|secret)\s*:\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
153
+ [
154
+ /((?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)(?:[*_~`]{1,3})?\s*=(?:[*_~]{1,2}(?=\s))?\s*)(['"])[^'"]{6,}\2/gi,
155
+ '$1$2***$2',
156
+ ],
157
+ [
158
+ /((?:\b|_)(?:password|passwd|passphrase)(?:[*_~`]{1,3})?\s*[::](?:[*_~]{1,2}(?=\s))?\s*)(['"])[^'"]{6,}\2/gi,
159
+ '$1$2***$2',
160
+ ],
161
+ [
162
+ /((?:(?<![A-Za-z][ \t][*_~]{0,3})(?:\b|_)(?:token|bearer|secret)(?:[*_~]{1,3})?|(?=_?(?:token|bearer|secret)`)(?<![A-Za-z][ \t]`[\w-]{0,40})(?<=`[\w-]{0,40})(?:\b|_)(?:token|bearer|secret)`)\s*[::](?:[*_~]{1,2}(?=\s))?\s*)(['"])[^'"]{6,}\2/gi,
163
+ '$1$2***$2',
164
+ ],
124
165
  // (b) structured keys + named env vars are unambiguous config even after a word
125
166
  // (`see api_key: "x"` DOES scrub, mirroring the unquoted structured-key path):
126
167
  [
127
- /((?:\b|_)(?:pgpassword|pgpass|mysql_pwd|api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token)\s*[=:]\s*)(['"])[^'"]{6,}\2/gi,
168
+ /((?:\b|_)(?:pgpassword|pgpass|mysql_pwd|api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token)(?:[*_~`]{1,3})?\s*[=::](?:[*_~]{1,2}(?=\s))?\s*)(['"])[^'"]{6,}\2/gi,
128
169
  '$1$2***$2',
129
170
  ],
130
171
  // AWS access keys: AKIA (long-term) + ASIA (STS temp) + AROA (role) + AIDA
131
172
  // (user) + ANPA/ANVA/AGPA (other principal types). All share the 4-letter
132
173
  // prefix + exactly 16 base32 chars shape — specific enough for near-zero FP.
133
- [/\b(?:AKIA|ASIA|AROA|AIDA|ANPA|ANVA|AGPA)[A-Z0-9]{16}\b/g, '***'],
174
+ [/\b(?:AKIA|ASIA|AROA|AIDA|ANPA|ANVA|AGPA)[A-Z0-9]{16}(?![A-Za-z0-9])/g, '***'],
134
175
  // OpenAI / Anthropic keys (sk-...) — specific prefixes have lower length threshold
135
176
  [/\bsk-(?:proj|ant|ant-api\d{2})-[a-zA-Z0-9_-]{8,}\b/g, '***'],
136
177
  [/\bsk-[a-zA-Z0-9_-]{20,}\b/g, '***'],
@@ -143,13 +184,30 @@ export const SECRET_PATTERNS = [
143
184
  [/\b(?:xox[bpasr]|xapp|xoxe)-[a-zA-Z0-9-]{10,}\b/g, '***'],
144
185
  // Slack incoming-webhook URL — the path after /services/ is the shared secret.
145
186
  [/(https:\/\/hooks\.slack\.com\/services\/)[A-Za-z0-9/]+/g, '$1***'],
146
- // JWT tokens (eyJ...eyJ...)
147
- [/\beyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+\b/g, '***'],
187
+ // JWT tokens (eyJ...eyJ...). A JWT begins its own token, so the start is `(?<![\w-])`, not
188
+ // `\b`: `-` is a base64url character, and `\b` let every `-eyJ` inside one dotless run be a
189
+ // fresh start that rescans the run to its end — quadratic, 9.6 s on 200k chars of `eyJ-`
190
+ // (D#130). A `-eyJ` start is the middle of a run, never a JWT's first character.
191
+ // A JWT glued by a hyphen to up to 40 characters of hyphenated words (`my-sess-eyJ…`,
192
+ // `X-Auth-Token-eyJ…`) is a start too. `\b` allowed any length; past 40 characters it is missed. The lookbehind needs a token
193
+ // boundary within those 41 characters, so a hyphen run has at most ~10 starts, not one per
194
+ // `-eyJ` (v6.19.0 pre-tag reviews P3-2, delta P3-1).
195
+ [
196
+ /(?:(?<![\w-])|(?<=(?:^|[^\w-])[\w-]{1,40}-))eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+\b/g,
197
+ '***',
198
+ ],
148
199
  // PEM private key blocks. `[A-Z0-9 ]*` covers every armor label — RSA/EC/DSA/
149
200
  // OPENSSH plus ENCRYPTED and PGP (… PRIVATE KEY BLOCK) — that the fixed
150
201
  // alternation missed; the block delimiters make FP impossible.
202
+ // The body stops at the next `-----BEGIN ` (D#130): with `[\s\S]*?` every header with no END
203
+ // scanned to the end of the text, on each of scrubSecrets' passes — quadratic, 8.3 s on 500k
204
+ // chars. A block whose END is missing ends where the next private-key BEGIN starts, so a cut-off
205
+ // key's body is scrubbed too (v6.19.0 pre-tag review P2-1: requiring the END there stored that
206
+ // body); any other BEGIN (a certificate) does not end it, so a bare header in prose does not
207
+ // erase the text up to one (delta review P3-2). A header with no END and no later key header
208
+ // is left as it was in v6.18.0.
151
209
  [
152
- /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g,
210
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----(?:(?!-----BEGIN [A-Z0-9 ]*PRIVATE KEY)[\s\S])*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|(?=-----BEGIN [A-Z0-9 ]*PRIVATE KEY))/g,
153
211
  '***PEM_KEY***',
154
212
  ],
155
213
  // Long hex strings in credential assignments (e.g. SECRET_KEY=abc123def456...).
@@ -160,7 +218,10 @@ export const SECRET_PATTERNS = [
160
218
  [/\bAIza[A-Za-z0-9_-]{35}\b/g, '***'],
161
219
  // Authorization header credentials — Bearer (opaque), Basic (base64 user:pass),
162
220
  // and GitHub's `token` scheme all carry secrets after the scheme word.
163
- [/(Authorization:\s*(?:Bearer|Basic|token)\s+)[^\s,;'"}\]]+/gi, '$1***'],
221
+ [
222
+ /(Authorization(?:[*_~`]{1,3})?[::](?:[*_~]{1,2}(?=\s))?\s*(?:Bearer|Basic|token)\s+)[^\s,;'"}\]]+/gi,
223
+ '$1***',
224
+ ],
164
225
  // R10 P1-6: the same header as a QUOTED KEY — `{"Authorization":"Bearer …"}`. The
165
226
  // pattern above needs `Authorization:` literally, and in JSON a quote sits between the
166
227
  // name and the colon, so a `curl -v` / fetch header dump walked straight through. The
@@ -194,10 +255,10 @@ export const SECRET_PATTERNS = [
194
255
  '$1$2://***',
195
256
  ],
196
257
  // npm tokens (npm_...)
197
- [/\bnpm_[a-zA-Z0-9]{36,}\b/g, '***'],
258
+ [/\bnpm_[a-zA-Z0-9]{36,}(?![A-Za-z0-9])/g, '***'],
198
259
  // Stripe keys (sk_live_, rk_live_, pk_live_, sk_test_, pk_test_) + webhook signing secret (whsec_)
199
- [/\b[srp]k_(?:live|test)_[a-zA-Z0-9]{20,}\b/g, '***'],
200
- [/\bwhsec_[a-zA-Z0-9]{20,}\b/g, '***'],
260
+ [/\b[srp]k_(?:live|test)_[a-zA-Z0-9]{20,}(?![A-Za-z0-9])/g, '***'],
261
+ [/\bwhsec_[a-zA-Z0-9]{20,}(?![A-Za-z0-9])/g, '***'],
201
262
  // SendGrid API keys: SG.<22>.<43> — two dots at fixed offsets make this
202
263
  // structurally unmistakable; near-zero false-positive risk.
203
264
  [/\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g, '***'],
@@ -224,8 +285,11 @@ export const SECRET_PATTERNS = [
224
285
  // (password|secret|api_key|auth_token|access_token|private_key) so a benign
225
286
  // `"token_count"` value (numeric, <6 non-quote chars after scrub) and prose
226
287
  // keys stay low-FP; over-scrub is the safe direction for at-rest memory.
288
+ // `\w{0,64}`, not `\w*`, on both sides of the noun here and in the next pattern (D#130): from
289
+ // one quote, `\w*` ran to the end of a word run and backtracked through every keyword in it,
290
+ // each rescanning the run — `"` + `secret` x 33k took 4.6 s. 64 is far past any key name.
227
291
  [
228
- /("\w*(?:password|passwd|secret|api[_-]?key|auth[_-]?token|access[_-]?token|private[_-]?key)\w*"\s*:\s*")[^"]{6,}(")/gi,
292
+ /("\w{0,64}(?:password|passwd|secret|api[_-]?key|auth[_-]?token|access[_-]?token|private[_-]?key)\w{0,64}"\s*:\s*")[^"]{6,}(")/gi,
229
293
  '$1***$2',
230
294
  ],
231
295
  // Quoted-KEY credential values — Python dict reprs `{'api_key': '...'}`, single-quoted
@@ -241,7 +305,7 @@ export const SECRET_PATTERNS = [
241
305
  // `'token_count': 123456`); `passphrase` added here too (double-quoted JSON passphrase is
242
306
  // subsumed by this pattern since `['"]` matches `"`). Over-scrub is the safe direction.
243
307
  [
244
- /(['"]\w*(?:password|passwd|passphrase|secret|api[_-]?key|auth[_-]?token|access[_-]?token|private[_-]?key)\w*['"]\s*:\s*)(['"])[^'"]{6,}\2/gi,
308
+ /(['"](?:\w{0,64}(?:password|passwd|passphrase|secret|api[_-]?key|auth[_-]?token|access[_-]?token|private[_-]?key)\w{0,64}|\w{1,64}_token)['"]\s*:\s*)(['"])[^'"]{6,}\2/gi,
245
309
  '$1$2***$2',
246
310
  ],
247
311
  // Session cookies in headers / urlencoded bodies (sessionid=, session_id=, JSESSIONID=, PHPSESSID=).
package/server.mjs CHANGED
@@ -17,7 +17,8 @@ import {
17
17
  formatSchemaSkewNotice,
18
18
  } from './lib/schema-skew.mjs';
19
19
  import { reRankWithContext, runIdleCleanup, buildServerInstructions } from './search-scoring.mjs';
20
- import { searchObservationsHybrid } from './search-engine.mjs';
20
+ import { searchObservationsHybrid, snippetAddsInfo } from './search-engine.mjs';
21
+ import { autoHeaderNote, autoLegend, autoTag } from './lib/provenance.mjs';
21
22
  import {
22
23
  deepSearch,
23
24
  resolveDeepMode,
@@ -461,7 +462,7 @@ function formatSearchOutput(
461
462
  // explicitly requested OR semantics — there's no "fallback" in that path.
462
463
  const fallbackHint = orFallbackFired && !args.or ? ' (relaxed AND→OR)' : '';
463
464
  lines.push(
464
- `Found ${countLabel} result(s)${qLabel}${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt, E# event)' : ''}\n`,
465
+ `Found ${countLabel} result(s)${qLabel}${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt, E# event)' : ''}${autoLegend(paginatedResults)}\n`,
465
466
  );
466
467
 
467
468
  // `~Nt` = estimated tokens to fetch this row's full body via mem_get (attachBodyTokens).
@@ -470,9 +471,9 @@ function formatSearchOutput(
470
471
  for (const r of paginatedResults) {
471
472
  if (r.source === 'obs') {
472
473
  lines.push(
473
- `#${r.id} ${typeIcon(r.type)} [${r.type}] ${truncate(r.title || r.subtitle || '(untitled)')} | ${r.project} | ${fmtDate(r.date)}${tok(r)}`,
474
+ `#${r.id} ${typeIcon(r.type)} [${r.type}]${autoTag(r)} ${truncate(r.title || r.subtitle || '(untitled)')} | ${r.project} | ${fmtDate(r.date)}${tok(r)}`,
474
475
  );
475
- if (r.snippet && r.snippet.length > 10 && r.snippet !== r.title) {
476
+ if (snippetAddsInfo(r.snippet, r.title)) {
476
477
  lines.push(` ${truncate(r.snippet, 100)}`);
477
478
  }
478
479
  } else if (r.source === 'session') {
@@ -900,7 +901,7 @@ server.registerTool(
900
901
  const renderFields = obsFieldFilter || OBS_FIELDS;
901
902
  for (const row of rows) {
902
903
  foundBySource.obs.add(row.id);
903
- const lines = [`── #${row.id} ──`];
904
+ const lines = [`── #${row.id}${autoHeaderNote(row)} ──`];
904
905
  // Retraction first (shared with the CLI `get` via get-core) — see supersededNotice.
905
906
  const retracted = supersededNotice(row);
906
907
  if (retracted) lines.push(retracted);
package/source-files.mjs CHANGED
@@ -81,6 +81,8 @@ export const SOURCE_FILES = [
81
81
  'lib/stats-quality.mjs',
82
82
  'lib/low-signal-patterns.mjs',
83
83
  'lib/private-strip.mjs',
84
+ // Which writer produced an observation (explicit save vs machine-written); search + get marks.
85
+ 'lib/provenance.mjs',
84
86
  'lib/citation-tracker.mjs',
85
87
  // v3.47 (D#78 P1): per-(obs,file) edge attribution. Imported by hook.mjs
86
88
  // (handleStop edge resolution). Missing from manifest → tarball hook.mjs
package/utils.mjs CHANGED
@@ -56,7 +56,8 @@ export {
56
56
  } from './bash-utils.mjs';
57
57
 
58
58
  // Internal imports for functions that remain in this module
59
- import { truncate } from './format-utils.mjs';
59
+ import { normalizeInline, truncate } from './format-utils.mjs';
60
+ import { stripPrivate } from './lib/private-strip.mjs';
60
61
  import { stripTestSuffix } from './bash-utils.mjs';
61
62
  // Static, and deliberately the dependency-free resolver (node:os + node:path only) —
62
63
  // debugCatch's sampler must not pull in the DB layer. See its comment below.
@@ -282,9 +283,56 @@ export function isRelatedToEpisode(episode, newFiles) {
282
283
  // magnitude above the longest cut here, so a secret that begins before the cut is still
283
284
  // seen whole by the patterns, at bounded cost.
284
285
  const DESC_SCRUB_WINDOW = 4096;
286
+
287
+ // Every field is cut strictly around private spans: closed `<private>` spans are redacted across
288
+ // the WHOLE input first (one linear pass; hook input is capped at 256 KiB), then the window stops
289
+ // before the first remaining `<private>` or private-key BEGIN, so no field shows a span whose end
290
+ // is out of view. Before v6.19.0 an unclosed opener in the window (a Grep line
291
+ // `notes.md:3:<private>bank pin 4412`) was shown as written (v6.19.0 pre-tag reviews P3-1, r3 P2-1).
292
+ // The PEM half is case-sensitive, like the scrubber's PEM pattern.
293
+ const PRIVATE_OPENER_RE = /<[Pp][Rr][Ii][Vv][Aa][Tt][Ee]>|-----BEGIN [A-Z0-9 ]*PRIVATE KEY/;
285
294
  function scrubTruncate(str, max) {
286
295
  if (typeof str !== 'string' || str === '') return truncate(str, max);
287
- return truncate(_scrubSecrets(str.slice(0, DESC_SCRUB_WINDOW)), max);
296
+ let win = stripPrivate(str).slice(0, DESC_SCRUB_WINDOW);
297
+ const at = win.search(PRIVATE_OPENER_RE);
298
+ if (at >= 0) win = win.slice(0, at);
299
+ // The window edge can split a surrogate pair; truncate only guards a cut it makes itself.
300
+ if (/[\uD800-\uDBFF]$/.test(win)) win = win.slice(0, -1);
301
+ return truncate(_scrubSecrets(win), max);
302
+ }
303
+
304
+ // Head+tail cut for command output. A check usually prints its verdict last ("...parsed: 0",
305
+ // "3 passed"); a head-only cut hands the episode summarizer an unresolved-looking fragment,
306
+ // and it has written a bugfix narrative for a check that passed. The tail comes from its own
307
+ // scrub window at the END of the original string: taking it from the head window would drop
308
+ // the verdict of any output longer than DESC_SCRUB_WINDOW.
309
+ //
310
+ // Output with a private span anywhere in it (a `<private>` tag or a PEM private-key marker) shows
311
+ // no tail, only v6.18.0's 60-character head (cut as above). The tail is scrubbed in its own
312
+ // window, which cannot see a span that crosses its edge, and pairing the markers per window
313
+ // stored span text two ways in the v6.19.0 pre-tag review (a cut inside a closed `<private>`, a
314
+ // key with no END more than 4096 characters back).
315
+ const PRIVATE_TAG_HINT_RE = /<\/?private>/i;
316
+ const HEAD_ONLY_MAX = 60;
317
+ // The early return needs the WHOLE output inside the head window: a long output whose first
318
+ // 4096 characters collapse to a few (whitespace) still has a tail to show (v6.19.0 pre-tag
319
+ // claims review F2).
320
+ function scrubTruncateEnds(str, max) {
321
+ if (typeof str === 'string' && (str.includes('PRIVATE KEY') || PRIVATE_TAG_HINT_RE.test(str))) {
322
+ return scrubTruncate(str, HEAD_ONLY_MAX);
323
+ }
324
+ const flat = scrubTruncate(str, DESC_SCRUB_WINDOW);
325
+ const whole = typeof str !== 'string' || str.length <= DESC_SCRUB_WINDOW;
326
+ if (whole && flat.length <= max) return flat;
327
+ const tailLen = Math.floor(max / 2) - 1;
328
+ const tailSrc = whole ? flat : normalizeInline(_scrubSecrets(str.slice(-DESC_SCRUB_WINDOW)));
329
+ // Drop a lone low surrogate the tail's cut may start on.
330
+ const tail = tailSrc.slice(-tailLen).replace(/^[\uDC00-\uDFFF]/, '');
331
+ // A head short enough to escape truncate's own "…" still gets one before the tail.
332
+ const budget = max - tailLen;
333
+ let head = truncate(flat, budget);
334
+ if (!head.endsWith('…')) head = head.length < budget ? `${head}…` : truncate(flat, budget - 1);
335
+ return head + tail;
288
336
  }
289
337
 
290
338
  export function makeEntryDesc(toolName, input, resp, opts) {
@@ -302,7 +350,7 @@ export function makeEntryDesc(toolName, input, resp, opts) {
302
350
  const isErr =
303
351
  opts?.isError ??
304
352
  (/\berror\b|\bfail(ed|ure)?\b|\bexception\b|\bpanic\b/i.test(resp) && resp.length > 30);
305
- const snippet = scrubTruncate(resp, 60);
353
+ const snippet = scrubTruncateEnds(resp, 100);
306
354
  return isErr ? `${cmd} → ERROR: ${snippet}` : `${cmd} → ${snippet}`;
307
355
  }
308
356
  case 'Grep':