claude-mem-lite 6.19.0 → 6.19.1

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.19.0",
12
+ "version": "6.19.1",
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.19.0",
3
+ "version": "6.19.1",
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/hook-optimize.mjs CHANGED
@@ -32,6 +32,7 @@ import { normalizeScope, SCOPE_PROMPT_LEGEND, insertObservationRow } from './lib
32
32
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
33
33
  import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
34
34
  import { MEMORY_INPUT_GUARD } from './lib/memory-input-guard.mjs';
35
+ import { MANUAL_SESSION_ID_PREFIX } from './lib/provenance.mjs';
35
36
 
36
37
  import { DAY_MS } from './lib/time-constants.mjs';
37
38
  // P1-14: same resolver as hook-shared.mjs — this was the second module that had never
@@ -1108,7 +1109,7 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
1108
1109
  -- keeper.search_aliases when it rebuilt the keeper's TF-IDF vector. Phase-2 removed that
1109
1110
  -- rebuild, so the column had no reader left and went with it. Do NOT re-add it on the
1110
1111
  -- strength of R10 P3-7 -- that finding is moot, not pending. executeMergeCluster reads
1111
- -- keeper.{id,importance,narrative,concepts,facts} and o.{id,title,type,narrative,
1112
+ -- keeper.{id,project,importance,narrative,concepts,facts} and o.{id,title,type,narrative,
1112
1113
  -- importance,access_count,lesson_learned}, and nothing else off these rows.
1113
1114
  SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig, lesson_learned, concepts, facts
1114
1115
  FROM observations
@@ -1311,10 +1312,33 @@ Return ONLY valid JSON:
1311
1312
  SELECT ${snapColList}, ? FROM observations WHERE id = ?`,
1312
1313
  ).run(keeper.id, keeper.id);
1313
1314
 
1315
+ // The keeper now holds model text. Search reads authorship from memory_session_id, so an
1316
+ // explicit save's `manual-` id would mark it as one (D#138); it moves to the compression
1317
+ // writer's id. A machine-written keeper keeps its id, and so does the snapshot above, which
1318
+ // is the original save. The writer's session row is best-effort: sdk_sessions refuses an id
1319
+ // shaped like a uuid, which `compress-<project>` is for a 27-character project shaped
1320
+ // xxxx-xxxx-xxxx-xxxxxxxxxxxx, and that refusal must not fail the merge (v6.19.1 pre-tag F5a).
1321
+ let rewriteSessionId = null;
1322
+ const keeperSession = db
1323
+ .prepare('SELECT memory_session_id FROM observations WHERE id = ?')
1324
+ .get(keeper.id);
1325
+ if (keeperSession?.memory_session_id?.startsWith(MANUAL_SESSION_ID_PREFIX)) {
1326
+ const compressSessionId = `compress-${keeper.project}`;
1327
+ try {
1328
+ db.prepare(
1329
+ `INSERT OR IGNORE INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
1330
+ VALUES (?, ?, ?, ?, ?, 'active')`,
1331
+ ).run(compressSessionId, compressSessionId, keeper.project, new Date().toISOString(), Date.now());
1332
+ rewriteSessionId = compressSessionId;
1333
+ } catch (e) {
1334
+ debugCatch(e, 'cluster-merge writer session');
1335
+ }
1336
+ }
1314
1337
  db.prepare(
1315
1338
  `
1316
1339
  UPDATE observations SET title=?, narrative=?, concepts=?, facts=?, text=?,
1317
- importance=?, lesson_learned=?, minhash_sig=?, optimized_at=?
1340
+ importance=?, lesson_learned=?, minhash_sig=?, optimized_at=?,
1341
+ memory_session_id = COALESCE(?, memory_session_id)
1318
1342
  WHERE id = ?
1319
1343
  `,
1320
1344
  ).run(
@@ -1327,6 +1351,7 @@ Return ONLY valid JSON:
1327
1351
  safe.lesson_learned,
1328
1352
  minhashSig,
1329
1353
  Date.now(),
1354
+ rewriteSessionId,
1330
1355
  keeper.id,
1331
1356
  );
1332
1357
 
package/lib/activity.mjs CHANGED
@@ -199,6 +199,8 @@ export function promoteInsightEvents(
199
199
  // manual-save contract) so it lands in the high-weight FTS field.
200
200
  lesson_learned: ev.body.slice(0, 500),
201
201
  now: new Date(ev.created_at_epoch),
202
+ // Event bodies are machine-written (episode summaries), so the row is not an explicit save.
203
+ sessionId: `promote-${ev.project}`,
202
204
  });
203
205
  mark.run(Date.now(), ev.id);
204
206
  return r;
@@ -86,6 +86,30 @@ export function listOpenWithOrdinal(db, project, limit = 10) {
86
86
  .all(project, limit);
87
87
  }
88
88
 
89
+ /**
90
+ * One open row's ordinal, numbered as listOpenWithOrdinal numbers it; null when the row is not
91
+ * open. `defer add` / `mem_defer` read it from a 50-row page before, so an item added past 50
92
+ * open printed `(item ?)` (D#123).
93
+ * @param {Database} db
94
+ * @param {string} project
95
+ * @param {number} id
96
+ * @returns {number|null}
97
+ */
98
+ export function openOrdinalOf(db, project, id) {
99
+ const row = db
100
+ .prepare(
101
+ `
102
+ SELECT ordinal FROM (
103
+ SELECT id, ROW_NUMBER() OVER (ORDER BY priority DESC, created_at_epoch ASC, id ASC) AS ordinal
104
+ FROM deferred_work
105
+ WHERE project = ? AND status = 'open'
106
+ ) WHERE id = ?
107
+ `,
108
+ )
109
+ .get(project, id);
110
+ return row ? row.ordinal : null;
111
+ }
112
+
89
113
  /**
90
114
  * How many open rows `defer list` / `mem_defer_list` left off their page, as a line to
91
115
  * print — '' when the page held them all. Without it a page one short of the open set
@@ -26,7 +26,8 @@ export function buildBridgePrompt(lesson, hunk) {
26
26
 
27
27
  // { ok:true, check } when the bridge produced a usable, applicable check;
28
28
  // { ok:false } on N/A / empty / error / timeout. NEVER throws — the caller
29
- // falls back to the baseline ACK_DIRECTIVE on { ok:false }.
29
+ // falls back to the arm's plain directive on { ok:false } (VERDICT_DIRECTIVE since 6.17.0,
30
+ // scripts/pre-tool-recall.js ACTIVE_DIRECTIVE).
30
31
  export async function bridgeLesson({ lesson, hunk, timeoutMs = 2500, _callLLM = callLLM }) {
31
32
  try {
32
33
  const raw = await _callLLM(buildBridgePrompt(lesson, hunk), timeoutMs);
@@ -1,7 +1,8 @@
1
1
  // Which writer produced an observation, read back from its memory_session_id. saveObservation
2
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.
3
+ // transcript import (`import-`), compression summaries and cluster-merge keepers (`compress-`),
4
+ // promoted events (`promote-`) and rows imported from older stores (a bare session uuid) are
5
+ // machine-written.
5
6
  //
6
7
  // Search and get mark the machine-written side, because explicit saves are the large majority
7
8
  // of a typical store and a mark on nearly every line would carry no information. An unknown id
@@ -144,6 +144,7 @@ export function formatSupersedeSkipped(skipped) {
144
144
  * @param {string|null} [params.lesson_learned] Caller validates ≤500 chars.
145
145
  * @param {boolean} [params.force=false] Skip the near-duplicate window (below).
146
146
  * @param {Date} [params.now] Override for tests.
147
+ * @param {string} [params.sessionId] Writer id; default `manual-<project>` (an explicit save).
147
148
  * Both result shapes carry `supersededIds` (observations actually tombstoned) and
148
149
  * `supersedeSkipped` (requested but NOT tombstoned, each with a `reason`:
149
150
  * `malformed-id` | `no-such-observation` | `no-such-event` | `other-project` |
@@ -198,7 +199,9 @@ export function saveObservation(db, params) {
198
199
  const safeTitle = scrubSecrets(rawTitle);
199
200
  const safeLesson = rawLesson ? scrubSecrets(rawLesson) : null;
200
201
 
201
- const sessionId = `${MANUAL_SESSION_ID_PREFIX}${project}`;
202
+ // An explicit save by default; a caller storing machine-written text names its own writer
203
+ // (lib/provenance.mjs reads authorship from this id).
204
+ const sessionId = params.sessionId || `${MANUAL_SESSION_ID_PREFIX}${project}`;
202
205
 
203
206
  // Ensure session exists (FK constraint). INSERT OR IGNORE makes this safe
204
207
  // under concurrent calls.
package/mem-cli.mjs CHANGED
@@ -163,6 +163,7 @@ import { aggregateMetrics, readMetrics } from './lib/metrics.mjs';
163
163
  import {
164
164
  insertDeferred,
165
165
  listOpenWithOrdinal,
166
+ openOrdinalOf,
166
167
  dropDeferred,
167
168
  formatDropReasonHint,
168
169
  resolveDeferredIds,
@@ -1448,9 +1449,8 @@ function cmdDeferAdd(db, args) {
1448
1449
  return;
1449
1450
  }
1450
1451
  // Compute the freshly-inserted row's ordinal for an immediately-actionable
1451
- // response ("ok, deferred this as item N"). Mirrors server.mjs:980.
1452
- const open = listOpenWithOrdinal(db, project, 50);
1453
- const ord = open.find((o) => o.id === r.id)?.ordinal ?? '?';
1452
+ // response ("ok, deferred this as item N"), as mem_defer does.
1453
+ const ord = openOrdinalOf(db, project, r.id) ?? '?';
1454
1454
  out(`[mem] Deferred as D#${r.id} (item ${ord}) in project "${project}".`);
1455
1455
  }
