@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 +15 -0
- package/README.md +13 -11
- package/dist/types/snapcompact.d.ts +50 -32
- package/package.json +6 -6
- package/src/snapcompact.ts +128 -49
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
|
|
19
|
-
|
|
|
20
|
-
| Anthropic | `11on16-bw`
|
|
21
|
-
| Google
|
|
22
|
-
| OpenAI
|
|
23
|
-
| Unknown
|
|
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 });
|
|
46
|
-
const shape = resolveShape(model);
|
|
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`
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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.
|
|
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
|
|
344
|
-
*
|
|
345
|
-
* 4,784 visual-token cap, billed
|
|
346
|
-
*
|
|
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
|
-
/**
|
|
355
|
-
*
|
|
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
|
|
360
|
-
/**
|
|
361
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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.
|
|
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.
|
|
38
|
-
"@oh-my-pi/pi-catalog": "18.10.
|
|
39
|
-
"@oh-my-pi/pi-natives": "18.10.
|
|
40
|
-
"@oh-my-pi/pi-utils": "18.10.
|
|
41
|
-
"@oh-my-pi/pi-wire": "18.10.
|
|
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"
|
package/src/snapcompact.ts
CHANGED
|
@@ -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`
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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 {
|
|
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.
|
|
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
|
|
487
|
-
*
|
|
488
|
-
* 4,784 visual-token cap, billed
|
|
489
|
-
*
|
|
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
|
-
/**
|
|
500
|
-
*
|
|
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
|
|
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
|
|
511
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
/**
|
|
564
|
-
export function
|
|
565
|
-
return
|
|
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
|
// ============================================================================
|