pi-condense 2.10.5 → 2.11.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 +12 -0
- package/README.md +1 -0
- package/index.ts +30 -16
- package/package.json +1 -1
- package/src/config.test.ts +22 -0
- package/src/config.ts +6 -0
- package/src/image-cap.test.ts +57 -0
- package/src/image-cap.ts +57 -0
- package/src/reload-rearm.integration.test.ts +15 -3
- package/src/types.ts +10 -0
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.0] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Per-request image cap (`maxImagesPerRequest`).** Every image a model has seen stays in the transcript, so a session that reads many images eventually sends more than a provider accepts in one request - one gateway answers `Too many images in request: 31 > 30` - and then fails on every request, including after a resume. When set, the `context` handler replaces the oldest `image` blocks with a text note once a request carries more than N, in steps of half of N so the prompt prefix - and with it the provider's prompt cache - changes only once per step; the session file is untouched. It runs even with `enabled: false`, since it guards request validity rather than context size. Default `null` applies the built-in limit of the model's wire API - `100` for `anthropic-messages`, the documented Anthropic maximum; no cap for other APIs - and a number overrides it for every API. Config-file-only.
|
|
15
|
+
|
|
16
|
+
## [2.10.6] - 2026-09-19
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `showPruneStatusLine: false` now also suppresses the transient `pruner loaded` startup widget on new and restored sessions.
|
|
21
|
+
|
|
10
22
|
## [2.10.5] - 2026-09-15
|
|
11
23
|
|
|
12
24
|
### Fixed
|
package/README.md
CHANGED
|
@@ -169,6 +169,7 @@ Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_A
|
|
|
169
169
|
| `pruneOn` | `agent-message` | Trigger mode - see Architecture above |
|
|
170
170
|
| `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 |
|
|
171
171
|
| `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` |
|
|
172
|
+
| `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 |
|
|
172
173
|
| `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md", "**/gauntlet-overrides.md"]` | Tool names / path globs that are never summarized; only the newest read per protected path stays verbatim (older reads of the same path are stubbed once the prompt cache is cold anyway) |
|
|
173
174
|
| `spillThreshold` | `65536` | Chars above which a single oversized result spills straight to a sidecar file |
|
|
174
175
|
|
package/index.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { loadConfig } from "./src/config.js";
|
|
18
|
+
import { capImages, imageLimitFor } from "./src/image-cap.js";
|
|
18
19
|
import { captureBatch, captureUnindexedBatchesFromSession, deriveLiveTurnIndex, groupBatchesByMode, projectBranchMessages } from "./src/batch-capture.js";
|
|
19
20
|
import { summarizeBatch, summarizeBatches, summarizeRange } from "./src/summarizer.js";
|
|
20
21
|
import { FallbackController } from "./src/summarizer-fallback.js";
|
|
@@ -827,20 +828,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
827
828
|
// Update footer status
|
|
828
829
|
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
829
830
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
831
|
+
if (currentConfig.value.showPruneStatusLine) {
|
|
832
|
+
ctx.ui.setWidget(
|
|
833
|
+
"pruner-boot",
|
|
834
|
+
[
|
|
835
|
+
`pruner loaded — pruning ${currentConfig.value.enabled ? "ON" : "OFF"} | model: ${currentConfig.value.summarizerModel}`,
|
|
836
|
+
],
|
|
837
|
+
{ placement: "belowEditor" },
|
|
838
|
+
);
|
|
839
|
+
setTimeout(() => {
|
|
840
|
+
try {
|
|
841
|
+
ctx.ui.setWidget("pruner-boot", undefined);
|
|
842
|
+
} catch {
|
|
843
|
+
// UI owner may be gone after session replacement.
|
|
844
|
+
}
|
|
845
|
+
}, 10000).unref?.();
|
|
846
|
+
}
|
|
844
847
|
});
|
|
845
848
|
|
|
846
849
|
// Rebuild index and stats after tree navigation too (branch may have different history)
|
|
@@ -1039,11 +1042,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1039
1042
|
|
|
1040
1043
|
// ── context: prune summarized tool results from next LLM call ─────────────
|
|
1041
1044
|
pi.on("context", async (event, ctx) => {
|
|
1042
|
-
if (!currentConfig.value.enabled) return undefined;
|
|
1043
|
-
|
|
1044
1045
|
let messages = event.messages;
|
|
1045
1046
|
let changed = false;
|
|
1046
1047
|
|
|
1048
|
+
// Request-validity guard, independent of `enabled`: a transcript past the
|
|
1049
|
+
// provider's per-request image limit fails every request until trimmed.
|
|
1050
|
+
const imageCap = imageLimitFor(currentConfig.value.maxImagesPerRequest, ctx.model?.api);
|
|
1051
|
+
if (imageCap !== null) {
|
|
1052
|
+
const capped = capImages(messages, imageCap);
|
|
1053
|
+
if (capped) {
|
|
1054
|
+
messages = capped;
|
|
1055
|
+
changed = true;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
if (!currentConfig.value.enabled) return changed ? { messages } : undefined;
|
|
1060
|
+
|
|
1047
1061
|
// pruneMessages is the single source of truth for "is there work to do".
|
|
1048
1062
|
// It returns the original array reference (pruned: false) only when none of
|
|
1049
1063
|
// the five phases changed anything; index/registry emptiness alone does not
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.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",
|
package/src/config.test.ts
CHANGED
|
@@ -176,6 +176,28 @@ describe("loadConfig frontierGapThresholdTokens normalization", () => {
|
|
|
176
176
|
});
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
describe("loadConfig maxImagesPerRequest normalization", () => {
|
|
180
|
+
it("defaults to null when unset", async () => {
|
|
181
|
+
await writeContextPrune({});
|
|
182
|
+
const config = await loadConfig();
|
|
183
|
+
expect(config.maxImagesPerRequest).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("floors a fractional value", async () => {
|
|
187
|
+
await writeContextPrune({ maxImagesPerRequest: 20.9 });
|
|
188
|
+
const config = await loadConfig();
|
|
189
|
+
expect(config.maxImagesPerRequest).toBe(20);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("falls back to null for 0, a value below 1, negative, Infinity, or a string", async () => {
|
|
193
|
+
for (const value of [0, 0.5, -3, Infinity, "20"]) {
|
|
194
|
+
await writeContextPrune({ maxImagesPerRequest: value });
|
|
195
|
+
const config = await loadConfig();
|
|
196
|
+
expect(config.maxImagesPerRequest).toBeNull();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
179
201
|
describe("saveConfig fails closed (#15)", () => {
|
|
180
202
|
const config: ContextPruneConfig = { ...DEFAULT_CONFIG, enabled: false };
|
|
181
203
|
|
package/src/config.ts
CHANGED
|
@@ -112,6 +112,12 @@ function normalize(existing: Partial<ContextPruneConfig>): ContextPruneConfig {
|
|
|
112
112
|
merged.frontierGapThresholdTokens > 0
|
|
113
113
|
? Math.floor(merged.frontierGapThresholdTokens)
|
|
114
114
|
: DEFAULT_CONFIG.frontierGapThresholdTokens,
|
|
115
|
+
maxImagesPerRequest:
|
|
116
|
+
typeof merged.maxImagesPerRequest === "number" &&
|
|
117
|
+
Number.isFinite(merged.maxImagesPerRequest) &&
|
|
118
|
+
merged.maxImagesPerRequest >= 1
|
|
119
|
+
? Math.floor(merged.maxImagesPerRequest)
|
|
120
|
+
: DEFAULT_CONFIG.maxImagesPerRequest,
|
|
115
121
|
};
|
|
116
122
|
}
|
|
117
123
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { IMAGE_OMITTED_NOTE, capImages, imageLimitFor } from "./image-cap.js";
|
|
3
|
+
|
|
4
|
+
const image = (n: number) => ({ type: "image", data: `img${n}`, mimeType: "image/png" });
|
|
5
|
+
const imagesOf = (messages: any[]) =>
|
|
6
|
+
messages.flatMap((m) => (Array.isArray(m.content) ? m.content : [])).filter((b: any) => b.type === "image");
|
|
7
|
+
|
|
8
|
+
describe("capImages", () => {
|
|
9
|
+
const reads = (count: number) =>
|
|
10
|
+
Array.from({ length: count }, (_, n) => ({
|
|
11
|
+
role: "toolResult",
|
|
12
|
+
content: [{ type: "text", text: "read" }, image(n)],
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
it("omits the oldest images once over the cap, in steps of half the cap", () => {
|
|
16
|
+
const out = capImages(reads(31), 30)!;
|
|
17
|
+
expect(imagesOf(out).map((b: any) => b.data)).toEqual(Array.from({ length: 16 }, (_, n) => `img${n + 15}`));
|
|
18
|
+
expect(out[0].content[1]).toEqual({ type: "text", text: IMAGE_OMITTED_NOTE });
|
|
19
|
+
expect(out[0].content[0]).toEqual({ type: "text", text: "read" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("keeps the omitted prefix stable while new images arrive within a step", () => {
|
|
23
|
+
const omitted = (count: number) =>
|
|
24
|
+
(capImages(reads(count), 20) ?? reads(count)).filter((m: any) => m.content[1].type === "text").length;
|
|
25
|
+
expect([20, 21, 25, 30, 31, 40, 41].map(omitted)).toEqual([0, 10, 10, 10, 20, 20, 30]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("counts images within one message newest-last", () => {
|
|
29
|
+
const out = capImages([{ role: "user", content: [image(1), image(2), image(3)] }], 2)!;
|
|
30
|
+
expect(out[0].content.map((b: any) => b.data ?? b.text)).toEqual([IMAGE_OMITTED_NOTE, "img2", "img3"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("does not mutate its input", () => {
|
|
34
|
+
const messages = [{ role: "user", content: [image(1), image(2)] }];
|
|
35
|
+
capImages(messages, 1);
|
|
36
|
+
expect(imagesOf(messages)).toHaveLength(2);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns undefined when the request is already under the cap", () => {
|
|
40
|
+
expect(capImages([{ role: "user", content: [image(1)] }, { role: "assistant", content: "text" }], 5)).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("imageLimitFor", () => {
|
|
45
|
+
it("prefers the configured number over the built-in", () => {
|
|
46
|
+
expect(imageLimitFor(30, "anthropic-messages")).toBe(30);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("falls back to the built-in Anthropic Messages limit", () => {
|
|
50
|
+
expect(imageLimitFor(null, "anthropic-messages")).toBe(100);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns null for an API without a built-in limit or no model", () => {
|
|
54
|
+
expect(imageLimitFor(null, "openai-completions")).toBeNull();
|
|
55
|
+
expect(imageLimitFor(null, undefined)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/image-cap.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request image cap (`maxImagesPerRequest`).
|
|
3
|
+
*
|
|
4
|
+
* Providers reject a request carrying more images than their limit, and every
|
|
5
|
+
* image a model has seen stays in the transcript, so a long session can cross
|
|
6
|
+
* the limit and then fail on every request. Once a request carries more than
|
|
7
|
+
* `max` images, `capImages` replaces the oldest ones with a text note, in steps
|
|
8
|
+
* of half the cap so the prompt prefix changes rarely. It never mutates its
|
|
9
|
+
* input; it returns `undefined` when nothing changes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const IMAGE_OMITTED_NOTE =
|
|
13
|
+
"[earlier image omitted from this request: over maxImagesPerRequest; re-read the file or re-attach it to view it]";
|
|
14
|
+
|
|
15
|
+
// Documented per-request limits of first-party wire APIs (Anthropic Messages:
|
|
16
|
+
// 100 images per request). Keyed by `api`, not `provider`: the limit is enforced
|
|
17
|
+
// by the protocol endpoint, whichever provider fronts it. Unlisted APIs
|
|
18
|
+
// (OpenAI 1,500, Gemini 3,600) sit far above any realistic transcript.
|
|
19
|
+
const BUILTIN_IMAGE_LIMITS: Record<string, number> = { "anthropic-messages": 100 };
|
|
20
|
+
|
|
21
|
+
/** The effective cap: an explicit `maxImagesPerRequest` wins, else the built-in limit for the model's API. */
|
|
22
|
+
export function imageLimitFor(configured: number | null, api: string | undefined): number | null {
|
|
23
|
+
if (configured !== null) return configured;
|
|
24
|
+
return api !== undefined ? (BUILTIN_IMAGE_LIMITS[api] ?? null) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Block = { type?: unknown };
|
|
28
|
+
|
|
29
|
+
export function capImages<M extends object>(messages: M[], max: number): M[] | undefined {
|
|
30
|
+
const total = messages.reduce((n, m) => {
|
|
31
|
+
const content = (m as { content?: unknown }).content;
|
|
32
|
+
return n + (Array.isArray(content) ? content.filter((b) => (b as Block)?.type === "image").length : 0);
|
|
33
|
+
}, 0);
|
|
34
|
+
if (total <= max) return undefined;
|
|
35
|
+
// Omit the oldest images in steps of half the cap, not one per new image:
|
|
36
|
+
// the omitted set then changes once per `step` new images, so the prompt
|
|
37
|
+
// prefix (and the provider's prompt cache) stays stable in between.
|
|
38
|
+
const step = Math.max(1, Math.ceil(max / 2));
|
|
39
|
+
let toOmit = Math.min(total, Math.ceil((total - max) / step) * step);
|
|
40
|
+
let result: M[] | undefined;
|
|
41
|
+
for (let i = 0; i < messages.length && toOmit > 0; i++) {
|
|
42
|
+
const content = (messages[i] as { content?: unknown }).content;
|
|
43
|
+
if (!Array.isArray(content)) continue;
|
|
44
|
+
let replaced: Block[] | undefined;
|
|
45
|
+
for (let j = 0; j < content.length && toOmit > 0; j++) {
|
|
46
|
+
if ((content[j] as Block)?.type !== "image") continue;
|
|
47
|
+
replaced ??= content.slice() as Block[];
|
|
48
|
+
replaced[j] = { type: "text", text: IMAGE_OMITTED_NOTE } as Block;
|
|
49
|
+
toOmit--;
|
|
50
|
+
}
|
|
51
|
+
if (replaced) {
|
|
52
|
+
result ??= messages.slice();
|
|
53
|
+
result[i] = { ...messages[i], content: replaced } as M;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
@@ -218,6 +218,7 @@ function bootExtension(
|
|
|
218
218
|
autoBudgetThreshold?: number | null;
|
|
219
219
|
budgetTurnDelta?: number | null;
|
|
220
220
|
frontierGapThresholdTokens?: number | null;
|
|
221
|
+
showPruneStatusLine?: boolean;
|
|
221
222
|
} = {},
|
|
222
223
|
) {
|
|
223
224
|
const agentDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-"));
|
|
@@ -229,7 +230,7 @@ function bootExtension(
|
|
|
229
230
|
autoBudgetThreshold: options.autoBudgetThreshold === undefined ? 0.5 : options.autoBudgetThreshold,
|
|
230
231
|
summarizerModel: "default",
|
|
231
232
|
minBatchChars: 1,
|
|
232
|
-
showPruneStatusLine: true,
|
|
233
|
+
showPruneStatusLine: options.showPruneStatusLine ?? true,
|
|
233
234
|
protectedTools: options.protectedTools ?? [],
|
|
234
235
|
chainCompression: {
|
|
235
236
|
enabled: options.chainCompressionEnabled ?? false,
|
|
@@ -258,6 +259,7 @@ function bootExtension(
|
|
|
258
259
|
const handlers = new Map<string, (event: any, ctx: any) => any>();
|
|
259
260
|
const commands = new Map<string, (args: string, ctx: any) => Promise<void>>();
|
|
260
261
|
const notifications: string[] = [];
|
|
262
|
+
const widgets: Array<{ id: string; content: unknown }> = [];
|
|
261
263
|
|
|
262
264
|
const pushPi = (type: string, data?: unknown) => {
|
|
263
265
|
piAppended.push({ type, data });
|
|
@@ -307,7 +309,9 @@ function bootExtension(
|
|
|
307
309
|
},
|
|
308
310
|
ui: {
|
|
309
311
|
setStatus() {},
|
|
310
|
-
setWidget() {
|
|
312
|
+
setWidget(id: string, content: unknown) {
|
|
313
|
+
widgets.push({ id, content });
|
|
314
|
+
},
|
|
311
315
|
notify(message: string) {
|
|
312
316
|
notifications.push(message);
|
|
313
317
|
},
|
|
@@ -315,7 +319,7 @@ function bootExtension(
|
|
|
315
319
|
},
|
|
316
320
|
};
|
|
317
321
|
|
|
318
|
-
return { handlers, commands, notifications, ctx, pi, piAppended, sessionAppended, appended, branch };
|
|
322
|
+
return { handlers, commands, notifications, widgets, ctx, pi, piAppended, sessionAppended, appended, branch };
|
|
319
323
|
}
|
|
320
324
|
|
|
321
325
|
async function boot(options?: Parameters<typeof bootExtension>[0]) {
|
|
@@ -326,6 +330,14 @@ async function boot(options?: Parameters<typeof bootExtension>[0]) {
|
|
|
326
330
|
}
|
|
327
331
|
|
|
328
332
|
describe("reload rearm (issue #6)", () => {
|
|
333
|
+
it("hides the startup widget when the prune status line is disabled", async () => {
|
|
334
|
+
const { handlers, ctx, widgets } = await boot({ showPruneStatusLine: false });
|
|
335
|
+
|
|
336
|
+
await handlers.get("session_start")!({}, ctx);
|
|
337
|
+
|
|
338
|
+
expect(widgets.some(({ id }) => id === "pruner-boot")).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
329
341
|
it("falls back to the run-local index at capture when getBranch throws, and the flush-time rescan re-derives the session-wide index", async () => {
|
|
330
342
|
const { handlers, ctx, notifications, appended } = await boot();
|
|
331
343
|
|
package/src/types.ts
CHANGED
|
@@ -421,6 +421,15 @@ export interface ContextPruneConfig {
|
|
|
421
421
|
* null (default) disables. Config-file-only — no settings overlay row.
|
|
422
422
|
*/
|
|
423
423
|
frontierGapThresholdTokens: number | null;
|
|
424
|
+
/**
|
|
425
|
+
* Request-validity guard: keep only the newest N image blocks in each
|
|
426
|
+
* outgoing request and replace older ones with a text note, so a long
|
|
427
|
+
* session never exceeds a provider's per-request image limit. Applies even
|
|
428
|
+
* when `enabled` is false. null (default) uses the built-in limit for the
|
|
429
|
+
* model's API (`anthropic-messages`: 100; other APIs: no cap).
|
|
430
|
+
* Config-file-only.
|
|
431
|
+
*/
|
|
432
|
+
maxImagesPerRequest: number | null;
|
|
424
433
|
}
|
|
425
434
|
|
|
426
435
|
/**
|
|
@@ -581,6 +590,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
|
|
|
581
590
|
spillPreviewBytes: 2048,
|
|
582
591
|
budgetTurnDelta: null,
|
|
583
592
|
frontierGapThresholdTokens: null,
|
|
593
|
+
maxImagesPerRequest: null,
|
|
584
594
|
};
|
|
585
595
|
|
|
586
596
|
// ── Captured batch ─────────────────────────────────────────────────────────
|