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 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
- // Projected inline (rather than importing pi-coding-agent's
102
- // createCustomMessage) because that helper isn't re-exported from the
103
- // package's "." export map -- shape mirrors createCustomMessage's output
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
- // Raw session branch, unwrapped once for the chain-compression block below.
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 or jumped sharply
952
- // this turn and it self-throttles because pendingBatches is drained right after.
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: ${budgetHit ? "context budget reached" : "context jumped this turn"} — compacting ${n} pending turn${n === 1 ? "" : "s"}`
957
- : `pruner: ${budgetHit ? "context budget reached" : "context jumped this turn"} — compacting work recovered after reload`,
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, { delivery: "session", trigger: n === 0 ? "rearmed" : budgetHit ? "budget" : "delta" });
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 branch = ctx.sessionManager.getBranch();
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.9.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 { captureBatch, captureUnindexedBatchesFromSession, serializeBatchForSummarizer } from "./batch-capture.js";
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
+ });
@@ -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
- // branch is SessionEntry[]. Each message entry has { type: "message", message: AgentMessage }.
75
- // We must unwrap the SessionEntry wrapper before accessing role/toolCallId.
76
- const entries = branch.filter((entry: any) => entry.type === "message");
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 seen while walking the branch.
87
- // All assistant tool-call batches between two consecutive user messages share the
88
- // same userTurnGroup. This is used by groupBatchesByMode to merge turns within
89
- // a single user → final-agent-message span when batchingMode === "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 < entries.length; i++) {
93
- const entry = entries[i];
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 so all subsequent assistant
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 < entries.length; j++) {
112
- const m = entries[j].message;
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 ts = entry.timestamp ? new Date(entry.timestamp).getTime() : (msg.timestamp ?? Date.now());
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,
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect } from "bun:test";
2
- import { shouldBudgetFlush, shouldDeltaFlush, usageFraction, MAX_BUDGET_WINDOW } from "./budget.js";
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
+ });
@@ -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
- middleIds = new Set();
70
- middleKeys = new Set();
71
- protectedIds = new Set();
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
- middleIds = new Set();
107
- middleKeys = new Set();
108
- protectedIds = new Set();
127
+ resetChain();
109
128
  state = "idle";
110
129
  }
111
130
  }