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 CHANGED
@@ -7,6 +7,15 @@ 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
+
15
+ ## [2.9.2] - 2026-08-31
16
+
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.
18
+
10
19
  ## [2.9.1] - 2026-08-18
11
20
 
12
21
  - **Orphan sweep: any foreign message is now a barrier ([#11](https://github.com/jjuraszek/pi-condense/issues/11)).** `sweepOrphanToolResults` only reset its open-call set on `assistant` messages, while pi-ai flushes synthetic tool results at both `assistant` and `user` boundaries (`convertToLlm` maps `custom`/`branchSummary`/`compactionSummary`/`bashExecution` to `user`). A foreign message spliced between a toolCall and its toolResult (observed: a pi-cohort control notice) let the real result through alongside pi-ai's synthetic - a duplicate `tool_use_id` the provider rejects permanently (Anthropic 400, branch bricked). Now any message that is neither `assistant` nor `toolResult` clears the open set, so the interleaved result is swept and pi-ai's repairable synthetic stands alone; already-broken branches un-brick on the next render. Deliberate conservative over-sweep (unknown roles, `excludeFromContext` bashExecution) - no role allowlist. Test helper `expectNoOrphanToolResults` carries the identical rule. Spec: `doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md` (partially supersedes the 2026-08-12 spec's section C).
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 |
@@ -995,7 +1003,7 @@ Phase 1 (per-batch summarization) is unaffected by chain closure and remains the
995
1003
 
996
1004
  Neither knob makes a chain close; they just keep Phase 1 flushing on schedule so raw toolResults do not pile up unsummarized for the whole run.
997
1005
 
998
- **Observability metrics** (`src/context-metrics.ts`, `computeContextMetrics`) exist precisely to make this shape of session visible instead of silently reporting `calls: 1` the way the triggering incident did. All three are chars/4 token estimates (`Math.round`, same convention as the reclaim footer) and surface on `/pruner status` (a `--- context ---` block), the footer status line (a compact suffix, shown only when the frontier gap is non-zero), and a `context-prune-flush-metrics` session entry written once per flush attempt regardless of outcome:
1006
+ **Observability metrics** (`src/context-metrics.ts`, `computeContextMetrics`) exist precisely to make this shape of session visible instead of silently reporting `calls: 1` the way the triggering incident did. All three are chars/4 token estimates (`Math.round`, same convention as the reclaim footer) and surface on `/pruner status` (a `--- context ---` block) and a `context-prune-flush-metrics` session entry written once per flush attempt regardless of outcome:
999
1007
 
1000
1008
  | Metric | Definition |
1001
1009
  |---|---|
package/README.md CHANGED
@@ -69,6 +69,8 @@ pi install npm:pi-condense
69
69
  | `agent-message` (default) | When the agent sends a final text-only reply | ~1 cache rewrite per task batch |
70
70
  | `on-demand` | Only when you run `/pruner now` | None until you ask |
71
71
 
72
+ With the default `agent-message` trigger (and `autoBudgetThreshold`/`budgetTurnDelta` unset), a non-interactive (`pi -p`) session sees its first flush only at the final reply - set `autoBudgetThreshold` (e.g. `0.8`) so flushes also fire mid-run. This is a property of single-prompt sessions, not a defect in the default.
73
+
72
74
  Before any summarizer call, a pre-flush pipeline can drop or redirect a batch at zero LLM cost: protected tools/paths are never touched, content-hash duplicates are aliased to the original, batches too small to be worth summarizing are skipped outright, and oversized single results are spilled straight to a sidecar file. Closed tool-call chains older than a rolling window are additionally range-compressed. Full pipeline and each safeguard: [PRUNING.md § Pre-flush Pipeline & Safeguards](PRUNING.md#pre-flush-pipeline--safeguards), [§ Chain Compression](PRUNING.md#chain-compression).
73
75
 
74
76
  ### External cost channel
@@ -84,7 +86,7 @@ Every summarizer cost update is emitted on the shared `pi.events` channel `cost:
84
86
  | Batch vs chain | A batch is one flush's worth of tool calls; a chain is a longer closed sequence eligible for range compression |
85
87
  | Prune frontier | The last attempted prune boundary - advances even on a skip, so nothing is reconsidered twice |
86
88
  | Diagnostics (`diag u/m/o/b`) | A self-hiding status-line segment surfacing prune-time degradations: `u` = unresolved chain range, `m` = detection/render id mismatch (informational, does not change what's dropped), `o` = orphan tool-result sweep, `b` = a zero-coverage chain with nothing left to backfill (genuine span mismatch, see below). Each letter's count is omitted when zero; the whole segment disappears when all four are zero. Backing session entries are `context-prune-diagnostic` - see below |
87
- | Context metrics (`think`/`gap`/`chain`) | Open-cycle thinking tokens, largest-chain share, frontier gap - what the pruner cannot (yet) reclaim, notably in single-chain sessions. See below and [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) |
89
+ | Context metrics (`thinking`/`chain share`/`frontier gap`) | Open-cycle thinking tokens, largest-chain share, frontier gap - what the pruner cannot (yet) reclaim, notably in single-chain sessions. Shown on `/pruner status`, never on the footer. See below and [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) |
88
90
  | Prompt-cache interaction | Why batching (not per-turn pruning) is the default - see [PRUNING.md](PRUNING.md#how-prefix-caching-works) |
89
91
  | `cost:external` | The shared cost-reporting channel pi-condense emits on (see above) |
90
92
 
@@ -100,10 +102,9 @@ The status-line `diag u<N>/m<N>/o<N>/b<N>` segment above is backed by `context-p
100
102
 
101
103
  ### Context metrics (`context-prune-flush-metrics`)
102
104
 
103
- Three metrics the pruner cannot yet reclaim - open-cycle thinking tokens, largest-chain share (%), frontier gap tokens - surface in three places, all backed by `computeContextMetrics` (`src/context-metrics.ts`):
105
+ Three metrics the pruner cannot yet reclaim - open-cycle thinking tokens, largest-chain share (%), frontier gap tokens - surface in two places, both backed by `computeContextMetrics` (`src/context-metrics.ts`). They are deliberately kept off the footer status line, which stays limited to prune state, reclaim, and diagnostics:
104
106
 
105
107
  - `/pruner status` prints a `--- context ---` block: `thinking:`, `chain share:`, `frontier gap:`, plus a `rearmed: yes` line while a reload-rearm probe (below) has recoverable work armed.
106
- - The footer status line appends `· think Nk · gap Nk · chain P%` - only when the frontier gap is non-zero, so an idle session's footer is unchanged.
107
108
  - Each flush attempt (every outcome, including empty/error) writes one `context-prune-flush-metrics` session entry with the pre-flush snapshot - session-log-only, never added to what the model sees, and not reconstructed on reload.
108
109
 
109
110
  These are most informative for long single-chain sessions where Phase 3 (chain compression) never gets a closed chain to act on - see [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) for the limitation and config guidance, and [PRUNING.md § Reload rearm](PRUNING.md#reload-rearm) for how a reload with recoverable pending work re-arms the automatic flush trigger.
@@ -167,6 +168,7 @@ Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_A
167
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) |
168
169
  | `pruneOn` | `agent-message` | Trigger mode - see Architecture above |
169
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` |
170
172
  | `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md"]` | Tool names / path globs that are never pruned |
171
173
  | `spillThreshold` | `65536` | Chars above which a single oversized result spills straight to a sidecar file |
172
174
 
@@ -180,6 +182,10 @@ pi-condense is the context-economy layer: it has no code dependency on the other
180
182
 
181
183
  No committed roadmap beyond what's already tracked in [CHANGELOG.md](CHANGELOG.md); proposals and in-progress work show up there and in repo issues first.
182
184
 
185
+ ## Contributing
186
+
187
+ See [CONTRIBUTING.md](CONTRIBUTING.md) - issues follow a Context / Problem / Idea / Acceptance Criteria template; PRs run the [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet) workflow (one-liners exempt from ceremony, never from keeping docs truthful).
188
+
183
189
  ## Support
184
190
 
185
191
  If this saves you tokens, [buy me a coffee](https://buymeacoffee.com/jjurasszek).
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";
@@ -93,28 +93,15 @@ export default function (pi: ExtensionAPI) {
93
93
  // Cleared on every non-concurrent flushPending invocation.
94
94
  let rearmedPending = false;
95
95
 
96
- // Latest ContextMetricsSnapshot, recomputed at reload probes, batch capture,
97
- // and flush entry. Cached (rather than recomputed on every widget refresh)
98
- // because computeContextMetrics walks the full branch.
99
- let metricsCache: ContextMetricsSnapshot | undefined;
100
96
  const computeMetricsSnapshot = (ctx: any): ContextMetricsSnapshot | undefined => {
101
97
  try {
102
98
  // Includes persisted custom_message entries (e.g. this extension's own
103
99
  // summary messages) alongside plain "message" entries: both are retained
104
100
  // LLM context, so both belong in the largest-chain-share denominator.
105
- // Projected inline (rather than importing pi-coding-agent's
106
- // createCustomMessage) because that helper isn't re-exported from the
107
- // package's "." export map -- shape mirrors createCustomMessage's output
108
- // (role "custom"), which never matches the user/assistant/toolResult
109
- // roles computeContextMetrics keys off, so it only inflates totalChars.
110
- const branch = ctx.sessionManager.getBranch()
111
- .filter((e: any) => (e.type === "message" && e.message) || e.type === "custom_message")
112
- .map((e: any) =>
113
- e.type === "custom_message"
114
- ? { role: "custom", customType: e.customType, content: e.content, display: e.display, details: e.details, timestamp: new Date(e.timestamp).getTime() }
115
- : e.message,
116
- );
117
- metricsCache = computeContextMetrics(
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());
104
+ return computeContextMetrics(
118
105
  branch,
119
106
  frontier.get(),
120
107
  (k: string) => indexer.isSummarized(k),
@@ -122,8 +109,8 @@ export default function (pi: ExtensionAPI) {
122
109
  );
123
110
  } catch (err) {
124
111
  console.error("pi-condense: context metrics computation failed", err);
112
+ return undefined;
125
113
  }
126
- return metricsCache;
127
114
  };
128
115
 
129
116
  type FlushResult =
@@ -566,7 +553,7 @@ export default function (pi: ExtensionAPI) {
566
553
 
567
554
  if (processedBatches.length === 0) {
568
555
  // Nothing was persisted (all calls failed or first call failed)
569
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
556
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
570
557
  outcome = "error";
571
558
  return { ok: false, reason: "summarizer-failed" };
572
559
  }
@@ -601,13 +588,11 @@ export default function (pi: ExtensionAPI) {
601
588
  ? "skipped-deduped"
602
589
  : "skipped-trivial";
603
590
 
604
- // 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.
605
592
  // Only materialized when chain compression is enabled.
606
593
  let branchMessages: any[] | undefined;
607
594
  if (currentConfig.value.chainCompression.enabled) {
608
- branchMessages = ctx.sessionManager.getBranch()
609
- .filter((e: any) => e.type === "message" && e.message)
610
- .map((e: any) => e.message);
595
+ branchMessages = projectBranchMessages(ctx.sessionManager.getBranch());
611
596
  }
612
597
 
613
598
  const frontierSnapshot: PruneFrontier = {
@@ -644,7 +629,7 @@ export default function (pi: ExtensionAPI) {
644
629
  return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
645
630
  }
646
631
 
647
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
632
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
648
633
  emitExternalCost(pi, statsAccum);
649
634
 
650
635
  // Chain compression — compress closed chains beyond the rolling window.
@@ -771,7 +756,7 @@ export default function (pi: ExtensionAPI) {
771
756
  // When the abort signal fired, summarizeBatch rethrows rather than
772
757
  // swallowing the error. Don't show a UI error — the user intended this.
773
758
  if (options.signal?.aborted) {
774
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
759
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
775
760
  return { ok: false, reason: "aborted" };
776
761
  }
777
762
  if (isStaleContextError(err)) {
@@ -816,10 +801,8 @@ export default function (pi: ExtensionAPI) {
816
801
  }
817
802
  }
818
803
 
819
- computeMetricsSnapshot(ctx);
820
-
821
804
  // Update footer status
822
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
805
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
823
806
 
824
807
  ctx.ui.setWidget(
825
808
  "pruner-boot",
@@ -856,8 +839,7 @@ export default function (pi: ExtensionAPI) {
856
839
  }
857
840
  }
858
841
 
859
- computeMetricsSnapshot(ctx);
860
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
842
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
861
843
  });
862
844
 
863
845
  // ── turn_end: capture batch, flush immediately or queue ──────────────────
@@ -934,13 +916,6 @@ export default function (pi: ExtensionAPI) {
934
916
  }
935
917
  }
936
918
 
937
- // Recompute regardless of whether trim produced a batch: a turn whose
938
- // toolResults are all protected/spilled/summarized/trimmed-empty still
939
- // changes the branch (thinking, open-segment size), so the cache must not
940
- // go stale on it. Placed before the pushedBatch/rearmedPending early
941
- // return below — a cache write is not gate evaluation.
942
- if (hasToolResults) computeMetricsSnapshot(ctx);
943
-
944
919
  // Mirrors main's `if (!batch) return;`: no freshly pushed batch this turn
945
920
  // means no gate evaluation, regardless of leftover pendingBatches from an
946
921
  // earlier turn — UNLESS a reload probe armed rearmedPending, in which case
@@ -954,24 +929,34 @@ export default function (pi: ExtensionAPI) {
954
929
  const usage = ctx.getContextUsage?.();
955
930
  const budgetHit = shouldBudgetFlush(usage, currentConfig.value.autoBudgetThreshold);
956
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);
957
937
  // Update the per-turn baseline; leave it unchanged when tokens is null (e.g.
958
938
  // right after a compaction) so the next real reading compares to the last known.
959
939
  const f = usageFraction(usage);
960
940
  if (f != null) previousFraction = f;
961
941
 
962
942
  const n = pendingBatches.length;
963
- 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";
964
945
  // Always surface this flush (even when the routine status line is off): it's a
965
- // significant, infrequent event — context crossed a threshold or jumped sharply
966
- // 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.
967
949
  safeNotify(
968
950
  ctx,
969
951
  n > 0
970
- ? `pruner: ${budgetHit ? "context budget reached" : "context jumped this turn"} — compacting ${n} pending turn${n === 1 ? "" : "s"}`
971
- : `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`,
972
954
  "info",
973
955
  );
974
- 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
+ });
975
960
  }
976
961
  });
977
962
 
@@ -1026,7 +1011,7 @@ export default function (pi: ExtensionAPI) {
1026
1011
  changed = true;
1027
1012
  statsAccum.setLiveReclaim(result.beforeChars, result.afterChars);
1028
1013
  }
1029
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
1014
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
1030
1015
 
1031
1016
  if (!changed) return undefined;
1032
1017
  return { messages };
@@ -1037,10 +1022,7 @@ export default function (pi: ExtensionAPI) {
1037
1022
 
1038
1023
  // ── Register /pruner command + summary message renderer ────────────
1039
1024
  const compactChains = async (ctx: any) => {
1040
- const branch = ctx.sessionManager.getBranch();
1041
- const branchMessages = branch
1042
- .filter((e: any) => e.type === "message" && e.message)
1043
- .map((e: any) => e.message);
1025
+ const branchMessages = projectBranchMessages(ctx.sessionManager.getBranch());
1044
1026
  const chains = detectChains(branchMessages, protectionPredicate);
1045
1027
  const inGrace = inGraceRecoveryToolCallIds(branchMessages, currentConfig.value.recoveryGraceTurns);
1046
1028
  const result = await compressEligible(
@@ -1082,7 +1064,6 @@ export default function (pi: ExtensionAPI) {
1082
1064
  compactChains,
1083
1065
  () => diagnostics.counts(),
1084
1066
  (ctx: any) => computeMetricsSnapshot(ctx) ?? EMPTY_METRICS_SNAPSHOT,
1085
- () => metricsCache,
1086
1067
  () => rearmedPending,
1087
1068
  );
1088
1069
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.9.1",
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.