pi-condense 2.10.4 → 2.10.6

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,18 @@ 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.6] - 2026-09-19
11
+
12
+ ### Fixed
13
+
14
+ - `showPruneStatusLine: false` now also suppresses the transient `pruner loaded` startup widget on new and restored sessions.
15
+
16
+ ## [2.10.5] - 2026-09-15
17
+
18
+ ### Fixed
19
+
20
+ - Mid-run auto-flush triggers (budget, delta, frontier-gap) stayed dead after every human reply until the new run's per-run turn index caught up with the session-wide persisted frontier: live `turn_end` batches carried Pi's run-local `event.turnIndex` while the frontier counts assistant messages session-wide. Live capture now derives the session-wide index from the session branch. Flush metrics entries gain a `stubCount` field. (#16)
21
+
10
22
  ## [2.10.4] - 2026-09-15
11
23
 
12
24
  ### Fixed
package/PRUNING.md CHANGED
@@ -301,7 +301,7 @@ Custom session entry types written by the extension (NOT in LLM context unless n
301
301
  | `context-prune-dedup-alias` | `indexer.registerDuplicate` | One entry per content-hash dedup hit; rebuilt on `session_start` to repopulate `dedupAliasToOriginal` |
302
302
  | `context-prune-chain` | `chain-compressor.compressEligible` (called from `flushPending` in `index.ts` and from `/pruner compact`) | One entry per chain that has been range-dropped from LLM context; drops are decided **positionally** by `resolveRange` (`src/chain-range-prune.ts`), not by id. `droppedToolCallIds` is a diagnostic cross-check only (recorded-vs-actual mismatch emits `range-id-mismatch`); `droppedOccurrenceKeys` (optional) is load-bearing - it's what the occurrence-keyed synthetic-body lookup (per-batch summary text/coverage) is keyed against; protected-output text is NOT keyed off it - `src/chain-range-prune.ts` pulls `protectedToolCallIds` live by bare id within the resolved range instead. Also carries optional `rangeSummaryText` (fused LLM range summary) when `fuseRangeSummary` is on, and optional `protectedToolCallIds` (verbatim protected outputs - ids protected by tool name or path glob - are relocated into the synthetic body as `<protected-output>` tags at render time). Optional `bodySource: "deterministic"` marks a chain that had zero per-batch summary coverage: `rangeSummaryText` then holds a zero-LLM stub (call count, tool histogram, span duration, `t<N>` refs) built by the uncovered-chain backfill path in `chain-compressor.ts`, instead of a summarizer-derived body. Rebuilt on `session_start` to repopulate the chain registry. |
303
303
  | `context-prune-diagnostic` | `pruneMessages` / `applyChainCompressions` / `chain-compressor.compressEligible` (via `DiagnosticSink.report`, `src/diagnostics.ts`) | One entry per distinct `(kind, dedupKey)` prune-time degradation (`unresolved-range` / `range-id-mismatch` / `orphan-sweep` / `backfill-empty`). Never in LLM context; deduped in-memory; reset on `session_start` and `session_tree`. Surfaced on the footer status widget as `diag u<N>/m<N>/o<N>/b<N>`. See [Diagnostics](#diagnostics). |
304
- | `context-prune-flush-metrics` | `flushPending` (end of every non-concurrent attempt, single `finally` emit site, outside the chain-compression try/catch) | One entry per flush attempt, all outcomes (incl. `empty`/`error`): trigger, batch counts, pre-flush `ContextMetricsSnapshot` (open-cycle thinking, largest-chain share, frontier gap). Append-only observability log - never in LLM context, never reconstructed on `session_start`. |
304
+ | `context-prune-flush-metrics` | `flushPending` (end of every non-concurrent attempt, single `finally` emit site, outside the chain-compression try/catch) | One entry per flush attempt, all outcomes (incl. `empty`/`error`): trigger, batch counts, `stubCount` (tool calls newly made stub-eligible by the flush: dedup aliases on processed batches plus calls from actually-indexed batches; `0` when nothing was indexed or aliased), and the pre-flush `ContextMetricsSnapshot` (open-cycle thinking, largest-chain share, frontier gap). Append-only observability log - never in LLM context, never reconstructed on `session_start`. |
305
305
 
306
306
  ## How the Model Re-reads Raw Outputs
307
307
 
@@ -708,6 +708,8 @@ This is rare in practice once `minBatchChars` is on, because the cases where sum
708
708
 
709
709
  The last attempted prune boundary is persisted as `context-prune-frontier` so `flushPending` knows where the previous attempt left off, even if that attempt was a skip rather than a real summary. Without this, a batch that's been skipped as oversized would be re-attempted (with the same LLM call, the same oversize result, the same skip) on every subsequent flush.
710
710
 
711
+ The frontier's `lastAttemptedTurnIndex` uses a session-wide numbering domain: it counts every projected assistant message from the start of the session, matching the rescan rule in `src/batch-capture.ts`. Pi's `event.turnIndex` resets on each `agent_start`, so live `turn_end` capture instead derives this session-wide index from the current session branch via `deriveLiveTurnIndex`. If `getBranch()` throws, capture temporarily falls back to the run-local index. When the branch is healthy at flush time, the rescan re-derives the session-wide index and the fallback value never persists; when branch reads keep failing through the flush, the queued run-local index persists and numbering self-corrects on the next healthy flush. This domain never decreases across compaction because compaction appends entries rather than removing them.
712
+
711
713
  ### Other UI / observability features
712
714
 
713
715
  - **Tree browser (`/pruner tree`):** interactive, foldable tree of pruned tool calls grouped under their summaries. `Ctrl-O` on a summary node opens the full markdown summary in a bordered overlay.
package/index.ts CHANGED
@@ -13,9 +13,9 @@
13
13
  * Usage: pi -e .
14
14
  */
15
15
 
16
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
17
17
  import { loadConfig } from "./src/config.js";
18
- import { captureBatch, captureUnindexedBatchesFromSession, groupBatchesByMode, projectBranchMessages } from "./src/batch-capture.js";
18
+ import { captureBatch, captureUnindexedBatchesFromSession, deriveLiveTurnIndex, 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";
@@ -241,6 +241,7 @@ export default function (pi: ExtensionAPI) {
241
241
  // the emitter falls back to pi.appendEntry.
242
242
  let capturedBatches = 0;
243
243
  let processedCount = 0;
244
+ let stubCount = 0;
244
245
  let outcome: FlushMetricsEntry["outcome"] = "empty";
245
246
  let appendEntry: ((customType: string, data?: unknown) => void) | undefined;
246
247
 
@@ -251,6 +252,7 @@ export default function (pi: ExtensionAPI) {
251
252
  trigger,
252
253
  capturedBatches,
253
254
  processedBatches: processedCount,
255
+ stubCount,
254
256
  outcome,
255
257
  metrics: entryMetrics,
256
258
  };
@@ -486,6 +488,7 @@ export default function (pi: ExtensionAPI) {
486
488
  totalRawCharCount += dedupRawChars;
487
489
  totalToolCallCount += dedupCount;
488
490
  totalDedupedCount += dedupCount;
491
+ stubCount += dedupCount;
489
492
  dedupedBatches.push(batch);
490
493
  processedBatches.push(batch);
491
494
  continue;
@@ -500,6 +503,7 @@ export default function (pi: ExtensionAPI) {
500
503
  totalRawCharCount += batchRawCharCount + dedupRawChars;
501
504
  totalToolCallCount += batch.toolCalls.length + dedupCount;
502
505
  totalDedupedCount += dedupCount;
506
+ stubCount += dedupCount;
503
507
  trivialBatches.push(batch);
504
508
  processedBatches.push(batch);
505
509
  continue;
@@ -541,8 +545,10 @@ export default function (pi: ExtensionAPI) {
541
545
  // Keep the in-memory summary-body registry current so chain compression
542
546
  // can build synthetic chain messages without rescanning session entries.
543
547
  indexer.registerSummaryBody(batchOccurrenceKeys, summaryText);
548
+ stubCount += batch.toolCalls.length + dedupCount;
544
549
  floorSources.push(...batch.toolCalls);
545
550
  } else {
551
+ stubCount += dedupCount;
546
552
  oversizedBatches.push(batch);
547
553
  }
548
554
  } catch (err) {
@@ -821,20 +827,22 @@ export default function (pi: ExtensionAPI) {
821
827
  // Update footer status
822
828
  setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
823
829
 
824
- ctx.ui.setWidget(
825
- "pruner-boot",
826
- [
827
- `pruner loaded — pruning ${currentConfig.value.enabled ? "ON" : "OFF"} | model: ${currentConfig.value.summarizerModel}`,
828
- ],
829
- { placement: "belowEditor" },
830
- );
831
- setTimeout(() => {
832
- try {
833
- ctx.ui.setWidget("pruner-boot", undefined);
834
- } catch {
835
- // UI owner may be gone after session replacement.
836
- }
837
- }, 10000).unref?.();
830
+ if (currentConfig.value.showPruneStatusLine) {
831
+ ctx.ui.setWidget(
832
+ "pruner-boot",
833
+ [
834
+ `pruner loaded — pruning ${currentConfig.value.enabled ? "ON" : "OFF"} | model: ${currentConfig.value.summarizerModel}`,
835
+ ],
836
+ { placement: "belowEditor" },
837
+ );
838
+ setTimeout(() => {
839
+ try {
840
+ ctx.ui.setWidget("pruner-boot", undefined);
841
+ } catch {
842
+ // UI owner may be gone after session replacement.
843
+ }
844
+ }, 10000).unref?.();
845
+ }
838
846
  });
839
847
 
840
848
  // Rebuild index and stats after tree navigation too (branch may have different history)
@@ -889,10 +897,25 @@ export default function (pi: ExtensionAPI) {
889
897
 
890
898
  let pushedBatch = false;
891
899
  if (hasToolResults) {
900
+ // Live batches must be numbered in the frontier's session-wide domain, not
901
+ // Pi's run-local event.turnIndex (which resets on agent_start, #16). The
902
+ // branch at turn_end already holds the just-ended assistant message (pi
903
+ // persists it at message_end, a strictly earlier event), so the derived
904
+ // index is the rescan index of this turn.
905
+ let liveTurnIndex = event.turnIndex;
906
+ let branch: SessionEntry[] | undefined;
907
+ try {
908
+ branch = ctx.sessionManager.getBranch();
909
+ } catch {
910
+ // Transient getBranch failure must never block the turn; fall back to
911
+ // the run-local index (pre-#16 behavior). The flush-time rescan still
912
+ // recovers the batch.
913
+ }
914
+ if (branch) liveTurnIndex = deriveLiveTurnIndex(branch);
892
915
  const capturedBatch = captureBatch(
893
916
  event.message,
894
917
  event.toolResults,
895
- event.turnIndex,
918
+ liveTurnIndex,
896
919
  Date.now()
897
920
  );
898
921
  // Drop user-protected tool/path results so they stay verbatim in context.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.10.4",
3
+ "version": "2.10.6",
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",
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  captureBatch,
4
4
  captureUnindexedBatchesFromSession,
5
+ deriveLiveTurnIndex,
5
6
  projectBranchMessages,
6
7
  serializeBatchForSummarizer,
7
8
  } from "./batch-capture.js";
@@ -180,6 +181,45 @@ describe("projectBranchMessages", () => {
180
181
  });
181
182
  });
182
183
 
184
+ describe("deriveLiveTurnIndex (#16)", () => {
185
+ // Branch mixing every entry class the projection must classify: tool-calling
186
+ // assistants, a text-only assistant, a custom_message steer, a pruner custom
187
+ // entry, a compaction entry. The last assistant carries a ready, unsummarized
188
+ // tool call so the rescan emits a batch for it.
189
+ function mixedBranch(): any[] {
190
+ return [
191
+ { type: "message", message: { role: "user", content: [{ type: "text", text: "start" }] } },
192
+ { type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "tc-old", name: "read", arguments: {} }] } },
193
+ { type: "message", message: { role: "toolResult", toolCallId: "tc-old", toolName: "read", content: [{ type: "text", text: "y".repeat(200) }], timestamp: 1 } },
194
+ { type: "message", message: { role: "assistant", content: [{ type: "text", text: "text only" }] } },
195
+ { type: "custom_message", customType: "gauntlet-gate", content: "go", display: true },
196
+ { type: "custom", customType: "context-prune-summary", data: {} },
197
+ { type: "compaction", summary: "..." },
198
+ { type: "message", message: { role: "user", content: [{ type: "text", text: "again" }] } },
199
+ { type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "tc-live", name: "read", arguments: {} }] } },
200
+ { type: "message", message: { role: "toolResult", toolCallId: "tc-live", toolName: "read", content: [{ type: "text", text: "x".repeat(200) }], timestamp: 2 } },
201
+ ];
202
+ }
203
+
204
+ test("returns the rescan index of the branch's last assistant message (AC5 parity)", () => {
205
+ const branch = mixedBranch();
206
+ // 3 projected assistant messages (tc-old, text-only, tc-live) -> last index 2.
207
+ // custom_message/custom/compaction entries never count.
208
+ expect(deriveLiveTurnIndex(branch)).toBe(2);
209
+ const rescan = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false });
210
+ const liveBatch = rescan.find((b) => b.toolCalls.some((tc) => tc.toolCallId === "tc-live"));
211
+ expect(liveBatch).toBeDefined();
212
+ expect(liveBatch!.turnIndex).toBe(deriveLiveTurnIndex(branch));
213
+ });
214
+
215
+ test("returns -1 when the branch has no projected assistant message", () => {
216
+ expect(deriveLiveTurnIndex([])).toBe(-1);
217
+ expect(
218
+ deriveLiveTurnIndex([{ type: "message", message: { role: "user", content: [{ type: "text", text: "hi" }] } }]),
219
+ ).toBe(-1);
220
+ });
221
+ });
222
+
183
223
  describe("custom-anchor group boundary", () => {
184
224
  function buildBranch(customType: string) {
185
225
  const entry = (message: any) => ({ type: "message", message });
@@ -1,3 +1,4 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
1
2
  import type { CapturedBatch, CapturedToolCall, BatchingMode } from "./types.js";
2
3
  import { occKey, resultTimestampOf } from "./occurrence-key.js";
3
4
  import { isChainAnchorCustom } from "./chain-detector.js";
@@ -17,6 +18,22 @@ export function projectBranchMessages(branch: any[]): any[] {
17
18
  .map((e: any) => (e.type === "custom_message" ? projectCustomMessageEntry(e) : e.message));
18
19
  }
19
20
 
21
+ /**
22
+ * Session-wide index for the live turn at `turn_end`: the index the rescan
23
+ * below assigns to the branch's last assistant message. Shares the rescan's
24
+ * counting rule (every projected assistant message, text-only included) so
25
+ * live capture and the persisted flush frontier live in one numbering domain.
26
+ * Returns -1 when the branch has no projected assistant message (harness-only;
27
+ * a real `turn_end` always follows a persisted assistant message).
28
+ */
29
+ export function deriveLiveTurnIndex(branch: SessionEntry[]): number {
30
+ let count = 0;
31
+ for (const msg of projectBranchMessages(branch)) {
32
+ if (msg.role === "assistant") count++;
33
+ }
34
+ return count - 1;
35
+ }
36
+
20
37
  /** True for SessionEntry shapes that project into an AgentMessage-like object (see projectBranchMessages). */
21
38
  function isProjectableEntry(e: any): boolean {
22
39
  return (e.type === "message" && e.message) || e.type === "custom_message";
@@ -109,8 +126,10 @@ export function captureUnindexedBatchesFromSession(
109
126
  // turnCounter increments for EVERY assistant message (not just prunable ones).
110
127
  // This makes turnIndex stable across multiple prune cycles: pruning removes
111
128
  // ToolResultMessages from the context event but leaves AssistantMessages in the
112
- // session branch, so the count of all assistant messages never decreases and
113
- // always matches Pi's own event.turnIndex numbering.
129
+ // session branch, so the count of all assistant messages never decreases. This
130
+ // session-wide count is the frontier's numbering domain; Pi's event.turnIndex
131
+ // matches it only inside one agent run (it resets on agent_start), so the live
132
+ // capture path derives the same index from the branch via deriveLiveTurnIndex.
114
133
  let turnCounter = 0;
115
134
 
116
135
  // userTurnGroup increments on every user message or eligible custom anchor seen
@@ -76,6 +76,27 @@ describe("pruneMessages", () => {
76
76
  expect(out[1].content[0].text).toContain("context_tree_query");
77
77
  });
78
78
 
79
+ it("two renders of unchanged history are byte-identical (AC6 cache-prefix guard)", () => {
80
+ const indexer = makeMockIndexer({
81
+ summarized: new Set(["tc1"]),
82
+ shortRefs: new Map([["tc1", "t1"]]),
83
+ });
84
+ const build = () => [
85
+ { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 0 },
86
+ {
87
+ role: "toolResult",
88
+ toolCallId: "tc1",
89
+ toolName: "bash",
90
+ content: [{ type: "text", text: "big output" }],
91
+ isError: false,
92
+ timestamp: 1,
93
+ },
94
+ ];
95
+ const first = pruneMessages(build(), indexer);
96
+ const second = pruneMessages(build(), indexer);
97
+ expect(JSON.stringify(second.messages)).toBe(JSON.stringify(first.messages));
98
+ });
99
+
79
100
  it("returns original array reference when nothing is summarized or compressed", () => {
80
101
  const indexer = makeMockIndexer();
81
102
  const messages = [{ role: "user", content: "hello", timestamp: 1 }];
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, mock } from "bun:test";
2
- import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import * as actualCompat from "@earendil-works/pi-ai/compat";
@@ -150,6 +150,44 @@ function pendingBatchEntries(toolCallId: string, text: string, timestamp: number
150
150
  ];
151
151
  }
152
152
 
153
+ function textOnlyTurns(count: number, startTs: number): any[] {
154
+ const msgs: any[] = [];
155
+ let t = startTs;
156
+ for (let i = 0; i < count; i++) {
157
+ msgs.push({ type: "message", message: { role: "user", content: [{ type: "text", text: `turn ${i}` }], timestamp: (t += 1000) } });
158
+ msgs.push({ type: "message", message: { role: "assistant", content: [{ type: "text", text: `answer ${i}` }], timestamp: (t += 1000) } });
159
+ }
160
+ return msgs;
161
+ }
162
+
163
+ function frontierEntry(turnIndex: number, toolCallId: string): any {
164
+ return {
165
+ type: "custom",
166
+ customType: "context-prune-frontier",
167
+ data: {
168
+ lastAttemptedToolCallId: toolCallId,
169
+ lastAttemptedToolName: "read",
170
+ lastAttemptedTurnIndex: turnIndex,
171
+ lastAttemptedTimestamp: Date.now(),
172
+ attemptedBatchCount: 1,
173
+ attemptedToolCallCount: 1,
174
+ rawCharCount: 1000,
175
+ summaryCharCount: 100,
176
+ outcome: "summarized",
177
+ },
178
+ };
179
+ }
180
+
181
+ async function fireTurn(handlers: Map<string, any>, ctx: any, branch: any[], toolCallId: string, text: string, runLocalIndex: number) {
182
+ const t = Date.now();
183
+ const [, assistant, result] = pendingBatchEntries(toolCallId, text, t);
184
+ branch.push(assistant, result);
185
+ await handlers.get("turn_end")!(
186
+ { message: assistant.message, toolResults: [result.message], turnIndex: runLocalIndex },
187
+ ctx,
188
+ );
189
+ }
190
+
153
191
  // Boots a fresh index.ts extension instance against an isolated agent dir +
154
192
  // session, mirroring the fixtures shared across the three scenarios below.
155
193
  //
@@ -180,6 +218,7 @@ function bootExtension(
180
218
  autoBudgetThreshold?: number | null;
181
219
  budgetTurnDelta?: number | null;
182
220
  frontierGapThresholdTokens?: number | null;
221
+ showPruneStatusLine?: boolean;
183
222
  } = {},
184
223
  ) {
185
224
  const agentDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-"));
@@ -191,7 +230,7 @@ function bootExtension(
191
230
  autoBudgetThreshold: options.autoBudgetThreshold === undefined ? 0.5 : options.autoBudgetThreshold,
192
231
  summarizerModel: "default",
193
232
  minBatchChars: 1,
194
- showPruneStatusLine: true,
233
+ showPruneStatusLine: options.showPruneStatusLine ?? true,
195
234
  protectedTools: options.protectedTools ?? [],
196
235
  chainCompression: {
197
236
  enabled: options.chainCompressionEnabled ?? false,
@@ -220,6 +259,7 @@ function bootExtension(
220
259
  const handlers = new Map<string, (event: any, ctx: any) => any>();
221
260
  const commands = new Map<string, (args: string, ctx: any) => Promise<void>>();
222
261
  const notifications: string[] = [];
262
+ const widgets: Array<{ id: string; content: unknown }> = [];
223
263
 
224
264
  const pushPi = (type: string, data?: unknown) => {
225
265
  piAppended.push({ type, data });
@@ -269,7 +309,9 @@ function bootExtension(
269
309
  },
270
310
  ui: {
271
311
  setStatus() {},
272
- setWidget() {},
312
+ setWidget(id: string, content: unknown) {
313
+ widgets.push({ id, content });
314
+ },
273
315
  notify(message: string) {
274
316
  notifications.push(message);
275
317
  },
@@ -277,7 +319,7 @@ function bootExtension(
277
319
  },
278
320
  };
279
321
 
280
- return { handlers, commands, notifications, ctx, pi, piAppended, sessionAppended, appended, branch };
322
+ return { handlers, commands, notifications, widgets, ctx, pi, piAppended, sessionAppended, appended, branch };
281
323
  }
282
324
 
283
325
  async function boot(options?: Parameters<typeof bootExtension>[0]) {
@@ -288,6 +330,118 @@ async function boot(options?: Parameters<typeof bootExtension>[0]) {
288
330
  }
289
331
 
290
332
  describe("reload rearm (issue #6)", () => {
333
+ it("hides the startup widget when the prune status line is disabled", async () => {
334
+ const { handlers, ctx, widgets } = await boot({ showPruneStatusLine: false });
335
+
336
+ await handlers.get("session_start")!({}, ctx);
337
+
338
+ expect(widgets.some(({ id }) => id === "pruner-boot")).toBe(false);
339
+ });
340
+
341
+ it("falls back to the run-local index at capture when getBranch throws, and the flush-time rescan re-derives the session-wide index", async () => {
342
+ const { handlers, ctx, notifications, appended } = await boot();
343
+
344
+ await handlers.get("session_start")!({}, ctx);
345
+ ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
346
+ const healthyGetBranch = ctx.sessionManager.getBranch;
347
+ ctx.sessionManager.getBranch = () => {
348
+ throw new Error("boom");
349
+ };
350
+
351
+ await expect(
352
+ handlers.get("turn_end")!(
353
+ {
354
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "read", arguments: {} }] },
355
+ toolResults: [
356
+ { role: "toolResult", toolCallId: "tc2", toolName: "read", content: [{ type: "text", text: "result" }], timestamp: Date.now() },
357
+ ],
358
+ turnIndex: 7,
359
+ },
360
+ ctx,
361
+ ),
362
+ ).resolves.toBeUndefined();
363
+ expect(notifications.filter((message) => message.includes("pruner: 1 turn queued"))).toHaveLength(1);
364
+
365
+ ctx.sessionManager.getBranch = healthyGetBranch;
366
+ await handlers.get("message_end")!(
367
+ { message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
368
+ ctx,
369
+ );
370
+
371
+ const flushMetrics = appended.find((entry) => entry.type === "context-prune-flush-metrics");
372
+ expect(flushMetrics).toBeDefined();
373
+ expect((flushMetrics!.data as any).outcome).not.toBe("error");
374
+
375
+ const frontier = appended.find((entry) => entry.type === "context-prune-frontier");
376
+ expect(frontier).toBeDefined();
377
+ // The flush-time rescan re-derives the session-wide index; the run-local fallback value never persists.
378
+ expect((frontier!.data as any).lastAttemptedTurnIndex).toBe(0);
379
+ });
380
+
381
+ it("persists the queued batch's run-local index when the flush itself runs on the getBranch fallback", async () => {
382
+ const { handlers, ctx, appended } = await boot();
383
+
384
+ await handlers.get("session_start")!({}, ctx);
385
+ ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
386
+ ctx.sessionManager.getBranch = () => {
387
+ throw new Error("boom");
388
+ };
389
+
390
+ // Long result text so the queued batch clears the oversized-summary guard
391
+ // and reaches indexer.addBatch (a 6-char result would be skipped-oversized
392
+ // and leave no index entry to assert against).
393
+ await handlers.get("turn_end")!(
394
+ {
395
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "read", arguments: {} }] },
396
+ toolResults: [
397
+ { role: "toolResult", toolCallId: "tc2", toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() },
398
+ ],
399
+ turnIndex: 7,
400
+ },
401
+ ctx,
402
+ );
403
+
404
+ // getBranch is still down at flush time: capturePendingBatches' catch
405
+ // branch serves pendingBatches.slice() (index.ts), so the queued batch's
406
+ // captured run-local index (7) is what reaches persistence.
407
+ await handlers.get("message_end")!(
408
+ { message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
409
+ ctx,
410
+ );
411
+
412
+ const flushMetrics = appended.find((entry) => entry.type === "context-prune-flush-metrics");
413
+ expect(flushMetrics).toBeDefined();
414
+ expect((flushMetrics!.data as any).outcome).toBe("summarized");
415
+
416
+ const indexEntry = appended.find((entry) => entry.type === "context-prune-index");
417
+ expect(indexEntry).toBeDefined();
418
+ expect((indexEntry!.data as any).toolCalls[0].turnIndex).toBe(7);
419
+
420
+ const frontier = appended.find((entry) => entry.type === "context-prune-frontier");
421
+ expect(frontier).toBeDefined();
422
+ expect((frontier!.data as any).lastAttemptedTurnIndex).toBe(7);
423
+ });
424
+
425
+ it("propagates turn-index derivation errors at turn_end", async () => {
426
+ const { handlers, ctx, branch } = await boot();
427
+
428
+ await handlers.get("session_start")!({}, ctx);
429
+ branch.push(null);
430
+
431
+ await expect(
432
+ handlers.get("turn_end")!(
433
+ {
434
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "read", arguments: {} }] },
435
+ toolResults: [
436
+ { role: "toolResult", toolCallId: "tc2", toolName: "read", content: [{ type: "text", text: "result" }], timestamp: Date.now() },
437
+ ],
438
+ turnIndex: 0,
439
+ },
440
+ ctx,
441
+ ),
442
+ ).rejects.toThrow();
443
+ });
444
+
291
445
  it("rearms the turn_end budget gate after a reload so recovered pending work still flushes", async () => {
292
446
  const { handlers, ctx, appended } = await boot();
293
447
 
@@ -784,6 +938,8 @@ describe("reload rearm (issue #6)", () => {
784
938
  expect(flushMetricsEntries.length).toBe(1);
785
939
  const fm = flushMetricsEntries[0].data as any;
786
940
  expect(fm.trigger).toBe("frontier-gap");
941
+ // defaultBranch's single tc1 call is summarized+indexed by this flush.
942
+ expect(fm.stubCount).toBe(1);
787
943
  expect(fm.metrics.frontierGapTokens).toBeGreaterThanOrEqual(10);
788
944
 
789
945
  expect(notifications.some((n) => n.includes("un-pruned tail exceeded frontier gap threshold"))).toBe(true);
@@ -895,6 +1051,9 @@ describe("reload rearm (issue #6)", () => {
895
1051
  expect(frontierEntries.length).toBe(1);
896
1052
  const firstFrontier = frontierEntries[0].data as any;
897
1053
  expect(firstFrontier.lastAttemptedToolCallId).toBe("tc-a");
1054
+ // Turn 2's flush indexed tc-a (1 call) and aliased nothing else.
1055
+ const firstMetrics = appended.filter((e) => e.type === "context-prune-flush-metrics");
1056
+ expect((firstMetrics[0].data as any).stubCount).toBe(1);
898
1057
 
899
1058
  // Grow the branch again (tc-b is still unsummarized/pending after the
900
1059
  // restore; add tc-c as this turn's new work) — gap stays over threshold.
@@ -1228,12 +1387,47 @@ describe("supersede floor cadence (spec 2026-09-07)", () => {
1228
1387
  const frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
1229
1388
  expect(frontierEntries.length).toBe(1);
1230
1389
  expect((frontierEntries[0].data as any).outcome).toBe("skipped-trivial");
1390
+ const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
1391
+ expect(flushMetricsEntries.length).toBe(1);
1392
+ expect((flushMetricsEntries[0].data as any).outcome).toBe("skipped-trivial");
1393
+ expect((flushMetricsEntries[0].data as any).stubCount).toBe(0);
1231
1394
 
1232
1395
  const rendered = await render(branch, handlers, ctx);
1233
1396
  expect(toolResultText(rendered, "r1")).toBe("content-r1");
1234
1397
  expect(toolResultText(rendered, "r2")).toBe("content-r2");
1235
1398
  });
1236
1399
 
1400
+ it("a partially deduped skipped-trivial batch counts only its alias in stubCount", async () => {
1401
+ const R = "R".repeat(400);
1402
+ const branch: any[] = [];
1403
+ const { handlers, ctx, appended } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
1404
+
1405
+ await handlers.get("session_start")!({}, ctx);
1406
+ await fireTurn(handlers, ctx, branch, "bashOrig", R, 1);
1407
+ expect(appended.some((e) => e.type === "context-prune-index")).toBe(true);
1408
+
1409
+ const assistant = {
1410
+ role: "assistant",
1411
+ content: [
1412
+ { type: "toolCall", id: "bashDup", name: "read", arguments: {} },
1413
+ { type: "toolCall", id: "trivial", name: "read", arguments: {} },
1414
+ ],
1415
+ };
1416
+ const toolResults = [
1417
+ { role: "toolResult", toolCallId: "bashDup", toolName: "read", content: [{ type: "text", text: R }], timestamp: 90 },
1418
+ { role: "toolResult", toolCallId: "trivial", toolName: "read", content: [{ type: "text", text: "" }], timestamp: 91 },
1419
+ ];
1420
+ branch.push({ type: "message", message: assistant }, ...toolResults.map((message) => ({ type: "message", message })));
1421
+ await handlers.get("turn_end")!({ message: assistant, toolResults, turnIndex: 2 }, ctx);
1422
+
1423
+ const aliasEntries = appended.filter((e) => e.type === "context-prune-dedup-alias");
1424
+ expect(aliasEntries.length).toBe(1);
1425
+ const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
1426
+ const metrics = flushMetricsEntries[flushMetricsEntries.length - 1].data as any;
1427
+ expect(metrics.outcome).toBe("skipped-trivial");
1428
+ expect(metrics.stubCount).toBe(1);
1429
+ });
1430
+
1237
1431
  it("a skipped-oversized batch (summary longer than raw) sets no floor", async () => {
1238
1432
  const branch: any[] = [];
1239
1433
  const { handlers, ctx, appended } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
@@ -1263,6 +1457,10 @@ describe("supersede floor cadence (spec 2026-09-07)", () => {
1263
1457
  const frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
1264
1458
  expect(frontierEntries.length).toBe(1);
1265
1459
  expect((frontierEntries[0].data as any).outcome).toBe("skipped-oversized");
1460
+ const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
1461
+ expect(flushMetricsEntries.length).toBe(1);
1462
+ expect((flushMetricsEntries[0].data as any).outcome).toBe("skipped-oversized");
1463
+ expect((flushMetricsEntries[0].data as any).stubCount).toBe(0);
1266
1464
 
1267
1465
  const rendered = await render(branch, handlers, ctx);
1268
1466
  expect(toolResultText(rendered, "r1")).toBe("content-r1");
@@ -1316,6 +1514,10 @@ describe("supersede floor cadence (spec 2026-09-07)", () => {
1316
1514
  expect(frontierEntries[frontierEntries.length - 1].data && (frontierEntries[frontierEntries.length - 1].data as any).outcome).toBe(
1317
1515
  "skipped-deduped",
1318
1516
  );
1517
+ const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
1518
+ const dedupMetrics = flushMetricsEntries[flushMetricsEntries.length - 1].data as any;
1519
+ expect(dedupMetrics.outcome).toBe("skipped-deduped");
1520
+ expect(dedupMetrics.stubCount).toBe(1);
1319
1521
  // The alias's own resultTimestamp (90) is <= the older read's (100) — the
1320
1522
  // property that lets it, alone, explain the activation below.
1321
1523
 
@@ -1434,3 +1636,108 @@ describe("supersede floor cadence (spec 2026-09-07)", () => {
1434
1636
  expect(toolResultText(rendered, "r_new")).toBe("content-r_new");
1435
1637
  });
1436
1638
  });
1639
+
1640
+ describe("session-wide live turn index (#16)", () => {
1641
+ it("AC8: pre-fix fixture with frontier 83 flushes one eligible live turn past the frontier", async () => {
1642
+ const fixture = readFileSync(join(import.meta.dirname, "fixtures", "gh16-frontier-83.jsonl"), "utf8")
1643
+ .trim()
1644
+ .split("\n")
1645
+ .map((line) => JSON.parse(line));
1646
+ const { handlers, ctx, appended } = await boot({
1647
+ branch: fixture,
1648
+ autoBudgetThreshold: null,
1649
+ budgetTurnDelta: null,
1650
+ frontierGapThresholdTokens: 100,
1651
+ });
1652
+ ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
1653
+ await handlers.get("session_start")!({}, ctx);
1654
+
1655
+ await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-live", "x".repeat(800), 0);
1656
+
1657
+ const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
1658
+ expect(fm.length).toBe(1);
1659
+ expect((fm[0].data as any).trigger).toBe("frontier-gap");
1660
+ expect((fm[0].data as any).outcome).toBe("summarized");
1661
+ expect((fm[0].data as any).stubCount).toBeGreaterThan(0);
1662
+
1663
+ const frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
1664
+ const next = frontierEntries[frontierEntries.length - 1].data as any;
1665
+ expect(next.lastAttemptedTurnIndex).toBeGreaterThanOrEqual(83);
1666
+ });
1667
+
1668
+ it("AC1: a new run's first batch survives the frontier and reaches the budget gate", async () => {
1669
+ const branch = [...textOnlyTurns(84, 1700000000000), frontierEntry(83, "tc-old")];
1670
+ const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: 0.5 });
1671
+ ctx.getContextUsage = () => ({ tokens: 900000, contextWindow: 1000000 });
1672
+ await handlers.get("session_start")!({}, ctx);
1673
+ await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-new", "x".repeat(400), 0);
1674
+ const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
1675
+ expect(fm.length).toBe(1);
1676
+ expect((fm[0].data as any).trigger).toBe("budget");
1677
+ expect((fm[0].data as any).trigger).not.toBe("rearmed");
1678
+ });
1679
+
1680
+ it("AC2: an already-summarized live batch is still dropped (no re-summarization)", async () => {
1681
+ const branch = [...textOnlyTurns(84, 1700000000000), frontierEntry(83, "tc-old")];
1682
+ const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: 0.5 });
1683
+ ctx.getContextUsage = () => ({ tokens: 900000, contextWindow: 1000000 });
1684
+ await handlers.get("session_start")!({}, ctx);
1685
+ await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-new", "x".repeat(400), 0);
1686
+ const callsAfterFirst = summarizerCalls;
1687
+ expect(appended.filter((e) => e.type === "context-prune-flush-metrics").length).toBe(1);
1688
+ const [assistant, result] = ctx.sessionManager.getBranch().slice(-2);
1689
+ await handlers.get("turn_end")!({ message: assistant.message, toolResults: [result.message], turnIndex: 1 }, ctx);
1690
+ expect(summarizerCalls).toBe(callsAfterFirst);
1691
+ expect(appended.filter((e) => e.type === "context-prune-flush-metrics").length).toBe(1);
1692
+ });
1693
+
1694
+ it("AC3: above-frontier live batches in a continuing run keep flushing", async () => {
1695
+ const branch = [...textOnlyTurns(84, 1700000000000), frontierEntry(83, "tc-old")];
1696
+ const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: 0.5 });
1697
+ ctx.getContextUsage = () => ({ tokens: 900000, contextWindow: 1000000 });
1698
+ await handlers.get("session_start")!({}, ctx);
1699
+ await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-a", "a".repeat(400), 0);
1700
+ await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-b", "b".repeat(400), 1);
1701
+ const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
1702
+ expect(fm.length).toBe(2);
1703
+ expect((fm[1].data as any).trigger).toBe("budget");
1704
+ });
1705
+
1706
+ it("AC4: equal-index partial turn keeps only the suffix after the recorded call", async () => {
1707
+ const branch = [...textOnlyTurns(2, 1700000000000), frontierEntry(2, "tc-2")];
1708
+ const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: 0.5 });
1709
+ ctx.getContextUsage = () => ({ tokens: 900000, contextWindow: 1000000 });
1710
+ await handlers.get("session_start")!({}, ctx);
1711
+ const t = Date.now();
1712
+ const assistant = { type: "message", message: { role: "assistant", content: [
1713
+ { type: "toolCall", id: "tc-1", name: "read", arguments: {} },
1714
+ { type: "toolCall", id: "tc-2", name: "read", arguments: {} },
1715
+ { type: "toolCall", id: "tc-3", name: "read", arguments: {} },
1716
+ ] }, timestamp: t };
1717
+ const results = ["tc-1", "tc-2", "tc-3"].map((id, i) => ({
1718
+ type: "message", message: { role: "toolResult", toolCallId: id, toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: t + 1 + i },
1719
+ }));
1720
+ ctx.sessionManager.getBranch().push(assistant, ...results);
1721
+ await handlers.get("turn_end")!({ message: assistant.message, toolResults: results.map((r) => r.message), turnIndex: 0 }, ctx);
1722
+ const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
1723
+ expect(fm.length).toBe(1);
1724
+ expect((fm[0].data as any).stubCount).toBe(1);
1725
+ const indexEntries = appended.filter((e) => e.type === "context-prune-index");
1726
+ const indexPayload = JSON.stringify(indexEntries.map((e) => e.data));
1727
+ expect(indexPayload).toContain("tc-3");
1728
+ expect(indexPayload).not.toContain("tc-1");
1729
+ });
1730
+
1731
+ it("AC7: frontier-gap fires on the second post-reply turn, not before", async () => {
1732
+ const branch = [...textOnlyTurns(51, 1700000000000), frontierEntry(50, "tc-old")];
1733
+ const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: null, budgetTurnDelta: null, frontierGapThresholdTokens: 1000 });
1734
+ ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
1735
+ await handlers.get("session_start")!({}, ctx);
1736
+ await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-g1", "g".repeat(3500), 0);
1737
+ expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
1738
+ await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-g2", "g".repeat(3500), 1);
1739
+ const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
1740
+ expect(fm.length).toBe(1);
1741
+ expect((fm[0].data as any).trigger).toBe("frontier-gap");
1742
+ });
1743
+ });
package/src/types.ts CHANGED
@@ -732,6 +732,8 @@ export interface FlushMetricsEntry {
732
732
  /** Batches after rescan+trim, before processing. */
733
733
  capturedBatches: number;
734
734
  processedBatches: number;
735
+ /** Tool calls this flush newly made stub-eligible: dedup aliases on processed batches plus calls of batches actually indexed. 0 when nothing was indexed or aliased (all-trivial/oversized, or failure before any batch was processed). */
736
+ stubCount: number;
735
737
  outcome: "summarized" | "skipped-oversized" | "skipped-deduped" | "skipped-trivial" | "empty" | "error";
736
738
  /** Computed at flush ENTRY (pre-flush pressure). */
737
739
  metrics: ContextMetricsSnapshot;