pi-condense 2.10.6 → 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 +6 -0
- package/README.md +1 -0
- package/index.ts +14 -2
- 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/types.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
|
|
|
7
7
|
Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
|
|
8
8
|
publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
|
|
9
9
|
|
|
10
|
+
## [2.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
|
+
|
|
10
16
|
## [2.10.6] - 2026-09-19
|
|
11
17
|
|
|
12
18
|
### 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";
|
|
@@ -1041,11 +1042,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1041
1042
|
|
|
1042
1043
|
// ── context: prune summarized tool results from next LLM call ─────────────
|
|
1043
1044
|
pi.on("context", async (event, ctx) => {
|
|
1044
|
-
if (!currentConfig.value.enabled) return undefined;
|
|
1045
|
-
|
|
1046
1045
|
let messages = event.messages;
|
|
1047
1046
|
let changed = false;
|
|
1048
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
|
+
|
|
1049
1061
|
// pruneMessages is the single source of truth for "is there work to do".
|
|
1050
1062
|
// It returns the original array reference (pruned: false) only when none of
|
|
1051
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
|
+
}
|
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 ─────────────────────────────────────────────────────────
|