@oh-my-pi/snapcompact 15.11.4 → 15.11.7

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,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.11.7] - 2026-06-12
6
+
7
+ ### Added
8
+
9
+ - Added `SHAPE_VARIANTS`, the catalog of research-eval frame variants the native renderer reproduces faithfully (`8x8r`/`8x8u`/`6x6u`/`5x8` × `sent`/`bw`), with `ShapeVariantName`, `SHAPE_VARIANT_NAMES`, and the `isShapeVariantName` guard
10
+ - `resolveShape(api, variant?)` now accepts an explicit variant name (or `"auto"`); forced variants keep their geometry but are re-priced for the target provider's image billing (token estimate and OpenAI `original` detail hint)
11
+ - Added the six research-eval winning frame variants to `SHAPE_VARIANTS`: `6x12-dim` (Claude fable), `8x13-bw` (Opus), `8on16-bw` (GPT grid runner-up), `doc-8on16-bw` (GPT), `doc-8on16-sent` (GLM), and `doc-8on16-sent-dim` (Gemini/Kimi), backed by new `Shape` fields `stretch` (disable Lanczos stretch: natural glyphs on a larger cell pitch), `columns` (two word-wrapped newspaper columns), `stopwordDim`, and the X.org `6x12`/`8x13` fonts
12
+ - Added `dimStopwords()`, which prints high-frequency function words in dim ink via zero-width markers (skipping spans that are already dim), and `wrap()`, the greedy word-wrap used to typeset doc-layout pages; `geometry`/`render`/`renderMany`/`frames`/`compact` understand doc shapes (wrap once, paginate into `2 * rows`-line pages), and compaction frames persist `columns`/`stopwordDim` for mixed-shape detection
13
+ - `resolveShape` now takes a `ShapeTarget` (`{ api, id }` — a pi-ai `Model` works as-is) and detects the ideal shape from the **model id**, not just the wire API: a Claude routed through Vertex or an OpenAI-compatible gateway keeps its Claude shape, with billing still priced by the API family actually carrying the request. `idealShapeVariant(modelId)` exposes the model-line table; unmeasured models fall back to the API family's winner
14
+ - `resolveShape` now also resolves an ideal **frame size** per model line, and billing estimates come from verified per-family formulas instead of flat 1568px constants: Anthropic bills 28px patches capped at 4,784 visual tokens (+5% margin), Gemini 3.x bills a fixed 1,120-token `media_resolution` budget per image at any pixel size, and OpenAI bills 32px patches × 1.2 under the 10,000-patch `detail: "original"` budget. High-res Claude lines (Opus 4.7+, Fable, Mythos — native 2576px-edge ingestion) get 1932px frames (same recall and cost, a third fewer frames); Gemini gets 2048px frames (+70% chars per frame at the same bill); GPT and Kimi stay at 1568px (area-proportional billing and a model-side 1792px processor cap, respectively). `idealShapeVariant` now returns an `IdealShape` (`{ variant, frameSize? }`)
15
+ - Added per-provider image-count budgets: `PROVIDER_IMAGE_BUDGETS`, `DEFAULT_PROVIDER_IMAGE_BUDGET`, `providerImageBudget()`, and `providerFrameBudget()` (the image budget clamped to `MAX_FRAMES`). OpenRouter is capped at its measured hard limit of 8 images per request (excess images are silently dropped with no error); unknown providers get a safe floor of 5
16
+ - Added `Archive.textTail`: archive content past the frame budget is no longer dropped — `compact()` stops rendering at the budget and keeps the newest unframed slice as verbatim text on the summary (capped at two frame capacities with middle elision, counted into `truncatedChars` when elided). The tail persists in `preserveData` and is folded back into frames by the next compaction
17
+
18
+ ### Changed
19
+
20
+ - Frames are no longer padded to a square: the native renderer clips each PNG's height to the text rows actually printed, so a partially filled frame (typically the newest) bills only the pixel rows it uses
21
+ - **Changed the OpenAI default shape from `6x6u-sent` to `8on16-bw`.** A production-regime mono eval (gpt-5.5, the full 800k-char SQuAD flow in one request, n=50) scored the old dense default f1 .602 vs .851 for `8on16-bw` rendered by the production pipeline, at near-equal total cost (the dense cells burned the frame savings on reasoning tokens); chunked exp14 had already scored `8on16-bw` .906. `SHAPES.openaiDense` is renamed to `SHAPES.openai`
22
+ - **Changed the Google default shape from `8x8r-sent` to `doc-8on16-sent-dim`.** Production-rendered mono eval on gemini-3.5-flash (400k chars, one request, n=25): f1 .900 vs .853 for the repeated grid at lower cost, agreeing with the chunked round-2 winner
23
+ - **Changed the Anthropic default shape from `8x8r-bw` to `6x12-dim`.** Production mono eval on claude-fable (400k chars, one request, n=25): f1 .840 vs .877 for the repeated grid — within noise — at 37% lower cost (12 frames instead of 21 per 400k chars), with clean completions in every probe; opus reads the same trade (.800 vs .833 at 42% lower cost)
24
+ - `normalize()` now keeps line structure: whitespace runs containing a line break collapse to `NEWLINE_GLYPH` (U+2588 FULL BLOCK, drawn by the native renderer as a pitch-black cell one character wide) instead of a plain space; leading/trailing breaks are trimmed, and the frame-reading prompt explains the marker
25
+ - `normalize()` now skips characters the fonts cannot render instead of printing `?` blanks: whole ANSI escape sequences are stripped, and bare control characters, zero-width format characters (ZWSP, BOM, directional marks), combining marks, and lone surrogates are dropped without occupying a cell; `?` remains the fallback for unsupported graphic characters only
26
+
5
27
  ## [15.11.4] - 2026-06-12
