@oh-my-pi/snapcompact 18.10.2 → 18.10.3

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
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.10.3] - 2026-10-12
6
+
7
+ ### Breaking Changes
8
+
9
+ - `providerImageBudget(provider)` and `providerFrameBudget(provider)` became `providerImageBudget(model)` and `providerFrameBudget(model)`, and `maxFramesForDataBudget(shape)` became `maxFramesForDataBudget(shape, budget)` with a required byte budget ([#15434](https://github.com/can1357/oh-my-pi/pull/15434) by [@will-bogusz](https://github.com/will-bogusz))
10
+ - Removed `PROVIDER_IMAGE_BUDGETS` and `FRAME_DATA_BYTES_BUDGET`: image counts come from the catalog's `image-count-budget` rules, and frame byte budgets from `frameDataBytesBudget(model)` (`DEFAULT_FRAME_DATA_BYTES_BUDGET` where the host has none) ([#15434](https://github.com/can1357/oh-my-pi/pull/15434) by [@will-bogusz](https://github.com/will-bogusz))
11
+
12
+ ### Added
13
+
14
+ - Added `plannedFrameTokens` and `Shape.frameTokensVerified`: archive sizing charges a frame its own price when the reader's `image-tokenization` rule is `verified`, and at least `FRAME_TOKEN_ESTIMATE` otherwise ([#15442](https://github.com/can1357/oh-my-pi/pull/15442) by [@will-bogusz](https://github.com/will-bogusz))
15
+
16
+ ### Fixed
17
+
18
+ - Fixed snapcompact frames for Claude and GPT on Cursor, including its `default` (Auto) route, being drawn at 1568–1932px, which Cursor shrank to 1024px before the model read them; frames now render at the host's catalog `image-max-edge` ([#15448](https://github.com/can1357/oh-my-pi/pull/15448) by [@will-bogusz](https://github.com/will-bogusz))
19
+
5
20
  ## [18.6.3] - 2026-10-06
6
21
 
7
22
  ### Breaking Changes
package/README.md CHANGED
@@ -15,17 +15,19 @@ Built for [omp](https://github.com/can1357/oh-my-pi)'s compaction pipeline, but
15
15
 
16
16
  Frame shapes are provider-aware, chosen by SQuAD recall evals (see `research/`) against real provider billing:
17
17
 
18
- | Reader | Default shape | Notes |
19
- | --- | --- | --- |
20
- | Anthropic | `11on16-bw` | X.org 8x13 glyphs on an 11px advance; high-res Claude lines get 1932px frames |
21
- | Google | `8on22-bw` @2048 | X.org 8x13 glyphs on a 22px pitch; Gemini bills a fixed per-image budget, so larger frames are free chars |
22
- | OpenAI | `8on22-bw` | X.org 8x13 glyphs on a 22px pitch, sent at `detail: "original"` |
23
- | Unknown | Anthropic shape | Per-provider image-count budgets guard against gateways that silently drop frames |
18
+ | Reader | Default shape | Notes |
19
+ | --------- | ---------------- | --------------------------------------------------------------------------------------------------------- |
20
+ | Anthropic | `11on16-bw` | X.org 8x13 glyphs on an 11px advance; high-res Claude lines get 1932px frames |
21
+ | Google | `8on22-bw` @2048 | X.org 8x13 glyphs on a 22px pitch; Gemini bills a fixed per-image budget, so larger frames are free chars |
22
+ | OpenAI | `8on22-bw` | X.org 8x13 glyphs on a 22px pitch, sent at `detail: "original"` |
23
+ | Unknown | Anthropic shape | Per-provider image-count budgets guard against gateways that silently drop frames |
24
24
 
25
- `resolveShape({ api, id })` matches the model id, not just the wire API — a Claude routed through Vertex or OpenRouter keeps its Claude shape. Frames are priced by the reading model's lineage: its catalog `image-tokenization` rule (so that Claude is billed as Claude, and Opus 4.6 under the standard 1,568-token tier), falling back to the catalog rule for the wire API carrying the request when the lineage has none. A reader with neither costs `FRAME_TOKEN_ESTIMATE` per frame.
25
+ `resolveShape({ api, id })` matches the model id, not just the wire API — a Claude routed through Vertex or OpenRouter keeps its Claude shape. Frames are priced by the reading model's lineage: its catalog `image-tokenization` rule (so that Claude is billed as Claude, and Opus 4.6 under the standard 1,568-token tier), falling back to the catalog rule for the wire API carrying the request when the lineage has none. A reader with neither costs `FRAME_TOKEN_ESTIMATE` per frame. Archive sizing (`plannedFrameTokens`) charges each frame its own price when the rule is `verified` against provider billing, and at least `FRAME_TOKEN_ESTIMATE` otherwise.
26
26
 
27
27
  Bitmap shapes keep their provider-tuned geometry and draw missing glyphs through the embedded Silver TrueType fallback one character at a time; East Asian (CJK/Kana/Hangul) glyphs render full-width across two cells so they stay legible in the narrow ASCII grid. Selecting `silver16-bw` uses Silver for the whole frame.
28
28
 
29
+ A host that downscales images before the model reads them (catalog `image-max-edge`; Cursor fits Claude and GPT images into 1024×1024) gets frames rendered at that edge, forced variants included, so the glyphs reach the model at the size they were drawn.
30
+
29
31
  ## Install
30
32
 
31
33
  ```sh
@@ -42,8 +44,8 @@ Render arbitrary text into LLM image blocks:
42
44
  import { renderMany, frames, resolveShape } from "@oh-my-pi/snapcompact";
43
45
 
44
46
  const images = renderMany(longText, { model }); // ImageContent[], first page first
45
- const count = frames(longText, { model }); // frame count without rendering
46
- const shape = resolveShape(model); // eval-optimal Shape for the reader
47
+ const count = frames(longText, { model }); // frame count without rendering
48
+ const shape = resolveShape(model); // eval-optimal Shape for the reader
47
49
  ```
48
50
 
49
51
  Run a full compaction pass over prepared messages:
@@ -58,11 +60,11 @@ const result = await compact(preparation, { model });
58
60
 
59
61
  ## API surface
60
62
 
61
- - **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images`, `historyBlocks`
63
+ - **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images`, `historyBlocks`, `capFrameDataBytes`
62
64
  - **Rendering**: `render`, `renderMany`, `frames`, `geometry`
63
65
  - **Shapes**: `SHAPES`, `SHAPE_VARIANTS`, `resolveShape`, `resolveShapeForText`, `idealShapeVariant`, `isShape`, `isShapeVariantName`
64
66
  - **Text**: `serializeConversation`, `normalize`, `scanRenderability`, `renderabilityProbeText`, `dimStopwords`, `wrap`
65
- - **Budgets**: `providerImageBudget`, `MAX_FRAMES_DEFAULT`, `FRAME_TOKEN_ESTIMATE`, `frameBilling`, `frameTokens`, `frameBillingKey`, `HQ_EDGE_FRAMES`
67
+ - **Budgets**: `providerImageBudget`, `frameDataBytesBudget`, `MAX_FRAMES_DEFAULT`, `FRAME_TOKEN_ESTIMATE`, `frameBilling`, `frameTokens`, `frameBillingKey`, `plannedFrameTokens`, `HQ_EDGE_FRAMES`
66
68
  - **File ops**: `createFileOps`, `computeFileLists`, `upsertFileOperations`
67
69
 
68
70
  ## References
@@ -32,11 +32,9 @@
32
32
  * billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
33
33
  * area-proportional, so resolution cannot improve chars/$ — 1568 stays.
34
34
  * `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
35
- * - **Unknown providers** default to `8on22-bw`. `providerImageBudget` still
36
- * caps per-request images per provider so inline imaging cannot flood a
37
- * request with attachments, but the old OpenRouter-specific 8-image cap is
38
- * gone; routers now use the same permissive budget as direct
39
- * Anthropic/Claude lines unless configured otherwise upstream.
35
+ * - **Unknown providers** default to `8on22-bw`. `providerImageBudget` caps
36
+ * per-request images at the deployment's catalog `image-count-budget`, so
37
+ * inline imaging cannot flood a request with attachments.
40
38
  *
41
39
  * The per-frame token estimate follows the reading model's catalog
42
40
  * `image-tokenization` rule (its lineage, not the gateway), so Claude behind
@@ -44,13 +42,17 @@
44
42
  * Models without a lineage rule fall back to the catalog rule for their wire
45
43
  * API; a reader with neither costs {@link FRAME_TOKEN_ESTIMATE} per frame.
46
44
  *
45
+ * A host that downscales every image before the model reads it (catalog
46
+ * `image-max-edge`, e.g. Cursor) gets frames rendered at that edge instead,
47
+ * so glyphs reach the model at the size they were drawn.
48
+ *
47
49
  * The whole pass is local and deterministic — no LLM call, no API key, no
48
50
  * latency beyond rendering. Rasterization and PNG encoding happen in native
49
51
  * code (`renderSnapcompactPng` in `crates/pi-natives/src/snapcompact.rs`).
50
52
  * Frames persist in the compaction entry's `preserveData` and are
51
53
  * re-attached to the compaction summary message on every context rebuild.
52
54
  */
53
- import type { Api, ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
55
+ import type { Api, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
54
56
  import { type ImageSize, type ImageTokenization, type ImageTokenizationTarget } from "@oh-my-pi/pi-catalog/compat/image-tokenization";
55
57
  /** One eval-validated frame shape: font, cell, ink, repetition, and size. */
56
58
  export interface Shape {
@@ -78,11 +80,13 @@ export interface Shape {
78
80
  frameSize: number;
79
81
  /** Per-frame billed-token estimate for the shape's target provider. */
80
82
  frameTokenEstimate: number;
83
+ /** `frameTokenEstimate` comes from a catalog rule measured against provider billing. */
84
+ frameTokensVerified?: boolean;
81
85
  /** Resolution hint attached to frame images (OpenAI-only). */
82
86
  imageDetail?: ImageContent["detail"];
83
87
  }
84
88
  /** Geometry half of a {@link Shape}: everything except provider billing. */
85
- export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "imageDetail">;
89
+ export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "frameTokensVerified" | "imageDetail">;
86
90
  /**
87
91
  * Frame variants exercised by the SQuAD evals in `research/` that the native
88
92
  * renderer reproduces faithfully, keyed by their research names. Font codes:
@@ -269,6 +273,11 @@ export declare function frameBilling(target: ShapeTarget | undefined): FrameBill
269
273
  export declare function frameBillingKey(billing: FrameBilling): string;
270
274
  /** Billed-token estimate for one rendered frame of `size` under `billing`. */
271
275
  export declare function frameTokens(billing: FrameBilling, size: ImageSize): number;
276
+ /**
277
+ * Per-frame tokens archive sizing charges `shape`: its own price when provider
278
+ * billing verified it, else at least {@link FRAME_TOKEN_ESTIMATE}.
279
+ */
280
+ export declare function plannedFrameTokens(shape: Shape): number;
272
281
  /** Eval-validated shapes, keyed by the provider family they won on. */
273
282
  export declare const SHAPES: {
274
283
  /** `11on16-bw`: 8x13 glyphs on an 11px advance (extra tracking), black ink.
@@ -312,7 +321,8 @@ export interface ShapeTarget {
312
321
  * Pick the frame shape for a reader. An explicit `variant` (anything but
313
322
  * `"auto"`) forces that geometry; otherwise the model id selects the
314
323
  * eval-winning shape — and frame size — for its model line, falling back to
315
- * the API family's winner when the model is unmeasured. Billing (token
324
+ * the API family's winner when the model is unmeasured. Either way the frame
325
+ * is no wider than the host's catalog `image-max-edge`. Billing (token
316
326
  * estimate, detail hint) is computed for the resolved frame size from the
317
327
  * model's catalog image rule. Accepts a full pi-ai `Model` or any
318
328
  * `{ api, id }` subset.
@@ -340,44 +350,46 @@ export declare const MAX_FRAMES_DEFAULT = 80;
340
350
  * foveated archive — the session head (oldest) and the slice just before the
341
351
  * text region (newest) — with the denser low-quality tier filling the middle. */
342
352
  export declare const HQ_EDGE_FRAMES = 3;
343
- /** Conservative per-frame token estimate for archive sizing and for frames of
344
- * unknown size — the upper bound across shapes: high-res Claude frames hit the
345
- * 4,784 visual-token cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the
346
- * overflow guard from undercounting a high-res archive at the raised
347
- * {@link MAX_FRAMES_DEFAULT}. */
353
+ /** Conservative per-frame token estimate for sizing archives whose frame price
354
+ * is not billing-verified, and for frames of unknown size — the upper bound
355
+ * across shapes: high-res Claude frames hit the 4,784 visual-token cap, billed
356
+ * at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
357
+ * undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
348
358
  export declare const FRAME_TOKEN_ESTIMATE = 5024;
349
359
  /** Conservative upper bound for one persisted frame's base64 payload. The
350
360
  * measured high-res Anthropic `8x13`/`11on16` PNG frames sit around 159 KB;
351
361
  * 170 KB leaves margin for denser glyph pages without permitting multi-MB
352
362
  * standing request bodies at large context windows. */
353
363
  export declare const FRAME_DATA_BYTES_ESTIMATE = 170000;
354
- /** Maximum snapcompact image base64 carried in every rebuilt provider request.
355
- * Above this, provider backends can accept the HTTP body but fail mid-stream
364
+ /** Frame payload cap for a deployment without a catalog `inline-image-byte-budget`:
365
+ * an unmeasured backend can accept the HTTP body and still fail mid-stream
356
366
  * with opaque 5xx errors. Keep this independent from visual-token budgeting:
357
367
  * a 1M-token model can afford 70 images on paper, but not the resulting
358
368
  * ~11 MB JSON payload on every turn. */
359
- export declare const FRAME_DATA_BYTES_BUDGET = 3000000;
360
- /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET} for frames
361
- * rendered by `shape`. {@link AREA_PRICED_VARIANTS} below 1932px are charged
369
+ export declare const DEFAULT_FRAME_DATA_BYTES_BUDGET = 3000000;
370
+ /** Base64 image bytes the archive frames riding every request to `model` may
371
+ * carry: half the deployment's inline-image byte budget, else
372
+ * {@link DEFAULT_FRAME_DATA_BYTES_BUDGET}. Frames are the oldest images in the
373
+ * request, so the send-time byte clamp evicts them first; the other half stays
374
+ * free for live screenshots. */
375
+ export declare function frameDataBytesBudget(model: Model | undefined): number;
376
+ /** Frame-count cap a `budget` of base64 bytes implies for frames rendered by
377
+ * `shape`. {@link AREA_PRICED_VARIANTS} below 1932px are charged
362
378
  * {@link FRAME_DATA_BYTES_ESTIMATE} scaled by pixel area; every other shape
363
379
  * pays the full estimate. */
364
- export declare function maxFramesForDataBudget(shape: ShapeGeometry): number;
380
+ export declare function maxFramesForDataBudget(shape: ShapeGeometry, budget: number): number;
365
381
  /** Base64 byte length for persisted snapcompact frames. */
366
382
  export declare function frameDataBytes(frames: readonly Pick<Frame, "data">[]): number;
367
- /**
368
- * Per-request image-count budgets by provider id. These cap how many images an
369
- * entire request may carry (archive/system-prompt/tool-result imaging combined).
370
- * The values are conservative policy caps under the vendor hard limits
371
- * (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe
372
- * floor rather than sending unbounded attachments.
373
- */
374
- export declare const PROVIDER_IMAGE_BUDGETS: Record<string, number>;
375
- /** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
383
+ /** Safe floor for a deployment whose catalog rules set no `image-count-budget`
384
+ * (strictest mainstream measured: Groq ~5). */
376
385
  export declare const DEFAULT_PROVIDER_IMAGE_BUDGET = 5;
377
- /** Per-request image budget for `provider`; unknown providers get the floor. */
378
- export declare function providerImageBudget(provider: string | undefined): number;
379
- /** Archive frame cap for `provider`: image budget, never above {@link MAX_FRAMES_DEFAULT}. */
380
- export declare function providerFrameBudget(provider: string | undefined): number;
386
+ /** Most images one request to `model`'s deployment may carry, archive,
387
+ * system-prompt and tool-result imaging combined. */
388
+ export declare function providerImageBudget(model: Model | undefined): number;
389
+ /** Archive frame cap for `model`: its image budget, never above {@link MAX_FRAMES_DEFAULT}. */
390
+ export declare function providerFrameBudget(model: Model | undefined): number;
391
+ /** Whether `model` may get a fresh `read` result as frames (catalog `snapcompact-image-reads`, opt-in). */
392
+ export declare function readerImagesReadResults(model: Model): boolean;
381
393
  /** Key under `CompactionEntry.preserveData` holding the frame archive. */
382
394
  export declare const PRESERVE_KEY = "snapcompact";
383
395
  /** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
@@ -620,6 +632,12 @@ export declare function images(archive: Archive): ImageContent[];
620
632
  * Runtime-only; reconstructed from {@link Archive} on each context rebuild
621
633
  * instead of persisted on the session entry. */
622
634
  export declare function historyBlocks(archive: Archive, options?: HistoryBlockOptions): (TextContent | ImageContent)[];
635
+ /**
636
+ * Re-applies a smaller frame byte budget to blocks {@link historyBlocks} already built: keeps the newest frames that
637
+ * fit, priced as {@link historyBlocks} prices them, and replaces each run of dropped frames with one omission notice.
638
+ * Returns `blocks` itself when every frame fits.
639
+ */
640
+ export declare function capFrameDataBytes(blocks: (TextContent | ImageContent)[], maxFrameDataBytes: number): (TextContent | ImageContent)[];
623
641
  /**
624
642
  * Run a snapcompact compaction over prepared messages. Fully local: serializes
625
643
  * the discarded history, appends it to the accumulated archive source text, and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/snapcompact",
4
- "version": "18.10.2",
4
+ "version": "18.10.3",
5
5
  "description": "Bitmap-frame context compression for vision-capable LLMs",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -34,11 +34,11 @@
34
34
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
35
35
  },
36
36
  "dependencies": {
37
- "@oh-my-pi/pi-ai": "18.10.2",
38
- "@oh-my-pi/pi-catalog": "18.10.2",
39
- "@oh-my-pi/pi-natives": "18.10.2",
40
- "@oh-my-pi/pi-utils": "18.10.2",
41
- "@oh-my-pi/pi-wire": "18.10.2"
37
+ "@oh-my-pi/pi-ai": "18.10.3",
38
+ "@oh-my-pi/pi-catalog": "18.10.3",
39
+ "@oh-my-pi/pi-natives": "18.10.3",
40
+ "@oh-my-pi/pi-utils": "18.10.3",
41
+ "@oh-my-pi/pi-wire": "18.10.3"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/bun": "^1.3.14"
@@ -32,11 +32,9 @@
32
32
  * billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
33
33
  * area-proportional, so resolution cannot improve chars/$ — 1568 stays.
34
34
  * `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
35
- * - **Unknown providers** default to `8on22-bw`. `providerImageBudget` still
36
- * caps per-request images per provider so inline imaging cannot flood a
37
- * request with attachments, but the old OpenRouter-specific 8-image cap is
38
- * gone; routers now use the same permissive budget as direct
39
- * Anthropic/Claude lines unless configured otherwise upstream.
35
+ * - **Unknown providers** default to `8on22-bw`. `providerImageBudget` caps
36
+ * per-request images at the deployment's catalog `image-count-budget`, so
37
+ * inline imaging cannot flood a request with attachments.
40
38
  *
41
39
  * The per-frame token estimate follows the reading model's catalog
42
40
  * `image-tokenization` rule (its lineage, not the gateway), so Claude behind
@@ -44,6 +42,10 @@
44
42
  * Models without a lineage rule fall back to the catalog rule for their wire
45
43
  * API; a reader with neither costs {@link FRAME_TOKEN_ESTIMATE} per frame.
46
44
  *
45
+ * A host that downscales every image before the model reads it (catalog
46
+ * `image-max-edge`, e.g. Cursor) gets frames rendered at that edge instead,
47
+ * so glyphs reach the model at the size they were drawn.
48
+ *
47
49
  * The whole pass is local and deterministic — no LLM call, no API key, no
48
50
  * latency beyond rendering. Rasterization and PNG encoding happen in native
49
51
  * code (`renderSnapcompactPng` in `crates/pi-natives/src/snapcompact.rs`).
@@ -51,14 +53,18 @@
51
53
  * re-attached to the compaction summary message on every context rebuild.
52
54
  */
53
55
 
54
- import type { Api, ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
56
+ import type { Api, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
57
+ import { reachesOfficialEndpoint } from "@oh-my-pi/pi-ai/providers/anthropic-state";
58
+ import { resolveCatalogPolicy } from "@oh-my-pi/pi-catalog/compat/catalog-policy";
55
59
  import {
56
60
  type ImageSize,
57
61
  type ImageTokenization,
58
62
  type ImageTokenizationTarget,
59
63
  imageTokens,
64
+ resolveImageMaxEdge,
60
65
  resolveImageTokenization,
61
66
  } from "@oh-my-pi/pi-catalog/compat/image-tokenization";
67
+ import { resolveImageCountBudget, resolveInlineImageByteBudget } from "@oh-my-pi/pi-catalog/compat/request-size";
62
68
  import { classifyModel, compareRevision, parseRevision } from "@oh-my-pi/pi-catalog/identity";
63
69
  import { renderSnapcompactPng, snapcompactSupportedChars } from "@oh-my-pi/pi-natives";
64
70
  import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils";
@@ -96,12 +102,14 @@ export interface Shape {
96
102
  frameSize: number;
97
103
  /** Per-frame billed-token estimate for the shape's target provider. */
98
104
  frameTokenEstimate: number;
105
+ /** `frameTokenEstimate` comes from a catalog rule measured against provider billing. */
106
+ frameTokensVerified?: boolean;
99
107
  /** Resolution hint attached to frame images (OpenAI-only). */
100
108
  imageDetail?: ImageContent["detail"];
101
109
  }
102
110
 
103
111
  /** Geometry half of a {@link Shape}: everything except provider billing. */
104
- export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "imageDetail">;
112
+ export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "frameTokensVerified" | "imageDetail">;
105
113
 
106
114
  /**
107
115
  * Frame variants exercised by the SQuAD evals in `research/` that the native
@@ -273,7 +281,22 @@ export function frameTokens(billing: FrameBilling, size: ImageSize): number {
273
281
  function priceShape(base: ShapeGeometry, target: ShapeTarget | undefined): Shape {
274
282
  const billing = frameBilling(target);
275
283
  const frameTokenEstimate = frameTokens(billing, { width: base.frameSize, height: base.frameSize });
276
- return { ...base, frameTokenEstimate, ...(billing.imageDetail && { imageDetail: billing.imageDetail }) };
284
+ return {
285
+ ...base,
286
+ frameTokenEstimate,
287
+ ...(billing.rule?.verified && { frameTokensVerified: true }),
288
+ ...(billing.imageDetail && { imageDetail: billing.imageDetail }),
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Per-frame tokens archive sizing charges `shape`: its own price when provider
294
+ * billing verified it, else at least {@link FRAME_TOKEN_ESTIMATE}.
295
+ */
296
+ export function plannedFrameTokens(shape: Shape): number {
297
+ return shape.frameTokensVerified
298
+ ? shape.frameTokenEstimate
299
+ : Math.max(FRAME_TOKEN_ESTIMATE, shape.frameTokenEstimate);
277
300
  }
278
301
 
279
302
  /** Eval-validated shapes, keyed by the provider family they won on. */
@@ -320,6 +343,7 @@ export function isShape(value: unknown): value is Shape {
320
343
  shape.frameSize > 0 &&
321
344
  typeof shape.frameTokenEstimate === "number" &&
322
345
  shape.frameTokenEstimate > 0 &&
346
+ (shape.frameTokensVerified === undefined || typeof shape.frameTokensVerified === "boolean") &&
323
347
  (detail === undefined || detail === "auto" || detail === "low" || detail === "high" || detail === "original")
324
348
  );
325
349
  }
@@ -414,16 +438,23 @@ export interface ShapeTarget {
414
438
  * Pick the frame shape for a reader. An explicit `variant` (anything but
415
439
  * `"auto"`) forces that geometry; otherwise the model id selects the
416
440
  * eval-winning shape — and frame size — for its model line, falling back to
417
- * the API family's winner when the model is unmeasured. Billing (token
441
+ * the API family's winner when the model is unmeasured. Either way the frame
442
+ * is no wider than the host's catalog `image-max-edge`. Billing (token
418
443
  * estimate, detail hint) is computed for the resolved frame size from the
419
444
  * model's catalog image rule. Accepts a full pi-ai `Model` or any
420
445
  * `{ api, id }` subset.
421
446
  */
422
447
  export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape {
423
- if (variant && variant !== "auto") return priceShape(SHAPE_VARIANTS[variant], model);
448
+ if (variant && variant !== "auto") return priceShape(fitHostImageEdge(SHAPE_VARIANTS[variant], model), model);
424
449
  const ideal = model?.id ? idealShapeVariant(model.id) : undefined;
425
450
  const base = SHAPE_VARIANTS[ideal?.variant ?? FAMILY_VARIANT[apiFamily(model?.api)]];
426
- return priceShape(ideal?.frameSize ? { ...base, frameSize: ideal.frameSize } : base, model);
451
+ return priceShape(fitHostImageEdge(ideal?.frameSize ? { ...base, frameSize: ideal.frameSize } : base, model), model);
452
+ }
453
+
454
+ /** Shrink `base` to the longest image edge `target`'s host passes to the model. */
455
+ function fitHostImageEdge(base: ShapeGeometry, target: ShapeTarget | undefined): ShapeGeometry {
456
+ const maxEdge = target ? resolveImageMaxEdge(target) : undefined;
457
+ return maxEdge !== undefined && base.frameSize > maxEdge ? { ...base, frameSize: maxEdge } : base;
427
458
  }
428
459
 
429
460
  const CJK_HEAVY_MIN_WIDE_CHARS = 8;
@@ -483,11 +514,11 @@ export const MAX_FRAMES_DEFAULT = 80;
483
514
  * text region (newest) — with the denser low-quality tier filling the middle. */
484
515
  export const HQ_EDGE_FRAMES = 3;
485
516
 
486
- /** Conservative per-frame token estimate for archive sizing and for frames of
487
- * unknown size — the upper bound across shapes: high-res Claude frames hit the
488
- * 4,784 visual-token cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the
489
- * overflow guard from undercounting a high-res archive at the raised
490
- * {@link MAX_FRAMES_DEFAULT}. */
517
+ /** Conservative per-frame token estimate for sizing archives whose frame price
518
+ * is not billing-verified, and for frames of unknown size — the upper bound
519
+ * across shapes: high-res Claude frames hit the 4,784 visual-token cap, billed
520
+ * at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
521
+ * undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
491
522
  export const FRAME_TOKEN_ESTIMATE = 5024;
492
523
 
493
524
  /** Conservative upper bound for one persisted frame's base64 payload. The
@@ -496,22 +527,32 @@ export const FRAME_TOKEN_ESTIMATE = 5024;
496
527
  * standing request bodies at large context windows. */
497
528
  export const FRAME_DATA_BYTES_ESTIMATE = 170_000;
498
529
 
499
- /** Maximum snapcompact image base64 carried in every rebuilt provider request.
500
- * Above this, provider backends can accept the HTTP body but fail mid-stream
530
+ /** Frame payload cap for a deployment without a catalog `inline-image-byte-budget`:
531
+ * an unmeasured backend can accept the HTTP body and still fail mid-stream
501
532
  * with opaque 5xx errors. Keep this independent from visual-token budgeting:
502
533
  * a 1M-token model can afford 70 images on paper, but not the resulting
503
534
  * ~11 MB JSON payload on every turn. */
504
- export const FRAME_DATA_BYTES_BUDGET = 3_000_000;
535
+ export const DEFAULT_FRAME_DATA_BYTES_BUDGET = 3_000_000;
536
+
537
+ /** Base64 image bytes the archive frames riding every request to `model` may
538
+ * carry: half the deployment's inline-image byte budget, else
539
+ * {@link DEFAULT_FRAME_DATA_BYTES_BUDGET}. Frames are the oldest images in the
540
+ * request, so the send-time byte clamp evicts them first; the other half stays
541
+ * free for live screenshots. */
542
+ export function frameDataBytesBudget(model: Model | undefined): number {
543
+ const inlineBudget = model ? resolveInlineImageByteBudget(model, reachesOfficialEndpoint(model)) : undefined;
544
+ return inlineBudget === undefined ? DEFAULT_FRAME_DATA_BYTES_BUDGET : Math.floor(inlineBudget / 2);
545
+ }
505
546
 
506
547
  /** Default edge variants whose frames shrink with pixel area. Inkier variants
507
548
  * keep the full {@link FRAME_DATA_BYTES_ESTIMATE} at any frame size. */
508
549
  const AREA_PRICED_VARIANTS: readonly ShapeGeometry[] = [SHAPE_VARIANTS["8on22-bw"], SHAPE_VARIANTS["11on16-bw"]];
509
550
 
510
- /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET} for frames
511
- * rendered by `shape`. {@link AREA_PRICED_VARIANTS} below 1932px are charged
551
+ /** Frame-count cap a `budget` of base64 bytes implies for frames rendered by
552
+ * `shape`. {@link AREA_PRICED_VARIANTS} below 1932px are charged
512
553
  * {@link FRAME_DATA_BYTES_ESTIMATE} scaled by pixel area; every other shape
513
554
  * pays the full estimate. */
514
- export function maxFramesForDataBudget(shape: ShapeGeometry): number {
555
+ export function maxFramesForDataBudget(shape: ShapeGeometry, budget: number): number {
515
556
  const areaPriced = AREA_PRICED_VARIANTS.some(
516
557
  variant =>
517
558
  variant.font === shape.font &&
@@ -525,7 +566,7 @@ export function maxFramesForDataBudget(shape: ShapeGeometry): number {
525
566
  );
526
567
  const areaRatio = areaPriced ? (shape.frameSize / HIGH_RES_ANTHROPIC_VARIANT.frameSize) ** 2 : 1;
527
568
  const frameBytes = Math.min(FRAME_DATA_BYTES_ESTIMATE, Math.ceil(FRAME_DATA_BYTES_ESTIMATE * areaRatio));
528
- return Math.max(1, Math.floor(FRAME_DATA_BYTES_BUDGET / frameBytes));
569
+ return Math.max(1, Math.floor(budget / frameBytes));
529
570
  }
530
571
 
531
572
  /** Base64 byte length for persisted snapcompact frames. */
@@ -533,36 +574,27 @@ export function frameDataBytes(frames: readonly Pick<Frame, "data">[]): number {
533
574
  return frames.reduce((sum, frame) => sum + frame.data.length, 0);
534
575
  }
535
576
 
536
- /**
537
- * Per-request image-count budgets by provider id. These cap how many images an
538
- * entire request may carry (archive/system-prompt/tool-result imaging combined).
539
- * The values are conservative policy caps under the vendor hard limits
540
- * (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe
541
- * floor rather than sending unbounded attachments.
542
- */
543
- export const PROVIDER_IMAGE_BUDGETS: Record<string, number> = {
544
- anthropic: 90,
545
- "amazon-bedrock": 90,
546
- openai: 200,
547
- "openai-codex": 200,
548
- google: 200,
549
- "google-vertex": 200,
550
- "google-gemini-cli": 200,
551
- openrouter: 90,
552
- umans: 10,
553
- };
554
-
555
- /** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
577
+ /** Safe floor for a deployment whose catalog rules set no `image-count-budget`
578
+ * (strictest mainstream measured: Groq ~5). */
556
579
  export const DEFAULT_PROVIDER_IMAGE_BUDGET = 5;
557
580
 
558
- /** Per-request image budget for `provider`; unknown providers get the floor. */
559
- export function providerImageBudget(provider: string | undefined): number {
560
- return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET;
581
+ /** Most images one request to `model`'s deployment may carry, archive,
582
+ * system-prompt and tool-result imaging combined. */
583
+ export function providerImageBudget(model: Model | undefined): number {
584
+ return (
585
+ (model ? resolveImageCountBudget(model, reachesOfficialEndpoint(model)) : undefined) ??
586
+ DEFAULT_PROVIDER_IMAGE_BUDGET
587
+ );
588
+ }
589
+
590
+ /** Archive frame cap for `model`: its image budget, never above {@link MAX_FRAMES_DEFAULT}. */
591
+ export function providerFrameBudget(model: Model | undefined): number {
592
+ return Math.min(providerImageBudget(model), MAX_FRAMES_DEFAULT);
561
593
  }
562
594
 
563
- /** Archive frame cap for `provider`: image budget, never above {@link MAX_FRAMES_DEFAULT}. */
564
- export function providerFrameBudget(provider: string | undefined): number {
565
- return Math.min(providerImageBudget(provider), MAX_FRAMES_DEFAULT);
595
+ /** Whether `model` may get a fresh `read` result as frames (catalog `snapcompact-image-reads`, opt-in). */
596
+ export function readerImagesReadResults(model: Model): boolean {
597
+ return resolveCatalogPolicy(model).snapcompactImageReads === true;
566
598
  }
567
599
 
568
600
  /** Key under `CompactionEntry.preserveData` holding the frame archive. */
@@ -1971,6 +2003,53 @@ export function historyBlocks(archive: Archive, options: HistoryBlockOptions = {
1971
2003
  return blocks;
1972
2004
  }
1973
2005
 
2006
+ /**
2007
+ * Re-applies a smaller frame byte budget to blocks {@link historyBlocks} already built: keeps the newest frames that
2008
+ * fit, priced as {@link historyBlocks} prices them, and replaces each run of dropped frames with one omission notice.
2009
+ * Returns `blocks` itself when every frame fits.
2010
+ */
2011
+ export function capFrameDataBytes(
2012
+ blocks: (TextContent | ImageContent)[],
2013
+ maxFrameDataBytes: number,
2014
+ ): (TextContent | ImageContent)[] {
2015
+ const kept = new Set<number>();
2016
+ let usedBytes = 0;
2017
+ let dropped = false;
2018
+ for (let index = blocks.length - 1; index >= 0; index--) {
2019
+ const block = blocks[index];
2020
+ if (block.type !== "image") continue;
2021
+ if (usedBytes + block.data.length > maxFrameDataBytes) {
2022
+ dropped = true;
2023
+ continue;
2024
+ }
2025
+ usedBytes += block.data.length;
2026
+ kept.add(index);
2027
+ }
2028
+ if (!dropped) return blocks;
2029
+
2030
+ const capped: (TextContent | ImageContent)[] = [];
2031
+ let omittedFrames = 0;
2032
+ let omittedBytes = 0;
2033
+ const flushOmitted = (): void => {
2034
+ if (omittedFrames === 0) return;
2035
+ capped.push({ type: "text", text: omittedFrameNotice(omittedFrames, omittedBytes) });
2036
+ omittedFrames = 0;
2037
+ omittedBytes = 0;
2038
+ };
2039
+ for (let index = 0; index < blocks.length; index++) {
2040
+ const block = blocks[index];
2041
+ if (block.type === "image" && !kept.has(index)) {
2042
+ omittedFrames++;
2043
+ omittedBytes += block.data.length;
2044
+ continue;
2045
+ }
2046
+ flushOmitted();
2047
+ capped.push(block);
2048
+ }
2049
+ flushOmitted();
2050
+ return capped;
2051
+ }
2052
+
1974
2053
  // ============================================================================
1975
2054
  // Compaction entry point
1976
2055
  // ============================================================================