1456
1456
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.19.0",
3
+ "version": "6.19.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.19.0",
9
+ "version": "6.19.1",
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.19.0",
3
+ "version": "6.19.1",
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",
package/secret-scrub.mjs CHANGED
@@ -184,32 +184,50 @@ export const SECRET_PATTERNS = [
184
184
  [/\b(?:xox[bpasr]|xapp|xoxe)-[a-zA-Z0-9-]{10,}\b/g, '***'],
185
185
  // Slack incoming-webhook URL — the path after /services/ is the shared secret.
186
186
  [/(https:\/\/hooks\.slack\.com\/services\/)[A-Za-z0-9/]+/g, '$1***'],
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).
187
+ // JWT tokens (eyJ...eyJ...), from any `eyJ`: one glued to a prefix (`my-sess-eyJ…`,
188
+ // `session-<uuid>-eyJ…`, `tok_eyJ…`) is still a JWT. Every `eyJ` of one dotless run used to be a
189
+ // fresh start that rescanned the run to its end — quadratic, 9.6 s on 200k chars of `eyJ-`
190
+ // (D#130) — and v6.19.0's bounded lookbehind that fixed it missed a prefix over 40 characters
191
+ // (round-3 review P3-2). Here a failed start consumes its run instead (`|eyJ[\w-]*`, returned
192
+ // unchanged), so the scan resumes after it. Nothing is lost: the first segment cannot contain a
193
+ // `.`, so every later `eyJ` of the same run reaches the same run end and fails the same way.
195
194
  [
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
- '***',
195
+ /eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+\b|eyJ[a-zA-Z0-9_-]*/g,
196
+ (m) => (m.includes('.') ? '***' : m),
198
197
  ],
