pi-condense 2.4.3 → 2.5.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.
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.5.0] - 2026-08-05
11
+
12
+ - **Removed the main-loop thinking strip (breaking: `contextPrune.thinkingStrip.*` no longer read).** The feature assumed thinking blocks we send are thinking blocks we are billed for. Verified against the live Anthropic API (`count_tokens` and real billed usage agree to the token), the actual rule is: thinking in a *closed* cycle bills 0 input tokens, and thinking in an *open* cycle survives only as an unbroken run starting at the cycle's first assistant turn — any gap discards everything after it. `keepLastTurns` kept the **last** K, so firing it punched a gap at the front and the API dropped all of it: the model received no thinking either way, and the no-gap → gap transition cost a full cache invalidation (measured +33% in a controlled 24-tool-call A/B, $0.6430 → $0.8531, with turn 17 rewriting 63113 tokens at 0% reuse). Across 58 sessions / 1233 open cycles the strip fired 253 times and reached its ~130-turn break-even twice. It was also redundant: chain compression's synthetic block is a `role: "user"` text message, which closes the cycle and frees all prior thinking server-side at zero cache cost. `pruneMessages` drops to three phases (`stub-replace → error-purge → chain-range-prune`); `src/thinking-strip.ts`, `ThinkingStripConfig`, `KEEP_LAST_TURNS_PRESETS`, the two `/pruner` settings entries, and `PruneFrontier.thinkingStripBoundaryTimestamp` are gone. A leftover `thinkingStrip` block in `settings.json` is inert and round-trips untouched (`normalize()` never filters unknown keys); a persisted `context-prune-frontier` entry carrying the old boundary field loads and is ignored. Rationale and measurements: `doc/specs/2026-08-05-remove-thinking-strip.md`.
13
+
10
14
  ## [2.4.3] - 2026-08-04
11
15
 
12
16
  - **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.
