pi-condense 2.9.1 → 2.10.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 +9 -0
- package/PRUNING.md +11 -3
- package/README.md +9 -3
- package/index.ts +32 -51
- package/package.json +1 -1
- package/src/batch-capture.test.ts +116 -1
- package/src/batch-capture.ts +47 -16
- package/src/budget.test.ts +21 -1
- package/src/budget.ts +10 -0
- package/src/chain-compressor.test.ts +17 -0
- package/src/chain-detector.test.ts +77 -1
- package/src/chain-detector.ts +26 -7
- package/src/chain-range-prune.test.ts +83 -0
- package/src/chain-range-prune.ts +7 -5
- package/src/commands.test.ts +1 -28
- package/src/commands.ts +8 -14
- package/src/config.test.ts +22 -0
- package/src/config.ts +6 -0
- package/src/context-metrics.test.ts +35 -5
- package/src/context-metrics.ts +5 -2
- package/src/reload-rearm.integration.test.ts +369 -98
- package/src/types.ts +17 -3
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { detectChains, withClosingMessage } from "./chain-detector.js";
|
|
2
|
+
import { detectChains, isChainAnchorCustom, withClosingMessage } from "./chain-detector.js";
|
|
3
|
+
|
|
4
|
+
const custom = (customType: string, timestamp: number) => ({ role: "custom", customType, timestamp });
|
|
3
5
|
|
|
4
6
|
// ── Minimal message factories ──────────────────────────────────────────────
|
|
5
7
|
|
|
@@ -349,3 +351,77 @@ describe("withClosingMessage", () => {
|
|
|
349
351
|
expect(ranges[1].finalAssistantTimestamp).toBe(800);
|
|
350
352
|
});
|
|
351
353
|
});
|
|
354
|
+
|
|
355
|
+
describe("chain-anchor custom messages", () => {
|
|
356
|
+
test("custom message while idle opens a chain", () => {
|
|
357
|
+
const messages = [
|
|
358
|
+
custom("pi-gauntlet-transition-recovery", 100),
|
|
359
|
+
{ role: "assistant", timestamp: 200, content: [{ type: "toolCall", id: "tc1", name: "read", input: {} }] },
|
|
360
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "read", timestamp: 250, content: [] },
|
|
361
|
+
{ role: "assistant", timestamp: 300, content: [{ type: "text", text: "done" }] },
|
|
362
|
+
];
|
|
363
|
+
const ranges = detectChains(messages);
|
|
364
|
+
expect(ranges).toHaveLength(1);
|
|
365
|
+
expect(ranges[0].startUserTimestamp).toBe(100);
|
|
366
|
+
expect(ranges[0].finalAssistantTimestamp).toBe(300);
|
|
367
|
+
expect(ranges[0].middleToolCallIds).toEqual(["tc1"]);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("custom message mid-chain is passthrough; enclosing chain closes as one range", () => {
|
|
371
|
+
const messages = [
|
|
372
|
+
{ role: "user", timestamp: 100, content: [{ type: "text", text: "go" }] },
|
|
373
|
+
{ role: "assistant", timestamp: 200, content: [{ type: "toolCall", id: "tc1", name: "read", input: {} }] },
|
|
374
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "read", timestamp: 250, content: [] },
|
|
375
|
+
custom("pi-gauntlet-transition-recovery", 260),
|
|
376
|
+
{ role: "assistant", timestamp: 300, content: [{ type: "toolCall", id: "tc2", name: "read", input: {} }] },
|
|
377
|
+
{ role: "toolResult", toolCallId: "tc2", toolName: "read", timestamp: 350, content: [] },
|
|
378
|
+
{ role: "assistant", timestamp: 400, content: [{ type: "text", text: "done" }] },
|
|
379
|
+
];
|
|
380
|
+
const ranges = detectChains(messages);
|
|
381
|
+
expect(ranges).toHaveLength(1);
|
|
382
|
+
expect(ranges[0].startUserTimestamp).toBe(100);
|
|
383
|
+
expect(ranges[0].middleToolCallIds).toEqual(["tc1", "tc2"]);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("context-prune-summary custom is passthrough in both states", () => {
|
|
387
|
+
// idle state: a summary custom must not anchor anything
|
|
388
|
+
const idleMessages = [
|
|
389
|
+
custom("context-prune-summary", 50),
|
|
390
|
+
{ role: "assistant", timestamp: 200, content: [{ type: "toolCall", id: "tc1", name: "read", input: {} }] },
|
|
391
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "read", timestamp: 250, content: [] },
|
|
392
|
+
{ role: "assistant", timestamp: 300, content: [{ type: "text", text: "done" }] },
|
|
393
|
+
];
|
|
394
|
+
expect(detectChains(idleMessages)).toHaveLength(0);
|
|
395
|
+
// in-chain: a summary custom between turns leaves the chain unbroken
|
|
396
|
+
const midMessages = [
|
|
397
|
+
{ role: "user", timestamp: 100, content: [{ type: "text", text: "go" }] },
|
|
398
|
+
{ role: "assistant", timestamp: 200, content: [{ type: "toolCall", id: "tc1", name: "read", input: {} }] },
|
|
399
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "read", timestamp: 250, content: [] },
|
|
400
|
+
custom("context-prune-summary", 260),
|
|
401
|
+
{ role: "assistant", timestamp: 300, content: [{ type: "toolCall", id: "tc2", name: "read", input: {} }] },
|
|
402
|
+
{ role: "toolResult", toolCallId: "tc2", toolName: "read", timestamp: 350, content: [] },
|
|
403
|
+
{ role: "assistant", timestamp: 400, content: [{ type: "text", text: "done" }] },
|
|
404
|
+
];
|
|
405
|
+
const ranges = detectChains(midMessages);
|
|
406
|
+
expect(ranges).toHaveLength(1);
|
|
407
|
+
expect(ranges[0].middleToolCallIds).toEqual(["tc1", "tc2"]);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("future context-prune-* customType is excluded", () => {
|
|
411
|
+
const messages = [
|
|
412
|
+
custom("context-prune-whatever", 100),
|
|
413
|
+
{ role: "assistant", timestamp: 200, content: [{ type: "toolCall", id: "tc1", name: "read", input: {} }] },
|
|
414
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "read", timestamp: 250, content: [] },
|
|
415
|
+
{ role: "assistant", timestamp: 300, content: [{ type: "text", text: "done" }] },
|
|
416
|
+
];
|
|
417
|
+
expect(detectChains(messages)).toHaveLength(0);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("isChainAnchorCustom predicate", () => {
|
|
421
|
+
expect(isChainAnchorCustom({ role: "custom", customType: "pi-gauntlet-transition-recovery" })).toBe(true);
|
|
422
|
+
expect(isChainAnchorCustom({ role: "custom", customType: "context-prune-summary" })).toBe(false);
|
|
423
|
+
expect(isChainAnchorCustom({ role: "custom" })).toBe(true); // missing customType -> not pruner-namespaced
|
|
424
|
+
expect(isChainAnchorCustom({ role: "user" })).toBe(false);
|
|
425
|
+
expect(isChainAnchorCustom(undefined)).toBe(false);
|
|
426
|
+
});
|
|
427
|
+
});
|
package/src/chain-detector.ts
CHANGED
|
@@ -23,12 +23,22 @@ function collectToolCalls(msg: any): { id: string; name: string; args: unknown }
|
|
|
23
23
|
.map((b: any) => ({ id: b.id as string, name: b.name as string, args: b.input ?? b.arguments }));
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* A non-pruner custom message: eligible to open a chain (while the detector is
|
|
28
|
+
* idle) and to act as a resolveRange start anchor. The `context-prune-`
|
|
29
|
+
* namespace prefix excludes every pruner-emitted custom message — today
|
|
30
|
+
* context-prune-summary; by construction any future pruner customType.
|
|
31
|
+
*/
|
|
32
|
+
export function isChainAnchorCustom(msg: any): boolean {
|
|
33
|
+
return msg?.role === "custom" && !String(msg.customType ?? "").startsWith("context-prune-");
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
type State = "idle" | "inChain";
|
|
27
37
|
|
|
28
38
|
/**
|
|
29
39
|
* Walks an AgentMessage array and emits ChainRange records for each detectable chain.
|
|
30
40
|
*
|
|
31
|
-
* A chain is: [user message] → [assistant+toolResult turns...] → [text-only assistant].
|
|
41
|
+
* A chain is: [user message or eligible custom message] → [assistant+toolResult turns...] → [text-only assistant].
|
|
32
42
|
* Synthetic chain messages (injected by chain-range-prune) are treated as passthroughs —
|
|
33
43
|
* not chain starts. This is defensive; the detector normally runs pre-compression.
|
|
34
44
|
*
|
|
@@ -49,6 +59,12 @@ export function detectChains(
|
|
|
49
59
|
let middleKeys = new Set<string>();
|
|
50
60
|
let protectedIds = new Set<string>();
|
|
51
61
|
|
|
62
|
+
const resetChain = () => {
|
|
63
|
+
middleIds = new Set();
|
|
64
|
+
middleKeys = new Set();
|
|
65
|
+
protectedIds = new Set();
|
|
66
|
+
};
|
|
67
|
+
|
|
52
68
|
const emitInterrupted = () => {
|
|
53
69
|
if (state === "inChain" && chainStart) {
|
|
54
70
|
ranges.push({
|
|
@@ -66,9 +82,14 @@ export function detectChains(
|
|
|
66
82
|
if (isSyntheticChainMessage(msg)) continue; // passthrough — not a chain start
|
|
67
83
|
emitInterrupted();
|
|
68
84
|
chainStart = { timestamp: msg.timestamp };
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
resetChain();
|
|
86
|
+
state = "inChain";
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (state === "idle" && isChainAnchorCustom(msg)) {
|
|
91
|
+
chainStart = { timestamp: msg.timestamp };
|
|
92
|
+
resetChain();
|
|
72
93
|
state = "inChain";
|
|
73
94
|
continue;
|
|
74
95
|
}
|
|
@@ -103,9 +124,7 @@ export function detectChains(
|
|
|
103
124
|
finalAssistantTimestamp: msg.timestamp,
|
|
104
125
|
});
|
|
105
126
|
chainStart = null;
|
|
106
|
-
|
|
107
|
-
middleKeys = new Set();
|
|
108
|
-
protectedIds = new Set();
|
|
127
|
+
resetChain();
|
|
109
128
|
state = "idle";
|
|
110
129
|
}
|
|
111
130
|
}
|
|
@@ -559,6 +559,35 @@ describe("applyChainCompressions", () => {
|
|
|
559
559
|
expect(synthetic.content[0].text).not.toContain("<protected-output");
|
|
560
560
|
});
|
|
561
561
|
|
|
562
|
+
test("custom-anchored range: drops interior and relocates protected output, same as the user-anchored equivalent", () => {
|
|
563
|
+
const e = {
|
|
564
|
+
blockId: "b1",
|
|
565
|
+
startUserTimestamp: 1,
|
|
566
|
+
droppedToolCallIds: ["tc-read", "tc-todo"],
|
|
567
|
+
protectedToolCallIds: ["tc-todo"],
|
|
568
|
+
finalAssistantTimestamp: 9,
|
|
569
|
+
toolRefs: ["t1", "t2"],
|
|
570
|
+
compressedAt: 100,
|
|
571
|
+
};
|
|
572
|
+
const messages = [
|
|
573
|
+
{ role: "custom", customType: "pi-gauntlet-transition-recovery", timestamp: 1 },
|
|
574
|
+
{ role: "assistant", timestamp: 2, content: [
|
|
575
|
+
{ type: "toolCall", id: "tc-read", name: "read" },
|
|
576
|
+
{ type: "toolCall", id: "tc-todo", name: "todowrite" },
|
|
577
|
+
] },
|
|
578
|
+
{ role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "FILE" }] },
|
|
579
|
+
{ role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "PLAN-STATE" }] },
|
|
580
|
+
{ role: "assistant", timestamp: 9, content: [{ type: "text", text: "done" }] },
|
|
581
|
+
];
|
|
582
|
+
const out = applyChainCompressions(messages, [e] as any, () => "SUMMARY", false);
|
|
583
|
+
expect(out.find((m: any) => m.role === "toolResult" && m.toolCallId === "tc-todo")).toBeUndefined();
|
|
584
|
+
expect(out.find((m: any) => m.role === "assistant" && m.timestamp === 2)).toBeUndefined();
|
|
585
|
+
const synthetic = out.find((m: any) => typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"));
|
|
586
|
+
expect(synthetic.content[0].text).toContain('<protected-output tool="todowrite">');
|
|
587
|
+
expect(synthetic.content[0].text).toContain("PLAN-STATE");
|
|
588
|
+
expect(synthetic.content[0].text).not.toContain("FILE");
|
|
589
|
+
});
|
|
590
|
+
|
|
562
591
|
test("blockSummaryLookup: missing lookup leaves placeholder literal", () => {
|
|
563
592
|
const msgs = [
|
|
564
593
|
userMsg(100),
|
|
@@ -636,6 +665,60 @@ describe("resolveRange", () => {
|
|
|
636
665
|
const range = resolveRange({ startUserTimestamp: 100, finalAssistantTimestamp: 200 }, messages);
|
|
637
666
|
expect(range).toEqual({ startIndex: 0, endIndex: 1 });
|
|
638
667
|
});
|
|
668
|
+
|
|
669
|
+
function customAnchor(timestamp: number, customType = "pi-gauntlet-transition-recovery"): any {
|
|
670
|
+
return { role: "custom", customType, timestamp };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
test("resolves when the start anchor is an eligible non-pruner custom message", () => {
|
|
674
|
+
const messages = [
|
|
675
|
+
customAnchor(100),
|
|
676
|
+
assistantWithTools(200, ["tc1"]),
|
|
677
|
+
toolResult(300, "tc1"),
|
|
678
|
+
assistantText(400),
|
|
679
|
+
];
|
|
680
|
+
const range = resolveRange(entry({ startUserTimestamp: 100, finalAssistantTimestamp: 400 }), messages);
|
|
681
|
+
expect(range).toEqual({ startIndex: 0, endIndex: 3 });
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("returns null when a user message and an eligible custom message collide at the same start timestamp", () => {
|
|
685
|
+
const messages = [
|
|
686
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
|
|
687
|
+
customAnchor(100),
|
|
688
|
+
assistantWithTools(200, ["tc1"]),
|
|
689
|
+
toolResult(300, "tc1"),
|
|
690
|
+
assistantText(400),
|
|
691
|
+
];
|
|
692
|
+
const range = resolveRange(entry({ startUserTimestamp: 100, finalAssistantTimestamp: 400 }), messages);
|
|
693
|
+
expect(range).toBeNull();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("returns null when two eligible custom messages share the start timestamp", () => {
|
|
697
|
+
const messages = [
|
|
698
|
+
customAnchor(100),
|
|
699
|
+
customAnchor(100, "other-extension"),
|
|
700
|
+
assistantWithTools(200, ["tc1"]),
|
|
701
|
+
toolResult(300, "tc1"),
|
|
702
|
+
assistantText(400),
|
|
703
|
+
];
|
|
704
|
+
const range = resolveRange(entry({ startUserTimestamp: 100, finalAssistantTimestamp: 400 }), messages);
|
|
705
|
+
expect(range).toBeNull();
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("user-anchored resolution is unaffected by the custom-anchor widening (regression pin)", () => {
|
|
709
|
+
expect(resolveRange(entry(), base())).toEqual({ startIndex: 0, endIndex: 3 });
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("a context-prune-summary custom at the start timestamp does not count as a start match", () => {
|
|
713
|
+
const messages = [
|
|
714
|
+
summaryMsg(100, ["tc0"]),
|
|
715
|
+
assistantWithTools(200, ["tc1"]),
|
|
716
|
+
toolResult(300, "tc1"),
|
|
717
|
+
assistantText(400),
|
|
718
|
+
];
|
|
719
|
+
const range = resolveRange(entry({ startUserTimestamp: 100, finalAssistantTimestamp: 400 }), messages);
|
|
720
|
+
expect(range).toBeNull();
|
|
721
|
+
});
|
|
639
722
|
});
|
|
640
723
|
|
|
641
724
|
describe("applyChainCompressions - positional", () => {
|
package/src/chain-range-prune.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { CUSTOM_TYPE_SUMMARY } from "./types.js";
|
|
|
3
3
|
import type { ChainCompressionEntry } from "./types.js";
|
|
4
4
|
import { substituteBlockRefs } from "./nested-placeholders.js";
|
|
5
5
|
import { extractToolResultText } from "./batch-capture.js";
|
|
6
|
+
import { isChainAnchorCustom } from "./chain-detector.js";
|
|
6
7
|
import { bareToolCallId, occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
7
8
|
import type { DiagnosticSink } from "./diagnostics.js";
|
|
8
9
|
|
|
@@ -68,11 +69,12 @@ export function buildSyntheticChainMessage(
|
|
|
68
69
|
/**
|
|
69
70
|
* Resolves a persisted chain entry to a positional index range.
|
|
70
71
|
*
|
|
71
|
-
* Role-gated and unique-match-or-nothing: exactly one
|
|
72
|
+
* Role-gated and unique-match-or-nothing: exactly one start anchor (user
|
|
73
|
+
* message or eligible non-pruner custom, isChainAnchorCustom) at
|
|
72
74
|
* startUserTimestamp, exactly one assistant at finalAssistantTimestamp, and
|
|
73
|
-
*
|
|
74
|
-
* whole point: an id-set or timestamp-window fallback is what deleted
|
|
75
|
-
* turns (doc/specs/2026-08-12-toolcall-id-collisions.md).
|
|
75
|
+
* startIndex < endIndex. Otherwise null - the entry drops nothing. Fail-closed
|
|
76
|
+
* is the whole point: an id-set or timestamp-window fallback is what deleted
|
|
77
|
+
* live turns (doc/specs/2026-08-12-toolcall-id-collisions.md).
|
|
76
78
|
*/
|
|
77
79
|
export function resolveRange(
|
|
78
80
|
entry: Pick<ChainCompressionEntry, "startUserTimestamp" | "finalAssistantTimestamp">,
|
|
@@ -85,7 +87,7 @@ export function resolveRange(
|
|
|
85
87
|
let endMatches = 0;
|
|
86
88
|
for (let i = 0; i < messages.length; i++) {
|
|
87
89
|
const msg = messages[i];
|
|
88
|
-
if (msg.role === "user" && msg.timestamp === entry.startUserTimestamp) {
|
|
90
|
+
if ((msg.role === "user" || isChainAnchorCustom(msg)) && msg.timestamp === entry.startUserTimestamp) {
|
|
89
91
|
startMatches++;
|
|
90
92
|
if (startIndex < 0) startIndex = i;
|
|
91
93
|
} else if (msg.role === "assistant" && msg.timestamp === entry.finalAssistantTimestamp) {
|
package/src/commands.test.ts
CHANGED
|
@@ -11,10 +11,9 @@ function captureStatus(
|
|
|
11
11
|
config: ContextPruneConfig,
|
|
12
12
|
value?: Parameters<typeof setPruneStatusWidget>[2],
|
|
13
13
|
diagnostics?: Parameters<typeof setPruneStatusWidget>[3],
|
|
14
|
-
metrics?: Parameters<typeof setPruneStatusWidget>[4],
|
|
15
14
|
): string | undefined {
|
|
16
15
|
let captured: string | undefined;
|
|
17
|
-
setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics
|
|
16
|
+
setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics);
|
|
18
17
|
return captured;
|
|
19
18
|
}
|
|
20
19
|
|
|
@@ -59,7 +58,6 @@ function setupPrunerCommand(overrides: {
|
|
|
59
58
|
async () => ({ compressedEntries: [], skipped: 0 }),
|
|
60
59
|
undefined,
|
|
61
60
|
overrides.getContextMetrics,
|
|
62
|
-
undefined,
|
|
63
61
|
overrides.getRearmed,
|
|
64
62
|
);
|
|
65
63
|
|
|
@@ -213,28 +211,3 @@ describe("diagnostic counters on the status line", () => {
|
|
|
213
211
|
});
|
|
214
212
|
});
|
|
215
213
|
|
|
216
|
-
describe("context metrics suffix on the status line", () => {
|
|
217
|
-
const metrics: ContextMetricsSnapshot = {
|
|
218
|
-
openCycleThinkingTokens: 12000,
|
|
219
|
-
largestChainSharePct: 62,
|
|
220
|
-
frontierGapTokens: 195000,
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
it("appends a compact think/gap/chain segment when frontierGapTokens > 0", () => {
|
|
224
|
-
const text = pruneStatusText(cfg(true), undefined, undefined, metrics);
|
|
225
|
-
expect(text).toContain("\u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%");
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("omits the suffix when frontierGapTokens is 0", () => {
|
|
229
|
-
const withZeroGap = { ...metrics, frontierGapTokens: 0 };
|
|
230
|
-
expect(pruneStatusText(cfg(true), undefined, undefined, withZeroGap)).toBe(
|
|
231
|
-
pruneStatusText(cfg(true)),
|
|
232
|
-
);
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it("composes after the diag suffix when both are present", () => {
|
|
236
|
-
const mixedDiag = { "unresolved-range": 2, "range-id-mismatch": 0, "orphan-sweep": 1 } as const;
|
|
237
|
-
const text = pruneStatusText(cfg(true), undefined, mixedDiag, metrics);
|
|
238
|
-
expect(text).toBe("prune: ON \u00b7 diag u2/o1 \u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%");
|
|
239
|
-
});
|
|
240
|
-
});
|
package/src/commands.ts
CHANGED
|
@@ -64,7 +64,6 @@ export function pruneStatusText(
|
|
|
64
64
|
config: ContextPruneConfig,
|
|
65
65
|
reclaim?: LiveReclaim,
|
|
66
66
|
diagnostics?: Record<DiagnosticKind, number>,
|
|
67
|
-
metrics?: ContextMetricsSnapshot,
|
|
68
67
|
): string {
|
|
69
68
|
if (!config.enabled) return "prune: OFF";
|
|
70
69
|
const diag = diagnostics
|
|
@@ -76,14 +75,11 @@ export function pruneStatusText(
|
|
|
76
75
|
].filter(Boolean)
|
|
77
76
|
: [];
|
|
78
77
|
const suffix = diag.length > 0 ? ` \u00b7 diag ${diag.join("/")}` : "";
|
|
79
|
-
|
|
80
|
-
? ` \u00b7 think ${formatCompactCount(metrics.openCycleThinkingTokens)} \u00b7 gap ${formatCompactCount(metrics.frontierGapTokens)} \u00b7 chain ${metrics.largestChainSharePct}%`
|
|
81
|
-
: "";
|
|
82
|
-
if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}${metricsSuffix}`;
|
|
78
|
+
if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}`;
|
|
83
79
|
const beforeTok = Math.round(reclaim.beforeChars / 4);
|
|
84
80
|
const afterTok = Math.round(reclaim.afterChars / 4);
|
|
85
81
|
const reduction = Math.max(0, Math.round((1 - afterTok / beforeTok) * 100));
|
|
86
|
-
return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}
|
|
82
|
+
return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}`;
|
|
87
83
|
}
|
|
88
84
|
|
|
89
85
|
export function setPruneStatusWidget(
|
|
@@ -91,13 +87,12 @@ export function setPruneStatusWidget(
|
|
|
91
87
|
config: ContextPruneConfig,
|
|
92
88
|
value?: LiveReclaim | string,
|
|
93
89
|
diagnostics?: Record<DiagnosticKind, number>,
|
|
94
|
-
metrics?: ContextMetricsSnapshot,
|
|
95
90
|
): void {
|
|
96
91
|
if (!config.showPruneStatusLine) {
|
|
97
92
|
ctx.ui.setStatus(STATUS_WIDGET_ID, undefined);
|
|
98
93
|
return;
|
|
99
94
|
}
|
|
100
|
-
const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics
|
|
95
|
+
const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics);
|
|
101
96
|
// Leading-only separator: the footer joins extension status segments with a
|
|
102
97
|
// single space, so a trailing divider collides with the next segment's leading
|
|
103
98
|
// one and renders doubled. One leading bar yields single dividers between
|
|
@@ -481,7 +476,6 @@ export function registerCommands(
|
|
|
481
476
|
compactChains: (ctx: ExtensionCommandContext) => Promise<{ compressedEntries: ChainCompressionEntry[]; skipped: number }>,
|
|
482
477
|
getDiagnosticCounts?: () => Record<DiagnosticKind, number>,
|
|
483
478
|
getContextMetrics?: (ctx: ExtensionCommandContext) => ContextMetricsSnapshot,
|
|
484
|
-
getCachedMetrics?: () => ContextMetricsSnapshot | undefined,
|
|
485
479
|
getRearmed?: () => boolean,
|
|
486
480
|
): void {
|
|
487
481
|
// Register the /pruner command
|
|
@@ -829,7 +823,7 @@ export function registerCommands(
|
|
|
829
823
|
}
|
|
830
824
|
currentConfig.value = newConfig;
|
|
831
825
|
saveConfig(newConfig);
|
|
832
|
-
setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.()
|
|
826
|
+
setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.());
|
|
833
827
|
settingsList?.invalidate();
|
|
834
828
|
};
|
|
835
829
|
|
|
@@ -864,7 +858,7 @@ export function registerCommands(
|
|
|
864
858
|
currentConfig.value = { ...currentConfig.value, enabled: true };
|
|
865
859
|
saveConfig(currentConfig.value);
|
|
866
860
|
ctx.ui.notify("Context pruning enabled.");
|
|
867
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.()
|
|
861
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
868
862
|
break;
|
|
869
863
|
}
|
|
870
864
|
|
|
@@ -873,7 +867,7 @@ export function registerCommands(
|
|
|
873
867
|
currentConfig.value = { ...currentConfig.value, enabled: false };
|
|
874
868
|
saveConfig(currentConfig.value);
|
|
875
869
|
ctx.ui.notify("Context pruning disabled.");
|
|
876
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.()
|
|
870
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
877
871
|
break;
|
|
878
872
|
}
|
|
879
873
|
|
|
@@ -996,7 +990,7 @@ export function registerCommands(
|
|
|
996
990
|
currentConfig.value = { ...currentConfig.value, pruneOn: modeArg as ContextPruneConfig["pruneOn"] };
|
|
997
991
|
}
|
|
998
992
|
saveConfig(currentConfig.value);
|
|
999
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.()
|
|
993
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
1000
994
|
break;
|
|
1001
995
|
}
|
|
1002
996
|
|
|
@@ -1098,7 +1092,7 @@ export function registerCommands(
|
|
|
1098
1092
|
|
|
1099
1093
|
// Remove the widget and restore the normal footer status.
|
|
1100
1094
|
clearWidget();
|
|
1101
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.()
|
|
1095
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
1102
1096
|
|
|
1103
1097
|
if (!result.ok) {
|
|
1104
1098
|
const suffix = "error" in result && result.error ? ` (${result.error})` : "";
|
package/src/config.test.ts
CHANGED
|
@@ -125,3 +125,25 @@ describe("loadConfig backward compatibility with removed thinkingStrip key", ()
|
|
|
125
125
|
expect(written.contextPrune.thinkingStrip).toEqual(stale);
|
|
126
126
|
});
|
|
127
127
|
});
|
|
128
|
+
|
|
129
|
+
describe("loadConfig frontierGapThresholdTokens normalization", () => {
|
|
130
|
+
it("defaults to null when unset", async () => {
|
|
131
|
+
await writeContextPrune({});
|
|
132
|
+
const config = await loadConfig();
|
|
133
|
+
expect(config.frontierGapThresholdTokens).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("floors a fractional value", async () => {
|
|
137
|
+
await writeContextPrune({ frontierGapThresholdTokens: 80000.7 });
|
|
138
|
+
const config = await loadConfig();
|
|
139
|
+
expect(config.frontierGapThresholdTokens).toBe(80000);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("falls back to null for 0, negative, Infinity, or a string", async () => {
|
|
143
|
+
for (const value of [0, -5, Infinity, "80000"]) {
|
|
144
|
+
await writeContextPrune({ frontierGapThresholdTokens: value });
|
|
145
|
+
const config = await loadConfig();
|
|
146
|
+
expect(config.frontierGapThresholdTokens).toBeNull();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -106,6 +106,12 @@ function normalize(existing: Partial<ContextPruneConfig>): ContextPruneConfig {
|
|
|
106
106
|
merged.budgetTurnDelta <= 1
|
|
107
107
|
? merged.budgetTurnDelta
|
|
108
108
|
: DEFAULT_CONFIG.budgetTurnDelta,
|
|
109
|
+
frontierGapThresholdTokens:
|
|
110
|
+
typeof merged.frontierGapThresholdTokens === "number" &&
|
|
111
|
+
Number.isFinite(merged.frontierGapThresholdTokens) &&
|
|
112
|
+
merged.frontierGapThresholdTokens > 0
|
|
113
|
+
? Math.floor(merged.frontierGapThresholdTokens)
|
|
114
|
+
: DEFAULT_CONFIG.frontierGapThresholdTokens,
|
|
109
115
|
};
|
|
110
116
|
}
|
|
111
117
|
|
|
@@ -170,11 +170,11 @@ describe("computeContextMetrics", () => {
|
|
|
170
170
|
expect(openSegmentChars).toBeGreaterThan(chainChars);
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
-
test("largestChainSharePct: a
|
|
173
|
+
test("largestChainSharePct: a pruner custom message (customType starting with context-prune-) counts toward the denominator only, never the chain numerator", () => {
|
|
174
174
|
// Mirrors index.ts's branch projection for persisted summary custom_message
|
|
175
|
-
// entries:
|
|
176
|
-
//
|
|
177
|
-
//
|
|
175
|
+
// entries: a pruner customType (context-prune-summary) is excluded from
|
|
176
|
+
// chain anchoring by isChainAnchorCustom, so it cannot join a chain or the
|
|
177
|
+
// open segment -- it only inflates totalChars (the denominator).
|
|
178
178
|
// The customEntry sits between two final text-only assistant messages, so
|
|
179
179
|
// it lands outside both the chain range and the open-cycle segment --
|
|
180
180
|
// isolating the denominator effect from any open-segment interaction.
|
|
@@ -184,7 +184,7 @@ describe("computeContextMetrics", () => {
|
|
|
184
184
|
toolResult(300, "tc1", "bash", "x".repeat(2000)),
|
|
185
185
|
assistantText(400),
|
|
186
186
|
];
|
|
187
|
-
const customEntry = { role: "custom", customType: "
|
|
187
|
+
const customEntry = { role: "custom", customType: "context-prune-summary", content: "s".repeat(3000), display: true, timestamp: 450 };
|
|
188
188
|
const closer = assistantText(500, "ok");
|
|
189
189
|
|
|
190
190
|
const withoutCustom = computeContextMetrics(chainMsgs, null, noSummarized, noProtected);
|
|
@@ -199,6 +199,36 @@ describe("computeContextMetrics", () => {
|
|
|
199
199
|
expect(withCustom.largestChainSharePct).toBeLessThan(withoutCustom.largestChainSharePct);
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
+
test("largestChainSharePct: a custom-anchored chain (eligible non-pruner custom message opens a chain) is counted in the numerator", () => {
|
|
203
|
+
// Mirrors the user-anchored share test's arithmetic, but the chain start
|
|
204
|
+
// is an eligible custom message (isChainAnchorCustom) instead of a user
|
|
205
|
+
// message -- Wave 1's chain-detector already opens chains on these; this
|
|
206
|
+
// pins that computeContextMetrics' chain-start lookup finds them too.
|
|
207
|
+
const customAnchor = {
|
|
208
|
+
role: "custom",
|
|
209
|
+
customType: "pi-gauntlet-transition-recovery",
|
|
210
|
+
content: "resuming",
|
|
211
|
+
timestamp: 100,
|
|
212
|
+
};
|
|
213
|
+
const msgs = [
|
|
214
|
+
customAnchor,
|
|
215
|
+
assistantWithTools(200, ["tc1"]),
|
|
216
|
+
toolResult(300, "tc1", "bash", "x".repeat(2000)), // big closed chain
|
|
217
|
+
assistantText(400),
|
|
218
|
+
userMsg(500),
|
|
219
|
+
assistantText(600), // tiny open segment (empty, since it's the last text-only assistant)
|
|
220
|
+
];
|
|
221
|
+
const chars = msgs.map((m) => JSON.stringify(m).length);
|
|
222
|
+
const totalChars = chars.reduce((a, b) => a + b, 0);
|
|
223
|
+
const chainChars = chars[0] + chars[1] + chars[2] + chars[3]; // customAnchor..assistantText(400)
|
|
224
|
+
const openSegmentChars = 0; // last text-only assistant is msgs[5] itself; open segment is empty
|
|
225
|
+
const expectedPct = Math.round((100 * Math.max(chainChars, openSegmentChars)) / totalChars);
|
|
226
|
+
|
|
227
|
+
const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
|
|
228
|
+
expect(result.largestChainSharePct).toBe(expectedPct);
|
|
229
|
+
expect(chainChars).toBeGreaterThan(openSegmentChars);
|
|
230
|
+
});
|
|
231
|
+
|
|
202
232
|
test("largestChainSharePct: interrupted chain (null finalAssistantTimestamp) is counted", () => {
|
|
203
233
|
const msgs = [
|
|
204
234
|
userMsg(100),
|
package/src/context-metrics.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { detectChains } from "./chain-detector.js";
|
|
1
|
+
import { detectChains, isChainAnchorCustom } from "./chain-detector.js";
|
|
2
2
|
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
3
3
|
import type { ContextMetricsSnapshot, PruneFrontier } from "./types.js";
|
|
4
4
|
|
|
@@ -75,7 +75,9 @@ export function computeContextMetrics(
|
|
|
75
75
|
const chains = detectChains(branch, isProtected);
|
|
76
76
|
let largestClosedChainChars = 0;
|
|
77
77
|
for (const range of chains) {
|
|
78
|
-
const startIdx = branch.findIndex(
|
|
78
|
+
const startIdx = branch.findIndex(
|
|
79
|
+
(m) => (m.role === "user" || isChainAnchorCustom(m)) && m.timestamp === range.startUserTimestamp,
|
|
80
|
+
);
|
|
79
81
|
if (startIdx === -1) continue;
|
|
80
82
|
let endIdx: number;
|
|
81
83
|
if (range.finalAssistantTimestamp !== null) {
|
|
@@ -85,6 +87,7 @@ export function computeContextMetrics(
|
|
|
85
87
|
if (endIdx === -1) continue;
|
|
86
88
|
} else {
|
|
87
89
|
let nextUserIdx = -1;
|
|
90
|
+
// User-only: under idle-only semantics only a user message interrupts an open chain.
|
|
88
91
|
for (let i = startIdx + 1; i < branch.length; i++) {
|
|
89
92
|
if (branch[i].role === "user") {
|
|
90
93
|
nextUserIdx = i;
|