pi-condense 2.4.2 → 2.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,10 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
7
7
  Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
8
8
  publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
9
9
 
10
+ ## [2.4.3] - 2026-08-04
11
+
12
+ - **Flush-gated, timestamp-keyed thinking strip ([#3](https://github.com/jjuraszek/pi-condense/issues/3)).** Phase 4 (`stripOldThinking`, `src/thinking-strip.ts`) recomputed its keep-window from the *live assistant count on every `context` render*, so each turn the `(count - keepLastTurns)`-th assistant slid forward and its thinking was stripped **deep in history**. pi-ai sets prompt-cache breakpoints only at `tools + system + last message` (verified in `@earendil-works/pi-ai` `api/anthropic-messages.js` `convertMessages` - no in-history breakpoint), so every deep mutation busted the cached suffix - roughly every render inside a tool loop. The strip boundary is now a **flush-computed, persisted assistant-message timestamp** (`thinkingStripBoundaryTimestamp`, added to the `PruneFrontier` snapshot on the existing `context-prune-frontier` entry): fixed between flushes so consecutive renders are byte-stable in their historical prefix (the cache survives a whole tool loop), advancing only on a non-empty flush (piggybacking summarization's own cache bust - zero marginal busts), monotonically clamped (never re-adds thinking to an already-stripped message, even when `keepLastTurns` is increased mid-session), and keyed by timestamp rather than array index so it is robust to phase-3 chain-range middle drops. An absent boundary (pre-feature sessions, pre-first-flush) falls back to the original live-count window verbatim. Additive optional field, fully backward-compatible; **no new config key**, `keepLastTurns` presets unchanged. `PRUNING.md` cache-impact model corrected. Turns the old k-busts-per-render into ~1-per-request.
13
+
10
14
  ## [2.4.2] - 2026-08-04
11
15
 
12
16
  - **No-op renders skip context serialization.** `pruneMessages` (`src/pruner.ts`) computed `sizeMessages` (a full `JSON.stringify` over the entire message array) unconditionally on every `context` render, including no-ops where the result is never read (`index.ts` consumes `beforeChars`/`afterChars` only under `if (result.pruned)`). It now computes both sizes lazily in the pruned branch and returns a `{ beforeChars: 0, afterChars: 0 }` sentinel on a no-op, so a render that prunes nothing does zero `JSON.stringify` over the array. CPU/GC only - zero token cost, no wire or return-shape change. Also corrects a stale fast-path comment in the `context` handler (`index.ts`): index/registry emptiness alone does not imply a no-op, because error-purge and thinking-strip prune independently.
package/PRUNING.md CHANGED
@@ -1015,17 +1015,17 @@ Anthropic's extended-thinking contract during tool use:
1015
1015
 
1016
1016
  ### Transform position
1017
1017
 
1018
- Thinking strip runs **last**, after chain-range-prune, so "last K assistant turns" is measured over the turns that actually survive to the LLM:
1018
+ Thinking strip runs **last**, after chain-range-prune, at render time:
1019
1019
 
1020
1020
  ```
1021
1021
  [stub-replace] → [error-purge] → [chain-range-prune] → [thinking-strip]
1022
1022
  ```
1023
1023
 
1024
- In a session with no closed chains, Phases 1–3 may be no-ops and thinking strip does all the work. Where chain compression *does* fire, the two cooperate: chain compression drops whole old middle turns (including their thinking); thinking strip mops up thinking in the surviving recent / in-flight turns beyond K.
1024
+ The keep-window is a flush-computed assistant-message timestamp (the `keepLastTurns`-back boundary over the **raw** session branch — see the **Cache impact** note below), not a render-time recount over the post-phase-3 survivors. So when chain compression drops closed middle turns inside the window, fewer than `keepLastTurns` *surviving* turns may retain thinking — deliberate, and it only ever strips more, never re-adds. In a session with no closed chains, Phases 1–3 may be no-ops and thinking strip does all the work. Where chain compression *does* fire, the two cooperate: chain compression drops whole old middle turns (including their thinking); thinking strip mops up thinking in the surviving recent / in-flight turns beyond the boundary.
1025
1025
 
1026
1026
  ### Cache impact
1027
1027
 
1028
- Each new assistant turn slides the keep-window by one, stripping the turn that falls out and invalidating the prefix cache from that point (~K turns deep). The stable cached prefix (everything older than the window) still grows monotonically; only a K-deep tail churns. The trade vs the status quo: without stripping, thinking accrues without bound and is billed as cached input on every request until the window overflows; with stripping, total context is bounded at the cost of re-processing the last ~K turns' thinking each turn. Net-positive for long sessions; a literal no-op for sessions under `keepLastTurns` turns. Smaller K is cheaper on both savings and churn (worse only for reasoning continuity).
1028
+ pi-ai serializes prompt-cache breakpoints only at `tools`, `system`, and the last conversation message (verified in `@earendil-works/pi-ai` `api/anthropic-messages.js` `convertMessages`) - there is **no in-history breakpoint**. So the strip boundary is flush-gated, not per-render: it is a persisted assistant-message timestamp on the `context-prune-frontier` entry that stays fixed between flushes and advances only on a non-empty flush. Between flushes every render is byte-stable in its historical prefix, so the cache holds through a whole tool loop; the boundary moves at most once per flush (~once per request), turning the old k-busts-per-render into ~1-per-request. That residual bust is not always free: for a request shorter than `keepLastTurns` turns the boundary (tail-K) sits deeper than the current request's just-summarized tool results, so thinking-strip is the dominant invalidator, ~1 deep reprocess per request; for a request longer than `keepLastTurns` turns summarization's stub-replace reaches deeper and subsumes it (~0 marginal). Retained thinking is bounded to `keepLastTurns` raw-session turns, drifting up to `keepLastTurns + turns-since-flush` between flushes (deliberate: the frozen boundary is what buys cache stability). Note `error-purge` still mutates old history off a live per-render count, an independent cache-bust source not addressed here.
1029
1029
 
1030
1030
  ### Recovery
1031
1031
 
package/index.ts CHANGED
@@ -36,6 +36,7 @@ import { PruneFrontierTracker } from "./src/frontier.js";
36
36
  import { BlockRefIssuer } from "./src/block-refs.js";
37
37
  import { compressEligible } from "./src/chain-compressor.js";
38
38
  import { detectChains, withClosingMessage } from "./src/chain-detector.js";
39
+ import { computeThinkingBoundary } from "./src/thinking-strip.js";
39
40
  import { inGraceRecoveryToolCallIds } from "./src/recovery-grace.js";
40
41
  import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./src/budget.js";
41
42
  import { spillOversizedBatch } from "./src/spill.js";
@@ -486,6 +487,34 @@ export default function (pi: ExtensionAPI) {
486
487
  ? "skipped-deduped"
487
488
  : "skipped-trivial";
488
489
 
490
+ // Raw session branch, unwrapped once and shared by the thinking-strip boundary
491
+ // computation and the chain-compression block below - both walk it, so avoid a
492
+ // second O(session-size) pass on every flush. Only materialized when at least
493
+ // one consumer is enabled.
494
+ let branchMessages: any[] | undefined;
495
+ if (currentConfig.value.thinkingStrip.enabled || currentConfig.value.chainCompression.enabled) {
496
+ branchMessages = ctx.sessionManager.getBranch()
497
+ .filter((e: any) => e.type === "message" && e.message)
498
+ .map((e: any) => e.message);
499
+ }
500
+
501
+ // Flush-gated thinking-strip boundary: recompute the (count - keepLastTurns)-th
502
+ // assistant timestamp over the RAW branch (+ the not-yet-persisted closing
503
+ // assistant), monotonically clamped. Stays on the frontier snapshot so renders
504
+ // between flushes read a fixed value and keep the cache prefix. Carries prev
505
+ // through when disabled. Must run regardless of chainCompression.enabled.
506
+ let thinkingBoundary = frontier.get()?.thinkingStripBoundaryTimestamp;
507
+ if (currentConfig.value.thinkingStrip.enabled) {
508
+ const assistantTimestamps = withClosingMessage(branchMessages!, options.closingMessage)
509
+ .filter((m: any) => m?.role === "assistant" && typeof m.timestamp === "number")
510
+ .map((m: any) => m.timestamp);
511
+ thinkingBoundary = computeThinkingBoundary(
512
+ assistantTimestamps,
513
+ currentConfig.value.thinkingStrip.keepLastTurns,
514
+ thinkingBoundary,
515
+ );
516
+ }
517
+
489
518
  const frontierSnapshot: PruneFrontier = {
490
519
  lastAttemptedToolCallId: lastTC.toolCallId,
491
520
  lastAttemptedToolName: lastTC.toolName,
@@ -496,6 +525,7 @@ export default function (pi: ExtensionAPI) {
496
525
  rawCharCount: totalRawCharCount,
497
526
  summaryCharCount: totalSummaryCharCount,
498
527
  outcome: flushOutcome,
528
+ thinkingStripBoundaryTimestamp: thinkingBoundary,
499
529
  };
500
530
 
501
531
  try {
@@ -524,14 +554,11 @@ export default function (pi: ExtensionAPI) {
524
554
  // Non-fatal: a failure here does not roll back the successful summarization.
525
555
  if (currentConfig.value.chainCompression.enabled) {
526
556
  try {
527
- const branch = ctx.sessionManager.getBranch();
528
- const branchMessages = branch
529
- .filter((e: any) => e.type === "message" && e.message)
530
- .map((e: any) => e.message);
531
557
  // message_end fires before pi persists the closing assistant, so thread it
532
558
  // in here; otherwise the newest chain reads as open and K over-retains by 1.
533
- const chains = detectChains(withClosingMessage(branchMessages, options.closingMessage), protectionPredicate);
534
- const inGrace = inGraceRecoveryToolCallIds(branchMessages, currentConfig.value.recoveryGraceTurns);
559
+ // branchMessages was unwrapped once above (shared with the boundary block).
560
+ const chains = detectChains(withClosingMessage(branchMessages!, options.closingMessage), protectionPredicate);
561
+ const inGrace = inGraceRecoveryToolCallIds(branchMessages!, currentConfig.value.recoveryGraceTurns);
535
562
  const { compressedEntries } = await compressEligible(
536
563
  chains,
537
564
  currentConfig.value.chainCompression.rollingWindow,
@@ -831,6 +858,7 @@ export default function (pi: ExtensionAPI) {
831
858
  currentConfig.value.thinkingStrip,
832
859
  currentConfig.value,
833
860
  currentConfig.value.recoveryGraceTurns,
861
+ frontier.get()?.thinkingStripBoundaryTimestamp,
834
862
  );
835
863
  if (result.pruned) {
836
864
  messages = result.messages;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.4.2",
3
+ "version": "2.4.3",
4
4
  "description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { PruneFrontierTracker } from "./frontier.js";
3
+ import type { PruneFrontier } from "./types.js";
4
+
5
+ const base: PruneFrontier = {
6
+ lastAttemptedToolCallId: "tc1",
7
+ lastAttemptedToolName: "bash",
8
+ lastAttemptedTurnIndex: 3,
9
+ lastAttemptedTimestamp: 1000,
10
+ attemptedBatchCount: 1,
11
+ attemptedToolCallCount: 2,
12
+ rawCharCount: 500,
13
+ summaryCharCount: 100,
14
+ outcome: "summarized",
15
+ };
16
+
17
+ describe("PruneFrontierTracker.fromJSON - thinkingStripBoundaryTimestamp", () => {
18
+ test("round-trips the boundary field", () => {
19
+ const t = new PruneFrontierTracker();
20
+ t.fromJSON({ ...base, thinkingStripBoundaryTimestamp: 777 });
21
+ expect(t.get()?.thinkingStripBoundaryTimestamp).toBe(777);
22
+ });
23
+
24
+ test("absent boundary stays undefined (live-count fallback)", () => {
25
+ const t = new PruneFrontierTracker();
26
+ t.fromJSON({ ...base });
27
+ expect(t.get()?.thinkingStripBoundaryTimestamp).toBeUndefined();
28
+ });
29
+ });
30
+
31
+ describe("PruneFrontierTracker.reconstructFromSession - boundary survives reload", () => {
32
+ test("reconstructs thinkingStripBoundaryTimestamp from a persisted frontier entry", () => {
33
+ const t = new PruneFrontierTracker();
34
+ const entries = [
35
+ { type: "custom", customType: "context-prune-frontier", data: { ...base, thinkingStripBoundaryTimestamp: 555 } },
36
+ ];
37
+ const fakeCtx = { sessionManager: { getBranch: () => entries } } as any;
38
+ t.reconstructFromSession(fakeCtx);
39
+ expect(t.get()?.thinkingStripBoundaryTimestamp).toBe(555);
40
+ });
41
+
42
+ test("a persisted entry without the field reconstructs as undefined (live-count fallback)", () => {
43
+ const t = new PruneFrontierTracker();
44
+ const entries = [
45
+ { type: "custom", customType: "context-prune-frontier", data: { ...base } },
46
+ ];
47
+ const fakeCtx = { sessionManager: { getBranch: () => entries } } as any;
48
+ t.reconstructFromSession(fakeCtx);
49
+ expect(t.get()).not.toBeNull();
50
+ expect(t.get()?.thinkingStripBoundaryTimestamp).toBeUndefined();
51
+ });
52
+ });
package/src/frontier.ts CHANGED
@@ -32,6 +32,7 @@ export class PruneFrontierTracker {
32
32
  rawCharCount: data.rawCharCount ?? 0,
33
33
  summaryCharCount: data.summaryCharCount ?? 0,
34
34
  outcome: data.outcome ?? "summarized",
35
+ thinkingStripBoundaryTimestamp: data.thinkingStripBoundaryTimestamp,
35
36
  };
36
37
  }
37
38
 
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { pruneMessages, sizeMessages } from "./pruner.js";
3
- import type { ChainCompressionConfig, ChainCompressionEntry } from "./types.js";
3
+ import type { ChainCompressionConfig, ChainCompressionEntry, ThinkingStripConfig } from "./types.js";
4
+ import { ToolCallIndexer } from "./indexer.js";
4
5
 
5
6
  // Minimal mock exposing only the ToolCallIndexer surface that pruneMessages calls.
6
7
  function makeMockIndexer({
@@ -409,6 +410,25 @@ describe("pruneMessages", () => {
409
410
  expect(assistants.slice(0, 3).every((a: any) => !hasThinking(a))).toBe(true);
410
411
  expect(assistants.slice(-2).every((a: any) => hasThinking(a))).toBe(true);
411
412
  });
413
+
414
+ it("threads thinkingBoundaryTimestamp into phase 4", () => {
415
+ const indexer = new ToolCallIndexer();
416
+ const strip: ThinkingStripConfig = { enabled: true, keepLastTurns: 16 };
417
+ const messages: any[] = [
418
+ { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 },
419
+ { role: "assistant", content: [{ type: "thinking", thinking: "old", thinkingSignature: "s" }, { type: "text", text: "a" }], timestamp: 10, usage: {}, stopReason: "stop" },
420
+ { role: "assistant", content: [{ type: "thinking", thinking: "new", thinkingSignature: "s" }, { type: "text", text: "b" }], timestamp: 30, usage: {}, stopReason: "stop" },
421
+ ];
422
+ // Boundary 20: ts=10 assistant older -> stripped; ts=30 kept.
423
+ // Live-count would strip nothing (2 assistants < keepLastTurns=16), so a pass
424
+ // proves the boundary arg reached phase 4.
425
+ const { messages: out, pruned } = pruneMessages(messages, indexer, undefined, undefined, strip, undefined, 0, 20);
426
+ expect(pruned).toBe(true);
427
+ const older = out.find((m: any) => m.timestamp === 10) as any;
428
+ const newer = out.find((m: any) => m.timestamp === 30) as any;
429
+ expect(older.content.some((c: any) => c.type === "thinking")).toBe(false);
430
+ expect(newer.content.some((c: any) => c.type === "thinking")).toBe(true);
431
+ });
412
432
  });
413
433
 
414
434
  describe("render-time protection re-check", () => {
package/src/pruner.ts CHANGED
@@ -74,6 +74,7 @@ export function pruneMessages(
74
74
  thinkingStrip?: ThinkingStripConfig,
75
75
  protection?: ProtectionConfig,
76
76
  recoveryGraceTurns: number = 0,
77
+ thinkingBoundaryTimestamp?: number,
77
78
  ): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
78
79
  // Phase 1: stub-replace summarized tool results
79
80
  let pruned = false;
@@ -156,7 +157,7 @@ export function pruneMessages(
156
157
 
157
158
  // Phase 4: thinking strip — keep thinking only on the last K assistant turns
158
159
  if (thinkingStrip?.enabled) {
159
- const afterStrip = stripOldThinking(current, thinkingStrip);
160
+ const afterStrip = stripOldThinking(current, thinkingStrip, thinkingBoundaryTimestamp);
160
161
  if (afterStrip !== current) {
161
162
  current = afterStrip;
162
163
  pruned = true;
@@ -5,7 +5,7 @@ import { compressEligible } from "./chain-compressor.js";
5
5
  import { pruneMessages } from "./pruner.js";
6
6
  import { detectChains } from "./chain-detector.js";
7
7
  import { isProtected } from "./protected.js";
8
- import type { ChainRange, ChainCompressionConfig } from "./types.js";
8
+ import type { ChainRange, ChainCompressionConfig, ThinkingStripConfig } from "./types.js";
9
9
 
10
10
  // End-to-end of the in-memory B path (everything except the LLM call, which is
11
11
  // the shared runSummarization already exercised live): a span's per-batch
@@ -249,4 +249,46 @@ describe("range compression integration", () => {
249
249
  expect(synthetic.content[0].text).toContain("batch one body");
250
250
  expect(synthetic.content[0].text).toContain("batch two body");
251
251
  });
252
+
253
+ test("boundary strips thinking on survivors after a real phase-3 chain drop", async () => {
254
+ const indexer = new ToolCallIndexer();
255
+ const blockRefs = new BlockRefIssuer();
256
+ indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tc1" }]);
257
+ indexer.registerSummaryBody(["tc1"], "summary of batch 1");
258
+
259
+ const chain: ChainRange = {
260
+ startUserTimestamp: 100,
261
+ middleToolCallIds: ["tc1"],
262
+ finalAssistantTimestamp: 400,
263
+ };
264
+ const { compressedEntries } = await compressEligible([chain], 0, {
265
+ indexer,
266
+ blockRefs,
267
+ appendEntry: () => {},
268
+ now: () => 999,
269
+ });
270
+ expect(compressedEntries).toHaveLength(1);
271
+
272
+ // A later assistant turn (ts 500) that carries thinking and sits OLDER than the boundary.
273
+ const messages: any[] = [
274
+ { role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
275
+ { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" },
276
+ { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "o1" }], isError: false, timestamp: 210 },
277
+ { role: "assistant", content: [{ type: "text", text: "mid" }], timestamp: 400, usage: {}, stopReason: "end_turn" },
278
+ { role: "assistant", content: [{ type: "thinking", thinking: "old-think", thinkingSignature: "s" }, { type: "text", text: "after" }], timestamp: 500, usage: {}, stopReason: "stop" },
279
+ ];
280
+
281
+ const cc: ChainCompressionConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: true, fuseRangeSummary: false };
282
+ const strip: ThinkingStripConfig = { enabled: true, keepLastTurns: 16 };
283
+ // Boundary 600: the ts=500 assistant is older -> its thinking must be stripped,
284
+ // even though phase 3 has dropped the tc1 chain from the array first.
285
+ const { messages: out, pruned } = pruneMessages(messages, indexer, cc, undefined, strip, undefined, 0, 600);
286
+ expect(pruned).toBe(true);
287
+ // Chain middle dropped:
288
+ expect(out.filter((m: any) => m.role === "toolResult")).toHaveLength(0);
289
+ // Surviving ts=500 assistant older than boundary 600 -> thinking stripped:
290
+ const late = out.find((m: any) => m.role === "assistant" && m.timestamp === 500);
291
+ expect(late).toBeDefined();
292
+ expect(late.content.some((c: any) => c.type === "thinking")).toBe(false);
293
+ });
252
294
  });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { stripOldThinking } from "./thinking-strip.js";
2
+ import { stripOldThinking, computeThinkingBoundary } from "./thinking-strip.js";
3
3
  import type { ThinkingStripConfig } from "./types.js";
4
4
 
5
5
  const cfg = (enabled: boolean, keepLastTurns: number): ThinkingStripConfig => ({ enabled, keepLastTurns });
@@ -173,3 +173,85 @@ describe("stripOldThinking", () => {
173
173
  out.forEach((m, i) => expect(m.role).toBe(msgs[i].role));
174
174
  });
175
175
  });
176
+
177
+ describe("stripOldThinking (boundaryTimestamp path)", () => {
178
+ test("strips assistants older than boundary, keeps boundary and newer", () => {
179
+ const msgs = convo(20);
180
+ const assistantTs = msgs.filter((m) => m.role === "assistant").map((m) => m.timestamp);
181
+ const boundary = assistantTs[4];
182
+ const out = stripOldThinking(msgs, cfg(true, 16), boundary);
183
+ expect(out).not.toBe(msgs);
184
+ const assistants = out.filter((m) => m.role === "assistant");
185
+ for (const a of assistants) {
186
+ if (a.timestamp < boundary) expect(hasThinking(a)).toBe(false);
187
+ else expect(hasThinking(a)).toBe(true);
188
+ }
189
+ });
190
+
191
+ test("prefix is byte-stable across a growing tail at a fixed boundary (the AC)", () => {
192
+ const msgs = convo(20);
193
+ const boundary = msgs.filter((m) => m.role === "assistant").map((m) => m.timestamp)[4];
194
+ const first = stripOldThinking(msgs, cfg(true, 16), boundary);
195
+ const prefixLen = first.length;
196
+ const grown = [...msgs, assistantToolsThinking(100, ["tcNew"]), toolResult(101, "tcNew")];
197
+ const second = stripOldThinking(grown, cfg(true, 16), boundary);
198
+ expect(JSON.stringify(second.slice(0, prefixLen))).toBe(JSON.stringify(first));
199
+ });
200
+
201
+ test("undefined boundary falls back to live-count (same as 2-arg)", () => {
202
+ const msgs = convo(20);
203
+ const viaUndefined = stripOldThinking(msgs, cfg(true, 16), undefined);
204
+ const viaTwoArg = stripOldThinking(msgs, cfg(true, 16));
205
+ expect(JSON.stringify(viaUndefined)).toBe(JSON.stringify(viaTwoArg));
206
+ });
207
+
208
+ test("assistant without a timestamp is kept, never stripped", () => {
209
+ const noTs: any = { role: "assistant", content: [{ type: "thinking", thinking: "x", thinkingSignature: "s" }, { type: "text", text: "y" }], usage: {}, stopReason: "stop" };
210
+ const msgs = [userMsg(1), noTs, ...convo(20).slice(1)];
211
+ const out = stripOldThinking(msgs, cfg(true, 16), 9999);
212
+ const kept = out.find((m) => m.role === "assistant" && m.timestamp === undefined);
213
+ expect(hasThinking(kept)).toBe(true);
214
+ });
215
+
216
+ test("post-chain-drop survivor array: surviving older turns stripped, boundary honored", () => {
217
+ const full = convo(20);
218
+ const assistantTs = full.filter((m) => m.role === "assistant").map((m) => m.timestamp);
219
+ const boundary = assistantTs[10];
220
+ const survivor = [...full.slice(0, 6), ...full.slice(8)];
221
+ const out = stripOldThinking(survivor, cfg(true, 16), boundary);
222
+ for (const a of out.filter((m) => m.role === "assistant")) {
223
+ if (a.timestamp < boundary) expect(hasThinking(a)).toBe(false);
224
+ }
225
+ });
226
+ });
227
+
228
+ describe("computeThinkingBoundary", () => {
229
+ const ts = Array.from({ length: 40 }, (_, i) => (i + 1) * 10);
230
+
231
+ test("count <= keep returns prev unchanged", () => {
232
+ expect(computeThinkingBoundary(ts.slice(0, 16), 16, undefined)).toBeUndefined();
233
+ expect(computeThinkingBoundary(ts.slice(0, 10), 16, 123)).toBe(123);
234
+ });
235
+
236
+ test("count > keep returns the (count-keep)-th timestamp", () => {
237
+ expect(computeThinkingBoundary(ts.slice(0, 20), 16, undefined)).toBe(ts[4]);
238
+ });
239
+
240
+ test("keepLastTurns=0 is clamped to 1 (no out-of-bounds)", () => {
241
+ expect(computeThinkingBoundary(ts.slice(0, 20), 0, undefined)).toBe(ts[19]);
242
+ });
243
+
244
+ test("monotonic clamp: never regresses when keepLastTurns increases", () => {
245
+ const first = computeThinkingBoundary(ts.slice(0, 40), 16, undefined);
246
+ expect(first).toBe(ts[24]);
247
+ const second = computeThinkingBoundary(ts.slice(0, 40), 32, first);
248
+ expect(second).toBe(first);
249
+ });
250
+
251
+ test("an added trailing turn (e.g. closingMessage) advances the boundary by one", () => {
252
+ const before = computeThinkingBoundary(ts.slice(0, 20), 16, undefined);
253
+ const after = computeThinkingBoundary(ts.slice(0, 21), 16, before);
254
+ expect(after).toBe(ts[5]);
255
+ expect(after).toBeGreaterThan(before as number);
256
+ });
257
+ });
@@ -20,8 +20,29 @@ import type { ThinkingStripConfig } from "./types.js";
20
20
  * Returns the original array reference unchanged when nothing is stripped, so
21
21
  * `pruneMessages` can skip reconstruction.
22
22
  */
23
- export function stripOldThinking(messages: any[], config: ThinkingStripConfig): any[] {
23
+ export function stripOldThinking(
24
+ messages: any[],
25
+ config: ThinkingStripConfig,
26
+ boundaryTimestamp?: number,
27
+ ): any[] {
24
28
  if (!config.enabled) return messages;
29
+
30
+ // Flush-gated path: strip by the persisted timestamp boundary. Fixed between
31
+ // flushes, so consecutive renders produce a byte-identical historical prefix.
32
+ // `!(ts < boundary)` keeps a timestamp-less assistant (undefined < n === false),
33
+ // which is the provider-safe default (never over-strip an unknown-age turn).
34
+ if (boundaryTimestamp !== undefined && boundaryTimestamp !== null) {
35
+ let changed = false;
36
+ const out = messages.map((msg) => {
37
+ if (msg?.role !== "assistant" || !(msg.timestamp < boundaryTimestamp)) return msg;
38
+ if (!Array.isArray(msg.content) || !msg.content.some((c: any) => c.type === "thinking")) return msg;
39
+ changed = true;
40
+ return withoutThinkingBlocks(msg);
41
+ });
42
+ return changed ? out : messages;
43
+ }
44
+
45
+ // Fallback: live-count window (pre-first-flush / pre-feature sessions).
25
46
  const keep = Math.max(1, config.keepLastTurns);
26
47
 
27
48
  const assistantIdx: number[] = [];
@@ -40,3 +61,23 @@ export function stripOldThinking(messages: any[], config: ThinkingStripConfig):
40
61
  });
41
62
  return changed ? out : messages;
42
63
  }
64
+
65
+ /**
66
+ * Flush-time computation of the thinking-strip boundary: the timestamp of the
67
+ * (count - keepLastTurns)-th assistant message, monotonically clamped so the
68
+ * boundary never moves backward (a mid-session `keepLastTurns` increase must not
69
+ * re-add thinking to an already-stripped message). Stateless recompute - no
70
+ * running counter. `keepLastTurns` is clamped to >= 1 to match `stripOldThinking`
71
+ * and avoid an out-of-bounds index.
72
+ */
73
+ export function computeThinkingBoundary(
74
+ assistantTimestamps: number[],
75
+ keepLastTurns: number,
76
+ prev?: number,
77
+ ): number | undefined {
78
+ const keep = Math.max(1, keepLastTurns);
79
+ const count = assistantTimestamps.length;
80
+ if (count <= keep) return prev;
81
+ const candidate = assistantTimestamps[count - keep];
82
+ return Math.max(prev ?? candidate, candidate);
83
+ }
package/src/types.ts CHANGED
@@ -730,6 +730,14 @@ export interface PruneFrontier {
730
730
  summaryCharCount: number;
731
731
  /** Whether the attempt actually pruned or was skipped for being oversized */
732
732
  outcome: PruneFrontierOutcome;
733
+ /**
734
+ * Assistant-message timestamp marking the flush-gated thinking-strip boundary:
735
+ * thinking is stripped from every assistant message older than this. Advances
736
+ * only at flushes (stays fixed between them so renders are prefix-stable and the
737
+ * prompt cache survives a tool loop). Absent on pre-feature entries - the render
738
+ * path then falls back to the live-count window. See src/thinking-strip.ts.
739
+ */
740
+ thinkingStripBoundaryTimestamp?: number;
733
741
  }
734
742
 
735
743
  /**