6
28
 
7
29
  ### Breaking Changes
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # @oh-my-pi/snapcompact
2
+
3
+ Bitmap-frame context compression for vision-capable LLMs.
4
+
5
+ Instead of asking an LLM to summarize discarded conversation history, snapcompact serializes it and renders the text into dense PNG frames of pixel-font glyphs that vision models read back directly. The whole pass is local and deterministic — no LLM call, no API key, no latency beyond rendering. Rasterization and PNG encoding happen in native code (`@oh-my-pi/pi-natives`).
6
+
7
+ Built for [oh-my-pi](https://github.com/can1357/oh-my-pi)'s compaction pipeline, but the rendering API works on arbitrary text.
8
+
9
+ ## How it works
10
+
11
+ 1. Discarded history is serialized to compact text (`serializeConversation`), with per-tool-result and per-argument character caps.
12
+ 2. Text is normalized for the bundled bitmap fonts (`normalize`): ANSI sequences stripped, whitespace collapsed, newline runs folded into a single full-block glyph so line structure survives.
13
+ 3. Pages of text are rasterized into PNG frames (`render` / `renderMany`). Frame width is fixed per shape; height hugs the rows actually printed, so a partially filled frame never bills blank pixel rows.
14
+ 4. Frames persist in the compaction entry's `preserveData` and are re-attached to the summary message on every context rebuild.
15
+
16
+ Frame shapes are provider-aware, chosen by SQuAD recall evals (see `research/`) against real provider billing:
17
+
18
+ | Reader | Default shape | Notes |
19
+ | --- | --- | --- |
20
+ | Anthropic | `6x12-dim` | X.org 6x12 glyphs, stopwords dimmed gray; high-res Claude lines get 1932px frames |
21
+ | Google | `doc-8on16-sent-dim` @2048 | Two newspaper columns, sentence-hue ink; Gemini bills a fixed per-image budget, so larger frames are free chars |
22
+ | OpenAI | `8on16-bw` | 8x13 glyphs on a patch-aligned 16px pitch, sent at `detail: "original"` |
23
+ | Unknown | Anthropic shape | Per-provider image-count budgets guard against gateways that silently drop frames |
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, priced for the gateway actually carrying the request.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ bun add @oh-my-pi/snapcompact
31
+ ```
32
+
33
+ Ships TypeScript source directly (no build step); requires Bun ≥ 1.3.14.
34
+
35
+ ## Usage
36
+
37
+ Render arbitrary text into LLM image blocks:
38
+
39
+ ```ts
40
+ import { renderMany, frames, resolveShape } from "@oh-my-pi/snapcompact";
41
+
42
+ const images = renderMany(longText, { model }); // ImageContent[], first page first
43
+ const count = frames(longText, { model }); // frame count without rendering
44
+ const shape = resolveShape(model); // eval-optimal Shape for the reader
45
+ ```
46
+
47
+ Run a full compaction pass over prepared messages:
48
+
49
+ ```ts
50
+ import { compact } from "@oh-my-pi/snapcompact";
51
+
52
+ const result = await compact(preparation, { model, maxFrames: 8 });
53
+ // result.summary — text summary with <files> operations block
54
+ // result.preserveData — frame archive, re-attachable via getPreservedArchive() + images()
55
+ ```
56
+
57
+ ## API surface
58
+
59
+ - **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images`
60
+ - **Rendering**: `render`, `renderMany`, `frames`, `geometry`
61
+ - **Shapes**: `SHAPES`, `SHAPE_VARIANTS`, `resolveShape`, `idealShapeVariant`, `isShape`, `isShapeVariantName`
62
+ - **Text**: `serializeConversation`, `normalize`, `dimStopwords`, `wrap`
63
+ - **Budgets**: `providerImageBudget`, `providerFrameBudget`, `MAX_FRAMES`, `FRAME_TOKEN_ESTIMATE`
64
+ - **File ops**: `createFileOps`, `computeFileLists`, `upsertFileOperations`
65
+
66
+ ## References
67
+
68
+ - [Monorepo README](https://github.com/can1357/oh-my-pi#readme)
69
+ - [Compaction architecture](../../docs/compaction.md)
70
+ - [CHANGELOG](./CHANGELOG.md)
@@ -2,27 +2,40 @@
2
2
  * Snapcompact compaction: archive conversation history as dense bitmap images.
3
3
  *
4
4
  * Instead of asking an LLM to summarize discarded history, the serialized
5
- * conversation is rendered into square PNG frames of pixel-font text that
6
- * vision models read back directly, like an archivist at a snapcompact frame
7
- * reader.
5
+ * conversation is rendered into PNG frames of pixel-font text that vision
6
+ * models read back directly, like an archivist at a snapcompact frame
7
+ * reader. Frames are `frameSize` wide; their height hugs the text rows
8
+ * actually printed, so a partially filled frame never bills blank rows.
8
9
  *
9
10
  * The frame shape is provider-aware, following the snapcompact SQuAD evals
10
11
  * (`packages/snapcompact`, 200k-token monolithic runs):
11
12
  *
12
- * - **Anthropic** (`8x8r-bw`): unscii-8 square cells, black ink, every line
13
- * printed twice with the copy on a pale highlight band. Read at F1 parity
14
- * with raw text at ~2x lower cost; the colored variants drew refusals at
15
- * scale, the repeated plain shape did not.
16
- * - **Google** (`8x8r-sent`): same repeated grid with six-hue sentence
17
- * coloring (0.90 F1 at ~2.9x lower cost on gemini-3.5-flash).
18
- * - **OpenAI** (`6x6u-sent`): OpenAI bills a flat ~2.9k tokens per image, so
19
- * image count is the only cost lever — unscii-8 Lanczos-stretched to 6x6
20
- * cells packs the most readable chars per frame. Frames request
21
- * `detail: "original"`; the default `auto` downscale destroys 6px glyphs.
22
- * - **Unknown providers** default to the Anthropic shape (most
23
- * refusal-robust). Gateways that resize images (e.g. OpenRouter normalizes
24
- * visual payloads to a fixed token budget) defeat any shape — optical
25
- * context fails silently there.
13
+ * - **Anthropic** (`6x12-dim`): X.org 6x12 glyphs, black ink, stopwords
14
+ * dimmed gray recall within noise of the repeated `8x8r-bw` grid at
15
+ * ~40% lower cost; `8x8r-bw` remains the max-recall choice via the shape
16
+ * setting. Opus 4.7+/Fable/Mythos ingest high-res natively (2576px edge,
17
+ * 4,784 visual-token cap, no flag needed), so those lines get 1932px
18
+ * frames: same recall and cost, a third fewer frames. Older Claude lines
19
+ * downscale past 1568px and keep the standard frame.
20
+ * - **Google** (`doc-8on16-sent-dim` @2048): two word-wrapped newspaper
21
+ * columns of 8x13 glyphs, sentence-hue ink, dimmed stopwords. Gemini 3.x
22
+ * bills a fixed `media_resolution` budget per image (default 1,120
23
+ * tokens) regardless of pixels, so the 2048px frame carries +70% chars at
24
+ * the same bill (f1 .88 vs .90 at 1568). `ULTRA_HIGH` doubles the budget
25
+ * and reads 3072px frames, but loses on chars/$ deliberately unused.
26
+ * - **OpenAI** (`8on16-bw`): 8x13 glyphs on a patch-aligned 16px pitch,
27
+ * black ink (gpt-5.5 mono F1 .867 vs .602 for the previous `6x6u-sent`).
28
+ * Patch billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
29
+ * area-proportional, so resolution cannot improve chars/$ — 1568 stays.
30
+ * `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
31
+ * - **Unknown providers** default to the Anthropic shape. Gateways can
32
+ * defeat any shape silently: OpenRouter enforces a per-model image cap
33
+ * (measured: 8 images for glm-4.6v — frames past the cap are dropped with
34
+ * no error, billed tokens plateau exactly at 8x frame cost). The same
35
+ * frames routed direct to the vendor read fine (glm f1 .20 -> .78), so
36
+ * `providerImageBudget` caps per-request images per provider (OpenRouter
37
+ * 8, unknown 5) and `compact()` keeps any archive overflow as a text tail
38
+ * on the summary instead of rendering frames that would be dropped.
26
39
  *
27
40
  * The whole pass is local and deterministic — no LLM call, no API key, no
28
41
  * latency beyond rendering. Rasterization and PNG encoding happen in native
@@ -34,14 +47,22 @@ import type { Api, ImageContent, Message, Model } from "@oh-my-pi/pi-ai";
34
47
  /** One eval-validated frame shape: font, cell, ink, repetition, and size. */
35
48
  export interface Shape {
36
49
  /** Bundled font in the native renderer. */
37
- font: "5x8" | "8x8";
50
+ font: "5x8" | "8x8" | "6x12" | "8x13";
38
51
  /** Target cell advance in pixels; differing from the font's natural cell
39
52
  * renders via Lanczos stretch (anti-aliased RGB frame). */
40
53
  cellWidth: number;
41
54
  /** Target cell pitch in pixels. */
42
55
  cellHeight: number;
56
+ /** `false` → glyphs drawn at natural size on the cell pitch (8on16);
57
+ * `true`/`undefined` → legacy auto Lanczos stretch when cell ≠ natural. */
58
+ stretch?: boolean;
43
59
  /** Ink: `sent` cycles six hues at sentence boundaries; `bw` is black. */
44
60
  variant: "sent" | "bw";
61
+ /** Print stopwords in dim ink (research `dim`/`sent-dim` variants). */
62
+ stopwordDim?: boolean;
63
+ /** 1/undefined = row-major grid; 2 = two word-wrapped newspaper columns
64
+ * (research `doc`). */
65
+ columns?: number;
45
66
  /** Each text line is printed this many times; copies after the first sit
46
67
  * on a pale highlight band (redundancy coding). */
47
68
  lineRepeat: number;
@@ -52,55 +73,192 @@ export interface Shape {
52
73
  /** Resolution hint attached to frame images (OpenAI-only). */
53
74
  imageDetail?: ImageContent["detail"];
54
75
  }
55
- /** Eval-validated shapes, keyed by the provider family they won on. */
56
- export declare const SHAPES: {
57
- /** `8x8r-bw`: unscii square, black ink, lines doubled on highlight bands. */
58
- readonly anthropic: {
76
+ /** Geometry half of a {@link Shape}: everything except provider billing. */
77
+ export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "imageDetail">;
78
+ /**
79
+ * Frame variants exercised by the SQuAD evals in `research/` that the native
80
+ * renderer reproduces faithfully, keyed by their research names. Font codes:
81
+ * `8x8u` unscii square cell, `8x8r` unscii with every line printed twice
82
+ * (redundancy coding), `6x6u` unscii Lanczos-squeezed to 6x6 (densest
83
+ * readable cell), `5x8` the X.org legacy font on its 2576px frame, `6x12`
84
+ * and `8x13` the X.org misc fonts, `8on16` 8x13 glyphs on an 8x16 cell pitch
85
+ * (no stretch, extra leading), `doc-` prefixed shapes a two-column
86
+ * word-wrapped newspaper layout. Ink: `sent` cycles six hues at sentence
87
+ * boundaries, `bw` is plain black, `-dim` suffix prints stopwords in gray.
88
+ */
89
+ export declare const SHAPE_VARIANTS: {
90
+ readonly "8x8r-bw": {
59
91
  readonly font: "8x8";
60
92
  readonly cellWidth: 8;
61
93
  readonly cellHeight: 8;
62
94
  readonly variant: "bw";
63
95
  readonly lineRepeat: 2;
64
96
  readonly frameSize: 1568;
65
- readonly frameTokenEstimate: 3300;
66
97
  };
67
- /** `8x8r-sent`: the repeated grid with sentence-hue ink. */
68
- readonly google: {
98
+ readonly "8x8r-sent": {
69
99
  readonly font: "8x8";
70
100
  readonly cellWidth: 8;
71
101
  readonly cellHeight: 8;
72
102
  readonly variant: "sent";
73
103
  readonly lineRepeat: 2;
74
104
  readonly frameSize: 1568;
75
- readonly frameTokenEstimate: 1100;
76
105
  };
77
- /** `6x6u-sent`: unscii stretched to 6x6 — densest readable cell, fewest
78
- * frames (OpenAI bills per image, ~2.9k tokens flat). */
79
- readonly openaiDense: {
106
+ readonly "8x8u-bw": {
107
+ readonly font: "8x8";
108
+ readonly cellWidth: 8;
109
+ readonly cellHeight: 8;
110
+ readonly variant: "bw";
111
+ readonly lineRepeat: 1;
112
+ readonly frameSize: 1568;
113
+ };
114
+ readonly "8x8u-sent": {
115
+ readonly font: "8x8";
116
+ readonly cellWidth: 8;
117
+ readonly cellHeight: 8;
118
+ readonly variant: "sent";
119
+ readonly lineRepeat: 1;
120
+ readonly frameSize: 1568;
121
+ };
122
+ readonly "6x6u-bw": {
123
+ readonly font: "8x8";
124
+ readonly cellWidth: 6;
125
+ readonly cellHeight: 6;
126
+ readonly variant: "bw";
127
+ readonly lineRepeat: 1;
128
+ readonly frameSize: 1568;
129
+ };
130
+ readonly "6x6u-sent": {
80
131
  readonly font: "8x8";
81
132
  readonly cellWidth: 6;
82
133
  readonly cellHeight: 6;
83
134
  readonly variant: "sent";
84
135
  readonly lineRepeat: 1;
85
136
  readonly frameSize: 1568;
86
- readonly frameTokenEstimate: 2900;
87
- readonly imageDetail: "original";
88
137
  };
89
- /** Original 5x8 X.org shape (pre-shape-table sessions rendered this). */
90
- readonly legacy: {
138
+ readonly "5x8-bw": {
139
+ readonly font: "5x8";
140
+ readonly cellWidth: 5;
141
+ readonly cellHeight: 8;
142
+ readonly variant: "bw";
143
+ readonly lineRepeat: 1;
144
+ readonly frameSize: 2576;
145
+ };
146
+ readonly "5x8-sent": {
91
147
  readonly font: "5x8";
92
148
  readonly cellWidth: 5;
93
149
  readonly cellHeight: 8;
94
150
  readonly variant: "sent";
95
151
  readonly lineRepeat: 1;
96
152
  readonly frameSize: 2576;
97
- readonly frameTokenEstimate: 3300;
98
153
  };
154
+ readonly "6x12-dim": {
155
+ readonly font: "6x12";
156
+ readonly cellWidth: 6;
157
+ readonly cellHeight: 12;
158
+ readonly variant: "bw";
159
+ readonly stopwordDim: true;
160
+ readonly lineRepeat: 1;
161
+ readonly frameSize: 1568;
162
+ };
163
+ readonly "8x13-bw": {
164
+ readonly font: "8x13";
165
+ readonly cellWidth: 8;
166
+ readonly cellHeight: 13;
167
+ readonly variant: "bw";
168
+ readonly lineRepeat: 1;
169
+ readonly frameSize: 1568;
170
+ };
171
+ readonly "8on16-bw": {
172
+ readonly font: "8x13";
173
+ readonly cellWidth: 8;
174
+ readonly cellHeight: 16;
175
+ readonly stretch: false;
176
+ readonly variant: "bw";
177
+ readonly lineRepeat: 1;
178
+ readonly frameSize: 1568;
179
+ };
180
+ readonly "doc-8on16-bw": {
181
+ readonly font: "8x13";
182
+ readonly cellWidth: 8;
183
+ readonly cellHeight: 16;
184
+ readonly stretch: false;
185
+ readonly variant: "bw";
186
+ readonly columns: 2;
187
+ readonly lineRepeat: 1;
188
+ readonly frameSize: 1568;
189
+ };
190
+ readonly "doc-8on16-sent": {
191
+ readonly font: "8x13";
192
+ readonly cellWidth: 8;
193
+ readonly cellHeight: 16;
194
+ readonly stretch: false;
195
+ readonly variant: "sent";
196
+ readonly columns: 2;
197
+ readonly lineRepeat: 1;
198
+ readonly frameSize: 1568;
199
+ };
200
+ readonly "doc-8on16-sent-dim": {
201
+ readonly font: "8x13";
202
+ readonly cellWidth: 8;
203
+ readonly cellHeight: 16;
204
+ readonly stretch: false;
205
+ readonly variant: "sent";
206
+ readonly stopwordDim: true;
207
+ readonly columns: 2;
208
+ readonly lineRepeat: 1;
209
+ readonly frameSize: 1568;
210
+ };
211
+ };
212
+ /** Research name of one renderable frame variant. */
213
+ export type ShapeVariantName = keyof typeof SHAPE_VARIANTS;
214
+ /** All variant names, in declaration order (for settings enums). */
215
+ export declare const SHAPE_VARIANT_NAMES: readonly ShapeVariantName[];
216
+ /** Runtime guard for variant names loaded from config. */
217
+ export declare function isShapeVariantName(value: unknown): value is ShapeVariantName;
218
+ /** Eval-validated shapes, keyed by the provider family they won on. */
219
+ export declare const SHAPES: {
220
+ /** `6x12-dim`: X.org 6x12 glyphs, black ink with stopwords dimmed gray.
221
+ * Production mono eval on claude-fable: f1 .840 vs .877 for the repeated
222
+ * `8x8r-bw` grid (within noise at n=25) at 37% lower cost — 12 frames
223
+ * instead of 21 per 400k chars. Never refused in any run. */
224
+ anthropic: Shape;
225
+ /** `doc-8on16-sent-dim`: two word-wrapped columns, sentence hues, dimmed
226
+ * stopwords. Production mono eval on gemini-3.5-flash: f1 .900 vs .853
227
+ * for the repeated grid, at lower cost; also the chunked round-2 winner. */
228
+ google: Shape;
229
+ /** `8on16-bw`: 8x13 X.org glyphs on a 16px pitch, black ink. Mono eval on
230
+ * gpt-5.5 (200k-token single request, n=50): f1 .851 vs .602 for the
231
+ * previous `6x6u-sent` default at near-equal total cost; chunked exp14
232
+ * scored it .906. */
233
+ openai: Shape;
234
+ /** Original 5x8 X.org shape (pre-shape-table sessions rendered this). */
235
+ legacy: Shape;
99
236
  };
100
237
  /** Runtime guard for shape overrides loaded from config or preserve data. */
101
238
  export declare function isShape(value: unknown): value is Shape;
102
- /** Pick the eval-optimal frame shape for a provider API. */
103
- export declare function resolveShape(api?: Api): Shape;
239
+ /** One model line's ideal format: variant plus an optional frame-size
240
+ * override when the line reads larger frames at no extra cost. */
241
+ export interface IdealShape {
242
+ variant: ShapeVariantName;
243
+ frameSize?: number;
244
+ }
245
+ /** Eval-ideal format for a model id, or undefined when unmeasured. */
246
+ export declare function idealShapeVariant(modelId: string): IdealShape | undefined;
247
+ /** What will read the frames: the wire API (billing) and model id (shape). */
248
+ export interface ShapeTarget {
249
+ api?: Api;
250
+ id?: string;
251
+ }
252
+ /**
253
+ * Pick the frame shape for a reader. An explicit `variant` (anything but
254
+ * `"auto"`) forces that geometry; otherwise the model id selects the
255
+ * eval-winning shape — and frame size — for its model line, falling back to
256
+ * the API family's winner when the model is unmeasured. Billing (token
257
+ * estimate, detail hint) always follows the API family actually carrying
258
+ * the request, computed for the resolved frame size. Accepts a full pi-ai
259
+ * `Model` or any `{ api, id }` subset.
260
+ */
261
+ export declare function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape;
104
262
  /** Legacy frame edge in pixels (the 5x8 shape's eval-validated size). New
105
263
  * shapes carry their own `frameSize`. */
106
264
  export declare const FRAME_SIZE = 2576;
@@ -111,6 +269,21 @@ export declare const MAX_FRAMES = 8;
111
269
  /** Conservative per-frame token estimate used for context budgeting
112
270
  * (upper bound across shapes: Anthropic bills 1568*1568/750 ≈ 3,278). */
113
271
  export declare const FRAME_TOKEN_ESTIMATE = 3300;
272
+ /**
273
+ * Per-request image-count budgets by provider id. Routers and smaller
274
+ * providers enforce hard caps and silently DROP images past them (measured:
275
+ * OpenRouter caps at 8 — images 9+ vanish with no error and billed tokens
276
+ * plateau at 8x frame cost). First-party APIs allow far more; their values
277
+ * are conservative policy caps well under the measured hard limits
278
+ * (Anthropic 100, OpenAI 500, Gemini ~2500).
279
+ */
280
+ export declare const PROVIDER_IMAGE_BUDGETS: Record<string, number>;
281
+ /** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
282
+ export declare const DEFAULT_PROVIDER_IMAGE_BUDGET = 5;
283
+ /** Per-request image budget for `provider`; unknown providers get the floor. */
284
+ export declare function providerImageBudget(provider: string | undefined): number;
285
+ /** Archive frame budget for `provider`: its image budget clamped to {@link MAX_FRAMES}. */
286
+ export declare function providerFrameBudget(provider: string | undefined): number;
114
287
  /** Key under `CompactionEntry.preserveData` holding the frame archive. */
115
288
  export declare const PRESERVE_KEY = "snapcompact";
116
289
  /** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
@@ -118,7 +291,7 @@ export interface Frame {
118
291
  /** Base64-encoded PNG. */
119
292
  data: string;
120
293
  mimeType: string;
121
- /** Characters per row in the frame grid. */
294
+ /** Characters per row in the frame grid (per-column width on doc frames). */
122
295
  cols: number;
123
296
  /** Text rows in the frame grid (unique lines, not repeated copies). */
124
297
  rows: number;
@@ -128,6 +301,10 @@ export interface Frame {
128
301
  font?: Shape["font"];
129
302
  variant?: Shape["variant"];
130
303
  lineRepeat?: number;
304
+ /** 2 on two-column doc frames; absent on row-major grid frames. */
305
+ columns?: number;
306
+ /** True when stopwords were printed in dim ink. */
307
+ stopwordDim?: boolean;
131
308
  /** Resolution hint forwarded to the provider when re-attaching. */
132
309
  detail?: ImageContent["detail"];
133
310
  }
@@ -139,11 +316,18 @@ export interface Archive {
139
316
  totalChars: number;
140
317
  /** Characters dropped so far to respect the frame budget. */
141
318
  truncatedChars: number;
319
+ /** Most recent slice of archived history that exceeded the frame budget,
320
+ * kept verbatim as normalized text (dim markers and newline glyphs
321
+ * included). Shipped as plain text in the compaction summary and folded
322
+ * back into frames by the next compaction. */
323
+ textTail?: string;
142
324
  }
143
325
  export interface Geometry {
326
+ /** Characters per row (per-column line width when `columns === 2`). */
144
327
  cols: number;
145
328
  rows: number;
146
- /** Characters that fit one frame (cols * rows). */
329
+ /** Characters that fit one frame (nominal upper bound on doc shapes,
330
+ * where real consumption is wrap-dependent). */
147
331
  capacity: number;
148
332
  }
149
333
  export interface Options<TMessage = Message> extends SerializeOptions {
@@ -233,15 +417,39 @@ export interface SerializeOptions {
233
417
  dimToolResults?: boolean;
234
418
  }
235
419
  export declare function serializeConversation(messages: Message[], options?: SerializeOptions): string;
420
+ /** Printed in place of newline runs: the native renderer fills this cell
421
+ * entirely with pitch-black ink, so line structure survives whitespace
422
+ * collapsing at a one-cell cost. */
423
+ export declare const NEWLINE_GLYPH = "\u2588";
236
424
  /**
237
- * Prepare text for printing: collapse whitespace runs (incl. newlines) to
238
- * single spaces the eval's "paragraph breaks collapsed to spaces" format —
239
- * then fold everything outside the fonts' ASCII + Latin-1 coverage to ASCII
240
- * approximations (`?` as the last resort).
425
+ * Prepare text for printing: strip ANSI escape sequences, collapse horizontal
426
+ * whitespace runs to single spaces and newline-bearing runs to one
427
+ * {@link NEWLINE_GLYPH} (drawn as a pitch-black cell), then fold everything
428
+ * outside the fonts' ASCII + Latin-1 coverage to ASCII approximations.
429
+ * Unrenderable control/format/combining characters are dropped without
430
+ * occupying a cell; `?` remains the fallback for unsupported graphic
431
+ * characters. The zero-width ink toggles {@link DIM_ON}/{@link DIM_OFF} pass
432
+ * through untouched.
241
433
  */
242
434
  export declare function normalize(text: string): string;
435
+ /**
436
+ * Wrap each maximal alphabetic run that is a stopword in {@link DIM_ON} /
437
+ * {@link DIM_OFF} so it prints in dim gray ink. Spans that are already dim
438
+ * (e.g. archived tool output) pass through untouched — wrapping there would
439
+ * terminate the enclosing dim span early. Markers are zero-width, so the
440
+ * visible glyph count is unchanged.
441
+ */
442
+ export declare function dimStopwords(text: string): string;
443
+ /**
444
+ * Greedy word-wrap, no mid-word breaks (hard split only for width+ words) —
445
+ * ported verbatim from `research/exp14_bestgpt.py` `wrap()`. Zero-width dim
446
+ * markers count toward word length here; serialized history places them at
447
+ * word boundaries, so the drift is at most one cell per affected line.
448
+ */
449
+ export declare function wrap(text: string, width: number): string[];
243
450
  export declare function geometry(shape: Shape, size?: number): Geometry;
244
- /** Render one snapcompact frame from already-normalized text. */
451
+ /** Render one snapcompact frame from already-normalized text. Doc shapes
452
+ * (`columns === 2`) expect one page of `\n`-joined pre-wrapped lines. */
245
453
  export declare function render(text: string, shape: Shape, size?: number): RenderedFrame;
246
454
  /** Options for {@link renderMany} and {@link frames}. */
247
455
  export interface RenderManyOptions {
@@ -260,7 +468,9 @@ export interface RenderManyOptions {
260
468
  * Empty/whitespace-only input yields no frames.
261
469
  */
262
470
  export declare function renderMany(text: string, options?: RenderManyOptions): ImageContent[];
263
- /** Frames needed to hold `text` at the given shape/size, without rendering. */
471
+ /** Frames needed to hold `text` at the given shape/size, without rendering.
472
+ * For doc shapes this wraps the text once and counts pages of `2 * rows`
473
+ * lines; for grid shapes it divides by the frame capacity. */
264
474
  export declare function frames(text: string, options?: Pick<RenderManyOptions, "shape" | "model" | "frameSize">): number;
265
475
  /** Validate and extract a persisted frame archive from `preserveData`. */
266
476
  export declare function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined;
@@ -271,7 +481,10 @@ export declare function images(archive: Archive): ImageContent[];
271
481
  * the discarded history, prints it onto PNG frames in the provider-optimal
272
482
  * shape, merges previously archived frames (oldest dropped beyond the
273
483
  * budget), and produces a deterministic summary explaining how to read the
274
- * frames.
484
+ * frames. Pages past the frame budget are never rendered (providers with
485
+ * hard image caps silently drop excess frames on the wire) — the newest
486
+ * unrendered slice survives verbatim as a text tail on the summary and is
487
+ * folded back into frames by the next compaction.
275
488
  *
276
489
  * Frames archived under a different shape (provider switches, legacy 5x8
277
490
  * sessions) are kept as-is — each frame carries its own geometry, and the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/snapcompact",
4
- "version": "15.11.4",
4
+ "version": "15.11.7",
5
5
  "description": "Bitmap-frame context compression for vision-capable LLMs",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,9 +31,9 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-ai": "15.11.4",
35
- "@oh-my-pi/pi-natives": "15.11.4",
36
- "@oh-my-pi/pi-utils": "15.11.4"
34
+ "@oh-my-pi/pi-ai": "15.11.7",
35
+ "@oh-my-pi/pi-natives": "15.11.7",
36
+ "@oh-my-pi/pi-utils": "15.11.7"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/bun": "^1.3.14"
@@ -43,6 +43,7 @@
43
43
  },
44
44
  "files": [
45
45
  "src",
46
+ "README.md",
46
47
  "CHANGELOG.md",
47
48
  "dist/types"
48
49
  ],
@@ -1,6 +1,6 @@
1
1
  Prior conversation history has been archived verbatim onto {{frameCount}} snapcompact frame{{#if multipleFrames}}s{{/if}} — the bitmap image{{#if multipleFrames}}s{{/if}} attached below{{#if multipleFrames}}, ordered oldest to newest{{/if}}.
2
2
 
3
- Reading a frame: monospace {{fontCell}} pixel font on a white background, {{cols}} characters per row, {{rows}} text rows per frame; read left to right, top to bottom. Text flows continuously with no word wrap, so words may break across row ends. Whitespace runs (including newlines) were collapsed to single spaces. {{#if sentenceInk}}Ink color cycles through six colors, advancing at sentence boundaries — a color change marks a new sentence.{{else}}Glyphs are plain black ink.{{/if}}{{#if dimmedToolResults}} Tool output is printed in dim gray ink — gray text is archived tool output, not conversation.{{/if}}{{#if lineRepeated}} Every text line is printed twice in a row — first on the white background, then repeated on a pale yellow band. The copies are identical: read each line once and use the duplicate only to double-check hard glyphs.{{/if}} Roles are tagged inline as [User]:, [Assistant]:, [Assistant thinking]:, [Assistant tool calls]:, and [Tool result]:.
3
+ Reading a frame: monospace {{fontCell}} pixel font on a white background, {{#if docColumns}}typeset as two word-wrapped newspaper columns of {{cols}} characters by {{rows}} lines each — read the left column top to bottom, then the right column{{else}}{{cols}} characters per row, {{rows}} text rows per frame; read left to right, top to bottom. Text flows continuously with no word wrap, so words may break across row ends{{/if}}. Horizontal whitespace runs were collapsed to single spaces; line breaks print as a solid black cell (one character wide) — treat each as a newline. {{#if sentenceInk}}Ink color cycles through six colors, advancing at sentence boundaries — a color change marks a new sentence.{{else}}Glyphs are plain black ink.{{/if}}{{#if stopwordDimmed}} Common function words (the, of, and, …) are printed in dim gray; content words carry the full ink.{{/if}}{{#if dimmedToolResults}} Tool output is printed in dim gray ink — gray text is archived tool output, not conversation.{{/if}}{{#if lineRepeated}} Every text line is printed twice in a row — first on the white background, then repeated on a pale yellow band. The copies are identical: read each line once and use the duplicate only to double-check hard glyphs.{{/if}} Roles are tagged inline as [User]:, [Assistant]:, [Assistant thinking]:, [Assistant tool calls]:, and [Tool result]:.
4
4
  {{#if mixedShapes}}
5
5
 
6
6
  Older frames may use a different font, grid, or ink coloring than described above; the reading order is always the same (left to right, top to bottom, oldest frame first).
@@ -15,3 +15,10 @@ The earliest frame begins with "[Summary of earlier history]" — a condensed di
15
15
  {{/if}}
16
16
 
17
17
  Total archived: {{totalChars}} characters. Consult the frames whenever you need exact earlier details (user wording, decisions, file paths, tool output). If a region is hard to read, re-derive the fact from the workspace (re-read files, re-run commands) rather than guessing.
18
+ {{#if textTail}}
19
+
20
+ The frame budget ran out before the newest part of the archive. That remainder continues below as plain text — it is newer than every frame and ends where the live conversation resumes.
21
+
22
+ [Archived history, continued as text]
23
+ {{textTail}}
24
+ {{/if}}