pi-condense 2.13.1 → 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 +18 -0
- package/PRUNING.md +6 -4
- package/README.md +4 -2
- package/index.ts +7 -2
- package/package.json +8 -5
- package/src/batch-capture.test.ts +20 -0
- package/src/batch-capture.ts +4 -2
- package/src/chain-detector.test.ts +16 -0
- package/src/chain-detector.ts +5 -5
- package/src/commands.test.ts +7 -1
- package/src/commands.ts +1 -1
- package/src/context-metrics.test.ts +11 -1
- package/src/context-metrics.ts +3 -2
- package/src/protected.test.ts +41 -0
- package/src/protected.ts +10 -1
- package/src/pruner.test.ts +21 -0
- package/src/pruner.ts +1 -1
- package/src/reload-rearm.integration.test.ts +92 -0
- package/src/summarizer.ts +2 -2
- package/src/types.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ 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
|
+
|
|
16
|
+
## [2.14.0] - 2026-11-08
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- The compiled-against pi packages (`@earendil-works/pi-ai`, `pi-coding-agent`, `pi-tui`) move from `^1.3.0` to `^1.5.0`; the runtime floor stays pi >= 1.3.0 (`streamSimple`).
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Protected tool names and path globs also cover a result's recorded nested calls, including reads inside codemode scripts. Incomplete nested-call records keep the whole result inline verbatim even above the spill threshold and with path matching disabled; protected scripted reads are not superseded by later reads of the same path. (#27)
|
|
25
|
+
- Paid synthetic behavioral evals measure downstream context quality after summarization and pruning, with frozen committed baselines for both summarizers and an eval-needed decision gate for every repository change. Offline harness tests remain separate from paid runs. (#22)
|
|
26
|
+
- A private-session replay harness (`npm run eval:replay`) rebuilds real tool-call batches from session files into a corpus outside every checkout, summarizes each with production and a `--candidate` module, scores both against strings the assistant later reused, and fails on regressions; `matrix` compares model x thinking cells on retention, latency and cost. Synthetic eval reports now count evidence retained in the summary separately from evidence recovered on demand. (#23)
|
|
27
|
+
|
|
10
28
|
## [2.13.1] - 2026-10-22
|
|
11
29
|
|
|
12
30
|
### Changed
|
package/PRUNING.md
CHANGED
|
@@ -633,15 +633,17 @@ Implementation: `src/pruner.ts` `pruneMessages(messages, indexer)` returns `{ me
|
|
|
633
633
|
|
|
634
634
|
### Protected tools & paths
|
|
635
635
|
|
|
636
|
-
A tool call is protected if
|
|
636
|
+
A tool call is protected if its `toolName` is in `protectedTools`, its `args.path` (string) matches any glob in `protectedPaths`, or its result's recorded nested calls (`nestedCalls.calls`) include a name or path matching the same rules. This covers reads made inside codemode scripts and any other tool carrying `nestedCalls`, without a tool-name restriction. An incomplete nested-call record (`nestedCalls.complete === false`) protects the whole result unconditionally, even with both configuration lists empty. The recorder marks a record incomplete when arguments exceed 8 KiB per call or 32 KiB total, calls exceed 256, or a call is still running when the script ends; even a script that reads nothing protected can therefore stay verbatim. Nested call status does not affect matching.
|
|
637
|
+
|
|
638
|
+
Protected calls are filtered out **at capture time** - they never enter the `pendingBatches` queue, summarizer, new index entries, or oversized spill, so their raw `ToolResultMessage` stays inline verbatim even above `spillThreshold`. Inside newly compressed chains their outputs relocate verbatim as `<protected-output>` without blocking compression. Only direct reads can be superseded when a later read of the same path covers their delivered lines and the cadence gate allows it (below); a protected scripted read stays verbatim and is not stubbed by a later read of the same path.
|
|
637
639
|
|
|
638
640
|
**`protectedTools: string[]`** (default `[]`) - allowlist of tool names. Covers tools whose output is a small handle that must be reused byte-for-byte (e.g. a session-id) or planning tools like `todowrite` / `todoread`.
|
|
639
641
|
|
|
640
|
-
**`protectedPaths: string[]`** (default `["**/skills/**/*.md", "**/gauntlet-overrides.md"]`) - glob list matched against `args.path`. Designed for skill files that carry multi-step workflow gates; summarizing them is categorically lossy. Non-string or missing `path` arguments never match. Set `[]` to disable. Edit with `/pruner protected-paths`.
|
|
642
|
+
**`protectedPaths: string[]`** (default `["**/skills/**/*.md", "**/gauntlet-overrides.md"]`) - glob list matched against direct and recorded nested `args.path`. Designed for skill files that carry multi-step workflow gates; summarizing them is categorically lossy. Non-string or missing `path` arguments never match. Set `[]` to disable path matching only; nested tool-name matches and incomplete-record protection remain active. Edit with `/pruner protected-paths`.
|
|
641
643
|
|
|
642
644
|
Glob contract: full-path match against the raw `args.path` string with `\` normalized to `/`. `*` and `?` match within a segment (no `/`); `**` crosses segments; `**/` also matches zero directories (so `**/SKILL.md` matches a bare relative `SKILL.md`). Case-sensitive. All other characters are regex-escaped literals.
|
|
643
645
|
|
|
644
|
-
**Render-time re-check:** stub replacement runs in-flight on every turn (`pruneMessages`). If a tool call's persisted `args` now satisfy `isProtected` (e.g. a pattern was added mid-session), the stub is skipped and the raw result is left verbatim
|
|
646
|
+
**Render-time re-check:** stub replacement runs in-flight on every turn (`pruneMessages`). If a tool call's persisted name and `args`, or its live result's `nestedCalls`, now satisfy `isProtected` (e.g. a pattern was added mid-session or a scripted read was summarized before nested-call protection), the stub is skipped and the raw result is left verbatim. The stored index entry stays but is not applied; this repairs already-summarized records in existing sessions with no schema change. Declared limitation: records inside already-compressed chains (`context-prune-chain` entries) are NOT repaired - their `protectedToolCallIds` set is fixed at compression time (forward-only). Dedup-alias edge: an alias resolving to an unprotected original stays stubbed unless its own live result carries protecting `nestedCalls`, and its own `args.path` is not re-checked.
|
|
645
647
|
|
|
646
648
|
**Supersession (phase 1b, `src/supersede.ts`):** protected calls are never indexed, so content-hash dedup never sees them. A paired occurrence participates only when `isProtected(toolName, args)` is true and `typeof args.path === "string"`. Occurrences group by normalized `args.path` (backslash -> slash). An earlier occurrence is a supersession candidate only when **any later occurrence** of that path covers its delivered line range, not just when the last occurrence covers it. Different paths never interact; disjoint chunks stay verbatim.
|
|
647
649
|
|
|
@@ -973,7 +975,7 @@ Any other outcome - zero or multiple matches on either boundary, `finalAssistant
|
|
|
973
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).
|
|
974
976
|
- `/pruner compact` bypasses the window and compresses every eligible chain immediately (retroactive).
|
|
975
977
|
|
|
976
|
-
**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.
|
|
977
979
|
|
|
978
980
|
### Synthetic message format
|
|
979
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
|
-
|
|
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
|
|
|
@@ -172,7 +172,7 @@ Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_A
|
|
|
172
172
|
| `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 |
|
|
173
173
|
| `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` |
|
|
174
174
|
| `maxImagesPerRequest` | `null` | Once a request carries more than N images, the oldest become a text note (in cache-friendly steps of half of N) so a long session stays under a provider's per-request image limit; applies even with pruning off. `null` = built-in limit for the model's API (Anthropic Messages: 100; others: no cap); a number overrides it for every API |
|
|
175
|
-
| `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md", "**/gauntlet-overrides.md"]` | Tool names / path globs that
|
|
175
|
+
| `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md", "**/gauntlet-overrides.md"]` | Tool names / path globs that bypass summarization, including reads made inside codemode scripts through the result's recorded nested calls (`nestedCalls`). A protected scripted read stays verbatim and is not stubbed by a later read of the same path; only direct reads can supersede earlier reads when their delivered lines cover them and the cache-rewrite cadence allows it. An incomplete nested-call record (over 8 KiB of arguments per call, 32 KiB total, 256 calls, or a call still running when the script ends) keeps the whole result inline verbatim regardless of `protectedPaths`, even above `spillThreshold`. `protectedPaths: []` disables path matching only |
|
|
176
176
|
| `spillThreshold` | `65536` | Chars above which a single oversized result spills straight to a sidecar file |
|
|
177
177
|
|
|
178
178
|
The default also protects reads of [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet)'s per-repo `gauntlet-overrides.md` so the repo's harness contract stays available for gate decisions after pruning.
|
|
@@ -191,6 +191,8 @@ No committed roadmap beyond what's already tracked in [CHANGELOG.md](CHANGELOG.m
|
|
|
191
191
|
|
|
192
192
|
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).
|
|
193
193
|
|
|
194
|
+
For paid synthetic context-quality checks and retained behavioral baselines, see the [behavioral eval guide](eval/README.md).
|
|
195
|
+
|
|
194
196
|
## Support
|
|
195
197
|
|
|
196
198
|
If this saves you tokens, [buy me a coffee](https://buymeacoffee.com/jjurasszek).
|
package/index.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* Usage: pi -e .
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
16
17
|
import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
17
18
|
import { loadConfig } from "./src/config.js";
|
|
18
19
|
import { capImages, imageLimitFor } from "./src/image-cap.js";
|
|
@@ -62,7 +63,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
62
63
|
value: { ...DEFAULT_CONFIG },
|
|
63
64
|
};
|
|
64
65
|
|
|
65
|
-
const protectionPredicate = (name: string, args: unknown) => isProtected(name, args, currentConfig.value);
|
|
66
|
+
const protectionPredicate = (name: string, args: unknown, nested?: NestedToolCalls) => isProtected(name, args, currentConfig.value, nested);
|
|
66
67
|
|
|
67
68
|
// Shared indexer — rebuilt from session on every session_start / session_tree
|
|
68
69
|
const indexer = new ToolCallIndexer();
|
|
@@ -926,7 +927,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
926
927
|
// CapturedBatch is pruned, which is exactly what we want.
|
|
927
928
|
const filtered = {
|
|
928
929
|
...capturedBatch,
|
|
929
|
-
toolCalls: capturedBatch.toolCalls.filter((tc) => !
|
|
930
|
+
toolCalls: capturedBatch.toolCalls.filter((tc) => !protectionPredicate(tc.toolName, tc.args, tc.nestedCalls)),
|
|
930
931
|
};
|
|
931
932
|
|
|
932
933
|
// Eager spill: offload oversized single results to sidecar files before they
|
|
@@ -1020,9 +1021,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1020
1021
|
// finished using the raw tool results. flushPending captures the SessionManager
|
|
1021
1022
|
// before awaiting summarization so print-mode shutdown cannot invalidate the
|
|
1022
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.
|
|
1023
1027
|
pi.on("message_end", async (event, ctx) => {
|
|
1024
1028
|
if (!currentConfig.value.enabled) return;
|
|
1025
1029
|
if (currentConfig.value.pruneOn !== "agent-message") return;
|
|
1030
|
+
if (ctx.hasUI === false) return;
|
|
1026
1031
|
if (!isFinalAssistantMessage(event.message)) return;
|
|
1027
1032
|
await flushPending(ctx, { delivery: "session", closingMessage: event.message, trigger: "message-end" });
|
|
1028
1033
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.
|
|
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",
|
|
@@ -38,7 +38,10 @@
|
|
|
38
38
|
],
|
|
39
39
|
"scripts": {
|
|
40
40
|
"check:agents-core": "node scripts/check-agents-core.mjs",
|
|
41
|
-
"test": "bun test src/"
|
|
41
|
+
"test": "bun test src/",
|
|
42
|
+
"eval": "bun eval/run.ts",
|
|
43
|
+
"eval:replay": "bun eval/replay/run.ts",
|
|
44
|
+
"test:eval": "bun test eval/"
|
|
42
45
|
},
|
|
43
46
|
"pi": {
|
|
44
47
|
"extensions": [
|
|
@@ -57,9 +60,9 @@
|
|
|
57
60
|
"@sinclair/typebox": "*"
|
|
58
61
|
},
|
|
59
62
|
"devDependencies": {
|
|
60
|
-
"@earendil-works/pi-ai": "^1.
|
|
61
|
-
"@earendil-works/pi-coding-agent": "^1.
|
|
62
|
-
"@earendil-works/pi-tui": "^1.
|
|
63
|
+
"@earendil-works/pi-ai": "^1.5.0",
|
|
64
|
+
"@earendil-works/pi-coding-agent": "^1.5.0",
|
|
65
|
+
"@earendil-works/pi-tui": "^1.5.0",
|
|
63
66
|
"@sinclair/typebox": "^0.34.52"
|
|
64
67
|
}
|
|
65
68
|
}
|
|
@@ -11,6 +11,26 @@ import {
|
|
|
11
11
|
} from "./batch-capture.js";
|
|
12
12
|
import type { CapturedBatch, CapturedToolCall } from "./types.js";
|
|
13
13
|
import { ToolCallIndexer } from "./indexer.js";
|
|
14
|
+
import { isProtected } from "./protected.js";
|
|
15
|
+
import { DEFAULT_CONFIG } from "./types.js";
|
|
16
|
+
|
|
17
|
+
test("nested protection uses each turn's first paired result and preserves capture metadata", () => {
|
|
18
|
+
const nestedCalls = { complete: true, calls: [{ id: "nested", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" as const }] };
|
|
19
|
+
const assistant = { role: "assistant", content: [{ type: "toolCall", id: "same", name: "codemode", arguments: { code: "read()" } }] };
|
|
20
|
+
const result = (timestamp: number, nested?: typeof nestedCalls) => ({ role: "toolResult", toolCallId: "same", toolName: "codemode", timestamp, content: [{ type: "text", text: "body" }], ...(nested ? { nestedCalls: nested } : {}) });
|
|
21
|
+
const protectedResult = result(1, nestedCalls);
|
|
22
|
+
const plainResult = result(2);
|
|
23
|
+
const captured = captureBatch(assistant, [protectedResult, plainResult], 0, 0);
|
|
24
|
+
expect(captured.toolCalls[0].nestedCalls).toBe(nestedCalls);
|
|
25
|
+
expect(captureBatch(assistant, [plainResult], 1, 0).toolCalls[0]).not.toHaveProperty("nestedCalls");
|
|
26
|
+
expect(serializeBatchForSummarizer(captured)).not.toContain("nestedCalls");
|
|
27
|
+
const branch = [assistant, protectedResult, plainResult, assistant, plainResult].map(message => ({ type: "message", message }));
|
|
28
|
+
const batches = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false },
|
|
29
|
+
(name, args, nested) => isProtected(name, args, DEFAULT_CONFIG, nested));
|
|
30
|
+
expect(batches).toHaveLength(1);
|
|
31
|
+
expect(batches[0].turnIndex).toBe(1);
|
|
32
|
+
expect(batches[0].toolCalls[0].resultTimestamp).toBe(2);
|
|
33
|
+
});
|
|
14
34
|
|
|
15
35
|
function toolCall(overrides: Partial<CapturedToolCall> = {}): CapturedToolCall {
|
|
16
36
|
return {
|
package/src/batch-capture.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
2
3
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import type { CapturedBatch, CapturedToolCall, BatchingMode } from "./types.js";
|
|
4
5
|
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
@@ -111,6 +112,7 @@ export function captureBatch(
|
|
|
111
112
|
resultText,
|
|
112
113
|
isError,
|
|
113
114
|
...(resultTimestamp !== undefined ? { resultTimestamp } : {}),
|
|
115
|
+
...(match?.nestedCalls !== undefined ? { nestedCalls: match.nestedCalls } : {}),
|
|
114
116
|
} satisfies CapturedToolCall;
|
|
115
117
|
});
|
|
116
118
|
|
|
@@ -128,7 +130,7 @@ export function captureBatch(
|
|
|
128
130
|
export function captureUnindexedBatchesFromSession(
|
|
129
131
|
branch: any[],
|
|
130
132
|
indexer: { isSummarized(id: string): boolean },
|
|
131
|
-
exclude: (toolName: string, args: unknown) => boolean = () => false
|
|
133
|
+
exclude: (toolName: string, args: unknown, nested?: NestedToolCalls) => boolean = () => false
|
|
132
134
|
): CapturedBatch[] {
|
|
133
135
|
// Keep the SessionEntry wrapper alongside each projected message so the
|
|
134
136
|
// entry's own timestamp remains available as the preferred source below
|
|
@@ -191,7 +193,7 @@ export function captureUnindexedBatchesFromSession(
|
|
|
191
193
|
const result = turnResults.get(id);
|
|
192
194
|
if (!result) return false;
|
|
193
195
|
if (indexer.isSummarized(occKey(id, resultTimestampOf(result.timestamp)))) return false;
|
|
194
|
-
if (exclude(tc.name, tc.input ?? tc.arguments)) return false;
|
|
196
|
+
if (exclude(tc.name, tc.input ?? tc.arguments, result.nestedCalls)) return false;
|
|
195
197
|
return true;
|
|
196
198
|
});
|
|
197
199
|
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { detectChains, isChainAnchorCustom, withClosingMessage } from "./chain-detector.js";
|
|
3
|
+
import { isProtected } from "./protected.js";
|
|
4
|
+
import { DEFAULT_CONFIG } from "./types.js";
|
|
5
|
+
|
|
6
|
+
test("nested protected reads join a closed chain's protected ids", () => {
|
|
7
|
+
const messages = [
|
|
8
|
+
userMsg(1),
|
|
9
|
+
{ role: "assistant", timestamp: 2, content: [{ type: "toolCall", id: "script", name: "codemode", arguments: {} }] },
|
|
10
|
+
{ role: "toolResult", timestamp: 3, toolCallId: "script", toolName: "codemode", nestedCalls: {
|
|
11
|
+
complete: true, calls: [{ id: "read", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" }],
|
|
12
|
+
} },
|
|
13
|
+
{ role: "assistant", timestamp: 4, content: [{ type: "text", text: "done" }] },
|
|
14
|
+
];
|
|
15
|
+
const [chain] = detectChains(messages, (name, args, nested) => isProtected(name, args, DEFAULT_CONFIG, nested));
|
|
16
|
+
expect(chain.protectedToolCallIds).toEqual(["script"]);
|
|
17
|
+
expect(chain.finalAssistantTimestamp).toBe(4);
|
|
18
|
+
});
|
|
3
19
|
|
|
4
20
|
const custom = (customType: string, timestamp: number) => ({ role: "custom", customType, timestamp });
|
|
5
21
|
|
package/src/chain-detector.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
1
2
|
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
2
3
|
import type { ChainRange } from "./types.js";
|
|
3
4
|
|
|
@@ -45,12 +46,12 @@ type State = "idle" | "inChain";
|
|
|
45
46
|
* NOTE: Message identity uses `timestamp` (for user / final text-only assistant) and
|
|
46
47
|
* `toolCallId` sets (for middle tool-using turns). AgentMessage has no `.id` field.
|
|
47
48
|
*
|
|
48
|
-
* @param isProtected Predicate over (toolName, args); matching calls are never pruned
|
|
49
|
+
* @param isProtected Predicate over (toolName, args, nestedCalls); matching calls are never pruned
|
|
49
50
|
* and their outputs are relocated verbatim into compressed chains.
|
|
50
51
|
*/
|
|
51
52
|
export function detectChains(
|
|
52
53
|
messages: any[],
|
|
53
|
-
isProtected: (toolName: string, args: unknown) => boolean = () => false,
|
|
54
|
+
isProtected: (toolName: string, args: unknown, nested?: NestedToolCalls) => boolean = () => false,
|
|
54
55
|
): ChainRange[] {
|
|
55
56
|
const ranges: ChainRange[] = [];
|
|
56
57
|
let state: State = "idle";
|
|
@@ -108,9 +109,8 @@ export function detectChains(
|
|
|
108
109
|
if (msg.toolCallId) {
|
|
109
110
|
middleIds.add(msg.toolCallId);
|
|
110
111
|
middleKeys.add(occKey(msg.toolCallId, resultTimestampOf(msg.timestamp)));
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
if (isProtected(msg.toolName, undefined)) protectedIds.add(msg.toolCallId);
|
|
112
|
+
// Outer args live on the assistant block; nested calls live on the result.
|
|
113
|
+
if (isProtected(msg.toolName, undefined, msg.nestedCalls)) protectedIds.add(msg.toolCallId);
|
|
114
114
|
}
|
|
115
115
|
continue;
|
|
116
116
|
}
|
package/src/commands.test.ts
CHANGED
|
@@ -97,13 +97,14 @@ function setupPrunerCommand(overrides: {
|
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
describe("/pruner settings
|
|
100
|
+
describe("/pruner settings descriptions", () => {
|
|
101
101
|
initTheme("dark");
|
|
102
102
|
|
|
103
103
|
it.each([
|
|
104
104
|
["purgeErrorsEnabled", "Argument purge", "Shrink long strings in summarized calls when their results are stubbed in the same render, and in errored calls after the error cooldown, keeping argument shape."],
|
|
105
105
|
["purgeErrorsCooldown", "Error purge cooldown (turns)", "Wait this many turns after a tool error before purging its argument body."],
|
|
106
106
|
["purgeErrorsMinArgChars", "Argument purge min arg chars", "Only purge summarized or errored argument bodies at least this many chars."],
|
|
107
|
+
["protectedPaths", "Protected paths", "reads made inside codemode scripts"],
|
|
107
108
|
])("describes %s through the settings overlay", async (id, label, description) => {
|
|
108
109
|
const harness = setupPrunerCommand({ save: async () => {} });
|
|
109
110
|
await harness.run("settings");
|
|
@@ -114,6 +115,11 @@ describe("/pruner settings argument purge", () => {
|
|
|
114
115
|
expect(rendered).toContain(label);
|
|
115
116
|
expect(rendered).toContain(description);
|
|
116
117
|
if (id === "purgeErrorsCooldown") expect(rendered).not.toContain("same render");
|
|
118
|
+
if (id === "protectedPaths") {
|
|
119
|
+
expect(rendered).toContain("nestedCalls");
|
|
120
|
+
expect(rendered).toContain("incomplete nested-call record is kept verbatim regardless of this setting");
|
|
121
|
+
expect(rendered).toContain("Set to 'none' to disable path matching only");
|
|
122
|
+
}
|
|
117
123
|
});
|
|
118
124
|
|
|
119
125
|
it("persists the shared disabled toggle and minimum using only existing purgeErrors fields", async () => {
|
package/src/commands.ts
CHANGED
|
@@ -256,7 +256,7 @@ function protectedToolsDescription(config: ContextPruneConfig): string {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
function protectedPathsDescription(config: ContextPruneConfig): string {
|
|
259
|
-
return `Glob patterns
|
|
259
|
+
return `Glob patterns match a tool call's \`args.path\` and reads recorded in a result's \`nestedCalls\`, including reads made inside codemode scripts. Matching outputs bypass summarization. An incomplete nested-call record is kept verbatim regardless of this setting. Set to 'none' to disable path matching only. Currently: ${protectedToolsDisplay(config.protectedPaths)}. Edit via \`/pruner protected-paths\` (interactive) or \`/pruner protected-paths <comma-separated globs>\`. Default protects skill files and per-repo gauntlet overrides: **/skills/**/*.md, **/gauntlet-overrides.md`;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
const HELP_TEXT = `pruner — automatically summarizes tool-call outputs to keep context lean.
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { computeContextMetrics } from "./context-metrics.js";
|
|
3
|
-
import type
|
|
3
|
+
import { DEFAULT_CONFIG, type PruneFrontier } from "./types.js";
|
|
4
|
+
import { isProtected } from "./protected.js";
|
|
5
|
+
|
|
6
|
+
test("frontier gap excludes a result with a protected nested read", () => {
|
|
7
|
+
const messages = [userMsg(1), assistantWithTools(2, ["script"]), {
|
|
8
|
+
...toolResult(3, "script", "codemode"),
|
|
9
|
+
nestedCalls: { complete: true, calls: [{ id: "nested", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" }] },
|
|
10
|
+
}];
|
|
11
|
+
expect(computeContextMetrics(messages, null, () => false,
|
|
12
|
+
(name, args, nested) => isProtected(name, args, DEFAULT_CONFIG, nested)).frontierGapTokens).toBe(0);
|
|
13
|
+
});
|
|
4
14
|
|
|
5
15
|
// ── Minimal message factories (mirrors src/chain-detector.test.ts style) ───
|
|
6
16
|
|
package/src/context-metrics.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
1
2
|
import { detectChains, isChainAnchorCustom } from "./chain-detector.js";
|
|
2
3
|
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
3
4
|
import type { ContextMetricsSnapshot, PruneFrontier } from "./types.js";
|
|
@@ -49,7 +50,7 @@ export function computeContextMetrics(
|
|
|
49
50
|
branch: any[],
|
|
50
51
|
frontier: PruneFrontier | null,
|
|
51
52
|
isSummarized: (occurrenceKey: string) => boolean,
|
|
52
|
-
isProtected: (toolName: string, args: unknown) => boolean,
|
|
53
|
+
isProtected: (toolName: string, args: unknown, nested?: NestedToolCalls) => boolean,
|
|
53
54
|
): ContextMetricsSnapshot {
|
|
54
55
|
if (branch.length === 0) {
|
|
55
56
|
return { openCycleThinkingTokens: 0, largestChainSharePct: 0, frontierGapTokens: 0 };
|
|
@@ -156,7 +157,7 @@ export function computeContextMetrics(
|
|
|
156
157
|
const key = occKey(m.toolCallId, resultTimestampOf(m.timestamp));
|
|
157
158
|
if (isSummarized(key)) continue;
|
|
158
159
|
const args = findArgsForToolCallId(branch, i, m.toolCallId);
|
|
159
|
-
if (isProtected(m.toolName, args)) continue;
|
|
160
|
+
if (isProtected(m.toolName, args, m.nestedCalls)) continue;
|
|
160
161
|
frontierGapTokens += tokensOf(m);
|
|
161
162
|
}
|
|
162
163
|
|
package/src/protected.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
2
3
|
import { globToRegExp, isProtected, normalizePath } from "./protected.js";
|
|
3
4
|
import { DEFAULT_CONFIG } from "./types.js";
|
|
4
5
|
|
|
@@ -67,6 +68,46 @@ describe("isProtected", () => {
|
|
|
67
68
|
expect(isProtected("read", { path: "/h/src/app.ts" }, cfg)).toBe(false);
|
|
68
69
|
});
|
|
69
70
|
|
|
71
|
+
test.each(["ok", "error"] as const)("protects nested skill reads with status %s", (status) => {
|
|
72
|
+
expect(isProtected("codemode", { code: "console.log(await tools.read({ path: 'skills/x/SKILL.md' }))" }, cfg, {
|
|
73
|
+
complete: true,
|
|
74
|
+
calls: [{ id: "nested-read", name: "read", arguments: { path: "skills/x/SKILL.md" }, status }],
|
|
75
|
+
})).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test.each([undefined, {}, { path: "source.ts" }])("protects nested tool names with arguments %j and path matching disabled", (args) => {
|
|
79
|
+
for (const outerName of ["codemode", "another-script-tool"]) {
|
|
80
|
+
expect(isProtected(outerName, {}, { protectedTools: ["todowrite"], protectedPaths: [] }, {
|
|
81
|
+
complete: true, calls: [{ id: "nested", name: "todowrite", arguments: args, status: "ok" }],
|
|
82
|
+
})).toBe(true);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test.each([
|
|
87
|
+
{ complete: false, calls: [] },
|
|
88
|
+
{ complete: false, calls: [{ id: "write", name: "write", arguments: { path: "source.ts" }, status: "ok" }] },
|
|
89
|
+
{ complete: false, calls: [{ id: "read", name: "read", argumentsBytes: 8193, status: "ok" }] },
|
|
90
|
+
{ complete: false, calls: [{ id: "read", name: "read", arguments: { path: "source.ts" }, status: "unfinished" }] },
|
|
91
|
+
] satisfies NestedToolCalls[])("protects incomplete records %j even with empty configuration", (nested) => {
|
|
92
|
+
for (const config of [cfg, { protectedTools: [], protectedPaths: [] }]) {
|
|
93
|
+
expect(isProtected("codemode", {}, config, nested)).toBe(true);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test.each([
|
|
98
|
+
undefined,
|
|
99
|
+
{ complete: true, calls: [] },
|
|
100
|
+
{ complete: true, calls: [{ id: "read", name: "read", arguments: { path: "source.ts" }, status: "ok" }] },
|
|
101
|
+
{ complete: true, calls: [{ id: "read", name: "read", arguments: { path: 42 }, status: "ok" }] },
|
|
102
|
+
"malformed",
|
|
103
|
+
{ complete: false, calls: {} },
|
|
104
|
+
])("keeps unmatched or malformed nested records %j prunable without changing direct protection", (nested) => {
|
|
105
|
+
const record = nested as NestedToolCalls | undefined;
|
|
106
|
+
expect(isProtected("codemode", { code: "tools.read({path: 'skills/x/SKILL.md'})" }, cfg, record)).toBe(false);
|
|
107
|
+
expect(isProtected("todowrite", {}, cfg, record)).toBe(true);
|
|
108
|
+
expect(isProtected("read", { path: "skills/x/SKILL.md" }, cfg, record)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
70
111
|
test("backslashes normalized to forward slashes", () => {
|
|
71
112
|
expect(isProtected("read", { path: "h\\skills\\x\\SKILL.md" }, cfg)).toBe(true);
|
|
72
113
|
});
|
package/src/protected.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
1
3
|
/** Structural pick of ContextPruneConfig — keeps this module dependency-free. */
|
|
2
4
|
export interface ProtectionConfig {
|
|
3
5
|
protectedTools: readonly string[];
|
|
@@ -46,7 +48,7 @@ export function normalizePath(path: string): string {
|
|
|
46
48
|
return path.replace(/\\/g, "/");
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
function matches(toolName: string, args: unknown, config: ProtectionConfig): boolean {
|
|
50
52
|
if (config.protectedTools.includes(toolName)) return true;
|
|
51
53
|
if (config.protectedPaths.length === 0) return false;
|
|
52
54
|
const path = (args as Record<string, unknown> | null | undefined)?.path;
|
|
@@ -54,3 +56,10 @@ export function isProtected(toolName: string, args: unknown, config: ProtectionC
|
|
|
54
56
|
const normalized = normalizePath(path);
|
|
55
57
|
return config.protectedPaths.some((p) => globToRegExp(p).test(normalized));
|
|
56
58
|
}
|
|
59
|
+
|
|
60
|
+
export function isProtected(toolName: string, args: unknown, config: ProtectionConfig, nested?: NestedToolCalls): boolean {
|
|
61
|
+
if (matches(toolName, args, config)) return true;
|
|
62
|
+
if (!nested || typeof nested !== "object" || !Array.isArray(nested.calls)) return false;
|
|
63
|
+
if (nested.complete === false) return true;
|
|
64
|
+
return nested.calls.some((call) => matches(call?.name, call?.arguments, config));
|
|
65
|
+
}
|
package/src/pruner.test.ts
CHANGED
|
@@ -599,6 +599,27 @@ describe("render-time protection re-check", () => {
|
|
|
599
599
|
expect(messages[1].content[0].text).toBe("FULL SKILL BODY");
|
|
600
600
|
});
|
|
601
601
|
|
|
602
|
+
it("repairs an indexed script result from nested metadata with byte-identical repeat renders", () => {
|
|
603
|
+
const record = { toolCallId: "script", toolName: "codemode", args: { code: "read()" }, resultText: "FULL SKILL BODY", isError: false, turnIndex: 0, timestamp: 10 };
|
|
604
|
+
const oldIndexer = makeMockIndexer({
|
|
605
|
+
summarized: new Set(["script"]), shortRefs: new Map([["script", "t1"]]), records: new Map([["script", record]]),
|
|
606
|
+
});
|
|
607
|
+
const raw = [
|
|
608
|
+
{ ...skillAsst, content: [{ type: "toolCall", id: "script", name: "codemode", arguments: record.args }] },
|
|
609
|
+
{ ...skillMsg, toolCallId: "script", toolName: "codemode", nestedCalls: {
|
|
610
|
+
complete: true, calls: [{ id: "nested", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" }],
|
|
611
|
+
} },
|
|
612
|
+
];
|
|
613
|
+
const before = JSON.stringify(raw);
|
|
614
|
+
const render = () => pruneMessages(raw, oldIndexer as any, undefined, undefined,
|
|
615
|
+
{ protectedTools: [], protectedPaths: ["**/skills/**/*.md"] });
|
|
616
|
+
const first = render();
|
|
617
|
+
expect(first.pruned).toBe(false);
|
|
618
|
+
expect(JSON.stringify(first.messages)).toBe(before);
|
|
619
|
+
expect(JSON.stringify(render().messages)).toBe(JSON.stringify(first.messages));
|
|
620
|
+
expect(oldIndexer.getRecord("script")).toBe(record);
|
|
621
|
+
});
|
|
622
|
+
|
|
602
623
|
it("still stubs when no protection config is passed", () => {
|
|
603
624
|
const { messages, pruned } = pruneMessages([skillAsst, skillMsg], indexer as any);
|
|
604
625
|
expect(pruned).toBe(true);
|
package/src/pruner.ts
CHANGED
|
@@ -116,7 +116,7 @@ export function pruneMessages(
|
|
|
116
116
|
// session JSONL, so skipping the stub restores it verbatim.
|
|
117
117
|
// Dedup aliases resolve to the original record, so an alias whose own
|
|
118
118
|
// path is protected but whose original isn't stays stubbed (edge case).
|
|
119
|
-
if (protection && record && isProtected(record.toolName, record.args, protection)) {
|
|
119
|
+
if (protection && record && isProtected(record.toolName, record.args, protection, msg.nestedCalls)) {
|
|
120
120
|
return msg;
|
|
121
121
|
}
|
|
122
122
|
if (inGrace.has(key)) {
|
|
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { supersededStub } from "./supersede.js";
|
|
6
|
+
import { DEFAULT_CONFIG } from "./types.js";
|
|
6
7
|
|
|
7
8
|
// This must run before any module that transitively reads PI_CODING_AGENT_DIR
|
|
8
9
|
// (src/config.ts's getAgentDir()) is imported/executed.
|
|
@@ -323,6 +324,47 @@ async function boot(options?: Parameters<typeof bootExtension>[0]) {
|
|
|
323
324
|
return harness;
|
|
324
325
|
}
|
|
325
326
|
|
|
327
|
+
describe("nested protection capture", () => {
|
|
328
|
+
it.each([
|
|
329
|
+
{ mode: "live", incomplete: false },
|
|
330
|
+
{ mode: "rescan", incomplete: false },
|
|
331
|
+
{ mode: "live", incomplete: true },
|
|
332
|
+
{ mode: "rescan", incomplete: true },
|
|
333
|
+
])("%j excludes protected script output from summarizer and index", async ({ mode, incomplete }) => {
|
|
334
|
+
const branch: any[] = [];
|
|
335
|
+
const { handlers, ctx, appended } = await boot({ branch, ...(incomplete ? { protectedPaths: [] } : {}) });
|
|
336
|
+
await handlers.get("session_start")!({}, ctx);
|
|
337
|
+
const message = { role: "assistant", content: [
|
|
338
|
+
{ type: "toolCall", id: "script", name: "codemode", arguments: { code: "read()" } },
|
|
339
|
+
{ type: "toolCall", id: "control", name: "read", arguments: { path: "source.ts" } },
|
|
340
|
+
] };
|
|
341
|
+
const toolResults = [
|
|
342
|
+
{ role: "toolResult", toolCallId: "script", toolName: "codemode", timestamp: 10, content: [{ type: "text", text: "PROTECTED-SCRIPT-BODY".repeat(incomplete ? Math.ceil(DEFAULT_CONFIG.spillThreshold / "PROTECTED-SCRIPT-BODY".length) + 1 : 200) }], nestedCalls: {
|
|
343
|
+
complete: !incomplete, calls: incomplete ? [] : [{ id: "nested", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" }],
|
|
344
|
+
} },
|
|
345
|
+
{ role: "toolResult", toolCallId: "control", toolName: "read", timestamp: 11, content: [{ type: "text", text: "UNPROTECTED-CONTROL".repeat(200) }] },
|
|
346
|
+
];
|
|
347
|
+
branch.push(...[message, ...toolResults].map(message => ({ type: "message", message })));
|
|
348
|
+
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
349
|
+
const inputs: string[] = [];
|
|
350
|
+
const previousStream = streamImpl;
|
|
351
|
+
streamImpl = (_model, input) => { inputs.push(JSON.stringify(input)); return okStream(); };
|
|
352
|
+
try {
|
|
353
|
+
await handlers.get("turn_end")!({ message, toolResults, turnIndex: 0 }, ctx);
|
|
354
|
+
if (mode === "live") ctx.sessionManager.getBranch = () => { throw new Error("force pending live batch"); };
|
|
355
|
+
await handlers.get("message_end")!({ message: { role: "assistant", content: [{ type: "text", text: "done" }] } }, ctx);
|
|
356
|
+
expect(inputs).toHaveLength(1);
|
|
357
|
+
expect(inputs[0]).toContain("UNPROTECTED-CONTROL");
|
|
358
|
+
expect(inputs[0]).not.toContain("PROTECTED-SCRIPT-BODY");
|
|
359
|
+
const indexed = appended.filter(entry => entry.type === "context-prune-index")
|
|
360
|
+
.flatMap(entry => (entry.data as any).toolCalls.map((call: any) => call.toolCallId));
|
|
361
|
+
expect(indexed).toEqual(["control"]);
|
|
362
|
+
} finally {
|
|
363
|
+
streamImpl = previousStream;
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
326
368
|
describe("reload rearm (issue #6)", () => {
|
|
327
369
|
it("hides the startup widget when the prune status line is disabled", async () => {
|
|
328
370
|
const { handlers, ctx, widgets } = await boot({ showPruneStatusLine: false });
|
|
@@ -1735,3 +1777,53 @@ describe("session-wide live turn index (#16)", () => {
|
|
|
1735
1777
|
expect((fm[0].data as any).trigger).toBe("frontier-gap");
|
|
1736
1778
|
});
|
|
1737
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
|
+
});
|
package/src/summarizer.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
import { serializeBatchForSummarizer } from "./batch-capture.js";
|
|
12
12
|
import { FallbackController, type FallbackTransition } from "./summarizer-fallback.js";
|
|
13
13
|
|
|
14
|
-
const SYSTEM_PROMPT = `You are summarizing a batch of tool calls made by an AI coding assistant.
|
|
14
|
+
export const SYSTEM_PROMPT = `You are summarizing a batch of tool calls made by an AI coding assistant.
|
|
15
15
|
For each tool call provide:
|
|
16
16
|
- Tool name and a one-sentence description of what it did
|
|
17
17
|
- Key outcome, plus any file paths, identifiers, signatures, or error strings copied verbatim - never reword these
|
|
@@ -273,7 +273,7 @@ async function runOnce(
|
|
|
273
273
|
* session model, and the controller stays sticky in fallback until a
|
|
274
274
|
* per-cooldown probe of the primary succeeds.
|
|
275
275
|
*/
|
|
276
|
-
async function runSummarization(
|
|
276
|
+
export async function runSummarization(
|
|
277
277
|
userMessage: string,
|
|
278
278
|
config: ContextPruneConfig,
|
|
279
279
|
ctx: ExtensionContext,
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Shared types for the context-prune extension.
|
|
3
5
|
*
|
|
@@ -597,6 +599,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
|
|
|
597
599
|
|
|
598
600
|
/** A single tool call + its result as captured from turn_end */
|
|
599
601
|
export interface CapturedToolCall {
|
|
602
|
+
nestedCalls?: NestedToolCalls;
|
|
600
603
|
toolCallId: string;
|
|
601
604
|
toolName: string;
|
|
602
605
|
args: Record<string, unknown>;
|