package/PRUNING.md CHANGED
@@ -28,12 +28,11 @@
28
28
  10. [Chain Compression](#chain-compression)
29
29
  - [Protected-output relocation](#protected-output-relocation)
30
30
  11. [Error Purge](#error-purge)
31
- 12. [Main-loop Thinking Strip](#main-loop-thinking-strip)
32
- 13. [Why Summarization Works: Research Evidence](#why-summarization-works-research-evidence)
31
+ 12. [Why Summarization Works: Research Evidence](#why-summarization-works-research-evidence)
33
32
  - [SUPO — Summarization augmented Policy Optimization](#supo--summarization-augmented-policy-optimization)
34
33
  - [ReSum — Recursive Summarization for Long-Horizon Agents](#resum--recursive-summarization-for-long-horizon-agents)
35
34
  - [ACON — Agent Context Optimization](#acon--agent-context-optimization)
36
- 14. [Summary](#summary)
35
+ 13. [Summary](#summary)
37
36
 
38
37
  ---
39
38
 
@@ -688,7 +687,7 @@ The last attempted prune boundary is persisted as `context-prune-frontier` so `f
688
687
  - **Tree browser (`/pruner tree`):** interactive, foldable tree of pruned tool calls grouped under their summaries. `Ctrl-O` on a summary node opens the full markdown summary in a bordered overlay.
689
688
  - **Configurable summarizer thinking (`summarizerThinking`):** trade summary cost / latency for quality (`off` / `minimal` / `low` / `medium` / `high` / `xhigh`). `default` omits the option entirely so the provider chooses.
690
689
  - **Cumulative stats:** `context-prune-stats` entries track input/output tokens and cost of every summarizer call; full detail surfaces in `/pruner stats`. Cost is also emitted on the `cost:external` pi.events channel for external aggregators (cumulative per session, live only).
691
- - **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages) = JSON.stringify(messages).length`, comparing the input array before pruning to the result after. Estimated tokens = chars / 4. The measurement covers all four reclaim mechanisms in a single point (stub-replace, error-purge, chain-range-prune, thinking-strip); appears on the status line as `│ prune: ON · 92k->14k (-85%) │` once at least one prune has occurred (the `│ … │` wrapper keeps the segment visually isolated in the shared footer, load-order independent).
690
+ - **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages) = JSON.stringify(messages).length`, comparing the input array before pruning to the result after. Estimated tokens = chars / 4. The measurement covers all three reclaim mechanisms in a single point (stub-replace, error-purge, chain-range-prune); appears on the status line as `│ prune: ON · 92k->14k (-85%) │` once at least one prune has occurred (the `│ … │` wrapper keeps the segment visually isolated in the shared footer, load-order independent).
692
691
  - **Live progress for `/pruner now`:** an `aboveEditor` widget shows one row per pending batch with braille spinner, streamed summary-char count, and ✓ / ⚠ status.
693
692
 
694
693
  ### Summarizer outage fallback
@@ -863,6 +862,8 @@ A **closed chain** is a span of messages from one user message through any numbe
863
862
  | Final text-only assistant | **Kept**, thinking blocks stripped (safe — no following tool cycle depends on the signature) |
864
863
  | Synthetic `<compressed-chain>` user message | **Injected** immediately after the start user message |
865
864
 
865
+ **Why there is no separate thinking strip.** The synthetic `<compressed-chain>` block is injected as a `role: "user"` text message, which Anthropic reads as a genuine user turn. That closes the assistant cycle, and the API drops every prior thinking block from the context window server-side - verified against the live API: the same 8-turn loop bills 3152 thinking tokens with a full `toolResult` history and 0 once a user text block precedes the final assistant. So chain compression already reclaims thinking mass for free, as a side effect of the range drop that was rewriting that region anyway. Stub replacement does not have this effect: it preserves `role: "toolResult"`, so the cycle stays open. A dedicated strip phase shipped through v2.4.3; removed in v2.5.0 - see `doc/specs/2026-08-05-remove-thinking-strip.md`.
866
+
866
867
  ### Transform composition order
867
868
 
868
869
  ```
@@ -870,14 +871,13 @@ raw messages from session
870
871
 
871
872
  ├─ [1] tool-result stub-replace (per-batch; existing)
872
873
  ├─ [2] error-purge (phase 2)
873
- ├─ [3] chain-range-prune (runs AFTER stubs)
874
- for each compressed chain:
875
- drop middle assistants (by toolCallId overlap)
876
- drop middle toolResults (by toolCallId)
877
- suppress per-batch summaries (by toolCallRefs overlap)
878
- inject <compressed-chain> after start user
879
- strip thinking from final assistant
880
- └─ [4] thinking-strip (keep thinking on last K assistant turns)
874
+ └─ [3] chain-range-prune (runs AFTER stubs)
875
+ for each compressed chain:
876
+ drop middle assistants (by toolCallId overlap)
877
+ drop middle toolResults (by toolCallId)
878
+ suppress per-batch summaries (by toolCallRefs overlap)
879
+ inject <compressed-chain> after start user
880
+ strip thinking from final assistant
881
881
  ```
882
882
 
883
883
  ### Identification model
@@ -981,7 +981,7 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
981
981
  **Transform position:** Error purge runs in Phase 2, after stub-replace and before chain range prune.
982
982
 
983
983
  ```
984
- [stub-replace] → [error-purge] → [chain-range-prune] → [thinking-strip]
984
+ [stub-replace] → [error-purge] → [chain-range-prune]
985
985
  ```
986
986
 
987
987
  **Config keys:**
@@ -994,52 +994,6 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
994
994
 
995
995
  ---
996
996
 
997
- ## Main-loop Thinking Strip
998
-
999
- Chain compression and the summarizer target *tool* mass. But in long single-agent sessions the dominant cost is often **assistant `thinking` blocks**: on Opus 4.5+/Sonnet 4.6+ the API retains every prior-turn thinking block by default, and pi-ai replays them all (with signatures) on every request. One autonomous ops session held ~405 K tokens (~80% of a 500 K window) in thinking alone, untouched by every other strategy — chain compression only fires on *closed* spans, and that session was one long open span.
1000
-
1001
- Thinking strip is a deterministic, zero-LLM transform (Phase 4) that keeps `thinking` blocks only on the last `keepLastTurns` **assistant turns** and strips them from older assistant messages, leaving each message's `text` and `toolCall` blocks intact.
1002
-
1003
- ### Turn unit
1004
-
1005
- `keepLastTurns` counts **assistant messages**, not user-bounded spans. The target failure mode is a single long open chain (zero subagents, near-zero user turns) where a span-based window would keep everything. Counting assistant turns directly bounds thinking accumulation regardless of whether any chain closes.
1006
-
1007
- ### Provider safety
1008
-
1009
- Anthropic's extended-thinking contract during tool use:
1010
-
1011
- - Only the **last assistant turn's** thinking is required; "you can omit thinking blocks from prior assistant role turns" and the API auto-filters them.
1012
- - A message's thinking blocks must be dropped **all-or-nothing** ("the entire sequence of consecutive thinking blocks must match the outputs … you can't rearrange or modify the sequence"). The strip reuses `withoutThinkingBlocks`, which removes every thinking block (and its signature) from a message.
1013
-
1014
- `keepLastTurns` is clamped to `>= 1`, so the most-recent assistant turn — the one that may be awaiting tool results — always keeps its thinking. This is the minimum safe window; the default of 16 is far above the floor and preserves recent reasoning continuity.
1015
-
1016
- ### Transform position
1017
-
1018
- Thinking strip runs **last**, after chain-range-prune, at render time:
1019
-
1020
- ```
1021
- [stub-replace] → [error-purge] → [chain-range-prune] → [thinking-strip]
1022
- ```
1023
-
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
-
1026
- ### Cache impact
1027
-
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
-
1030
- ### Recovery
1031
-
1032
- Stripped thinking is **not** recoverable via `context_tree_query` — unlike tool outputs, thinking blocks are not indexed. The raw thinking remains in the session JSONL on disk (the `context` hook never mutates storage); reloading the session without the extension, or reading the file directly, shows the original blocks. Thinking is transient model-internal reasoning, so drop-without-recovery is intentional.
1033
-
1034
- ### Config keys
1035
-
1036
- | Key | Default | Description |
1037
- |---|---|---|
1038
- | `thinkingStrip.enabled` | `true` | Master toggle (gated behind the top-level `enabled`) |
1039
- | `thinkingStrip.keepLastTurns` | `16` | Keep thinking on the last N assistant turns; strip older. Clamped to `>= 1` |
1040
-
1041
- ---
1042
-
1043
997
  ## Summary
1044
998
 
1045
999
  | Concern | How Pruning Addresses It |
package/index.ts CHANGED
@@ -36,7 +36,6 @@ 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";
40
39
  import { inGraceRecoveryToolCallIds } from "./src/recovery-grace.js";
41
40
  import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./src/budget.js";
42
41
  import { spillOversizedBatch } from "./src/spill.js";
@@ -487,34 +486,15 @@ export default function (pi: ExtensionAPI) {
487
486
  ? "skipped-deduped"
488
487
  : "skipped-trivial";
489
488
 
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.
489
+ // Raw session branch, unwrapped once for the chain-compression block below.
490
+ // Only materialized when chain compression is enabled.
494
491
  let branchMessages: any[] | undefined;
495
- if (currentConfig.value.thinkingStrip.enabled || currentConfig.value.chainCompression.enabled) {
492
+ if (currentConfig.value.chainCompression.enabled) {
496
493
  branchMessages = ctx.sessionManager.getBranch()
497
494
  .filter((e: any) => e.type === "message" && e.message)
498
495
  .map((e: any) => e.message);
499
496
  }
500
497
 
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
-
518
498
  const frontierSnapshot: PruneFrontier = {
519
499
  lastAttemptedToolCallId: lastTC.toolCallId,
520
500
  lastAttemptedToolName: lastTC.toolName,
@@ -525,7 +505,6 @@ export default function (pi: ExtensionAPI) {
525
505
  rawCharCount: totalRawCharCount,
526
506
  summaryCharCount: totalSummaryCharCount,
527
507
  outcome: flushOutcome,
528
- thinkingStripBoundaryTimestamp: thinkingBoundary,
529
508
  };
530
509
 
531
510
  try {
@@ -556,7 +535,7 @@ export default function (pi: ExtensionAPI) {
556
535
  try {
557
536
  // message_end fires before pi persists the closing assistant, so thread it
558
537
  // in here; otherwise the newest chain reads as open and K over-retains by 1.
559
- // branchMessages was unwrapped once above (shared with the boundary block).
538
+ // branchMessages was unwrapped once above, gated on chainCompression.enabled.
560
539
  const chains = detectChains(withClosingMessage(branchMessages!, options.closingMessage), protectionPredicate);
561
540
  const inGrace = inGraceRecoveryToolCallIds(branchMessages!, currentConfig.value.recoveryGraceTurns);
562
541
  const { compressedEntries } = await compressEligible(
@@ -846,19 +825,16 @@ export default function (pi: ExtensionAPI) {
846
825
 
847
826
  // pruneMessages is the single source of truth for "is there work to do".
848
827
  // It returns the original array reference (pruned: false) only when none of
849
- // the four phases changed anything; index/registry emptiness alone does not
850
- // imply a no-op, since error-purge (phase 2) and thinking-strip (phase 4)
851
- // prune independently of them. Calling it unconditionally is safe and avoids
852
- // a split gate here.
828
+ // the three phases changed anything; index/registry emptiness alone does not
829
+ // imply a no-op, since error-purge (phase 2) prunes independently of them.
830
+ // Calling it unconditionally is safe and avoids a split gate here.
853
831
  const result = pruneMessages(
854
832
  messages,
855
833
  indexer,
856
834
  currentConfig.value.chainCompression,
857
835
  currentConfig.value.purgeErrors,
858
- currentConfig.value.thinkingStrip,
859
836
  currentConfig.value,
860
837
  currentConfig.value.recoveryGraceTurns,
861
- frontier.get()?.thinkingStripBoundaryTimestamp,
862
838
  );
863
839
  if (result.pruned) {
864
840
  messages = result.messages;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.4.3",
3
+ "version": "2.5.0",
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",
package/src/commands.ts CHANGED
@@ -16,7 +16,6 @@ import {
16
16
  SUMMARIZER_MAX_TIMEOUT_PRESETS,
17
17
  AUTO_BUDGET_PRESETS,
18
18
  ROLLING_WINDOW_PRESETS,
19
- KEEP_LAST_TURNS_PRESETS,
20
19
  PURGE_COOLDOWN_PRESETS,
21
20
  PURGE_MIN_ARG_PRESETS,
22
21
  DEFAULT_CONFIG,
@@ -648,22 +647,6 @@ export function registerCommands(
648
647
  currentValue: String(config.chainCompression.fuseRangeSummary),
649
648
  description: `Fuse a compressed chain's per-batch summaries into one cohesive LLM summary (one extra summarizer call per multi-batch span). Off keeps the per-batch concatenation. Currently ${config.chainCompression.fuseRangeSummary ? "ON" : "OFF"}.`,
650
649
  },
651
- {
652
- id: "thinkingStripEnabled",
653
- label: "Thinking strip",
654
- values: ["true", "false"],
655
- currentValue: String(config.thinkingStrip.enabled),
656
- description: `Strip thinking blocks from assistant turns older than the last ${config.thinkingStrip.keepLastTurns}. Reclaims main-loop thinking accumulation; no-op under ${config.thinkingStrip.keepLastTurns} turns. Currently ${config.thinkingStrip.enabled ? "ON" : "OFF"}.`,
657
- },
658
- {
659
- id: "thinkingStripKeepLastTurns",
660
- label: "Thinking keep (last N turns)",
661
- values: KEEP_LAST_TURNS_PRESETS.map((p) => p.value),
662
- currentValue: KEEP_LAST_TURNS_PRESETS.some((p) => p.value === String(config.thinkingStrip.keepLastTurns))
663
- ? String(config.thinkingStrip.keepLastTurns)
664
- : KEEP_LAST_TURNS_PRESETS[2].value,
665
- description: `Keep thinking on the last N assistant turns; strip older. Counts assistant turns, not chains. Currently ${config.thinkingStrip.keepLastTurns}.`,
666
- },
667
650
  {
668
651
  id: "purgeErrorsEnabled",
669
652
  label: "Error purge",
@@ -801,14 +784,6 @@ export function registerCommands(
801
784
  newConfig.chainCompression = { ...newConfig.chainCompression, stripFinalAssistantThinking: newValue === "true" };
802
785
  } else if (id === "chainCompressionFuseRange") {
803
786
  newConfig.chainCompression = { ...newConfig.chainCompression, fuseRangeSummary: newValue === "true" };
804
- } else if (id === "thinkingStripEnabled") {
805
- newConfig.thinkingStrip = { ...newConfig.thinkingStrip, enabled: newValue === "true" };
806
- } else if (id === "thinkingStripKeepLastTurns") {
807
- const parsed = Number.parseInt(newValue, 10);
808
- newConfig.thinkingStrip = {
809
- ...newConfig.thinkingStrip,
810
- keepLastTurns: Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_CONFIG.thinkingStrip.keepLastTurns,
811
- };
812
787
  } else if (id === "purgeErrorsEnabled") {
813
788
  newConfig.purgeErrors = { ...newConfig.purgeErrors, enabled: newValue === "true" };
814
789
  } else if (id === "purgeErrorsCooldown") {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, beforeAll, afterAll } from "bun:test";
2
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { DEFAULT_CONFIG } from "./types.js";
@@ -14,6 +14,7 @@ import { DEFAULT_CONFIG } from "./types.js";
14
14
  */
15
15
  let tmpDir: string;
16
16
  let loadConfig: typeof import("./config.js").loadConfig;
17
+ let saveConfig: typeof import("./config.js").saveConfig;
17
18
  let settingsPath: typeof import("./config.js").settingsPath;
18
19
 
19
20
  beforeAll(async () => {
@@ -21,6 +22,7 @@ beforeAll(async () => {
21
22
  process.env.PI_CODING_AGENT_DIR = tmpDir;
22
23
  const mod = await import("./config.js");
23
24
  loadConfig = mod.loadConfig;
25
+ saveConfig = mod.saveConfig;
24
26
  settingsPath = mod.settingsPath;
25
27
  });
26
28
 
@@ -99,3 +101,27 @@ describe("loadConfig summarizer timeout normalization", () => {
99
101
  expect(config.summarizerIdleTimeoutMs).toBe(1234);
100
102
  });
101
103
  });
104
+
105
+ describe("loadConfig backward compatibility with removed thinkingStrip key", () => {
106
+ it("loads without error and round-trips a stale contextPrune.thinkingStrip block unchanged", async () => {
107
+ const stale = { enabled: true, keepLastTurns: 16 };
108
+ await writeContextPrune({ thinkingStrip: stale });
109
+
110
+ const config = await loadConfig();
111
+
112
+ // thinkingStrip is no longer a recognized key: DEFAULT_CONFIG carries no
113
+ // such field, so nothing reads or acts on it.
114
+ expect((DEFAULT_CONFIG as unknown as Record<string, unknown>).thinkingStrip).toBeUndefined();
115
+ // normalize() spreads { ...DEFAULT_CONFIG, ...existing } and re-spreads
116
+ // the merge, so the unrecognized key survives verbatim on the loaded value.
117
+ expect((config as unknown as Record<string, unknown>).thinkingStrip).toEqual(stale);
118
+
119
+ // saveConfig() re-serializes the same config object it's given, so the
120
+ // stale block written above must still be present, byte-equivalent, after
121
+ // a full load -> save round trip through the real settingsPath() file.
122
+ await saveConfig(config);
123
+ const raw = await readFile(settingsPath(), "utf-8");
124
+ const written = JSON.parse(raw);
125
+ expect(written.contextPrune.thinkingStrip).toEqual(stale);
126
+ });
127
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { PruneFrontierTracker } from "./frontier.js";
3
+ import { pruneMessages } from "./pruner.js";
3
4
  import type { PruneFrontier } from "./types.js";
4
5
 
5
6
  const base: PruneFrontier = {
@@ -14,39 +15,159 @@ const base: PruneFrontier = {
14
15
  outcome: "summarized",
15
16
  };
16
17
 
17
- describe("PruneFrontierTracker.fromJSON - thinkingStripBoundaryTimestamp", () => {
18
- test("round-trips the boundary field", () => {
18
+ describe("PruneFrontierTracker.fromJSON", () => {
19
+ test("round-trips a full frontier", () => {
19
20
  const t = new PruneFrontierTracker();
20
- t.fromJSON({ ...base, thinkingStripBoundaryTimestamp: 777 });
21
- expect(t.get()?.thinkingStripBoundaryTimestamp).toBe(777);
21
+ t.fromJSON({ ...base });
22
+ expect(t.get()?.lastAttemptedToolCallId).toBe("tc1");
23
+ expect(t.get()?.outcome).toBe("summarized");
22
24
  });
23
25
 
24
- test("absent boundary stays undefined (live-count fallback)", () => {
26
+ test("ignores an entry with no lastAttemptedToolCallId", () => {
25
27
  const t = new PruneFrontierTracker();
26
- t.fromJSON({ ...base });
27
- expect(t.get()?.thinkingStripBoundaryTimestamp).toBeUndefined();
28
+ t.fromJSON({} as PruneFrontier);
29
+ expect(t.get()).toBeNull();
30
+ });
31
+
32
+ test("tolerates a legacy entry carrying the removed thinkingStripBoundaryTimestamp", () => {
33
+ const t = new PruneFrontierTracker();
34
+ t.fromJSON({ ...base, thinkingStripBoundaryTimestamp: 777 } as PruneFrontier);
35
+ expect(t.get()?.lastAttemptedToolCallId).toBe("tc1");
36
+ expect((t.get() as any).thinkingStripBoundaryTimestamp).toBeUndefined();
28
37
  });
29
38
  });
30
39
 
31
- describe("PruneFrontierTracker.reconstructFromSession - boundary survives reload", () => {
32
- test("reconstructs thinkingStripBoundaryTimestamp from a persisted frontier entry", () => {
40
+ describe("PruneFrontierTracker.reconstructFromSession", () => {
41
+ test("reconstructs from a persisted frontier entry", () => {
33
42
  const t = new PruneFrontierTracker();
34
43
  const entries = [
35
- { type: "custom", customType: "context-prune-frontier", data: { ...base, thinkingStripBoundaryTimestamp: 555 } },
44
+ { type: "custom", customType: "context-prune-frontier", data: { ...base, lastAttemptedTimestamp: 2000 } },
36
45
  ];
37
46
  const fakeCtx = { sessionManager: { getBranch: () => entries } } as any;
38
47
  t.reconstructFromSession(fakeCtx);
39
- expect(t.get()?.thinkingStripBoundaryTimestamp).toBe(555);
48
+ expect(t.get()?.lastAttemptedTimestamp).toBe(2000);
40
49
  });
41
50
 
42
- test("a persisted entry without the field reconstructs as undefined (live-count fallback)", () => {
51
+ // Spans frontier.ts + pruner.ts on purpose: proves a resumed legacy frontier carries
52
+ // no boundary into an *actively-pruning* pipeline, not just an inert one. Phase 1
53
+ // (stub-replace) and Phase 3 (chain-range-prune) are both wired live here -- a
54
+ // summarized toolResult gets stubbed and a chain entry produces a synthetic
55
+ // <compressed-chain> message -- and every surviving assistant turn, both older and
56
+ // newer than the legacy boundary, still carries its thinking block. This does not
57
+ // (and cannot) prove the deleted thinking-strip phase stays deleted; it proves the
58
+ // phases that remain do not touch thinking regardless of the legacy field's presence.
59
+ test("a legacy frontier entry with thinkingStripBoundaryTimestamp resumes without error and strips nothing", () => {
60
+ const legacyBoundary = 555;
43
61
  const t = new PruneFrontierTracker();
44
62
  const entries = [
45
- { type: "custom", customType: "context-prune-frontier", data: { ...base } },
63
+ {
64
+ type: "custom",
65
+ customType: "context-prune-frontier",
66
+ data: { ...base, lastAttemptedTimestamp: 2000, thinkingStripBoundaryTimestamp: legacyBoundary },
67
+ },
46
68
  ];
47
69
  const fakeCtx = { sessionManager: { getBranch: () => entries } } as any;
48
- t.reconstructFromSession(fakeCtx);
49
- expect(t.get()).not.toBeNull();
50
- expect(t.get()?.thinkingStripBoundaryTimestamp).toBeUndefined();
70
+
71
+ expect(() => t.reconstructFromSession(fakeCtx)).not.toThrow();
72
+ const frontier = t.get();
73
+ expect(frontier).not.toBeNull();
74
+ expect(frontier?.lastAttemptedToolCallId).toBe("tc1");
75
+ expect((frontier as any).thinkingStripBoundaryTimestamp).toBeUndefined();
76
+
77
+ const chainEntry = {
78
+ blockId: "b1",
79
+ startUserTimestamp: 560,
80
+ droppedToolCallIds: ["tc-old"],
81
+ finalAssistantTimestamp: 600,
82
+ toolRefs: ["told"],
83
+ compressedAt: 9999,
84
+ };
85
+
86
+ const indexer = {
87
+ isSummarized: (id: string) => id === "tc-old" || id === "tc-stub",
88
+ getShortRefForToolCallId: (id: string) => (id === "tc-stub" ? "t1" : id === "tc-old" ? "told" : undefined),
89
+ getRecord: () => undefined,
90
+ getChainEntries: () => [chainEntry],
91
+ getPerBatchSummaryTextForToolCallIds: () => "chain summary text",
92
+ findChainEntryByBlockId: () => undefined,
93
+ } as any;
94
+
95
+ const mkAsst = (ts: number) => ({
96
+ role: "assistant",
97
+ content: [
98
+ { type: "thinking", thinking: "t", thinkingSignature: "s" },
99
+ { type: "text", text: "x" },
100
+ ],
101
+ timestamp: ts,
102
+ usage: {},
103
+ stopReason: "end_turn",
104
+ });
105
+ const mkAsstWithCall = (ts: number, toolCallId: string) => ({
106
+ role: "assistant",
107
+ content: [
108
+ { type: "thinking", thinking: "t", thinkingSignature: "s" },
109
+ { type: "toolCall", id: toolCallId, name: "bash", arguments: {} },
110
+ ],
111
+ timestamp: ts,
112
+ usage: {},
113
+ stopReason: "tool_use",
114
+ });
115
+
116
+ // Timestamps straddle the legacy boundary: old code would have stripped the ones below it.
117
+ // tc-stub is a plain summarized tool result (phase 1 target, outside the chain).
118
+ // tc-old is dropped by the chain entry (phase 3 target).
119
+ const messages: any[] = [
120
+ { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 },
121
+ mkAsst(legacyBoundary - 100),
122
+ mkAsstWithCall(legacyBoundary - 55, "tc-stub"),
123
+ {
124
+ role: "toolResult",
125
+ toolCallId: "tc-stub",
126
+ toolName: "bash",
127
+ content: [{ type: "text", text: "raw stub-target output" }],
128
+ isError: false,
129
+ timestamp: legacyBoundary - 50,
130
+ },
131
+ mkAsst(legacyBoundary - 1),
132
+ { role: "user", content: [{ type: "text", text: "do it" }], timestamp: 560 },
133
+ mkAsstWithCall(570, "tc-old"),
134
+ {
135
+ role: "toolResult",
136
+ toolCallId: "tc-old",
137
+ toolName: "bash",
138
+ content: [{ type: "text", text: "raw chain output" }],
139
+ isError: false,
140
+ timestamp: 575,
141
+ },
142
+ mkAsst(600),
143
+ mkAsst(legacyBoundary + 100),
144
+ ];
145
+
146
+ const { messages: out, pruned } = pruneMessages(messages, indexer, {
147
+ enabled: true,
148
+ rollingWindow: 0,
149
+ stripFinalAssistantThinking: false,
150
+ fuseRangeSummary: false,
151
+ });
152
+
153
+ // Non-vacuity: the pipeline actually did something.
154
+ expect(pruned).toBe(true);
155
+
156
+ // Phase 1 fired: the summarized-but-not-chained toolResult was stub-replaced.
157
+ const stubResult = out.find((m: any) => m.role === "toolResult" && m.toolCallId === "tc-stub") as any;
158
+ expect(stubResult).toBeDefined();
159
+ expect(stubResult.content[0].text).toContain("`t1`");
160
+ expect(stubResult.content[0].text).not.toContain("raw stub-target output");
161
+
162
+ // Phase 3 fired: the chain entry produced a synthetic compressed-chain message.
163
+ const synthetic = out.find(
164
+ (m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"),
165
+ );
166
+ expect(synthetic).toBeDefined();
167
+
168
+ // Every surviving assistant turn, older and newer than the legacy boundary, keeps thinking.
169
+ const assistants = out.filter((m: any) => m.role === "assistant");
170
+ expect(assistants.length).toBe(5);
171
+ expect(assistants.every((a: any) => a.content.some((c: any) => c.type === "thinking"))).toBe(true);
51
172
  });
52
173
  });
package/src/frontier.ts CHANGED
@@ -32,7 +32,6 @@ 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,
36
35
  };
37
36
  }
38
37
 
@@ -1,7 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { pruneMessages, sizeMessages } from "./pruner.js";
3
- import type { ChainCompressionConfig, ChainCompressionEntry, ThinkingStripConfig } from "./types.js";
4
- import { ToolCallIndexer } from "./indexer.js";
3
+ import type { ChainCompressionConfig, ChainCompressionEntry } from "./types.js";
5
4
 
6
5
  // Minimal mock exposing only the ToolCallIndexer surface that pruneMessages calls.
7
6
  function makeMockIndexer({
@@ -375,7 +374,7 @@ describe("pruneMessages", () => {
375
374
  expect(pruned).toBe(false);
376
375
  });
377
376
 
378
- it("composes stub-replace (Phase 1) with thinking-strip (Phase 4)", () => {
377
+ it("leaves thinking blocks on every assistant turn (no thinking-strip phase)", () => {
379
378
  const indexer = makeMockIndexer({ summarized: new Set(["c10"]), shortRefs: new Map([["c10", "t1"]]) });
380
379
  const mkAsst = (ts: number) => ({
381
380
  role: "assistant",
@@ -394,40 +393,17 @@ describe("pruneMessages", () => {
394
393
  messages.push(mkAsst(10 + i));
395
394
  messages.push({ role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: "o" }], isError: false, timestamp: 100 + i });
396
395
  }
397
- const { messages: out, pruned } = pruneMessages(messages, indexer, undefined, undefined, {
398
- enabled: true,
399
- keepLastTurns: 2,
396
+ const { messages: out, pruned } = pruneMessages(messages, indexer, {
397
+ enabled: true, rollingWindow: 0, stripFinalAssistantThinking: false, fuseRangeSummary: false,
400
398
  });
399
+ // Phase 1 still fires: c10's toolResult is stub-replaced.
401
400
  expect(pruned).toBe(true);
402
-
403
- // Phase 1: c10 toolResult stub-replaced
404
401
  const tr = out.find((m: any) => m.role === "toolResult" && m.toolCallId === "c10") as any;
405
402
  expect(tr.content[0].text).toContain("`t1`");
406
-
407
- // Phase 4: oldest 3 assistant turns stripped, last 2 keep thinking
403
+ // No phase strips thinking any more — all five assistants keep theirs.
408
404
  const assistants = out.filter((m: any) => m.role === "assistant");
409
- const hasThinking = (m: any) => m.content.some((c: any) => c.type === "thinking");
410
- expect(assistants.slice(0, 3).every((a: any) => !hasThinking(a))).toBe(true);
411
- expect(assistants.slice(-2).every((a: any) => hasThinking(a))).toBe(true);
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);
405
+ expect(assistants.length).toBe(5);
406
+ expect(assistants.every((a: any) => a.content.some((c: any) => c.type === "thinking"))).toBe(true);
431
407
  });
432
408
  });
433
409
 
@@ -457,7 +433,7 @@ describe("render-time protection re-check", () => {
457
433
 
458
434
  it("leaves a summarized record verbatim once its path matches protectedPaths", () => {
459
435
  const { messages, pruned } = pruneMessages(
460
- [skillMsg], indexer as any, undefined, undefined, undefined,
436
+ [skillMsg], indexer as any, undefined, undefined,
461
437
  { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
462
438
  );
463
439
  expect(pruned).toBe(false);
@@ -488,7 +464,7 @@ describe("pruneMessages recovery grace", () => {
488
464
  shortRefs: new Map([["tc-recover", "t1"]]),
489
465
  });
490
466
  const messages = [mkQueryResult("tc-recover", 1)];
491
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
467
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
492
468
  expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
493
469
  });
494
470
 
@@ -498,7 +474,7 @@ describe("pruneMessages recovery grace", () => {
498
474
  shortRefs: new Map([["tc-recover", "t1"]]),
499
475
  });
500
476
  const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
501
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
477
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
502
478
  const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
503
479
  expect(tr.content[0].text).toContain("context_tree_query");
504
480
  expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
@@ -510,7 +486,7 @@ describe("pruneMessages recovery grace", () => {
510
486
  shortRefs: new Map([["tc-recover", "t1"]]),
511
487
  });
512
488
  const messages = [mkQueryResult("tc-recover", 1)];
513
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 0);
489
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 0);
514
490
  expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
515
491
  expect(out[0].content[0].text).toContain("context_tree_query");
516
492
  });
@@ -530,7 +506,7 @@ describe("pruneMessages recovery grace", () => {
530
506
  timestamp: 1,
531
507
  },
532
508
  ];
533
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
509
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
534
510
  expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
535
511
  expect(out[0].content[0].text).toContain("context_tree_query");
536
512
  });
@@ -546,7 +522,7 @@ describe("pruneMessages recovery grace", () => {
546
522
  });
547
523
  const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
548
524
  const { messages: out } = pruneMessages(
549
- messages, indexer, undefined, undefined, undefined,
525
+ messages, indexer, undefined, undefined,
550
526
  { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
551
527
  0,
552
528
  );
@@ -565,7 +541,7 @@ describe("pruneMessages recovery grace", () => {
565
541
  }]]),
566
542
  });
567
543
  const messages = [mkQueryResult("tc-recover", 1)];
568
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
544
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
569
545
  expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
570
546
  });
571
547
 
@@ -580,7 +556,7 @@ describe("pruneMessages recovery grace", () => {
580
556
  }]]),
581
557
  });
582
558
  const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
583
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
559
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
584
560
  const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
585
561
  expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
586
562
  expect(tr.content[0].text).toContain("/blobs/tc-recover.txt");
@@ -592,7 +568,7 @@ describe("sizeMessages", () => {
592
568
  it("counts hidden fields (thinking blocks), not just visible text", () => {
593
569
  // Two messages with identical visible .text but different hidden content.
594
570
  // sizeMessages must count the full serialized weight so all reclaim
595
- // mechanisms (thinking-strip, error-purge, etc.) register correctly.
571
+ // mechanisms (stub-replace, error-purge, chain-range-prune) register correctly.
596
572
  const withThinking = [{
597
573
  role: "assistant",
598
574
  content: [
package/src/pruner.ts CHANGED
@@ -1,23 +1,22 @@
1
1
  import type { ToolCallIndexer } from "./indexer.js";
2
- import type { ChainCompressionConfig, ErrorPurgeConfig, ThinkingStripConfig } from "./types.js";
2
+ import type { ChainCompressionConfig, ErrorPurgeConfig } from "./types.js";
3
3
  import { isProtected, type ProtectionConfig } from "./protected.js";
4
4
  import { applyChainCompressions } from "./chain-range-prune.js";
5
5
  import { purgeErroredArgs } from "./error-purge.js";
6
- import { stripOldThinking } from "./thinking-strip.js";
7
6
  import { inGraceRecoveryToolCallIds } from "./recovery-grace.js";
8
7
 
9
8
  /**
10
9
  * Estimate of a message array's context weight. Serializing the whole array
11
10
  * (not just visible text) is deliberate: it counts tool-call argument bodies
12
- * (error-purge), thinking blocks (thinking-strip), and tool-result arrays
13
- * (stub-replace / chain-range) so all reclaim mechanisms register.
11
+ * (error-purge) and tool-result arrays (stub-replace / chain-range) so all
12
+ * reclaim mechanisms register.
14
13
  */
15
14
  export function sizeMessages(messages: any[]): number {
16
15
  return JSON.stringify(messages).length;
17
16
  }
18
17
 
19
18
  /**
20
- * Transforms the `context` event message array in two passes:
19
+ * Transforms the `context` event message array in three phases:
21
20
  *
22
21
  * Phase 1 — stub-replace: ToolResultMessages for summarized tool calls are
23
22
  * replaced with short stubs pointing the model at `context_tree_query`.
@@ -43,12 +42,6 @@ export function sizeMessages(messages: any[]): number {
43
42
  * synthetic user message wrapping the existing per-batch summary text.
44
43
  * Only runs when `chainCompression.enabled` and chain entries exist.
45
44
  *
46
- * Phase 4 — thinking strip: keep `thinking` blocks only on the last
47
- * `keepLastTurns` assistant turns; strip them from older assistant messages
48
- * (preserving text + toolCall). Runs last so the window counts the assistant
49
- * turns that actually survive to the LLM. Only runs when
50
- * `thinkingStrip.enabled`.
51
- *
52
45
  * Return shape:
53
46
  * - `pruned: true` — at least one change happened; the returned
54
47
  * `messages` is a freshly allocated array.
@@ -71,10 +64,8 @@ export function pruneMessages(
71
64
  indexer: ToolCallIndexer,
72
65
  chainCompression?: ChainCompressionConfig,
73
66
  errorPurge?: ErrorPurgeConfig,
74
- thinkingStrip?: ThinkingStripConfig,
75
67
  protection?: ProtectionConfig,
76
68
  recoveryGraceTurns: number = 0,
77
- thinkingBoundaryTimestamp?: number,
78
69
  ): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
79
70
  // Phase 1: stub-replace summarized tool results
80
71
  let pruned = false;
@@ -155,15 +146,6 @@ export function pruneMessages(
155
146
  }
156
147
  }
157
148
 
158
- // Phase 4: thinking strip — keep thinking only on the last K assistant turns
159
- if (thinkingStrip?.enabled) {
160
- const afterStrip = stripOldThinking(current, thinkingStrip, thinkingBoundaryTimestamp);
161
- if (afterStrip !== current) {
162
- current = afterStrip;
163
- pruned = true;
164
- }
165
- }
166
-
167
149
  return pruned
168
150
  ? { messages: current, pruned, beforeChars: sizeMessages(messages), afterChars: sizeMessages(current) }
169
151
  : { messages, pruned, beforeChars: 0, afterChars: 0 };
@@ -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, ThinkingStripConfig } from "./types.js";
8
+ import type { ChainRange, ChainCompressionConfig } 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,46 +249,4 @@ 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
- });
294
252
  });
package/src/types.ts CHANGED
@@ -163,19 +163,6 @@ export const ROLLING_WINDOW_PRESETS: { value: string; label: string }[] = [
163
163
  { value: "10", label: "10" },
164
164
  ];
165
165
 
166
- /**
167
- * Cycling preset values for the `thinkingStrip.keepLastTurns` setting.
168
- * Stored as strings because SettingsList cycles string values; converted to
169
- * number when applied. Counts ASSISTANT turns (messages), not closed chains.
170
- */
171
- export const KEEP_LAST_TURNS_PRESETS: { value: string; label: string }[] = [
172
- { value: "4", label: "4" },
173
- { value: "8", label: "8" },
174
- { value: "16", label: "16 (default)" },
175
- { value: "32", label: "32" },
176
- { value: "64", label: "64" },
177
- ];
178
-
179
166
  /**
180
167
  * Cycling preset values for the `minBatchChars` setting in the SettingsList.
181
168
  * Stored as strings because SettingsList cycles string values; converted to
@@ -349,8 +336,6 @@ export interface ContextPruneConfig {
349
336
  chainCompression: ChainCompressionConfig;
350
337
  /** Replace failed toolCall argument bodies with compact stubs after a cooldown window. */
351
338
  purgeErrors: ErrorPurgeConfig;
352
- /** Rolling main-loop thinking-block strip: keep thinking only on the last K assistant turns. */
353
- thinkingStrip: ThinkingStripConfig;
354
339
  /**
355
340
  * Pre-flush content-hash dedup pass. When `true`, each captured tool call
356
341
  * is hashed by `(toolName, normalize(resultText))` and compared against
@@ -497,18 +482,6 @@ export interface ErrorPurgeConfig {
497
482
  minArgChars: number;
498
483
  }
499
484
 
500
- export interface ThinkingStripConfig {
501
- enabled: boolean;
502
- /**
503
- * Keep `thinking` blocks on the last K assistant turns; strip them from
504
- * older assistant messages (preserving text + toolCall blocks). Counts
505
- * assistant messages, not closed chains. Clamped to >= 1 so the most-recent
506
- * assistant turn always keeps its thinking (Anthropic requires the last
507
- * assistant turn's thinking during tool use). Default 16.
508
- */
509
- keepLastTurns: number;
510
- }
511
-
512
485
  export const DEFAULT_CONFIG: ContextPruneConfig = {
513
486
  enabled: false,
514
487
  showPruneStatusLine: true,
@@ -534,10 +507,6 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
534
507
  cooldownTurns: 2,
535
508
  minArgChars: 500,
536
509
  },
537
- thinkingStrip: {
538
- enabled: true,
539
- keepLastTurns: 16,
540
- },
541
510
  dedupByContentHash: true,
542
511
  autoBudgetThreshold: null,
543
512
  spillThreshold: 65536,
@@ -730,14 +699,6 @@ export interface PruneFrontier {
730
699
  summaryCharCount: number;
731
700
  /** Whether the attempt actually pruned or was skipped for being oversized */
732
701
  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;
741
702
  }
742
703
 
743
704
  /**
@@ -1,257 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { stripOldThinking, computeThinkingBoundary } from "./thinking-strip.js";
3
- import type { ThinkingStripConfig } from "./types.js";
4
-
5
- const cfg = (enabled: boolean, keepLastTurns: number): ThinkingStripConfig => ({ enabled, keepLastTurns });
6
-
7
- function userMsg(ts: number): any {
8
- return { role: "user", content: [{ type: "text", text: "go" }], timestamp: ts };
9
- }
10
-
11
- function assistantToolsThinking(ts: number, toolCallIds: string[], thinkingBlocks = 1): any {
12
- const content: any[] = [];
13
- for (let i = 0; i < thinkingBlocks; i++) {
14
- content.push({ type: "thinking", thinking: `t${ts}-${i}`, thinkingSignature: `sig${ts}-${i}` });
15
- }
16
- content.push({ type: "text", text: "working" });
17
- for (const id of toolCallIds) content.push({ type: "toolCall", id, name: "bash", arguments: { cmd: "ls" } });
18
- return { role: "assistant", content, timestamp: ts, usage: {}, stopReason: "toolUse" };
19
- }
20
-
21
- function assistantTextThinking(ts: number): any {
22
- return {
23
- role: "assistant",
24
- content: [
25
- { type: "thinking", thinking: "final reasoning", thinkingSignature: "sigf" },
26
- { type: "text", text: "done" },
27
- ],
28
- timestamp: ts,
29
- usage: {},
30
- stopReason: "stop",
31
- };
32
- }
33
-
34
- function toolResult(ts: number, toolCallId: string): any {
35
- return {
36
- role: "toolResult",
37
- toolCallId,
38
- toolName: "bash",
39
- content: [{ type: "text", text: "out" }],
40
- isError: false,
41
- timestamp: ts,
42
- };
43
- }
44
-
45
- function hasThinking(msg: any): boolean {
46
- return Array.isArray(msg.content) && msg.content.some((c: any) => c.type === "thinking");
47
- }
48
-
49
- function countThinking(msg: any): number {
50
- return Array.isArray(msg.content) ? msg.content.filter((c: any) => c.type === "thinking").length : 0;
51
- }
52
-
53
- /** user, then (n-1) tool-using assistant turns each followed by a toolResult, then 1 final text assistant. */
54
- function convo(nAssistantTurns: number): any[] {
55
- const msgs: any[] = [userMsg(1)];
56
- let ts = 2;
57
- for (let i = 0; i < nAssistantTurns - 1; i++) {
58
- const id = `tc${i}`;
59
- msgs.push(assistantToolsThinking(ts++, [id]));
60
- msgs.push(toolResult(ts++, id));
61
- }
62
- msgs.push(assistantTextThinking(ts++));
63
- return msgs;
64
- }
65
-
66
- describe("stripOldThinking", () => {
67
- test("disabled → same reference", () => {
68
- const msgs = convo(20);
69
- expect(stripOldThinking(msgs, cfg(false, 16))).toBe(msgs);
70
- });
71
-
72
- test("fewer assistant turns than keepLastTurns → same reference", () => {
73
- const msgs = convo(10);
74
- expect(stripOldThinking(msgs, cfg(true, 16))).toBe(msgs);
75
- });
76
-
77
- test("exactly keepLastTurns assistant turns → same reference (nothing older)", () => {
78
- const msgs = convo(16);
79
- expect(stripOldThinking(msgs, cfg(true, 16))).toBe(msgs);
80
- });
81
-
82
- test("strips thinking from turns older than the last K, keeps the last K", () => {
83
- const msgs = convo(20);
84
- const out = stripOldThinking(msgs, cfg(true, 16));
85
- expect(out).not.toBe(msgs);
86
- const assistants = out.filter((m) => m.role === "assistant");
87
- expect(assistants.length).toBe(20);
88
- for (const a of assistants.slice(-16)) expect(hasThinking(a)).toBe(true);
89
- for (const a of assistants.slice(0, 4)) expect(hasThinking(a)).toBe(false);
90
- });
91
-
92
- test("keepLastTurns=1 keeps only the most-recent assistant turn's thinking", () => {
93
- const msgs = convo(5);
94
- const out = stripOldThinking(msgs, cfg(true, 1));
95
- const assistants = out.filter((m) => m.role === "assistant");
96
- expect(hasThinking(assistants[assistants.length - 1])).toBe(true);
97
- for (const a of assistants.slice(0, -1)) expect(hasThinking(a)).toBe(false);
98
- });
99
-
100
- test("keepLastTurns=0 is clamped to 1 (never strips the last assistant turn)", () => {
101
- const msgs = convo(5);
102
- const out = stripOldThinking(msgs, cfg(true, 0));
103
- const assistants = out.filter((m) => m.role === "assistant");
104
- expect(hasThinking(assistants[assistants.length - 1])).toBe(true);
105
- expect(hasThinking(assistants[0])).toBe(false);
106
- });
107
-
108
- test("trailing tool-use assistant awaiting results keeps its thinking", () => {
109
- const msgs: any[] = [userMsg(1)];
110
- let ts = 2;
111
- for (let i = 0; i < 4; i++) {
112
- const id = `x${i}`;
113
- msgs.push(assistantToolsThinking(ts++, [id]));
114
- msgs.push(toolResult(ts++, id));
115
- }
116
- const out = stripOldThinking(msgs, cfg(true, 1));
117
- const assistants = out.filter((m) => m.role === "assistant");
118
- const last = assistants[assistants.length - 1];
119
- expect(hasThinking(last)).toBe(true);
120
- expect(last.content.some((c: any) => c.type === "toolCall")).toBe(true);
121
- });
122
-
123
- test("stripped assistant keeps its text and toolCall blocks", () => {
124
- const msgs = convo(20);
125
- const out = stripOldThinking(msgs, cfg(true, 16));
126
- const firstAssistant = out.find((m) => m.role === "assistant");
127
- expect(hasThinking(firstAssistant)).toBe(false);
128
- expect(firstAssistant.content.some((c: any) => c.type === "text")).toBe(true);
129
- expect(firstAssistant.content.some((c: any) => c.type === "toolCall")).toBe(true);
130
- });
131
-
132
- test("strips all thinking blocks from a message (all-or-nothing)", () => {
133
- const msgs: any[] = [userMsg(1), assistantToolsThinking(2, ["a"], 2), toolResult(3, "a")];
134
- let ts = 4;
135
- for (let i = 0; i < 3; i++) {
136
- const id = `b${i}`;
137
- msgs.push(assistantToolsThinking(ts++, [id], 2));
138
- msgs.push(toolResult(ts++, id));
139
- }
140
- msgs.push(assistantTextThinking(ts++));
141
- const out = stripOldThinking(msgs, cfg(true, 2));
142
- expect(countThinking(out[1])).toBe(0);
143
- });
144
-
145
- test("no thinking anywhere → same reference", () => {
146
- const msgs: any[] = [userMsg(1)];
147
- let ts = 2;
148
- for (let i = 0; i < 20; i++) {
149
- const id = `n${i}`;
150
- msgs.push({
151
- role: "assistant",
152
- content: [{ type: "text", text: "x" }, { type: "toolCall", id, name: "bash", arguments: {} }],
153
- timestamp: ts++,
154
- usage: {},
155
- stopReason: "toolUse",
156
- });
157
- msgs.push(toolResult(ts++, id));
158
- }
159
- expect(stripOldThinking(msgs, cfg(true, 4))).toBe(msgs);
160
- });
161
-
162
- test("idempotent: second pass returns same reference", () => {
163
- const msgs = convo(20);
164
- const once = stripOldThinking(msgs, cfg(true, 16));
165
- const twice = stripOldThinking(once, cfg(true, 16));
166
- expect(twice).toBe(once);
167
- });
168
-
169
- test("preserves message order and length", () => {
170
- const msgs = convo(20);
171
- const out = stripOldThinking(msgs, cfg(true, 16));
172
- expect(out.length).toBe(msgs.length);
173
- out.forEach((m, i) => expect(m.role).toBe(msgs[i].role));
174
- });
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
- });
@@ -1,83 +0,0 @@
1
- import { withoutThinkingBlocks } from "./chain-range-prune.js";
2
- import type { ThinkingStripConfig } from "./types.js";
3
-
4
- /**
5
- * Rolling main-loop thinking strip.
6
- *
7
- * Keeps `thinking` blocks on the last `keepLastTurns` assistant turns and
8
- * strips them from all older assistant messages, preserving each message's
9
- * `text` and `toolCall` blocks. "Turn" counts ASSISTANT messages, not
10
- * user-bounded spans — the target failure mode is a single long open chain
11
- * (zero subagents, near-zero user turns) where a span-based window keeps
12
- * everything.
13
- *
14
- * Provider safety (Anthropic): during tool use only the LAST assistant turn's
15
- * thinking is required; prior turns may be omitted, and a message's thinking
16
- * blocks must be dropped all-or-nothing. `keepLastTurns` is clamped to >= 1 so
17
- * the most-recent assistant turn always keeps its thinking. Stripping reuses
18
- * `withoutThinkingBlocks` (drops the whole block incl. signature).
19
- *
20
- * Returns the original array reference unchanged when nothing is stripped, so
21
- * `pruneMessages` can skip reconstruction.
22
- */
23
- export function stripOldThinking(
24
- messages: any[],
25
- config: ThinkingStripConfig,
26
- boundaryTimestamp?: number,
27
- ): any[] {
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).
46
- const keep = Math.max(1, config.keepLastTurns);
47
-
48
- const assistantIdx: number[] = [];
49
- for (let i = 0; i < messages.length; i++) {
50
- if (messages[i]?.role === "assistant") assistantIdx.push(i);
51
- }
52
- if (assistantIdx.length <= keep) return messages;
53
-
54
- const firstKeptAssistant = assistantIdx[assistantIdx.length - keep];
55
- let changed = false;
56
- const out = messages.map((msg, i) => {
57
- if (i >= firstKeptAssistant || msg?.role !== "assistant") return msg;
58
- if (!Array.isArray(msg.content) || !msg.content.some((c: any) => c.type === "thinking")) return msg;
59
- changed = true;
60
- return withoutThinkingBlocks(msg);
61
- });
62
- return changed ? out : messages;
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
- }