pi-condense 2.10.6 → 2.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md 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.1] - 2026-09-29
11
+
12
+ ### Fixed
13
+
14
+ - The frontier-gap metric prices image blocks at a flat estimate instead of counting base64 as text, so screenshot reads no longer trigger a premature frontier-gap flush that prunes images before the model sees them.
15
+
16
+ ## [2.11.0] - 2026-09-24
17
+
18
+ ### Added
19
+
20
+ - **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.
21
+
10
22
  ## [2.10.6] - 2026-09-19
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";
@@ -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.10.6",
3
+ "version": "2.11.1",
4
4
  "description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -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
 
@@ -260,6 +260,22 @@ describe("computeContextMetrics", () => {
260
260
  expect(result.frontierGapTokens).toBe(expected);
261
261
  });
262
262
 
263
+ test("frontierGapTokens: image blocks use a flat estimate instead of base64 length", () => {
264
+ const msgs = [userMsg(100), assistantWithTools(200, ["tc1", "tc2", "tc3"])];
265
+ for (let i = 0; i < 3; i++) {
266
+ const result = toolResult(300 + i, `tc${i + 1}`, "read", "screenshot");
267
+ result.content.push({ type: "image", data: "A".repeat(137000), mimeType: "image/png" });
268
+ msgs.push(result);
269
+ }
270
+ const expected = msgs.slice(2).reduce((sum, msg) => {
271
+ const stub = { ...msg, content: msg.content.map((block: any) => block.type === "image" ? { type: "image" } : block) };
272
+ return sum + Math.round((JSON.stringify(stub).length + 1600 * 4) / 4);
273
+ }, 0);
274
+ const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
275
+ expect(result.frontierGapTokens).toBe(expected);
276
+ expect(result.frontierGapTokens).toBeLessThan(20000);
277
+ });
278
+
263
279
  test("frontierGapTokens: boundary mid-turn split excludes at-or-before calls, includes later calls in same turn", () => {
264
280
  const msgs = [userMsg(100), assistantWithTools(200, ["tc1", "tc2"]), toolResult(300, "tc1"), toolResult(310, "tc2")];
265
281
  const frontier = fullFrontier({ lastAttemptedToolCallId: "tc1", lastAttemptedTurnIndex: 0 });
@@ -2,8 +2,17 @@ import { detectChains, isChainAnchorCustom } from "./chain-detector.js";
2
2
  import { occKey, resultTimestampOf } from "./occurrence-key.js";
3
3
  import type { ContextMetricsSnapshot, PruneFrontier } from "./types.js";
4
4
 
5
+ const IMAGE_TOKEN_ESTIMATE = 1600;
6
+
5
7
  function charsOf(msg: any): number {
6
- return JSON.stringify(msg).length;
8
+ if (!Array.isArray(msg.content)) return JSON.stringify(msg).length;
9
+ let imageCount = 0;
10
+ const content = msg.content.map((block: any) => {
11
+ if (block?.type !== "image") return block;
12
+ imageCount++;
13
+ return { type: "image" };
14
+ });
15
+ return JSON.stringify({ ...msg, content }).length + imageCount * IMAGE_TOKEN_ESTIMATE * 4;
7
16
  }
8
17
 
9
18
  function tokensOf(msg: any): number {
@@ -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
+ });
@@ -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 ─────────────────────────────────────────────────────────