pi-condense 2.14.0 → 2.15.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,12 @@ 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.0] - 2026-11-09
11
+
12
+ ### Changed
13
+
14
+ - 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)
15
+
10
16
  ## [2.14.0] - 2026-11-08
11
17
 
12
18
  ### 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
 
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
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.14.0",
3
+ "version": "2.15.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",
@@ -1777,3 +1777,53 @@ describe("session-wide live turn index (#16)", () => {
1777
1777
  expect((fm[0].data as any).trigger).toBe("frontier-gap");
1778
1778
  });
1779
1779
  });
1780
+
1781
+ describe("final agent-message flush in sessions without a UI (#29)", () => {
1782
+ const finalReply = { message: { role: "assistant", content: [{ type: "text", text: "done" }] } };
1783
+
1784
+ it("skips the message-end flush when ctx.hasUI is false", async () => {
1785
+ const branch: any[] = [];
1786
+ const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: null, budgetTurnDelta: null });
1787
+ ctx.hasUI = false;
1788
+ ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
1789
+ await handlers.get("session_start")!({}, ctx);
1790
+ const callsBefore = summarizerCalls;
1791
+
1792
+ await fireTurn(handlers, ctx, branch, "tc-p", "p".repeat(800), 0);
1793
+ await handlers.get("message_end")!(finalReply, ctx);
1794
+
1795
+ expect(summarizerCalls).toBe(callsBefore);
1796
+ expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
1797
+ expect(appended.some((e) => e.type === "context-prune-summary")).toBe(false);
1798
+ });
1799
+
1800
+ it("still flushes mid-prompt on the turn_end budget trigger when ctx.hasUI is false", async () => {
1801
+ const branch: any[] = [];
1802
+ const { handlers, ctx, appended } = await boot({ branch });
1803
+ ctx.hasUI = false;
1804
+ await handlers.get("session_start")!({}, ctx);
1805
+
1806
+ await fireTurn(handlers, ctx, branch, "tc-b", "b".repeat(800), 0);
1807
+
1808
+ const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
1809
+ expect(fm.length).toBe(1);
1810
+ expect((fm[0].data as any).trigger).toBe("budget");
1811
+ expect((fm[0].data as any).outcome).toBe("summarized");
1812
+ });
1813
+
1814
+ it("flushes on the final assistant message when ctx.hasUI is true", async () => {
1815
+ const branch: any[] = [];
1816
+ const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: null, budgetTurnDelta: null });
1817
+ ctx.hasUI = true;
1818
+ ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
1819
+ await handlers.get("session_start")!({}, ctx);
1820
+
1821
+ await fireTurn(handlers, ctx, branch, "tc-u", "u".repeat(800), 0);
1822
+ await handlers.get("message_end")!(finalReply, ctx);
1823
+
1824
+ const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
1825
+ expect(fm.length).toBe(1);
1826
+ expect((fm[0].data as any).trigger).toBe("message-end");
1827
+ expect((fm[0].data as any).outcome).toBe("summarized");
1828
+ });
1829
+ });