pi-condense 2.11.3 → 2.11.5
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 +10 -3
- package/package.json +1 -1
- package/src/batch-capture.test.ts +70 -2
- package/src/batch-capture.ts +9 -6
- package/src/commands.ts +1 -1
- package/src/summarizer.test.ts +23 -0
- package/src/summarizer.ts +2 -0
- package/src/types.ts +3 -3
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.11.5] - 2026-10-08
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- `minBatchChars` defaults to 2000 (was 1000): batches under 2,000 raw chars compress to only about 44% of their size when summarized, so they now stay verbatim and skip the summarizer call. Set `contextPrune.minBatchChars` explicitly to keep the old threshold.
|
|
15
|
+
|
|
16
|
+
## [2.11.4] - 2026-10-08
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- The summarizer now sees the first 4,000 and last 4,000 characters of each tool result instead of only the first 2,000, so an error at the end of a test run or the newest rows of a `git log` reach the summary instead of being reported as missing; the prompt tells the model an elided middle is omitted, not empty.
|
|
21
|
+
|
|
10
22
|
## [2.11.3] - 2026-10-04
|
|
11
23
|
|
|
12
24
|
### Fixed
|
package/PRUNING.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
- [Stub-replace instead of delete](#stub-replace-instead-of-delete)
|
|
20
20
|
- [Protected tools](#protected-tools)
|
|
21
21
|
- [Eager single-result spill](#eager-single-result-spill)
|
|
22
|
+
- [Summarizer input window](#summarizer-input-window)
|
|
22
23
|
- [Trivial-batch skip (minBatchChars)](#trivial-batch-skip-minbatchchars)
|
|
23
24
|
- [Content-hash dedup](#content-hash-dedup)
|
|
24
25
|
- [Oversized summary skip](#oversized-summary-skip)
|
|
@@ -599,7 +600,7 @@ captured batches (from turn_end or session scan)
|
|
|
599
600
|
│ identical (toolName, normalize(resultText)) → alias of original;
|
|
600
601
|
│ no LLM call; persist as context-prune-dedup-alias
|
|
601
602
|
│
|
|
602
|
-
├─ 5. Trivial-batch skip (config: minBatchChars, default
|
|
603
|
+
├─ 5. Trivial-batch skip (config: minBatchChars, default 2000)
|
|
603
604
|
│ batches whose remaining raw chars < threshold → skip; no LLM call;
|
|
604
605
|
│ leave originals in context; advance frontier
|
|
605
606
|
│
|
|
@@ -668,13 +669,19 @@ Full output: <sidecar path>. Use context_tree_query(<shortRef>) to retrieve.]
|
|
|
668
669
|
|
|
669
670
|
**Hybrid storage:** bodies below `spillThreshold` stay inline in the `context-prune-index` session entry (portable, as before); only oversized bodies are spilled. Moving the session `.jsonl` without its `-blobs/` directory loses only the giant-blob recovery path; the stub and head preview remain in the index entry.
|
|
670
671
|
|
|
672
|
+
### Summarizer input window
|
|
673
|
+
|
|
674
|
+
Each tool result reaches the summarizer LLM through a fixed head + tail window: results up to 8,000 chars are sent whole; longer ones are sent as their first 4,000 chars, a ` ...[N chars elided]... ` marker, and their last 4,000 chars. The prompt tells the model the elided region was removed, never that it was empty. Results at or above `spillThreshold` are spilled before any summarizer call and never windowed, unless the sidecar write fails; the result then flushes inline and is windowed like any other.
|
|
675
|
+
|
|
676
|
+
Why a window and not the whole result: summarizer input is paid on every flush, and budget-triggered sessions flush every turn, so an uncapped 64k-char result would cost about 16k tokens per summary and raise the chance of a `length`-stopped, unusable summary. Why head + tail and not head only: the facts a summary most often needs - the error at the end of a test run, the newest rows of a log - sit at the tail. The constants are internal, not settings; the fixed total is the per-result cost ceiling. The indexer keeps the full result; `context_tree_query` is unaffected.
|
|
677
|
+
|
|
671
678
|
### Trivial-batch skip (minBatchChars)
|
|
672
679
|
|
|
673
|
-
`minBatchChars: number` (default `
|
|
680
|
+
`minBatchChars: number` (default `2000`) is a pre-flush guard against "summary would be roughly the same size as the input" cases. If the total raw `resultText` across a batch is below the threshold, the batch is skipped: no summarizer LLM call, no `context-prune-index` entry, no `context-prune-summary` injection. The frontier still advances, so the same tool calls are not reconsidered next flush.
|
|
674
681
|
|
|
675
682
|
Why it exists: a short LLM summary like "Tool X did Y" is itself ~50–150 chars per call. For a 200-byte file read or an `ls` of a short directory, the summary is the same size or larger than the input — the post-call `skipped-oversized` mechanism would catch it anyway, but only after the LLM round-trip and the cost. `minBatchChars` short-circuits the obvious cases at zero LLM cost.
|
|
676
683
|
|
|
677
|
-
Set `minBatchChars: 0` to disable. The default `
|
|
684
|
+
Set `minBatchChars: 0` to disable. The default `2000` skips obvious trivial batches (`git status`, small file reads, short directory listings) without affecting realistic tool outputs; batches under 2,000 chars compress to only about 44% of their size when summarized (the per-call bullet scaffolding dominates), against about 7% for a typical session, so summarizing them costs an LLM call for little context saved. Edit with `/pruner min-batch-chars <n>` or via the settings overlay.
|
|
678
685
|
|
|
679
686
|
### Content-hash dedup
|
|
680
687
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.5",
|
|
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",
|
|
@@ -58,6 +58,74 @@ describe("serializeBatchForSummarizer", () => {
|
|
|
58
58
|
expect(result).toContain("[[2:read]] Tool:");
|
|
59
59
|
expect(result).toContain("[[3:write]] Tool:");
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
const MARKER = /^ \.\.\.\[(\d+) chars elided\]\.\.\. $/;
|
|
63
|
+
|
|
64
|
+
function resultBody(serialized: string): string {
|
|
65
|
+
const prefix = "Result (OK): ";
|
|
66
|
+
return serialized.slice(serialized.indexOf(prefix) + prefix.length);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test("a result of 8,000 chars serializes whole with no marker", () => {
|
|
70
|
+
const raw = "a".repeat(8000);
|
|
71
|
+
const body = resultBody(serializeBatchForSummarizer(batch([toolCall({ resultText: raw })])));
|
|
72
|
+
expect(body).toBe(raw);
|
|
73
|
+
expect(body).not.toContain("chars elided");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a result of 8,001 chars becomes head 4,000 + marker + tail 4,000", () => {
|
|
77
|
+
const raw = "h".repeat(4000) + "m" + "t".repeat(4000);
|
|
78
|
+
const body = resultBody(serializeBatchForSummarizer(batch([toolCall({ resultText: raw })])));
|
|
79
|
+
expect(body).toBe("h".repeat(4000) + " ...[1 chars elided]... " + "t".repeat(4000));
|
|
80
|
+
expect(body.length).toBe(8024);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a result of 20,000 chars keeps raw.slice(0, 4000) and raw.slice(16000) around a 12000 marker", () => {
|
|
84
|
+
const raw = Array.from({ length: 20000 }, (_, i) => String.fromCharCode(97 + (i % 26))).join("");
|
|
85
|
+
const body = resultBody(serializeBatchForSummarizer(batch([toolCall({ resultText: raw })])));
|
|
86
|
+
expect(body.startsWith(raw.slice(0, 4000))).toBe(true);
|
|
87
|
+
expect(body.endsWith(raw.slice(16000))).toBe(true);
|
|
88
|
+
expect(body).toContain(" ...[12000 chars elided]... ");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a sentinel in the last 100 chars of a 5,175-char result survives; one at char 10,000 of 20,000 does not", () => {
|
|
92
|
+
const tailRaw = "x".repeat(5075) + "ERROR_AT_TAIL" + "x".repeat(5175 - 5075 - "ERROR_AT_TAIL".length);
|
|
93
|
+
expect(tailRaw.length).toBe(5175);
|
|
94
|
+
expect(serializeBatchForSummarizer(batch([toolCall({ resultText: tailRaw })]))).toContain("ERROR_AT_TAIL");
|
|
95
|
+
|
|
96
|
+
const midRaw = "x".repeat(10000) + "MID_SENTINEL" + "x".repeat(20000 - 10000 - "MID_SENTINEL".length);
|
|
97
|
+
expect(midRaw.length).toBe(20000);
|
|
98
|
+
expect(serializeBatchForSummarizer(batch([toolCall({ resultText: midRaw })]))).not.toContain("MID_SENTINEL");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("result body equals raw up to 8,000 and head + marker + tail above, for boundary lengths", () => {
|
|
102
|
+
for (const length of [0, 1, 7999, 8000, 8001, 65535, 1008000]) {
|
|
103
|
+
const raw = Array.from({ length }, (_, i) => String.fromCharCode(97 + (i % 26))).join("");
|
|
104
|
+
const body = resultBody(serializeBatchForSummarizer(batch([toolCall({ resultText: raw })])));
|
|
105
|
+
if (length <= 8000) {
|
|
106
|
+
expect(body).toBe(raw);
|
|
107
|
+
} else {
|
|
108
|
+
const marker = ` ...[${length - 8000} chars elided]... `;
|
|
109
|
+
expect(marker).toMatch(MARKER);
|
|
110
|
+
expect(body).toBe(raw.slice(0, 4000) + marker + raw.slice(-4000));
|
|
111
|
+
expect(body.length - raw.length).toBeLessThanOrEqual(marker.length);
|
|
112
|
+
expect(body.length).toBe(8000 + 23 + String(length - 8000).length);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("labels and Tool: lines are unchanged for windowed results", () => {
|
|
118
|
+
const b = batch([
|
|
119
|
+
toolCall({ toolCallId: "a", toolName: "read", resultText: "r".repeat(9000) }),
|
|
120
|
+
toolCall({ toolCallId: "b", toolName: "bash", resultText: "s".repeat(9000) }),
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
const result = serializeBatchForSummarizer(b);
|
|
124
|
+
|
|
125
|
+
expect(result).toContain("[[1:read]] Tool: read(");
|
|
126
|
+
expect(result).toContain("[[2:bash]] Tool: bash(");
|
|
127
|
+
expect(result.match(/chars elided/g)?.length).toBe(2);
|
|
128
|
+
});
|
|
61
129
|
});
|
|
62
130
|
|
|
63
131
|
describe("occurrence capture", () => {
|
|
@@ -316,10 +384,10 @@ describe("image markers", () => {
|
|
|
316
384
|
expect(imageMarkerDigests("no markers here")).toEqual([]);
|
|
317
385
|
});
|
|
318
386
|
|
|
319
|
-
test("a result with >
|
|
387
|
+
test("a result with >8,000 text chars plus an image keeps the marker in the summarizer input", () => {
|
|
320
388
|
const message = { role: "assistant", content: [{ type: "toolCall", id: "r1", name: "read", input: { path: "a.png" } }], timestamp: 1 };
|
|
321
389
|
const results = [
|
|
322
|
-
{ role: "toolResult", toolCallId: "r1", toolName: "read", content: [{ type: "text", text: "x".repeat(
|
|
390
|
+
{ role: "toolResult", toolCallId: "r1", toolName: "read", content: [{ type: "text", text: "x".repeat(9000) }, img("AAAA")], isError: false, timestamp: 2 },
|
|
323
391
|
];
|
|
324
392
|
const out = serializeBatchForSummarizer(captureBatch(message, results, 0, 9999));
|
|
325
393
|
expect(out).toContain(`[image returned: image/png sha256:${imageDigest("AAAA")}]`);
|
package/src/batch-capture.ts
CHANGED
|
@@ -57,8 +57,8 @@ export function imageMarkerDigests(text: string): string[] {
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Joins a ToolResultMessage's content into one string: one marker line per
|
|
60
|
-
* image block, then the text blocks. Markers lead
|
|
61
|
-
*
|
|
60
|
+
* image block, then the text blocks. Markers lead so they survive the
|
|
61
|
+
* summarizer's head + tail window.
|
|
62
62
|
*/
|
|
63
63
|
export function extractToolResultText(msg: any): string {
|
|
64
64
|
const content: any[] = Array.isArray(msg?.content) ? msg.content : [];
|
|
@@ -218,6 +218,10 @@ export function captureUnindexedBatchesFromSession(
|
|
|
218
218
|
return batches;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
// Fixed, not a setting: the 8,000-char total is the per-result ceiling on summarizer input.
|
|
222
|
+
const HEAD_CHARS = 4000;
|
|
223
|
+
const TAIL_CHARS = 4000;
|
|
224
|
+
|
|
221
225
|
/** Serializes a single CapturedBatch into readable text for the summarizer LLM. */
|
|
222
226
|
export function serializeBatchForSummarizer(batch: CapturedBatch): string {
|
|
223
227
|
const parts: string[] = [];
|
|
@@ -231,10 +235,9 @@ export function serializeBatchForSummarizer(batch: CapturedBatch): string {
|
|
|
231
235
|
const argsJson = JSON.stringify(tc.args, null, 2);
|
|
232
236
|
|
|
233
237
|
let resultText = tc.resultText;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
resultText = resultText.slice(0, MAX_CHARS) + ` ...[${remaining} chars truncated]`;
|
|
238
|
+
if (resultText.length > HEAD_CHARS + TAIL_CHARS) {
|
|
239
|
+
const elided = resultText.length - HEAD_CHARS - TAIL_CHARS;
|
|
240
|
+
resultText = `${resultText.slice(0, HEAD_CHARS)} ...[${elided} chars elided]... ${resultText.slice(-TAIL_CHARS)}`;
|
|
238
241
|
}
|
|
239
242
|
|
|
240
243
|
return `[[${index + 1}:${tc.toolName}]] Tool: ${tc.toolName}(${argsJson})\nResult (${status}): ${resultText}`;
|
package/src/commands.ts
CHANGED
|
@@ -591,7 +591,7 @@ export function registerCommands(
|
|
|
591
591
|
values: MIN_BATCH_CHARS_PRESETS.map((p) => p.value),
|
|
592
592
|
currentValue: MIN_BATCH_CHARS_PRESETS.some((p) => p.value === String(config.minBatchChars))
|
|
593
593
|
? String(config.minBatchChars)
|
|
594
|
-
: MIN_BATCH_CHARS_PRESETS[
|
|
594
|
+
: MIN_BATCH_CHARS_PRESETS[3].value, // fall back to "2000" if a custom value isn't in the preset cycle
|
|
595
595
|
description: minBatchCharsDescription(config),
|
|
596
596
|
},
|
|
597
597
|
{
|
package/src/summarizer.test.ts
CHANGED
|
@@ -58,4 +58,27 @@ describe("summarizer prompt", () => {
|
|
|
58
58
|
await summarizeBatch(batch, DEFAULT_CONFIG, ctx);
|
|
59
59
|
expect(JSON.stringify(seenInput)).toContain("means the tool returned an image you cannot see");
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
it("tells the model that an elided middle is omitted, not empty", async () => {
|
|
63
|
+
const model = { id: "m", provider: "p", name: "M" };
|
|
64
|
+
const ctx = {
|
|
65
|
+
model,
|
|
66
|
+
modelRegistry: {
|
|
67
|
+
find: () => model,
|
|
68
|
+
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "k", headers: {} }),
|
|
69
|
+
getProviderAuth: async () => undefined,
|
|
70
|
+
},
|
|
71
|
+
ui: { notify() {} },
|
|
72
|
+
} as any;
|
|
73
|
+
const batch = {
|
|
74
|
+
turnIndex: 0,
|
|
75
|
+
timestamp: 0,
|
|
76
|
+
assistantText: "",
|
|
77
|
+
toolCalls: [{ toolCallId: "a", toolName: "bash", args: {}, resultText: "x".repeat(9000), isError: false }],
|
|
78
|
+
} as any;
|
|
79
|
+
await summarizeBatch(batch, DEFAULT_CONFIG, ctx);
|
|
80
|
+
const sent = JSON.stringify(seenInput);
|
|
81
|
+
expect(sent).toContain("chars elided");
|
|
82
|
+
expect(sent).toContain("never report the elided region as empty or absent");
|
|
83
|
+
});
|
|
61
84
|
});
|
package/src/summarizer.ts
CHANGED
|
@@ -22,6 +22,8 @@ Keep each tool call to 1-3 bullet points. Skip calls that succeeded with nothing
|
|
|
22
22
|
|
|
23
23
|
A result line of the form [image returned: <type> sha256:<hash>] means the tool returned an image you cannot see: state that an image was returned, and never describe or guess its content.
|
|
24
24
|
|
|
25
|
+
A result containing " ...[N chars elided]... " had its middle removed before you saw it: summarize the head and tail you can see, and never report the elided region as empty or absent.
|
|
26
|
+
|
|
25
27
|
Begin the first bullet of each tool call with that tool's [[N:toolname]] label, copied verbatim (both the number and the name) from its line in the input, as the plain, first thing on the line - no bold, backticks, or list numbering around it. Do not renumber, rename, or invent labels; if you skip a tool, skip its label too.`;
|
|
26
28
|
|
|
27
29
|
const RANGE_SYSTEM_PROMPT = `You are fusing several per-step summaries of one CLOSED sub-task from an AI coding assistant's history into a SINGLE cohesive summary.
|
package/src/types.ts
CHANGED
|
@@ -194,8 +194,8 @@ export const ROLLING_WINDOW_PRESETS: { value: string; label: string }[] = [
|
|
|
194
194
|
export const MIN_BATCH_CHARS_PRESETS: { value: string; label: string }[] = [
|
|
195
195
|
{ value: "0", label: "0 (disabled)" },
|
|
196
196
|
{ value: "500", label: "500" },
|
|
197
|
-
{ value: "1000", label: "1000
|
|
198
|
-
{ value: "2000", label: "2000" },
|
|
197
|
+
{ value: "1000", label: "1000" },
|
|
198
|
+
{ value: "2000", label: "2000 (default)" },
|
|
199
199
|
{ value: "5000", label: "5000" },
|
|
200
200
|
];
|
|
201
201
|
|
|
@@ -567,7 +567,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
|
|
|
567
567
|
pruneOn: "agent-message",
|
|
568
568
|
batchingMode: "turn",
|
|
569
569
|
quietOversizedSkips: false,
|
|
570
|
-
minBatchChars:
|
|
570
|
+
minBatchChars: 2000,
|
|
571
571
|
recoveryGraceTurns: 3,
|
|
572
572
|
summarizerIdleTimeoutMs: 20000,
|
|
573
573
|
summarizerMaxTimeoutMs: 180000,
|