199
198
  // PEM private key blocks. `[A-Z0-9 ]*` covers every armor label — RSA/EC/DSA/
200
199
  // OPENSSH plus ENCRYPTED and PGP (… PRIVATE KEY BLOCK) — that the fixed
201
200
  // alternation missed; the block delimiters make FP impossible.
202
201
  // The body stops at the next `-----BEGIN ` (D#130): with `[\s\S]*?` every header with no END
203
202
  // 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.
203
+ // chars. A block whose END is missing is left to the next pattern.
209
204
  [
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,
205
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----(?:(?!-----BEGIN [A-Z0-9 ]*PRIVATE KEY)[\s\S])*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g,
211
206
  '***PEM_KEY***',
212
207
  ],
208
+ // A cut-off key (`head id_rsa`, a tool output cut mid-key): the header and the WHOLE lines of
209
+ // base64 (or RFC 1421 headers) that follow it. Stored whole through v6.19.0 when no later key
210
+ // header followed. Whole lines only, so prose naming a header mid-sentence keeps its text
211
+ // (v6.19.0 round-3 P3-3: ending the block at the next key header erased the prose between two).
212
+ // A body line starts with 16+ base64 characters, and one shorter whole line may follow the last
213
+ // of them (a key's last line): a line of one word or number is prose (v6.19.1 pre-tag review F3),
214
+ // so the body needs a long line, and blank lines count only between long ones. A long line need
215
+ // not be whole, so a key cut mid-line loses the cut line too. A line break may be JSON-escaped
216
+ // (`\n` as two characters) and a quote may end the last line (v6.19.1 claims review F1). A header
217
+ // value may hold a backslash that does not start an escaped break (a Windows path), and it does
218
+ // not share its whitespace with a second quantifier, which was quadratic (delta review P1).
219
+ [
220
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----(?:[ \t]*(?:\r?\n|\\r\\n|\\n)(?=[ \t]*(?:\r?\n|\\r\\n|\\n)))*(?:[ \t]*(?:\r?\n|\\r\\n|\\n)[ \t]*(?:[A-Za-z0-9+/=]{16,}|(?:Proc-Type|DEK-Info|Version|Comment|Hash|Charset):(?:[^\r\n\\]|\\(?![rn]))*(?=\r?\n|\\[rn]|["']|$)))(?:(?:[ \t]*(?:\r?\n|\\r\\n|\\n)(?=[ \t]*(?:\r?\n|\\r\\n|\\n)))*(?:[ \t]*(?:\r?\n|\\r\\n|\\n)[ \t]*(?:[A-Za-z0-9+/=]{16,}|(?:Proc-Type|DEK-Info|Version|Comment|Hash|Charset):(?:[^\r\n\\]|\\(?![rn]))*(?=\r?\n|\\[rn]|["']|$))))*(?:[ \t]*(?:\r?\n|\\r\\n|\\n)[ \t]*[A-Za-z0-9+/=]{1,15}[ \t]*(?=\r?\n|\\[rn]|["']|$))?/g,
221
+ '***PEM_KEY***',
222
+ ],
223
+ // The other end (`tail key.pem`): whole base64 lines ending in a private-key END, the same line
224
+ // rule as above but with two shorter lines allowed before the END (an armored PGP key's last data
225
+ // line and its `=XXXX` checksum; delta review P2). A line may start after a quote. A run of lines
226
+ // that does not end there is consumed and returned unchanged, so no line starts a second scan.
227
+ [
228
+ /(?:(?<![^\n])|(?<=\\n|["']))(?:[ \t]*[A-Za-z0-9+/=]{16,}[ \t]*(?:\r?\n|\\r\\n|\\n))+(?:[ \t]*[A-Za-z0-9+/=]{1,15}[ \t]*(?:\r?\n|\\r\\n|\\n)){0,2}(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----)?/g,
229
+ (m) => (m.endsWith('-----') ? '***PEM_KEY***' : m),
230
+ ],
213
231
  // Long hex strings in credential assignments (e.g. SECRET_KEY=abc123def456...).
214
232
  // `hash` deliberately excluded: `hash: <40hex>` / `hash=<md5>` are git SHAs and
215
233
  // checksums (real, preserved data in this hash-heavy repo), not credentials.
package/server.mjs CHANGED
@@ -125,6 +125,7 @@ import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
125
125
  import {
126
126
  insertDeferred,
127
127
  listOpenWithOrdinal,
128
+ openOrdinalOf,
128
129
  dropDeferred,
129
130
  formatDropReasonHint,
130
131
  resolveDeferredIds,
@@ -1221,8 +1222,7 @@ server.registerTool(
1221
1222
  });
1222
1223
  // Compute the ordinal for the freshly-inserted row so the response is
1223
1224
  // immediately actionable ("ok, I deferred this as item 1").
1224
- const open = listOpenWithOrdinal(db, project, 50);
1225
- const ord = open.find((o) => o.id === r.id)?.ordinal ?? null;
1225
+ const ord = openOrdinalOf(db, project, r.id);
1226
1226
  return {
1227
1227
  content: [
1228
1228
  {
package/utils.mjs CHANGED
@@ -289,13 +289,34 @@ const DESC_SCRUB_WINDOW = 4096;
289
289
  // before the first remaining `<private>` or private-key BEGIN, so no field shows a span whose end
290
290
  // is out of view. Before v6.19.0 an unclosed opener in the window (a Grep line
291
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/;
294
- function scrubTruncate(str, max) {
292
+ // A `</private>` or private-key END that comes first is a span whose START is out of view (a
293
+ // nested span, `tail key.pem`), so everything before it may be its inside: the field shows nothing
294
+ // (round-3 P3-1). The PEM half is case-sensitive, like the scrubber's PEM pattern.
295
+ const PRIVATE_MARK_RE = /<\/?[Pp][Rr][Ii][Vv][Aa][Tt][Ee]>|-----(?:BEGIN|END) [A-Z0-9 ]*PRIVATE KEY/;
296
+ // A window edge that cuts a token leaves a fragment shorter than its pattern needs (`ghp_` and 12
297
+ // of its 36 characters), and whitespace collapsing can bring it into view: the cut token is
298
+ // dropped (defect review P3-6, round-3 P3-4/P3-6). A head window with no whitespace keeps it: its
299
+ // start is intact and it is the only part shown. A tail window with no whitespace is all one cut token.
300
+ const isWs = (c) => c === ' ' || c === '\n' || c === '\t' || c === '\r' || /\s/.test(c);
301
+ function dropCutTokenAtEnd(win, next) {
302
+ if (next === undefined || isWs(next)) return win;
303
+ let i = win.length;
304
+ while (i > 0 && !isWs(win[i - 1])) i--;
305
+ return i > 0 ? win.slice(0, i) : win;
306
+ }
307
+ function dropCutTokenAtStart(win, prev) {
308
+ if (prev === undefined || isWs(prev)) return win;
309
+ let i = 0;
310
+ while (i < win.length && !isWs(win[i])) i++;
311
+ return win.slice(i);
312
+ }
313
+ function scrubTruncate(str, max, window = DESC_SCRUB_WINDOW) {
295
314
  if (typeof str !== 'string' || str === '') return truncate(str, 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);
315
+ const stripped = stripPrivate(str);
316
+ let win = stripped.slice(0, window);
317
+ const mark = PRIVATE_MARK_RE.exec(win);
318
+ if (mark) win = mark[0][1] === '/' || mark[0].startsWith('-----END') ? '' : win.slice(0, mark.index);
319
+ else win = dropCutTokenAtEnd(win, stripped[window]);
299
320
  // The window edge can split a surrogate pair; truncate only guards a cut it makes itself.
300
321
  if (/[\uD800-\uDBFF]$/.test(win)) win = win.slice(0, -1);
301
322
  return truncate(_scrubSecrets(win), max);
@@ -314,6 +335,17 @@ function scrubTruncate(str, max) {
314
335
  // key with no END more than 4096 characters back).
315
336
  const PRIVATE_TAG_HINT_RE = /<\/?private>/i;
316
337
  const HEAD_ONLY_MAX = 60;
338
+ // The tail window is scrubbed with TAIL_CONTEXT characters before it, which are then dropped: a
339
+ // label cut by the window's edge (`pass|word: <value>`) is seen whole, so its value is not left
340
+ // unlabelled at the window's start, where whitespace collapsing can bring it into view (v6.19.1
341
+ // pre-tag review F1). A replacement inside the context moves the cut by its length change; the
342
+ // token the cut lands in is dropped either way.
343
+ const TAIL_CONTEXT = 256;
344
+ function scrubTailWindow(str, window) {
345
+ const scrubbed = _scrubSecrets(str.slice(-(window + TAIL_CONTEXT)));
346
+ return dropCutTokenAtStart(scrubbed.slice(TAIL_CONTEXT), scrubbed[TAIL_CONTEXT - 1]);
347
+ }
348
+ const oneSpace = (s) => s.replace(/\s+/g, ' ');
317
349
  // The early return needs the WHOLE output inside the head window: a long output whose first
318
350
  // 4096 characters collapse to a few (whitespace) still has a tail to show (v6.19.0 pre-tag
319
351
  // claims review F2).
@@ -321,11 +353,20 @@ function scrubTruncateEnds(str, max) {
321
353
  if (typeof str === 'string' && (str.includes('PRIVATE KEY') || PRIVATE_TAG_HINT_RE.test(str))) {
322
354
  return scrubTruncate(str, HEAD_ONLY_MAX);
323
355
  }
324
- const flat = scrubTruncate(str, DESC_SCRUB_WINDOW);
325
- const whole = typeof str !== 'string' || str.length <= DESC_SCRUB_WINDOW;
356
+ // Up to two windows long, one window covers the whole output: two overlapping windows showed
357
+ // the same text twice (delta review P3-4).
358
+ const window =
359
+ typeof str === 'string' && str.length <= 2 * DESC_SCRUB_WINDOW
360
+ ? 2 * DESC_SCRUB_WINDOW
361
+ : DESC_SCRUB_WINDOW;
362
+ // Whitespace runs are one space: a blank-line run no longer spends the budget (or, before the
363
+ // one-window rule, hid behind the window edge).
364
+ const flat = oneSpace(scrubTruncate(str, window, window));
365
+ const whole = typeof str !== 'string' || str.length <= window;
326
366
  if (whole && flat.length <= max) return flat;
327
367
  const tailLen = Math.floor(max / 2) - 1;
328
- const tailSrc = whole ? flat : normalizeInline(_scrubSecrets(str.slice(-DESC_SCRUB_WINDOW)));
368
+ const tailSrc = whole ? flat : oneSpace(normalizeInline(scrubTailWindow(str, window)));
369
+ if (tailSrc === '') return truncate(flat, max);
329
370
  // Drop a lone low surrogate the tail's cut may start on.
330
371
  const tail = tailSrc.slice(-tailLen).replace(/^[\uDC00-\uDFFF]/, '');
331
372
  // A head short enough to escape truncate's own "…" still gets one before the tail.