pi-condense 2.9.2 → 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 +5 -0
- package/PRUNING.md +10 -2
- package/README.md +1 -0
- package/index.ts +24 -28
- 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/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 +356 -18
- package/src/types.ts +17 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,11 @@ 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.10.0] - 2026-09-01
|
|
11
|
+
|
|
12
|
+
- **Custom-message chain anchors ([#13](https://github.com/jjuraszek/pi-condense/issues/13)).** A non-pruner `role: "custom"` message (`customType` not prefixed `context-prune-`) can now open a chain, but only while the chain detector is idle - a non-pruner custom seen mid-chain stays passthrough, not a new anchor. `resolveRange` accepts these as start anchors fail-closed; persisted `custom_message` steers reach chain detection through a shared projection (`src/batch-capture.ts` `projectBranchMessages`); in `agent-message` batching, eligible customs also bound summary groups.
|
|
13
|
+
- **Opt-in `frontierGapThresholdTokens` flush trigger.** New absolute-token flush trigger (default `null`, disabled), ORed with `autoBudgetThreshold`/`budgetTurnDelta` (precedence: budget, then delta, then frontier-gap) - fires at `turn_end` once the un-pruned tail past the prune frontier (`frontierGapTokens`) reaches the configured token count, independent of window size. Config-file-only, no `/pruner settings` row; self-throttling via frontier advance on processed flush outcomes (empty attempts advance nothing and rewrite nothing) - after a mid-flush summarizer failure the next gated turn may re-fire while consuming the remaining backlog, so the cadence bound is amortized per new tail growth rather than per-turn-exact. New `"frontier-gap"` value on `context-prune-flush-metrics` entries.
|
|
14
|
+
|
|
10
15
|
## [2.9.2] - 2026-08-31
|
|
11
16
|
|
|
12
17
|
- **Context metrics removed from the footer status line.** The ` · think Nk · gap Nk · chain P%` suffix rendered on nearly every non-idle session and crowded the footer for no actionable signal. The metrics stay on `/pruner status` (`--- context ---`) and in the `context-prune-flush-metrics` session entries; the footer is back to prune state, reclaim, and `diag`. The snapshot cache that existed only to feed the widget is gone - both remaining consumers compute on demand.
|
package/PRUNING.md
CHANGED
|
@@ -838,6 +838,14 @@ Use case: a single enormous tool result can jump context usage by 20–30 percen
|
|
|
838
838
|
|
|
839
839
|
`null` = off (default).
|
|
840
840
|
|
|
841
|
+
### Frontier-gap flush trigger
|
|
842
|
+
|
|
843
|
+
`frontierGapThresholdTokens: number | null` (default `null`) is a third flush trigger, ORed with `autoBudgetThreshold` and `budgetTurnDelta` at `turn_end` (precedence within that handler: budget, then delta, then frontier-gap - only the first that fires flushes that turn). Unlike the other two, which measure a *fraction* of the context window, this one is an absolute token count against `frontierGapTokens` (the un-pruned tail past the persisted prune frontier, same metric as `/pruner status` and `computeContextMetrics`). Rationale: a window-fraction trigger becomes unreachable on a large enough advertised window (the same problem `MAX_BUDGET_WINDOW` addresses for the level form) long before the un-pruned tail is actually a problem in tokens - this trigger is the absolute-token complement, independent of window size.
|
|
844
|
+
|
|
845
|
+
It only evaluates at `turn_end`, same call site as the other two triggers, and is self-throttling: `frontierGapTokens` is measured against the persisted prune frontier, which advances on every processed flush outcome (summarized and skipped-* alike, including one this trigger itself causes) - an attempt that finds zero capturable batches does not advance it, and rewrites nothing, so it cannot churn the cache - so under normal operation it cannot fire again until the un-pruned tail regrows by another threshold-worth of tokens; on a mid-flush summarizer failure the frontier advances only to the persisted prefix, so the next gated turn may re-fire while consuming the remaining backlog, making the bound amortized (one extra prefix rewrite per threshold-worth of new tail growth) rather than per-turn-exact under failures. Recommended starting value `80000` - well above the ~5k-15k tokens a session typically accumulates per flush, so it only fires when flushes stop happening for an unusually long stretch (e.g. a long auto-continued run). Config-file-only - no `/pruner settings` row.
|
|
846
|
+
|
|
847
|
+
`null` = off (default).
|
|
848
|
+
|
|
841
849
|
---
|
|
842
850
|
|
|
843
851
|
## Chain Compression
|
|
@@ -846,7 +854,7 @@ Chain compression is a second layer on top of the per-batch tool-result stub pru
|
|
|
846
854
|
|
|
847
855
|
### What a closed chain is
|
|
848
856
|
|
|
849
|
-
A **closed chain** is a span of messages from one user message through any number of tool-using assistant turns and their results, ending in a final text-only assistant reply:
|
|
857
|
+
A **closed chain** is a span of messages from one user message - or a non-pruner custom message (`role: "custom"` with a `customType` not prefixed `context-prune-`), the latter only accepted as a chain start while the chain detector is idle (mid-chain, a non-pruner custom is passthrough, not a new anchor) - through any number of tool-using assistant turns and their results, ending in a final text-only assistant reply:
|
|
850
858
|
|
|
851
859
|
```
|
|
852
860
|
[user msg] ← chain start (kept raw)
|
|
@@ -861,7 +869,7 @@ A **closed chain** is a span of messages from one user message through any numbe
|
|
|
861
869
|
|
|
862
870
|
| Part | After chain compression |
|
|
863
871
|
|---|---|
|
|
864
|
-
| Start user message | **Kept raw** |
|
|
872
|
+
| Start user message (or idle-anchored custom message) | **Kept raw** |
|
|
865
873
|
| Middle assistant turns (all) | **Dropped** — assistant thinking + signatures + toolCall argument blocks |
|
|
866
874
|
| Middle tool results (all) | **Dropped** — already stub-replaced by the per-batch pruner; now fully removed |
|
|
867
875
|
| Per-batch summary message(s) for this chain | **Suppressed** — replaced by the chain-level synthetic |
|
package/README.md
CHANGED
|
@@ -168,6 +168,7 @@ Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_A
|
|
|
168
168
|
| `summarizerModel` | `"default"` | Pin a cheap model instead of reusing your active one - see the plan-by-plan table in [doc/configuration.md](doc/configuration.md#choosing-a-summarizer-model) |
|
|
169
169
|
| `pruneOn` | `agent-message` | Trigger mode - see Architecture above |
|
|
170
170
|
| `autoBudgetThreshold` | `null` | Fraction (e.g. `0.8`) of the context window that force-flushes everything regardless of `pruneOn`; the trigger point is capped at 300k tokens |
|
|
171
|
+
| `frontierGapThresholdTokens` | `null` | Opt-in absolute-token flush trigger: fires at `turn_end` once the un-pruned tail past the prune frontier reaches N tokens, regardless of window size; recommended starting value `80000` |
|
|
171
172
|
| `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md"]` | Tool names / path globs that are never pruned |
|
|
172
173
|
| `spillThreshold` | `65536` | Chars above which a single oversized result spills straight to a sidecar file |
|
|
173
174
|
|
package/index.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { loadConfig } from "./src/config.js";
|
|
18
|
-
import { captureBatch, captureUnindexedBatchesFromSession, groupBatchesByMode } from "./src/batch-capture.js";
|
|
18
|
+
import { captureBatch, captureUnindexedBatchesFromSession, groupBatchesByMode, projectBranchMessages } from "./src/batch-capture.js";
|
|
19
19
|
import { summarizeBatch, summarizeBatches, summarizeRange } from "./src/summarizer.js";
|
|
20
20
|
import { FallbackController } from "./src/summarizer-fallback.js";
|
|
21
21
|
import { ToolCallIndexer } from "./src/indexer.js";
|
|
@@ -47,7 +47,7 @@ import { BlockRefIssuer } from "./src/block-refs.js";
|
|
|
47
47
|
import { compressEligible } from "./src/chain-compressor.js";
|
|
48
48
|
import { detectChains, withClosingMessage } from "./src/chain-detector.js";
|
|
49
49
|
import { inGraceRecoveryToolCallIds } from "./src/recovery-grace.js";
|
|
50
|
-
import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./src/budget.js";
|
|
50
|
+
import { shouldBudgetFlush, shouldDeltaFlush, shouldFrontierGapFlush, usageFraction } from "./src/budget.js";
|
|
51
51
|
import { spillOversizedBatch } from "./src/spill.js";
|
|
52
52
|
import { occKey } from "./src/occurrence-key.js";
|
|
53
53
|
import { DiagnosticSink } from "./src/diagnostics.js";
|
|
@@ -98,18 +98,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
98
98
|
// Includes persisted custom_message entries (e.g. this extension's own
|
|
99
99
|
// summary messages) alongside plain "message" entries: both are retained
|
|
100
100
|
// LLM context, so both belong in the largest-chain-share denominator.
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
// (role "custom"), which never matches the user/assistant/toolResult
|
|
105
|
-
// roles computeContextMetrics keys off, so it only inflates totalChars.
|
|
106
|
-
const branch = ctx.sessionManager.getBranch()
|
|
107
|
-
.filter((e: any) => (e.type === "message" && e.message) || e.type === "custom_message")
|
|
108
|
-
.map((e: any) =>
|
|
109
|
-
e.type === "custom_message"
|
|
110
|
-
? { role: "custom", customType: e.customType, content: e.content, display: e.display, details: e.details, timestamp: new Date(e.timestamp).getTime() }
|
|
111
|
-
: e.message,
|
|
112
|
-
);
|
|
101
|
+
// Shared projection (src/batch-capture.ts projectBranchMessages) so this
|
|
102
|
+
// matches the chain-detection feed sites exactly.
|
|
103
|
+
const branch = projectBranchMessages(ctx.sessionManager.getBranch());
|
|
113
104
|
return computeContextMetrics(
|
|
114
105
|
branch,
|
|
115
106
|
frontier.get(),
|
|
@@ -597,13 +588,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
597
588
|
? "skipped-deduped"
|
|
598
589
|
: "skipped-trivial";
|
|
599
590
|
|
|
600
|
-
//
|
|
591
|
+
// Projected session branch (message + custom_message entries) for the chain-compression block below.
|
|
601
592
|
// Only materialized when chain compression is enabled.
|
|
602
593
|
let branchMessages: any[] | undefined;
|
|
603
594
|
if (currentConfig.value.chainCompression.enabled) {
|
|
604
|
-
branchMessages = ctx.sessionManager.getBranch()
|
|
605
|
-
.filter((e: any) => e.type === "message" && e.message)
|
|
606
|
-
.map((e: any) => e.message);
|
|
595
|
+
branchMessages = projectBranchMessages(ctx.sessionManager.getBranch());
|
|
607
596
|
}
|
|
608
597
|
|
|
609
598
|
const frontierSnapshot: PruneFrontier = {
|
|
@@ -940,24 +929,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
940
929
|
const usage = ctx.getContextUsage?.();
|
|
941
930
|
const budgetHit = shouldBudgetFlush(usage, currentConfig.value.autoBudgetThreshold);
|
|
942
931
|
const deltaHit = shouldDeltaFlush(usage, previousFraction, currentConfig.value.budgetTurnDelta);
|
|
932
|
+
// Frontier-gap auto-flush (opt-in): absolute un-pruned tail size, for huge
|
|
933
|
+
// windows where fractional thresholds never trip. Threshold null (default)
|
|
934
|
+
// skips the metrics snapshot entirely; a failed snapshot fails closed.
|
|
935
|
+
const gapThreshold = currentConfig.value.frontierGapThresholdTokens;
|
|
936
|
+
const gapHit = gapThreshold != null && shouldFrontierGapFlush(computeMetricsSnapshot(ctx), gapThreshold);
|
|
943
937
|
// Update the per-turn baseline; leave it unchanged when tokens is null (e.g.
|
|
944
938
|
// right after a compaction) so the next real reading compares to the last known.
|
|
945
939
|
const f = usageFraction(usage);
|
|
946
940
|
if (f != null) previousFraction = f;
|
|
947
941
|
|
|
948
942
|
const n = pendingBatches.length;
|
|
949
|
-
if ((n > 0 || rearmedPending) && !isFlushing && (budgetHit || deltaHit)) {
|
|
943
|
+
if ((n > 0 || rearmedPending) && !isFlushing && (budgetHit || deltaHit || gapHit)) {
|
|
944
|
+
const reason = budgetHit ? "context budget reached" : deltaHit ? "context jumped this turn" : "un-pruned tail exceeded frontier gap threshold";
|
|
950
945
|
// Always surface this flush (even when the routine status line is off): it's a
|
|
951
|
-
// significant, infrequent event — context crossed a threshold
|
|
952
|
-
// this turn
|
|
946
|
+
// significant, infrequent event — context crossed a threshold, jumped sharply
|
|
947
|
+
// this turn, or the un-pruned tail grew past the gap threshold — and it
|
|
948
|
+
// self-throttles because pendingBatches is drained right after.
|
|
953
949
|
safeNotify(
|
|
954
950
|
ctx,
|
|
955
951
|
n > 0
|
|
956
|
-
? `pruner: ${
|
|
957
|
-
: `pruner: ${
|
|
952
|
+
? `pruner: ${reason} — compacting ${n} pending turn${n === 1 ? "" : "s"}`
|
|
953
|
+
: `pruner: ${reason} — compacting work recovered after reload`,
|
|
958
954
|
"info",
|
|
959
955
|
);
|
|
960
|
-
await flushPending(ctx, {
|
|
956
|
+
await flushPending(ctx, {
|
|
957
|
+
delivery: "session",
|
|
958
|
+
trigger: n === 0 ? "rearmed" : budgetHit ? "budget" : deltaHit ? "delta" : "frontier-gap",
|
|
959
|
+
});
|
|
961
960
|
}
|
|
962
961
|
});
|
|
963
962
|
|
|
@@ -1023,10 +1022,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1023
1022
|
|
|
1024
1023
|
// ── Register /pruner command + summary message renderer ────────────
|
|
1025
1024
|
const compactChains = async (ctx: any) => {
|
|
1026
|
-
const
|
|
1027
|
-
const branchMessages = branch
|
|
1028
|
-
.filter((e: any) => e.type === "message" && e.message)
|
|
1029
|
-
.map((e: any) => e.message);
|
|
1025
|
+
const branchMessages = projectBranchMessages(ctx.sessionManager.getBranch());
|
|
1030
1026
|
const chains = detectChains(branchMessages, protectionPredicate);
|
|
1031
1027
|
const inGrace = inGraceRecoveryToolCallIds(branchMessages, currentConfig.value.recoveryGraceTurns);
|
|
1032
1028
|
const result = await compressEligible(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.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",
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
captureBatch,
|
|
4
|
+
captureUnindexedBatchesFromSession,
|
|
5
|
+
projectBranchMessages,
|
|
6
|
+
serializeBatchForSummarizer,
|
|
7
|
+
} from "./batch-capture.js";
|
|
3
8
|
import type { CapturedBatch, CapturedToolCall } from "./types.js";
|
|
4
9
|
|
|
5
10
|
function toolCall(overrides: Partial<CapturedToolCall> = {}): CapturedToolCall {
|
|
@@ -123,3 +128,113 @@ describe("occurrence capture", () => {
|
|
|
123
128
|
expect(captureUnindexedBatchesFromSession(branch, { isSummarized: () => false })).toEqual([]);
|
|
124
129
|
});
|
|
125
130
|
});
|
|
131
|
+
|
|
132
|
+
describe("captureUnindexedBatchesFromSession entry timestamp fallback", () => {
|
|
133
|
+
test("uses the entry's timestamp when the inner message lacks one", () => {
|
|
134
|
+
const branch = [
|
|
135
|
+
{
|
|
136
|
+
type: "message",
|
|
137
|
+
timestamp: "2026-08-31T10:00:00.000Z",
|
|
138
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }] },
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
type: "message",
|
|
142
|
+
message: { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "ok" }], isError: false, timestamp: 1150 },
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const batches = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false });
|
|
147
|
+
|
|
148
|
+
expect(batches).toHaveLength(1);
|
|
149
|
+
expect(batches[0].timestamp).toBe(new Date("2026-08-31T10:00:00.000Z").getTime());
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("projectBranchMessages", () => {
|
|
154
|
+
test("projects custom_message entries as role custom and drops unknown entry types", () => {
|
|
155
|
+
const branch = [
|
|
156
|
+
{ type: "message", message: { role: "user", content: [{ type: "text", text: "hi" }] } },
|
|
157
|
+
{
|
|
158
|
+
type: "custom_message",
|
|
159
|
+
customType: "x",
|
|
160
|
+
content: "c",
|
|
161
|
+
display: true,
|
|
162
|
+
details: {},
|
|
163
|
+
timestamp: "2026-08-31T10:00:00.000Z",
|
|
164
|
+
},
|
|
165
|
+
{ type: "other" },
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const msgs = projectBranchMessages(branch);
|
|
169
|
+
|
|
170
|
+
expect(msgs).toHaveLength(2);
|
|
171
|
+
expect(msgs[0]).toBe((branch[0] as any).message);
|
|
172
|
+
expect(msgs[1]).toEqual({
|
|
173
|
+
role: "custom",
|
|
174
|
+
customType: "x",
|
|
175
|
+
content: "c",
|
|
176
|
+
display: true,
|
|
177
|
+
details: {},
|
|
178
|
+
timestamp: new Date("2026-08-31T10:00:00.000Z").getTime(),
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("custom-anchor group boundary", () => {
|
|
184
|
+
function buildBranch(customType: string) {
|
|
185
|
+
const entry = (message: any) => ({ type: "message", message });
|
|
186
|
+
return [
|
|
187
|
+
entry({ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 }),
|
|
188
|
+
entry({
|
|
189
|
+
role: "assistant",
|
|
190
|
+
content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }],
|
|
191
|
+
timestamp: 1100,
|
|
192
|
+
}),
|
|
193
|
+
entry({
|
|
194
|
+
role: "toolResult",
|
|
195
|
+
toolCallId: "tc1",
|
|
196
|
+
toolName: "bash",
|
|
197
|
+
content: [{ type: "text", text: "ok1" }],
|
|
198
|
+
isError: false,
|
|
199
|
+
timestamp: 1150,
|
|
200
|
+
}),
|
|
201
|
+
{
|
|
202
|
+
type: "custom_message",
|
|
203
|
+
customType,
|
|
204
|
+
content: "c",
|
|
205
|
+
display: true,
|
|
206
|
+
details: {},
|
|
207
|
+
timestamp: "2026-08-31T10:00:00.000Z",
|
|
208
|
+
},
|
|
209
|
+
entry({
|
|
210
|
+
role: "assistant",
|
|
211
|
+
content: [{ type: "toolCall", id: "tc2", name: "bash", input: {} }],
|
|
212
|
+
timestamp: 2100,
|
|
213
|
+
}),
|
|
214
|
+
entry({
|
|
215
|
+
role: "toolResult",
|
|
216
|
+
toolCallId: "tc2",
|
|
217
|
+
toolName: "bash",
|
|
218
|
+
content: [{ type: "text", text: "ok2" }],
|
|
219
|
+
isError: false,
|
|
220
|
+
timestamp: 2150,
|
|
221
|
+
}),
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test("an eligible custom anchor (pi-gauntlet-transition-recovery) pins a new userTurnGroup", () => {
|
|
226
|
+
const branch = buildBranch("pi-gauntlet-transition-recovery");
|
|
227
|
+
const batches = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false });
|
|
228
|
+
|
|
229
|
+
expect(batches).toHaveLength(2);
|
|
230
|
+
expect(batches[0].userTurnGroup).not.toBe(batches[1].userTurnGroup);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a pruner custom (context-prune-summary) passes through without a new group", () => {
|
|
234
|
+
const branch = buildBranch("context-prune-summary");
|
|
235
|
+
const batches = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false });
|
|
236
|
+
|
|
237
|
+
expect(batches).toHaveLength(2);
|
|
238
|
+
expect(batches[0].userTurnGroup).toBe(batches[1].userTurnGroup);
|
|
239
|
+
});
|
|
240
|
+
});
|
package/src/batch-capture.ts
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
import type { CapturedBatch, CapturedToolCall, BatchingMode } from "./types.js";
|
|
2
2
|
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
3
|
+
import { isChainAnchorCustom } from "./chain-detector.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Unwraps a SessionEntry[] branch into AgentMessage-like objects, including
|
|
7
|
+
* persisted custom_message entries (extension steers) projected as
|
|
8
|
+
* role "custom". Shared by computeMetricsSnapshot, flushPending chain detection,
|
|
9
|
+
* compactChains, and the rescan below so chain anchor timestamps are identical
|
|
10
|
+
* at every site. Projected inline (rather than importing pi-coding-agent's
|
|
11
|
+
* createCustomMessage) because that helper isn't re-exported from the
|
|
12
|
+
* package's "." export map.
|
|
13
|
+
*/
|
|
14
|
+
export function projectBranchMessages(branch: any[]): any[] {
|
|
15
|
+
return branch
|
|
16
|
+
.filter(isProjectableEntry)
|
|
17
|
+
.map((e: any) => (e.type === "custom_message" ? projectCustomMessageEntry(e) : e.message));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True for SessionEntry shapes that project into an AgentMessage-like object (see projectBranchMessages). */
|
|
21
|
+
function isProjectableEntry(e: any): boolean {
|
|
22
|
+
return (e.type === "message" && e.message) || e.type === "custom_message";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Projects a single custom_message SessionEntry into its role "custom" message shape. */
|
|
26
|
+
function projectCustomMessageEntry(e: any): any {
|
|
27
|
+
return { role: "custom", customType: e.customType, content: e.content, display: e.display, details: e.details, timestamp: new Date(e.timestamp).getTime() };
|
|
28
|
+
}
|
|
3
29
|
|
|
4
30
|
/** Joins the text blocks of a ToolResultMessage into a single string. */
|
|
5
31
|
export function extractToolResultText(msg: any): string {
|
|
@@ -71,9 +97,13 @@ export function captureUnindexedBatchesFromSession(
|
|
|
71
97
|
indexer: { isSummarized(id: string): boolean },
|
|
72
98
|
exclude: (toolName: string, args: unknown) => boolean = () => false
|
|
73
99
|
): CapturedBatch[] {
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
100
|
+
// Keep the SessionEntry wrapper alongside each projected message so the
|
|
101
|
+
// entry's own timestamp remains available as the preferred source below
|
|
102
|
+
// (projection alone loses that wrapper for "message" entries).
|
|
103
|
+
const projected = branch
|
|
104
|
+
.filter(isProjectableEntry)
|
|
105
|
+
.map((e: any) => ({ entry: e, msg: e.type === "custom_message" ? projectCustomMessageEntry(e) : e.message }));
|
|
106
|
+
const msgs = projected.map((p) => p.msg);
|
|
77
107
|
|
|
78
108
|
const batches: CapturedBatch[] = [];
|
|
79
109
|
// turnCounter increments for EVERY assistant message (not just prunable ones).
|
|
@@ -83,19 +113,19 @@ export function captureUnindexedBatchesFromSession(
|
|
|
83
113
|
// always matches Pi's own event.turnIndex numbering.
|
|
84
114
|
let turnCounter = 0;
|
|
85
115
|
|
|
86
|
-
// userTurnGroup increments on every user message
|
|
87
|
-
// All assistant tool-call batches between two
|
|
88
|
-
// same userTurnGroup. This is used by
|
|
89
|
-
// a single user → final-agent-message
|
|
116
|
+
// userTurnGroup increments on every user message or eligible custom anchor seen
|
|
117
|
+
// while walking the branch. All assistant tool-call batches between two
|
|
118
|
+
// consecutive boundaries share the same userTurnGroup. This is used by
|
|
119
|
+
// groupBatchesByMode to merge turns within a single user → final-agent-message
|
|
120
|
+
// span when batchingMode === "agent-message".
|
|
90
121
|
let userTurnGroup = 0;
|
|
91
122
|
|
|
92
|
-
for (let i = 0; i <
|
|
93
|
-
const
|
|
94
|
-
const msg = entry.message;
|
|
123
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
124
|
+
const msg = msgs[i];
|
|
95
125
|
|
|
96
|
-
// Advance userTurnGroup on every user message
|
|
97
|
-
// batches get a new group number.
|
|
98
|
-
if (msg.role === "user") {
|
|
126
|
+
// Advance userTurnGroup on every user message or eligible custom anchor so
|
|
127
|
+
// all subsequent assistant batches get a new group number.
|
|
128
|
+
if (msg.role === "user" || isChainAnchorCustom(msg)) {
|
|
99
129
|
userTurnGroup++;
|
|
100
130
|
continue;
|
|
101
131
|
}
|
|
@@ -108,8 +138,8 @@ export function captureUnindexedBatchesFromSession(
|
|
|
108
138
|
// Per-turn result map: only the results between this assistant message and
|
|
109
139
|
// the next one. A branch-wide map is last-wins and mis-pairs repeated ids.
|
|
110
140
|
const turnResults = new Map<string, any>();
|
|
111
|
-
for (let j = i + 1; j <
|
|
112
|
-
const m =
|
|
141
|
+
for (let j = i + 1; j < msgs.length; j++) {
|
|
142
|
+
const m = msgs[j];
|
|
113
143
|
if (m.role === "assistant") break;
|
|
114
144
|
if (m.role === "toolResult" && m.toolCallId && !turnResults.has(m.toolCallId)) {
|
|
115
145
|
turnResults.set(m.toolCallId, m);
|
|
@@ -138,7 +168,8 @@ export function captureUnindexedBatchesFromSession(
|
|
|
138
168
|
// an intermediate completed subset in the middle of a longer tool chain
|
|
139
169
|
// without accidentally capturing later unresolved calls from the same
|
|
140
170
|
// assistant message as "(no result)" placeholders.
|
|
141
|
-
const
|
|
171
|
+
const entryTimestamp = projected[i].entry.timestamp;
|
|
172
|
+
const ts = entryTimestamp ? new Date(entryTimestamp).getTime() : (msg.timestamp ?? Date.now());
|
|
142
173
|
const batch = captureBatch(msg, results, currentTurnIndex, ts);
|
|
143
174
|
batches.push({
|
|
144
175
|
...batch,
|
package/src/budget.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
shouldBudgetFlush,
|
|
4
|
+
shouldDeltaFlush,
|
|
5
|
+
shouldFrontierGapFlush,
|
|
6
|
+
usageFraction,
|
|
7
|
+
MAX_BUDGET_WINDOW,
|
|
8
|
+
} from "./budget.js";
|
|
3
9
|
|
|
4
10
|
const usage = (tokens: number | null, contextWindow: number) =>
|
|
5
11
|
({ tokens, contextWindow, percent: null }) as any;
|
|
@@ -111,3 +117,17 @@ describe("shouldDeltaFlush", () => {
|
|
|
111
117
|
expect(shouldDeltaFlush(usage(620_000, 1_000_000), prev, 0.1)).toBe(false);
|
|
112
118
|
});
|
|
113
119
|
});
|
|
120
|
+
|
|
121
|
+
describe("shouldFrontierGapFlush", () => {
|
|
122
|
+
it("returns false when threshold is null", () => {
|
|
123
|
+
expect(shouldFrontierGapFlush({ frontierGapTokens: 999999 }, null)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
it("returns false when snapshot is undefined (fail closed)", () => {
|
|
126
|
+
// the spec's `snap === undefined` branch: metrics computation failed
|
|
127
|
+
expect(shouldFrontierGapFlush(undefined, 80000)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
it("fires at and above the threshold", () => {
|
|
130
|
+
expect(shouldFrontierGapFlush({ frontierGapTokens: 80000 }, 80000)).toBe(true);
|
|
131
|
+
expect(shouldFrontierGapFlush({ frontierGapTokens: 79999 }, 80000)).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
});
|
package/src/budget.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ContextUsage } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ContextMetricsSnapshot } from "./types.js";
|
|
2
3
|
|
|
3
4
|
// Ceiling on what the budget triggers treat as the context window. Advertised
|
|
4
5
|
// windows reach 1M, which makes any (0,1] fraction unreachable in a real session.
|
|
@@ -51,3 +52,12 @@ export function shouldDeltaFlush(
|
|
|
51
52
|
if (current == null) return false;
|
|
52
53
|
return current - previousFraction >= delta;
|
|
53
54
|
}
|
|
55
|
+
|
|
56
|
+
/** Fail-closed: an undefined snapshot (metrics computation failed) never fires. */
|
|
57
|
+
export function shouldFrontierGapFlush(
|
|
58
|
+
snapshot: ContextMetricsSnapshot | undefined,
|
|
59
|
+
threshold: number | null,
|
|
60
|
+
): boolean {
|
|
61
|
+
if (threshold == null) return false;
|
|
62
|
+
return snapshot != null && snapshot.frontierGapTokens >= threshold;
|
|
63
|
+
}
|
|
@@ -710,6 +710,23 @@ describe("compressEligible - deterministic zero-LLM branch", () => {
|
|
|
710
710
|
expect(registerChainCalls).toHaveLength(1);
|
|
711
711
|
});
|
|
712
712
|
|
|
713
|
+
test("custom-anchored uncovered chain still compresses deterministically and backfills", async () => {
|
|
714
|
+
const messages = [
|
|
715
|
+
{ role: "custom", customType: "pi-gauntlet-transition-recovery", timestamp: 1000 },
|
|
716
|
+
{ role: "assistant", timestamp: 1001, content: [{ type: "toolCall", id: "c1", name: "bash", input: { cmd: "a" } }] },
|
|
717
|
+
{ role: "toolResult", toolCallId: "c1", toolName: "bash", timestamp: 1050, isError: false, content: [{ type: "text", text: "out1" }] },
|
|
718
|
+
{ role: "assistant", timestamp: 1002, content: [{ type: "toolCall", id: "c2", name: "read", input: { path: "x" } }] },
|
|
719
|
+
{ role: "toolResult", toolCallId: "c2", toolName: "read", timestamp: 1150, isError: false, content: [{ type: "text", text: "out2" }] },
|
|
720
|
+
{ role: "assistant", timestamp: 1200, content: [{ type: "text", text: "done" }] },
|
|
721
|
+
];
|
|
722
|
+
const { deps, backfillCalls } = makeDeterministicDeps({ messages });
|
|
723
|
+
const result = await compressEligible([uncoveredChain()], 0, deps as any);
|
|
724
|
+
expect(result.compressedEntries).toHaveLength(1);
|
|
725
|
+
expect(result.compressedEntries[0].bodySource).toBe("deterministic");
|
|
726
|
+
expect(result.compressedEntries[0].startUserTimestamp).toBe(1000);
|
|
727
|
+
expect(backfillCalls).toHaveLength(1);
|
|
728
|
+
});
|
|
729
|
+
|
|
713
730
|
test("covered path is untouched: backfill never invoked, entry matches identity pin", async () => {
|
|
714
731
|
const { deps, backfillCalls } = makeDeterministicDeps();
|
|
715
732
|
// Override to simulate coverage so the covered branch (not the deterministic one) runs.
|
|
@@ -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
|
}
|