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 +12 -0
- package/PRUNING.md +3 -3
- package/README.md +1 -1
- package/index.ts +24 -19
- package/package.json +1 -1
- package/src/commands.test.ts +1 -0
- package/src/commands.ts +5 -0
- package/src/reload-rearm.integration.test.ts +70 -1
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.**
|
|
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
|
-
|
|
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
|
|
184
|
-
// it
|
|
185
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
package/src/commands.test.ts
CHANGED
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
|
+
});
|