pi-condense 2.14.0 → 2.15.1

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.15.1] - 2026-11-10
11
+
12
+ ### Fixed
13
+
14
+ - `/pruner on` runs the reload rearm probe when it flips pruning from off to on, so tool results produced while pruning was disabled, or recovered by a reload that happened while it was disabled, reach the next `turn_end` gate. Before, only `session_start` and `session_tree` probed, and enabling later left that work unflushed until a fresh batch arrived. (#29)
15
+
16
+ ## [2.15.0] - 2026-11-09
17
+
18
+ ### Changed
19
+
20
+ - pi-condense treats sessions without a UI (`pi -p`, `--mode json`, an SDK host with no UI context) as single-prompt and skips the final `agent-message` flush: a single-prompt print child has no later call to read the pruned context. Multi-message print runs and headless SDK hosts still rely on budget, delta, and frontier-gap flushes at `turn_end` (`autoBudgetThreshold`, `budgetTurnDelta`, `frontierGapThresholdTokens`); pending work stays recoverable through the reload rearm. (#29)
21
+
10
22
  ## [2.14.0] - 2026-11-08
11
23
 
12
24
  ### Changed
package/PRUNING.md CHANGED
@@ -975,7 +975,7 @@ Any other outcome - zero or multiple matches on either boundary, `finalAssistant
975
975
  - With K=3: the three most-recently-closed chains stay as-is; the fourth triggers compression of the oldest. Exactly K closed chains are kept raw (the open/live chain is never counted).
976
976
  - `/pruner compact` bypasses the window and compresses every eligible chain immediately (retroactive).
977
977
 
978
- **Closing-message threading.** In agent-message mode the flush runs from `message_end`, which pi emits to extensions *before* it persists the message to the session (`agent-session.js` runs `_emitExtensionEvent` ahead of `sessionManager.appendMessage`). So at flush time `getBranch()` is missing the just-closed final assistant, and the newest chain would read as open — over-retaining by one (effective K+1). `index.ts` threads the triggering `event.message` through `FlushOptions.closingMessage` into `withClosingMessage` so the chain closes and the window is exactly K.
978
+ **Closing-message threading.** pi-condense treats `ctx.hasUI === false` sessions (`pi -p`, `--mode json`, an SDK host with no UI context) as single-prompt and skips the final agent-message flush: a single-prompt print child has no later call to read the pruned context. Multi-message print runs and headless SDK hosts still rely on the `turn_end` budget/delta/frontier-gap triggers (`autoBudgetThreshold`, `budgetTurnDelta`, `frontierGapThresholdTokens`) for pruning. In sessions with a UI, the agent-message flush runs from `message_end`, which pi emits to extensions *before* it persists the message to the session (`agent-session.js` runs `_emitExtensionEvent` ahead of `sessionManager.appendMessage`). So at flush time `getBranch()` is missing the just-closed final assistant, and the newest chain would read as open - over-retaining by one (effective K+1). `index.ts` threads the triggering `event.message` through `FlushOptions.closingMessage` into `withClosingMessage` so the chain closes and the window is exactly K.
979
979
 
980
980
  ### Synthetic message format
981
981
 
@@ -1074,9 +1074,9 @@ See `doc/specs/2026-08-12-single-chain-observability-trigger-repair.md` for the
1074
1074
 
1075
1075
  ### Reload rearm
1076
1076
 
1077
- A reload (`session_start` or `session_tree`) rescans the branch for completed-but-unflushed tool-call batches past the frontier (the same rescan `flushPending` itself uses, no queue reconstruction). If that rescan finds recoverable work, a transient in-memory flag arms: the next `turn_end` evaluates the budget/delta trigger even on a turn that contributes no new tool results itself, instead of silently requiring a fresh batch to reach the gate. No flush runs at reload time - arming only changes what an *existing* trigger sees on the next turn.
1077
+ A reload (`session_start` or `session_tree`), and `/pruner on` when it flips pruning from off to on, rescans the branch for completed-but-unflushed tool-call batches past the frontier (the same rescan `flushPending` itself uses, no queue reconstruction). If that rescan finds recoverable work, a transient in-memory flag arms: the next `turn_end` evaluates the budget/delta trigger even on a turn that contributes no new tool results itself, instead of silently requiring a fresh batch to reach the gate. No flush runs at reload time - arming only changes what an *existing* trigger sees on the next turn.
1078
1078
 
1079
- The flag clears the moment any flush attempt runs (any outcome) and is re-evaluated on the next reload. A reload followed by total idleness - no further turns at all - gets visibility only: the footer, `/pruner status`, and the `agent_end` pending notice (`prune: recovered pending (reload)`) all reflect the recoverable work, but nothing flushes automatically. This is by design - no immediate flush at boot (print-mode sessions may die under it; a boot-time compact was not requested by the user).
1079
+ The flag clears the moment any flush attempt runs (any outcome) and is re-evaluated on the next reload or enable. `turn_end` captures nothing while pruning is disabled, so the `/pruner on` probe is what puts tool results from a disabled stretch back in front of the gate; a `/pruner on` while already enabled does not probe. A reload followed by total idleness - no further turns at all - gets visibility only: the footer, `/pruner status`, and the `agent_end` pending notice (`prune: recovered pending (reload)`) all reflect the recoverable work, but nothing flushes automatically. This is by design - no immediate flush at boot (print-mode sessions may die under it; a boot-time compact was not requested by the user).
1080
1080
 
1081
1081
  ---
1082
1082
 
package/README.md CHANGED
@@ -69,7 +69,7 @@ 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.
72
+ pi-condense treats a session without a UI (`pi -p`, `--mode json`, an SDK host with no UI context) as single-prompt and skips the final `agent-message` flush there: for a single-prompt `-p` / `--mode json` child, nothing reads the pruned context afterwards. A multi-message print run or a headless SDK host that wants its final messages pruned still relies on the `turn_end` budget/delta/frontier-gap flushes: set `autoBudgetThreshold` (e.g. `0.8`), `budgetTurnDelta`, or `frontierGapThresholdTokens` to enable them. Pending work stays recoverable through the reload rearm if the session is resumed.
73
73
 
74
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).
75
75
 
package/index.ts CHANGED
@@ -180,9 +180,9 @@ export default function (pi: ExtensionAPI) {
180
180
  // ── Helper: capture + trim + group pending batches (no LLM work) ──────────
181
181
  // Exposed to commands.ts via registerCommands so /pruner now can preview the
182
182
  // queue before opening the multi-row progress overlay.
183
- // `rethrow` is for the reload rearm probe only (session_start/session_tree):
184
- // it needs to observe a rescan failure so it can console.error and leave
185
- // rearmedPending false, per spec. Every other caller (turn_end capture path,
183
+ // `rethrow` is for probeRearm only: it needs to observe a rescan failure so
184
+ // it can console.error and leave rearmedPending false, per spec. Every
185
+ // other caller (turn_end capture path,
186
186
  // flushPending, /pruner commands) keeps the existing swallow-and-fall-back
187
187
  // behavior so a transient getBranch failure there never blocks the turn.
188
188
  const capturePendingBatches = (ctx: any, opts?: { rethrow?: boolean }): CapturedBatch[] => {
@@ -200,6 +200,20 @@ export default function (pi: ExtensionAPI) {
200
200
  return groupBatchesByMode(batches, currentConfig.value.batchingMode);
201
201
  };
202
202
 
203
+ // Arms the one-shot rearm flag when the persisted branch holds un-pruned
204
+ // tool results the in-memory queue does not know about: after a reload, a
205
+ // branch switch, or a stretch with pruning disabled (turn_end captures
206
+ // nothing while disabled). A disabled pruner never arms.
207
+ const probeRearm = (ctx: any): void => {
208
+ rearmedPending = false;
209
+ if (!currentConfig.value.enabled) return;
210
+ try {
211
+ rearmedPending = capturePendingBatches(ctx, { rethrow: true }).length > 0;
212
+ } catch (err) {
213
+ console.error("pi-condense: reload rearm probe failed", err);
214
+ }
215
+ };
216
+
203
217
  // Summarizes + indexes all pending batches.
204
218
  // When options.onProgress is provided batches are processed sequentially
205
219
  // (one LLM call each) so the caller can update per-row UI. Otherwise all
@@ -817,14 +831,7 @@ export default function (pi: ExtensionAPI) {
817
831
  // Clear any batches queued before the session reload
818
832
  pendingBatches.length = 0;
819
833
  previousFraction = null;
820
- rearmedPending = false;
821
- if (currentConfig.value.enabled) {
822
- try {
823
- rearmedPending = capturePendingBatches(ctx, { rethrow: true }).length > 0;
824
- } catch (err) {
825
- console.error("pi-condense: reload rearm probe failed", err);
826
- }
827
- }
834
+ probeRearm(ctx);
828
835
 
829
836
  // Update footer status
830
837
  setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
@@ -859,14 +866,7 @@ export default function (pi: ExtensionAPI) {
859
866
  // Pending batches belong to the old branch — discard them
860
867
  pendingBatches.length = 0;
861
868
  previousFraction = null;
862
- rearmedPending = false;
863
- if (currentConfig.value.enabled) {
864
- try {
865
- rearmedPending = capturePendingBatches(ctx, { rethrow: true }).length > 0;
866
- } catch (err) {
867
- console.error("pi-condense: reload rearm probe failed", err);
868
- }
869
- }
869
+ probeRearm(ctx);
870
870
 
871
871
  setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
872
872
  });
@@ -1021,9 +1021,13 @@ export default function (pi: ExtensionAPI) {
1021
1021
  // finished using the raw tool results. flushPending captures the SessionManager
1022
1022
  // before awaiting summarization so print-mode shutdown cannot invalidate the
1023
1023
  // persistence path while the summarizer model is running.
1024
+ //
1025
+ // No-UI sessions are treated as single-prompt to avoid a final prune print children never read.
1026
+ // Multi-prompt print/SDK hosts rely on turn_end budget/delta/frontier-gap triggers instead.
1024
1027
  pi.on("message_end", async (event, ctx) => {
1025
1028
  if (!currentConfig.value.enabled) return;
1026
1029
  if (currentConfig.value.pruneOn !== "agent-message") return;
1030
+ if (ctx.hasUI === false) return;
1027
1031
  if (!isFinalAssistantMessage(event.message)) return;
1028
1032
  await flushPending(ctx, { delivery: "session", closingMessage: event.message, trigger: "message-end" });
1029
1033
  });
@@ -1134,5 +1138,6 @@ export default function (pi: ExtensionAPI) {
1134
1138
  () => diagnostics.counts(),
1135
1139
  (ctx: any) => computeMetricsSnapshot(ctx) ?? EMPTY_METRICS_SNAPSHOT,
1136
1140
  () => rearmedPending,
1141
+ probeRearm,
1137
1142
  );
1138
1143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.14.0",
3
+ "version": "2.15.1",
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",
@@ -73,6 +73,7 @@ function setupPrunerCommand(overrides: {
73
73
  undefined,
74
74
  overrides.getContextMetrics,
75
75
  overrides.getRearmed,
76
+ undefined,
76
77
  overrides.save,
77
78
  );
78
79
 
package/src/commands.ts CHANGED
@@ -477,6 +477,7 @@ export function registerCommands(
477
477
  getDiagnosticCounts?: () => Record<DiagnosticKind, number>,
478
478
  getContextMetrics?: (ctx: ExtensionCommandContext) => ContextMetricsSnapshot,
479
479
  getRearmed?: () => boolean,
480
+ probeRearm?: (ctx: ExtensionCommandContext) => void,
480
481
  save: (config: ContextPruneConfig) => Promise<void> = saveConfig,
481
482
  ): void {
482
483
  // Register the /pruner command
@@ -856,8 +857,12 @@ export function registerCommands(
856
857
 
857
858
  // ── /pruner on ──
858
859
  case "on": {
860
+ const wasEnabled = currentConfig.value.enabled;
859
861
  currentConfig.value = { ...currentConfig.value, enabled: true };
860
862
  void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
863
+ // Tool results produced while disabled were never queued; the probe
864
+ // puts them back in front of the next gate check.
865
+ if (!wasEnabled) probeRearm?.(ctx);
861
866
  ctx.ui.notify("Context pruning enabled.");
862
867
  setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
863
868
  break;
@@ -214,12 +214,13 @@ function bootExtension(
214
214
  budgetTurnDelta?: number | null;
215
215
  frontierGapThresholdTokens?: number | null;
216
216
  showPruneStatusLine?: boolean;
217
+ enabled?: boolean;
217
218
  } = {},
218
219
  ) {
219
220
  const agentDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-"));
220
221
  process.env.PI_CODING_AGENT_DIR = agentDir;
221
222
  const contextPruneSettings: any = {
222
- enabled: true,
223
+ enabled: options.enabled ?? true,
223
224
  pruneOn: "agent-message",
224
225
  batchingMode: "agent-message",
225
226
  autoBudgetThreshold: options.autoBudgetThreshold === undefined ? 0.5 : options.autoBudgetThreshold,
@@ -557,6 +558,24 @@ describe("reload rearm (issue #6)", () => {
557
558
  expect(statusCalls).toContain("\u2502 prune: recovered pending (reload)");
558
559
  });
559
560
 
561
+ it("/pruner on rearms the gate so work recovered from a disabled stretch flushes", async () => {
562
+ const { handlers, commands, ctx, appended } = await boot({ enabled: false });
563
+ await handlers.get("session_start")!({}, ctx);
564
+ const callsBefore = summarizerCalls;
565
+
566
+ const budgetTurn = { toolResults: [], message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, turnIndex: 2 };
567
+ await handlers.get("turn_end")!(budgetTurn, ctx);
568
+ expect(summarizerCalls).toBe(callsBefore);
569
+
570
+ await commands.get("pruner")!("on", ctx);
571
+ await handlers.get("turn_end")!(budgetTurn, ctx);
572
+
573
+ expect(summarizerCalls).toBe(callsBefore + 1);
574
+ const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
575
+ expect(fm.length).toBe(1);
576
+ expect((fm[0].data as any).trigger).toBe("rearmed");
577
+ });
578
+
560
579
  it("reports a rescan failure to console.error and leaves rearmedPending false, without failing session_start (G4)", async () => {
561
580
  // Spec (Component 2, Rescan failure): "if the reload rearm probe throws,
562
581
  // console.error ... and leave rearmedPending = false - reload must never
@@ -1777,3 +1796,53 @@ describe("session-wide live turn index (#16)", () => {
1777
1796
  expect((fm[0].data as any).trigger).toBe("frontier-gap");
1778
1797
  });
1779
1798
  });
1799
+
1800
+ describe("final agent-message flush in sessions without a UI (#29)", () => {
1801
+ const finalReply = { message: { role: "assistant", content: [{ type: "text", text: "done" }] } };
1802
+
1803
+ it("skips the message-end flush when ctx.hasUI is false", async () => {
1804
+ const branch: any[] = [];
1805
+ const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: null, budgetTurnDelta: null });
1806
+ ctx.hasUI = false;
1807
+ ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
1808
+ await handlers.get("session_start")!({}, ctx);
1809
+ const callsBefore = summarizerCalls;
1810
+
1811
+ await fireTurn(handlers, ctx, branch, "tc-p", "p".repeat(800), 0);
1812
+ await handlers.get("message_end")!(finalReply, ctx);
1813
+
1814
+ expect(summarizerCalls).toBe(callsBefore);
1815
+ expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
1816
+ expect(appended.some((e) => e.type === "context-prune-summary")).toBe(false);
1817
+ });
1818
+
1819
+ it("still flushes mid-prompt on the turn_end budget trigger when ctx.hasUI is false", async () => {
1820
+ const branch: any[] = [];
1821
+ const { handlers, ctx, appended } = await boot({ branch });
1822
+ ctx.hasUI = false;
1823
+ await handlers.get("session_start")!({}, ctx);
1824
+
1825
+ await fireTurn(handlers, ctx, branch, "tc-b", "b".repeat(800), 0);
1826
+
1827
+ const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
1828
+ expect(fm.length).toBe(1);
1829
+ expect((fm[0].data as any).trigger).toBe("budget");
1830
+ expect((fm[0].data as any).outcome).toBe("summarized");
1831
+ });
1832
+
1833
+ it("flushes on the final assistant message when ctx.hasUI is true", async () => {
1834
+ const branch: any[] = [];
1835
+ const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: null, budgetTurnDelta: null });
1836
+ ctx.hasUI = true;
1837
+ ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
1838
+ await handlers.get("session_start")!({}, ctx);
1839
+
1840
+ await fireTurn(handlers, ctx, branch, "tc-u", "u".repeat(800), 0);
1841
+ await handlers.get("message_end")!(finalReply, ctx);
1842
+
1843
+ const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
1844
+ expect(fm.length).toBe(1);
1845
+ expect((fm[0].data as any).trigger).toBe("message-end");
1846
+ expect((fm[0].data as any).outcome).toBe("summarized");
1847
+ });
1848
+ });