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 +4 -0
- package/PRUNING.md +3 -3
- package/index.ts +34 -6
- package/package.json +1 -1
- package/src/frontier.test.ts +52 -0
- package/src/frontier.ts +1 -0
- package/src/pruner.test.ts +21 -1
- package/src/pruner.ts +2 -1
- package/src/range-compression.integration.test.ts +43 -1
- package/src/thinking-strip.test.ts +83 -1
- package/src/thinking-strip.ts +42 -1
- package/src/types.ts +8 -0
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
534
|
-
const
|
|
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.
|
|
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
package/src/pruner.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/thinking-strip.ts
CHANGED
|
@@ -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(
|
|
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
|
/**
|