@oh-my-pi/snapcompact 15.11.6 → 15.11.8
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 +22 -0
- package/README.md +70 -0
- package/dist/types/snapcompact.d.ts +258 -45
- package/package.json +5 -4
- package/src/prompts/snapcompact-summary.md +8 -1
- package/src/snapcompact.ts +584 -110
package/src/snapcompact.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
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** (`
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
@@ -44,14 +57,22 @@ import snapcompactSummaryPrompt from "./prompts/snapcompact-summary.md" with { t
|
|
|
44
57
|
/** One eval-validated frame shape: font, cell, ink, repetition, and size. */
|
|
45
58
|
export interface Shape {
|
|
46
59
|
/** Bundled font in the native renderer. */
|
|
47
|
-
font: "5x8" | "8x8";
|
|
60
|
+
font: "5x8" | "8x8" | "6x12" | "8x13";
|
|
48
61
|
/** Target cell advance in pixels; differing from the font's natural cell
|
|
49
62
|
* renders via Lanczos stretch (anti-aliased RGB frame). */
|
|
50
63
|
cellWidth: number;
|
|
51
64
|
/** Target cell pitch in pixels. */
|
|
52
65
|
cellHeight: number;
|
|
66
|
+
/** `false` → glyphs drawn at natural size on the cell pitch (8on16);
|
|
67
|
+
* `true`/`undefined` → legacy auto Lanczos stretch when cell ≠ natural. */
|
|
68
|
+
stretch?: boolean;
|
|
53
69
|
/** Ink: `sent` cycles six hues at sentence boundaries; `bw` is black. */
|
|
54
70
|
variant: "sent" | "bw";
|
|
71
|
+
/** Print stopwords in dim ink (research `dim`/`sent-dim` variants). */
|
|
72
|
+
stopwordDim?: boolean;
|
|
73
|
+
/** 1/undefined = row-major grid; 2 = two word-wrapped newspaper columns
|
|
74
|
+
* (research `doc`). */
|
|
75
|
+
columns?: number;
|
|
55
76
|
/** Each text line is printed this many times; copies after the first sit
|
|
56
77
|
* on a pale highlight band (redundancy coding). */
|
|
57
78
|
lineRepeat: number;
|
|
@@ -63,51 +84,162 @@ export interface Shape {
|
|
|
63
84
|
imageDetail?: ImageContent["detail"];
|
|
64
85
|
}
|
|
65
86
|
|
|
66
|
-
/**
|
|
67
|
-
export
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
/** Geometry half of a {@link Shape}: everything except provider billing. */
|
|
88
|
+
export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "imageDetail">;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Frame variants exercised by the SQuAD evals in `research/` that the native
|
|
92
|
+
* renderer reproduces faithfully, keyed by their research names. Font codes:
|
|
93
|
+
* `8x8u` unscii square cell, `8x8r` unscii with every line printed twice
|
|
94
|
+
* (redundancy coding), `6x6u` unscii Lanczos-squeezed to 6x6 (densest
|
|
95
|
+
* readable cell), `5x8` the X.org legacy font on its 2576px frame, `6x12`
|
|
96
|
+
* and `8x13` the X.org misc fonts, `8on16` 8x13 glyphs on an 8x16 cell pitch
|
|
97
|
+
* (no stretch, extra leading), `doc-` prefixed shapes a two-column
|
|
98
|
+
* word-wrapped newspaper layout. Ink: `sent` cycles six hues at sentence
|
|
99
|
+
* boundaries, `bw` is plain black, `-dim` suffix prints stopwords in gray.
|
|
100
|
+
*/
|
|
101
|
+
export const SHAPE_VARIANTS = {
|
|
102
|
+
"8x8r-bw": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "bw", lineRepeat: 2, frameSize: 1568 },
|
|
103
|
+
"8x8r-sent": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "sent", lineRepeat: 2, frameSize: 1568 },
|
|
104
|
+
"8x8u-bw": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "bw", lineRepeat: 1, frameSize: 1568 },
|
|
105
|
+
"8x8u-sent": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "sent", lineRepeat: 1, frameSize: 1568 },
|
|
106
|
+
"6x6u-bw": { font: "8x8", cellWidth: 6, cellHeight: 6, variant: "bw", lineRepeat: 1, frameSize: 1568 },
|
|
107
|
+
"6x6u-sent": { font: "8x8", cellWidth: 6, cellHeight: 6, variant: "sent", lineRepeat: 1, frameSize: 1568 },
|
|
108
|
+
"5x8-bw": { font: "5x8", cellWidth: 5, cellHeight: 8, variant: "bw", lineRepeat: 1, frameSize: 2576 },
|
|
109
|
+
"5x8-sent": { font: "5x8", cellWidth: 5, cellHeight: 8, variant: "sent", lineRepeat: 1, frameSize: 2576 },
|
|
110
|
+
"6x12-dim": {
|
|
111
|
+
font: "6x12",
|
|
112
|
+
cellWidth: 6,
|
|
113
|
+
cellHeight: 12,
|
|
114
|
+
variant: "bw",
|
|
115
|
+
stopwordDim: true,
|
|
116
|
+
lineRepeat: 1,
|
|
117
|
+
frameSize: 1568,
|
|
118
|
+
},
|
|
119
|
+
"8x13-bw": { font: "8x13", cellWidth: 8, cellHeight: 13, variant: "bw", lineRepeat: 1, frameSize: 1568 },
|
|
120
|
+
"8on16-bw": {
|
|
121
|
+
font: "8x13",
|
|
71
122
|
cellWidth: 8,
|
|
72
|
-
cellHeight:
|
|
123
|
+
cellHeight: 16,
|
|
124
|
+
stretch: false,
|
|
73
125
|
variant: "bw",
|
|
74
|
-
lineRepeat:
|
|
126
|
+
lineRepeat: 1,
|
|
75
127
|
frameSize: 1568,
|
|
76
|
-
frameTokenEstimate: 3300,
|
|
77
128
|
},
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
font: "8x8",
|
|
129
|
+
"doc-8on16-bw": {
|
|
130
|
+
font: "8x13",
|
|
81
131
|
cellWidth: 8,
|
|
82
|
-
cellHeight:
|
|
83
|
-
|
|
84
|
-
|
|
132
|
+
cellHeight: 16,
|
|
133
|
+
stretch: false,
|
|
134
|
+
variant: "bw",
|
|
135
|
+
columns: 2,
|
|
136
|
+
lineRepeat: 1,
|
|
85
137
|
frameSize: 1568,
|
|
86
|
-
frameTokenEstimate: 1100,
|
|
87
138
|
},
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
cellHeight: 6,
|
|
139
|
+
"doc-8on16-sent": {
|
|
140
|
+
font: "8x13",
|
|
141
|
+
cellWidth: 8,
|
|
142
|
+
cellHeight: 16,
|
|
143
|
+
stretch: false,
|
|
94
144
|
variant: "sent",
|
|
145
|
+
columns: 2,
|
|
95
146
|
lineRepeat: 1,
|
|
96
147
|
frameSize: 1568,
|
|
97
|
-
frameTokenEstimate: 2900,
|
|
98
|
-
imageDetail: "original",
|
|
99
148
|
},
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
149
|
+
"doc-8on16-sent-dim": {
|
|
150
|
+
font: "8x13",
|
|
151
|
+
cellWidth: 8,
|
|
152
|
+
cellHeight: 16,
|
|
153
|
+
stretch: false,
|
|
105
154
|
variant: "sent",
|
|
155
|
+
stopwordDim: true,
|
|
156
|
+
columns: 2,
|
|
106
157
|
lineRepeat: 1,
|
|
107
|
-
frameSize:
|
|
108
|
-
frameTokenEstimate: 3300,
|
|
158
|
+
frameSize: 1568,
|
|
109
159
|
},
|
|
110
|
-
} as const satisfies Record<string,
|
|
160
|
+
} as const satisfies Record<string, ShapeGeometry>;
|
|
161
|
+
|
|
162
|
+
/** Research name of one renderable frame variant. */
|
|
163
|
+
export type ShapeVariantName = keyof typeof SHAPE_VARIANTS;
|
|
164
|
+
|
|
165
|
+
/** All variant names, in declaration order (for settings enums). */
|
|
166
|
+
export const SHAPE_VARIANT_NAMES = Object.keys(SHAPE_VARIANTS) as readonly ShapeVariantName[];
|
|
167
|
+
|
|
168
|
+
/** Runtime guard for variant names loaded from config. */
|
|
169
|
+
export function isShapeVariantName(value: unknown): value is ShapeVariantName {
|
|
170
|
+
return typeof value === "string" && value in SHAPE_VARIANTS;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Provider families with distinct image billing. */
|
|
174
|
+
type BillingFamily = "anthropic" | "google" | "openai";
|
|
175
|
+
|
|
176
|
+
function billingFamily(api?: Api): BillingFamily {
|
|
177
|
+
switch (api) {
|
|
178
|
+
case "openai-completions":
|
|
179
|
+
case "openai-responses":
|
|
180
|
+
case "openai-codex-responses":
|
|
181
|
+
case "azure-openai-responses":
|
|
182
|
+
return "openai";
|
|
183
|
+
case "google-generative-ai":
|
|
184
|
+
case "google-gemini-cli":
|
|
185
|
+
case "google-vertex":
|
|
186
|
+
return "google";
|
|
187
|
+
default:
|
|
188
|
+
// anthropic-messages, bedrock-converse-stream, and anything unknown
|
|
189
|
+
// share Anthropic's pixel-area pricing as the safe ceiling.
|
|
190
|
+
return "anthropic";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Per-frame billing for a square frame of edge `frameSize`, by family.
|
|
196
|
+
* Formulas verified against live bills in the resolution benchmarks:
|
|
197
|
+
* - Anthropic: 28px patches, capped at 4,784 visual tokens (the API
|
|
198
|
+
* downscales past the cap; 1568 → 3,136 measured) + 5% margin.
|
|
199
|
+
* - Google: Gemini 3.x bills a fixed `media_resolution` budget per image —
|
|
200
|
+
* default HIGH = 1,120 tokens — regardless of pixel size.
|
|
201
|
+
* - OpenAI: 32px patches × 1.2 flagship multiplier, 10,000-patch budget at
|
|
202
|
+
* `detail: "original"` (1568 → 2,881 measured).
|
|
203
|
+
*/
|
|
204
|
+
function familyBilling(family: BillingFamily, frameSize: number): Pick<Shape, "frameTokenEstimate" | "imageDetail"> {
|
|
205
|
+
switch (family) {
|
|
206
|
+
case "google":
|
|
207
|
+
return { frameTokenEstimate: 1120 };
|
|
208
|
+
case "openai": {
|
|
209
|
+
const patches = Math.min(Math.ceil(frameSize / 32) ** 2, 10_000);
|
|
210
|
+
return { frameTokenEstimate: Math.ceil(patches * 1.2), imageDetail: "original" };
|
|
211
|
+
}
|
|
212
|
+
default: {
|
|
213
|
+
const patches = Math.min(Math.ceil(frameSize / 28) ** 2, 4784);
|
|
214
|
+
return { frameTokenEstimate: Math.ceil(patches * 1.05) };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Attach a provider family's billing to a variant geometry. */
|
|
220
|
+
function priceShape(base: ShapeGeometry, family: BillingFamily): Shape {
|
|
221
|
+
return { ...base, ...familyBilling(family, base.frameSize) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Eval-validated shapes, keyed by the provider family they won on. */
|
|
225
|
+
export const SHAPES = {
|
|
226
|
+
/** `6x12-dim`: X.org 6x12 glyphs, black ink with stopwords dimmed gray.
|
|
227
|
+
* Production mono eval on claude-fable: f1 .840 vs .877 for the repeated
|
|
228
|
+
* `8x8r-bw` grid (within noise at n=25) at 37% lower cost — 12 frames
|
|
229
|
+
* instead of 21 per 400k chars. Never refused in any run. */
|
|
230
|
+
anthropic: priceShape(SHAPE_VARIANTS["6x12-dim"], "anthropic"),
|
|
231
|
+
/** `doc-8on16-sent-dim`: two word-wrapped columns, sentence hues, dimmed
|
|
232
|
+
* stopwords. Production mono eval on gemini-3.5-flash: f1 .900 vs .853
|
|
233
|
+
* for the repeated grid, at lower cost; also the chunked round-2 winner. */
|
|
234
|
+
google: priceShape(SHAPE_VARIANTS["doc-8on16-sent-dim"], "google"),
|
|
235
|
+
/** `8on16-bw`: 8x13 X.org glyphs on a 16px pitch, black ink. Mono eval on
|
|
236
|
+
* gpt-5.5 (200k-token single request, n=50): f1 .851 vs .602 for the
|
|
237
|
+
* previous `6x6u-sent` default at near-equal total cost; chunked exp14
|
|
238
|
+
* scored it .906. */
|
|
239
|
+
openai: priceShape(SHAPE_VARIANTS["8on16-bw"], "openai"),
|
|
240
|
+
/** Original 5x8 X.org shape (pre-shape-table sessions rendered this). */
|
|
241
|
+
legacy: priceShape(SHAPE_VARIANTS["5x8-sent"], "anthropic"),
|
|
242
|
+
} satisfies Record<string, Shape>;
|
|
111
243
|
|
|
112
244
|
/** Runtime guard for shape overrides loaded from config or preserve data. */
|
|
113
245
|
export function isShape(value: unknown): value is Shape {
|
|
@@ -117,12 +249,15 @@ export function isShape(value: unknown): value is Shape {
|
|
|
117
249
|
const variant = shape.variant;
|
|
118
250
|
const detail = shape.imageDetail;
|
|
119
251
|
return (
|
|
120
|
-
(font === "5x8" || font === "8x8") &&
|
|
252
|
+
(font === "5x8" || font === "8x8" || font === "6x12" || font === "8x13") &&
|
|
121
253
|
typeof shape.cellWidth === "number" &&
|
|
122
254
|
shape.cellWidth > 0 &&
|
|
123
255
|
typeof shape.cellHeight === "number" &&
|
|
124
256
|
shape.cellHeight > 0 &&
|
|
257
|
+
(shape.stretch === undefined || typeof shape.stretch === "boolean") &&
|
|
125
258
|
(variant === "sent" || variant === "bw") &&
|
|
259
|
+
(shape.stopwordDim === undefined || typeof shape.stopwordDim === "boolean") &&
|
|
260
|
+
(shape.columns === undefined || shape.columns === 1 || shape.columns === 2) &&
|
|
126
261
|
typeof shape.lineRepeat === "number" &&
|
|
127
262
|
shape.lineRepeat > 0 &&
|
|
128
263
|
typeof shape.frameSize === "number" &&
|
|
@@ -133,23 +268,80 @@ export function isShape(value: unknown): value is Shape {
|
|
|
133
268
|
);
|
|
134
269
|
}
|
|
135
270
|
|
|
136
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
271
|
+
/** Eval-winning variant per provider family (billing fallback when the
|
|
272
|
+
* model id matches no known reader line). */
|
|
273
|
+
const FAMILY_VARIANT: Record<BillingFamily, ShapeVariantName> = {
|
|
274
|
+
anthropic: "6x12-dim",
|
|
275
|
+
google: "doc-8on16-sent-dim",
|
|
276
|
+
openai: "8on16-bw",
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const FAMILY_SHAPE: Record<BillingFamily, Shape> = {
|
|
280
|
+
anthropic: SHAPES.anthropic,
|
|
281
|
+
google: SHAPES.google,
|
|
282
|
+
openai: SHAPES.openai,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/** One model line's ideal format: variant plus an optional frame-size
|
|
286
|
+
* override when the line reads larger frames at no extra cost. */
|
|
287
|
+
export interface IdealShape {
|
|
288
|
+
variant: ShapeVariantName;
|
|
289
|
+
frameSize?: number;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Eval-winning format per model line, matched against the model id. The
|
|
293
|
+
* wire API only identifies the gateway — a Claude served through Vertex or
|
|
294
|
+
* OpenRouter still reads best with its own shape. Patterns cover the model
|
|
295
|
+
* lines the mono evals measured; everything else falls back to the API
|
|
296
|
+
* family's winner at the standard 1568px frame. First match wins. */
|
|
297
|
+
const MODEL_VARIANTS: readonly (readonly [RegExp, IdealShape])[] = [
|
|
298
|
+
// Opus 4.7+ and Fable/Mythos read high-res natively (2576px edge under a
|
|
299
|
+
// 4,784 visual-token cap → 1932px square sweet spot): same recall and
|
|
300
|
+
// cost as 1568, a third fewer frames (12 → 8 per 400k chars).
|
|
301
|
+
[/claude.*(fable|mythos)/i, { variant: "6x12-dim", frameSize: 1932 }],
|
|
302
|
+
[/claude-?opus-?4[.-][7-9]/i, { variant: "6x12-dim", frameSize: 1932 }],
|
|
303
|
+
// Older Claude lines downscale past 1568px — keep the safe size.
|
|
304
|
+
[/claude/i, { variant: "6x12-dim" }],
|
|
305
|
+
// Gemini 3.x bills a fixed 1,120-token budget per image regardless of
|
|
306
|
+
// pixels: 2048px packs +70% chars per frame at the same bill.
|
|
307
|
+
[/gemini/i, { variant: "doc-8on16-sent-dim", frameSize: 2048 }],
|
|
308
|
+
// gpt-5.5 patch billing is area-proportional; 1568 is already optimal.
|
|
309
|
+
[/gpt|codex/i, { variant: "8on16-bw" }],
|
|
310
|
+
// kimi's image processor downscales past 1792px (64×64 28px patches);
|
|
311
|
+
// 1568 wins on chars/$ and reads at f1 .973 (≤8 frames per request).
|
|
312
|
+
[/kimi/i, { variant: "8on16-bw" }],
|
|
313
|
+
// glm-4.6v .780 mono via direct vendor routing.
|
|
314
|
+
[/glm/i, { variant: "8on16-bw" }],
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
/** Eval-ideal format for a model id, or undefined when unmeasured. */
|
|
318
|
+
export function idealShapeVariant(modelId: string): IdealShape | undefined {
|
|
319
|
+
return MODEL_VARIANTS.find(([pattern]) => pattern.test(modelId))?.[1];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** What will read the frames: the wire API (billing) and model id (shape). */
|
|
323
|
+
export interface ShapeTarget {
|
|
324
|
+
api?: Api;
|
|
325
|
+
id?: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Pick the frame shape for a reader. An explicit `variant` (anything but
|
|
330
|
+
* `"auto"`) forces that geometry; otherwise the model id selects the
|
|
331
|
+
* eval-winning shape — and frame size — for its model line, falling back to
|
|
332
|
+
* the API family's winner when the model is unmeasured. Billing (token
|
|
333
|
+
* estimate, detail hint) always follows the API family actually carrying
|
|
334
|
+
* the request, computed for the resolved frame size. Accepts a full pi-ai
|
|
335
|
+
* `Model` or any `{ api, id }` subset.
|
|
336
|
+
*/
|
|
337
|
+
export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape {
|
|
338
|
+
const family = billingFamily(model?.api);
|
|
339
|
+
if (variant && variant !== "auto") return priceShape(SHAPE_VARIANTS[variant], family);
|
|
340
|
+
const ideal = model?.id ? idealShapeVariant(model.id) : undefined;
|
|
341
|
+
const name = ideal?.variant ?? FAMILY_VARIANT[family];
|
|
342
|
+
if (name === FAMILY_VARIANT[family] && ideal?.frameSize === undefined) return FAMILY_SHAPE[family];
|
|
343
|
+
const base = SHAPE_VARIANTS[name];
|
|
344
|
+
return priceShape(ideal?.frameSize ? { ...base, frameSize: ideal.frameSize } : base, family);
|
|
153
345
|
}
|
|
154
346
|
|
|
155
347
|
// ============================================================================
|
|
@@ -169,6 +361,37 @@ export const MAX_FRAMES = 8;
|
|
|
169
361
|
* (upper bound across shapes: Anthropic bills 1568*1568/750 ≈ 3,278). */
|
|
170
362
|
export const FRAME_TOKEN_ESTIMATE = 3300;
|
|
171
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Per-request image-count budgets by provider id. Routers and smaller
|
|
366
|
+
* providers enforce hard caps and silently DROP images past them (measured:
|
|
367
|
+
* OpenRouter caps at 8 — images 9+ vanish with no error and billed tokens
|
|
368
|
+
* plateau at 8x frame cost). First-party APIs allow far more; their values
|
|
369
|
+
* are conservative policy caps well under the measured hard limits
|
|
370
|
+
* (Anthropic 100, OpenAI 500, Gemini ~2500).
|
|
371
|
+
*/
|
|
372
|
+
export const PROVIDER_IMAGE_BUDGETS: Record<string, number> = {
|
|
373
|
+
anthropic: 90,
|
|
374
|
+
"amazon-bedrock": 90,
|
|
375
|
+
openai: 200,
|
|
376
|
+
google: 200,
|
|
377
|
+
"google-vertex": 200,
|
|
378
|
+
"google-gemini-cli": 200,
|
|
379
|
+
openrouter: 8,
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
|
|
383
|
+
export const DEFAULT_PROVIDER_IMAGE_BUDGET = 5;
|
|
384
|
+
|
|
385
|
+
/** Per-request image budget for `provider`; unknown providers get the floor. */
|
|
386
|
+
export function providerImageBudget(provider: string | undefined): number {
|
|
387
|
+
return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Archive frame budget for `provider`: its image budget clamped to {@link MAX_FRAMES}. */
|
|
391
|
+
export function providerFrameBudget(provider: string | undefined): number {
|
|
392
|
+
return Math.min(MAX_FRAMES, providerImageBudget(provider));
|
|
393
|
+
}
|
|
394
|
+
|
|
172
395
|
/** Key under `CompactionEntry.preserveData` holding the frame archive. */
|
|
173
396
|
export const PRESERVE_KEY = "snapcompact";
|
|
174
397
|
|
|
@@ -181,7 +404,7 @@ export interface Frame {
|
|
|
181
404
|
/** Base64-encoded PNG. */
|
|
182
405
|
data: string;
|
|
183
406
|
mimeType: string;
|
|
184
|
-
/** Characters per row in the frame grid. */
|
|
407
|
+
/** Characters per row in the frame grid (per-column width on doc frames). */
|
|
185
408
|
cols: number;
|
|
186
409
|
/** Text rows in the frame grid (unique lines, not repeated copies). */
|
|
187
410
|
rows: number;
|
|
@@ -191,6 +414,10 @@ export interface Frame {
|
|
|
191
414
|
font?: Shape["font"];
|
|
192
415
|
variant?: Shape["variant"];
|
|
193
416
|
lineRepeat?: number;
|
|
417
|
+
/** 2 on two-column doc frames; absent on row-major grid frames. */
|
|
418
|
+
columns?: number;
|
|
419
|
+
/** True when stopwords were printed in dim ink. */
|
|
420
|
+
stopwordDim?: boolean;
|
|
194
421
|
/** Resolution hint forwarded to the provider when re-attaching. */
|
|
195
422
|
detail?: ImageContent["detail"];
|
|
196
423
|
}
|
|
@@ -203,12 +430,19 @@ export interface Archive {
|
|
|
203
430
|
totalChars: number;
|
|
204
431
|
/** Characters dropped so far to respect the frame budget. */
|
|
205
432
|
truncatedChars: number;
|
|
433
|
+
/** Most recent slice of archived history that exceeded the frame budget,
|
|
434
|
+
* kept verbatim as normalized text (dim markers and newline glyphs
|
|
435
|
+
* included). Shipped as plain text in the compaction summary and folded
|
|
436
|
+
* back into frames by the next compaction. */
|
|
437
|
+
textTail?: string;
|
|
206
438
|
}
|
|
207
439
|
|
|
208
440
|
export interface Geometry {
|
|
441
|
+
/** Characters per row (per-column line width when `columns === 2`). */
|
|
209
442
|
cols: number;
|
|
210
443
|
rows: number;
|
|
211
|
-
/** Characters that fit one frame (
|
|
444
|
+
/** Characters that fit one frame (nominal upper bound on doc shapes,
|
|
445
|
+
* where real consumption is wrap-dependent). */
|
|
212
446
|
capacity: number;
|
|
213
447
|
}
|
|
214
448
|
|
|
@@ -396,6 +630,17 @@ function truncateForSummary(text: string, maxChars: number, headRatio: number):
|
|
|
396
630
|
|
|
397
631
|
const DIM_MARKERS = /[\u000e\u000f]/g;
|
|
398
632
|
|
|
633
|
+
/** Cap on the unrendered archive text tail, in frame-capacity units: enough
|
|
634
|
+
* to keep the newest discarded history readable without re-inflating the
|
|
635
|
+
* context a compaction just shrank. */
|
|
636
|
+
const TEXT_TAIL_MAX_PAGES = 2;
|
|
637
|
+
|
|
638
|
+
/** Normalized archive text → plain text: drop zero-width dim toggles and
|
|
639
|
+
* print newline glyphs as real newlines. */
|
|
640
|
+
function toPlainText(text: string): string {
|
|
641
|
+
return stripDimMarkers(text).replaceAll(NEWLINE_GLYPH, "\n");
|
|
642
|
+
}
|
|
643
|
+
|
|
399
644
|
/** Strip stray ink toggles from raw content so it cannot forge dim spans. */
|
|
400
645
|
function stripDimMarkers(text: string): string {
|
|
401
646
|
return text.replace(DIM_MARKERS, "");
|
|
@@ -517,18 +762,52 @@ const CHAR_FOLD: Record<string, string> = {
|
|
|
517
762
|
"\u2718": "x",
|
|
518
763
|
};
|
|
519
764
|
|
|
765
|
+
/** Printed in place of newline runs: the native renderer fills this cell
|
|
766
|
+
* entirely with pitch-black ink, so line structure survives whitespace
|
|
767
|
+
* collapsing at a one-cell cost. */
|
|
768
|
+
export const NEWLINE_GLYPH = "\u2588";
|
|
769
|
+
|
|
770
|
+
/** Collapsed in one pass: whitespace plus zero-width format characters (ZWSP,
|
|
771
|
+
* BOM, directional marks — JS `\s` already counts BOM as whitespace, so they
|
|
772
|
+
* must fold here, before the per-character pass). */
|
|
773
|
+
const COLLAPSIBLE = /[\s\p{Cf}]+/gu;
|
|
774
|
+
|
|
775
|
+
/** Runs carrying one of these collapse to {@link NEWLINE_GLYPH}. */
|
|
776
|
+
const LINE_BREAK = /[\n\r\u2028\u2029]/;
|
|
777
|
+
|
|
778
|
+
/** Leading/trailing spaces or newline glyphs add no information to a frame. */
|
|
779
|
+
const EDGE_RUNS = /^[ \u2588]+|[ \u2588]+$/g;
|
|
780
|
+
|
|
781
|
+
/** Glyph-less code points skipped outright instead of printing `?`: controls
|
|
782
|
+
* (bare ESC/BEL/NUL — full ANSI sequences are stripped beforehand),
|
|
783
|
+
* combining marks the fonts cannot compose, and lone surrogates. */
|
|
784
|
+
const UNRENDERABLE = /[\p{Cc}\p{Mn}\p{Me}\p{Cs}]/u;
|
|
785
|
+
|
|
520
786
|
/**
|
|
521
|
-
* Prepare text for printing:
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
787
|
+
* Prepare text for printing: strip ANSI escape sequences, collapse horizontal
|
|
788
|
+
* whitespace runs to single spaces and newline-bearing runs to one
|
|
789
|
+
* {@link NEWLINE_GLYPH} (drawn as a pitch-black cell), then fold everything
|
|
790
|
+
* outside the fonts' ASCII + Latin-1 coverage to ASCII approximations.
|
|
791
|
+
* Unrenderable control/format/combining characters are dropped without
|
|
792
|
+
* occupying a cell; `?` remains the fallback for unsupported graphic
|
|
793
|
+
* characters. The zero-width ink toggles {@link DIM_ON}/{@link DIM_OFF} pass
|
|
794
|
+
* through untouched.
|
|
525
795
|
*/
|
|
526
796
|
export function normalize(text: string): string {
|
|
527
|
-
const
|
|
797
|
+
const stripped = text.includes("\u001b") ? Bun.stripANSI(text) : text;
|
|
798
|
+
const collapsed = stripped
|
|
799
|
+
// A run of pure format chars (BOM is both \s and Cf) vanishes; only a
|
|
800
|
+
// run containing genuine whitespace separates words.
|
|
801
|
+
.replace(COLLAPSIBLE, run => (LINE_BREAK.test(run) ? NEWLINE_GLYPH : /[^\p{Cf}]/u.test(run) ? " " : ""))
|
|
802
|
+
.replace(EDGE_RUNS, "");
|
|
528
803
|
let out = "";
|
|
529
804
|
for (const ch of collapsed) {
|
|
530
805
|
const cp = ch.codePointAt(0) as number;
|
|
531
|
-
if (cp < 0x7f || (cp >= 0xa0 && cp <= 0xff)) {
|
|
806
|
+
if ((cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff)) {
|
|
807
|
+
out += ch;
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
if (ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) {
|
|
532
811
|
out += ch;
|
|
533
812
|
continue;
|
|
534
813
|
}
|
|
@@ -538,39 +817,167 @@ export function normalize(text: string): string {
|
|
|
538
817
|
} else if (cp >= 0x2500 && cp <= 0x257f) {
|
|
539
818
|
// Box drawing: keep table skeletons legible.
|
|
540
819
|
out += cp === 0x2502 || cp === 0x2503 ? "|" : cp === 0x2500 || cp === 0x2501 ? "-" : "+";
|
|
541
|
-
} else {
|
|
820
|
+
} else if (!UNRENDERABLE.test(ch)) {
|
|
542
821
|
out += "?";
|
|
543
822
|
}
|
|
544
823
|
}
|
|
545
824
|
return out;
|
|
546
825
|
}
|
|
547
826
|
|
|
827
|
+
// ============================================================================
|
|
828
|
+
// Stopword dimming
|
|
829
|
+
// ============================================================================
|
|
830
|
+
|
|
831
|
+
/** High-frequency function words a reader can reconstruct from context; the
|
|
832
|
+
* dim shapes render them in light gray so content words carry the contrast
|
|
833
|
+
* (verbatim from `research/bdf.py` `_STOPWORDS`). */
|
|
834
|
+
const STOPWORDS: ReadonlySet<string> = new Set(
|
|
835
|
+
(
|
|
836
|
+
"the a an and or of to in on at as is are was were be been by for with that this it its from had has have not but " +
|
|
837
|
+
"he she his her they their them which also who whom when where while will would could should there then than " +
|
|
838
|
+
"into over under about after before between during each such these those some most more other only same so"
|
|
839
|
+
).split(" "),
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
/** Maximal alphabetic runs (ASCII + Latin-1 letters, the fonts' coverage). */
|
|
843
|
+
const ALPHA_RUN = /[a-zA-Z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff]+/g;
|
|
844
|
+
|
|
845
|
+
/** Splitter that keeps the zero-width ink toggles as their own segments. */
|
|
846
|
+
const DIM_MARKER_SPLIT = /([\u000e\u000f])/;
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Wrap each maximal alphabetic run that is a stopword in {@link DIM_ON} /
|
|
850
|
+
* {@link DIM_OFF} so it prints in dim gray ink. Spans that are already dim
|
|
851
|
+
* (e.g. archived tool output) pass through untouched — wrapping there would
|
|
852
|
+
* terminate the enclosing dim span early. Markers are zero-width, so the
|
|
853
|
+
* visible glyph count is unchanged.
|
|
854
|
+
*/
|
|
855
|
+
export function dimStopwords(text: string): string {
|
|
856
|
+
const parts = text.split(DIM_MARKER_SPLIT);
|
|
857
|
+
let dim = false;
|
|
858
|
+
let out = "";
|
|
859
|
+
for (const part of parts) {
|
|
860
|
+
if (part === DIM_ON) {
|
|
861
|
+
dim = true;
|
|
862
|
+
out += part;
|
|
863
|
+
} else if (part === DIM_OFF) {
|
|
864
|
+
dim = false;
|
|
865
|
+
out += part;
|
|
866
|
+
} else if (dim) {
|
|
867
|
+
out += part;
|
|
868
|
+
} else {
|
|
869
|
+
out += part.replace(ALPHA_RUN, word => (STOPWORDS.has(word.toLowerCase()) ? DIM_ON + word + DIM_OFF : word));
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return out;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ============================================================================
|
|
876
|
+
// Doc layout (two word-wrapped newspaper columns)
|
|
877
|
+
// ============================================================================
|
|
878
|
+
|
|
879
|
+
/** Char cells between the two doc columns (research exp14 `GUTTER`). */
|
|
880
|
+
const DOC_GUTTER = 3;
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Greedy word-wrap, no mid-word breaks (hard split only for width+ words) —
|
|
884
|
+
* ported verbatim from `research/exp14_bestgpt.py` `wrap()`. Zero-width dim
|
|
885
|
+
* markers count toward word length here; serialized history places them at
|
|
886
|
+
* word boundaries, so the drift is at most one cell per affected line.
|
|
887
|
+
*/
|
|
888
|
+
export function wrap(text: string, width: number): string[] {
|
|
889
|
+
const lines: string[] = [];
|
|
890
|
+
let cur = "";
|
|
891
|
+
for (const token of text.split(/\s+/)) {
|
|
892
|
+
if (token.length === 0) continue;
|
|
893
|
+
let word = token;
|
|
894
|
+
while (word.length > width) {
|
|
895
|
+
// Pathological; never hit on prose.
|
|
896
|
+
if (cur) {
|
|
897
|
+
lines.push(cur);
|
|
898
|
+
cur = "";
|
|
899
|
+
}
|
|
900
|
+
lines.push(word.slice(0, width));
|
|
901
|
+
word = word.slice(width);
|
|
902
|
+
}
|
|
903
|
+
if (!cur) {
|
|
904
|
+
cur = word;
|
|
905
|
+
} else if (cur.length + 1 + word.length <= width) {
|
|
906
|
+
cur += ` ${word}`;
|
|
907
|
+
} else {
|
|
908
|
+
lines.push(cur);
|
|
909
|
+
cur = word;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (cur) lines.push(cur);
|
|
913
|
+
return lines;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Paginate already-normalized text for a doc shape: wrap once at the column
|
|
918
|
+
* width, then slice into pages of `2 * rows` lines, each page `\n`-joined.
|
|
919
|
+
* Every input character lands on exactly one page (whitespace becomes the
|
|
920
|
+
* wrap points).
|
|
921
|
+
*/
|
|
922
|
+
function docPages(normalized: string, geo: Geometry): string[] {
|
|
923
|
+
const lines = wrap(normalized, geo.cols);
|
|
924
|
+
const perPage = 2 * geo.rows;
|
|
925
|
+
const pages: string[] = [];
|
|
926
|
+
for (let offset = 0; offset < lines.length; offset += perPage) {
|
|
927
|
+
pages.push(lines.slice(offset, offset + perPage).join("\n"));
|
|
928
|
+
}
|
|
929
|
+
return pages;
|
|
930
|
+
}
|
|
931
|
+
|
|
548
932
|
// ============================================================================
|
|
549
933
|
// Rendering
|
|
550
934
|
// ============================================================================
|
|
551
935
|
|
|
552
936
|
export function geometry(shape: Shape, size: number = shape.frameSize): Geometry {
|
|
553
|
-
const
|
|
937
|
+
const gridCols = Math.floor(size / shape.cellWidth);
|
|
554
938
|
const rows = Math.floor(size / shape.cellHeight / shape.lineRepeat);
|
|
555
|
-
|
|
939
|
+
if (shape.columns === 2) {
|
|
940
|
+
const cols = Math.floor((gridCols - DOC_GUTTER) / 2);
|
|
941
|
+
return { cols, rows, capacity: 2 * cols * rows };
|
|
942
|
+
}
|
|
943
|
+
return { cols: gridCols, rows, capacity: gridCols * rows };
|
|
556
944
|
}
|
|
557
945
|
|
|
558
|
-
|
|
946
|
+
const NEWLINES = /\n/g;
|
|
947
|
+
|
|
948
|
+
/** Render one snapcompact frame from already-normalized text. Doc shapes
|
|
949
|
+
* (`columns === 2`) expect one page of `\n`-joined pre-wrapped lines. */
|
|
559
950
|
export function render(text: string, shape: Shape, size: number = shape.frameSize): RenderedFrame {
|
|
560
951
|
const { cols, rows, capacity } = geometry(shape, size);
|
|
561
|
-
|
|
952
|
+
let visible = text.length - (text.match(DIM_MARKERS)?.length ?? 0);
|
|
953
|
+
// Doc line separators consume no cell; in the grid they print as a blank.
|
|
954
|
+
if (shape.columns === 2) visible -= text.match(NEWLINES)?.length ?? 0;
|
|
562
955
|
const chars = Math.min(visible, capacity);
|
|
563
956
|
const data = renderSnapcompactPng(text, {
|
|
564
957
|
size,
|
|
565
958
|
font: shape.font,
|
|
566
959
|
cellWidth: shape.cellWidth,
|
|
567
960
|
cellHeight: shape.cellHeight,
|
|
961
|
+
stretch: shape.stretch,
|
|
568
962
|
variant: shape.variant,
|
|
569
963
|
lineRepeat: shape.lineRepeat,
|
|
964
|
+
columns: shape.columns,
|
|
570
965
|
});
|
|
571
966
|
return { data, cols, rows, chars };
|
|
572
967
|
}
|
|
573
968
|
|
|
969
|
+
/** Stateful per-page text finisher: re-opens a dim span the previous page
|
|
970
|
+
* boundary cut through, then applies stopword dimming when the shape asks
|
|
971
|
+
* for it (after pagination, so capacity math never sees the markers). */
|
|
972
|
+
function pageFinisher(shape: Shape): (page: string) => string {
|
|
973
|
+
let dimOpen = false;
|
|
974
|
+
return page => {
|
|
975
|
+
const text = dimOpen ? DIM_ON + page : page;
|
|
976
|
+
dimOpen = text.lastIndexOf(DIM_ON) > text.lastIndexOf(DIM_OFF);
|
|
977
|
+
return shape.stopwordDim ? dimStopwords(text) : text;
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
574
981
|
/** Options for {@link renderMany} and {@link frames}. */
|
|
575
982
|
export interface RenderManyOptions {
|
|
576
983
|
/** Explicit shape; wins over `model`. */
|
|
@@ -589,29 +996,45 @@ export interface RenderManyOptions {
|
|
|
589
996
|
* Empty/whitespace-only input yields no frames.
|
|
590
997
|
*/
|
|
591
998
|
export function renderMany(text: string, options?: RenderManyOptions): ImageContent[] {
|
|
592
|
-
const shape = options?.shape ?? resolveShape(options?.model
|
|
999
|
+
const shape = options?.shape ?? resolveShape(options?.model);
|
|
593
1000
|
const frameSize = options?.frameSize ?? shape.frameSize;
|
|
594
1001
|
const geo = geometry(shape, frameSize);
|
|
595
1002
|
const normalized = normalize(text);
|
|
596
1003
|
const frames: ImageContent[] = [];
|
|
597
|
-
|
|
598
|
-
if (options?.maxFrames !== undefined && frames.length >= options.maxFrames) break;
|
|
599
|
-
const rendered = render(normalized.slice(offset, offset + geo.capacity), shape, frameSize);
|
|
1004
|
+
const push = (rendered: RenderedFrame): void => {
|
|
600
1005
|
frames.push({
|
|
601
1006
|
type: "image",
|
|
602
1007
|
data: rendered.data,
|
|
603
1008
|
mimeType: "image/png",
|
|
604
1009
|
...(shape.imageDetail ? { detail: shape.imageDetail } : {}),
|
|
605
1010
|
});
|
|
1011
|
+
};
|
|
1012
|
+
if (shape.columns === 2) {
|
|
1013
|
+
const finish = pageFinisher(shape);
|
|
1014
|
+
for (const page of docPages(normalized, geo)) {
|
|
1015
|
+
if (options?.maxFrames !== undefined && frames.length >= options.maxFrames) break;
|
|
1016
|
+
push(render(finish(page), shape, frameSize));
|
|
1017
|
+
}
|
|
1018
|
+
return frames;
|
|
1019
|
+
}
|
|
1020
|
+
for (let offset = 0; offset < normalized.length; offset += geo.capacity) {
|
|
1021
|
+
if (options?.maxFrames !== undefined && frames.length >= options.maxFrames) break;
|
|
1022
|
+
let chunk = normalized.slice(offset, offset + geo.capacity);
|
|
1023
|
+
if (shape.stopwordDim) chunk = dimStopwords(chunk);
|
|
1024
|
+
push(render(chunk, shape, frameSize));
|
|
606
1025
|
}
|
|
607
1026
|
return frames;
|
|
608
1027
|
}
|
|
609
1028
|
|
|
610
|
-
/** Frames needed to hold `text` at the given shape/size, without rendering.
|
|
1029
|
+
/** Frames needed to hold `text` at the given shape/size, without rendering.
|
|
1030
|
+
* For doc shapes this wraps the text once and counts pages of `2 * rows`
|
|
1031
|
+
* lines; for grid shapes it divides by the frame capacity. */
|
|
611
1032
|
export function frames(text: string, options?: Pick<RenderManyOptions, "shape" | "model" | "frameSize">): number {
|
|
612
|
-
const shape = options?.shape ?? resolveShape(options?.model
|
|
1033
|
+
const shape = options?.shape ?? resolveShape(options?.model);
|
|
613
1034
|
const geo = geometry(shape, options?.frameSize ?? shape.frameSize);
|
|
614
|
-
|
|
1035
|
+
const normalized = normalize(text);
|
|
1036
|
+
if (shape.columns === 2) return Math.ceil(wrap(normalized, geo.cols).length / (2 * geo.rows));
|
|
1037
|
+
return Math.ceil(normalized.length / geo.capacity);
|
|
615
1038
|
}
|
|
616
1039
|
|
|
617
1040
|
// ============================================================================
|
|
@@ -639,6 +1062,7 @@ export function getPreservedArchive(preserveData: Record<string, unknown> | unde
|
|
|
639
1062
|
frames,
|
|
640
1063
|
totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0,
|
|
641
1064
|
truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0,
|
|
1065
|
+
...(typeof archive.textTail === "string" && archive.textTail.length > 0 ? { textTail: archive.textTail } : {}),
|
|
642
1066
|
};
|
|
643
1067
|
}
|
|
644
1068
|
|
|
@@ -661,7 +1085,10 @@ export function images(archive: Archive): ImageContent[] {
|
|
|
661
1085
|
* the discarded history, prints it onto PNG frames in the provider-optimal
|
|
662
1086
|
* shape, merges previously archived frames (oldest dropped beyond the
|
|
663
1087
|
* budget), and produces a deterministic summary explaining how to read the
|
|
664
|
-
* frames.
|
|
1088
|
+
* frames. Pages past the frame budget are never rendered (providers with
|
|
1089
|
+
* hard image caps silently drop excess frames on the wire) — the newest
|
|
1090
|
+
* unrendered slice survives verbatim as a text tail on the summary and is
|
|
1091
|
+
* folded back into frames by the next compaction.
|
|
665
1092
|
*
|
|
666
1093
|
* Frames archived under a different shape (provider switches, legacy 5x8
|
|
667
1094
|
* sessions) are kept as-is — each frame carries its own geometry, and the
|
|
@@ -679,7 +1106,7 @@ export async function compact<TMessage = Message>(
|
|
|
679
1106
|
if (!firstKeptEntryId) {
|
|
680
1107
|
throw new Error("First kept entry has no ID - session may need migration");
|
|
681
1108
|
}
|
|
682
|
-
const shape = options?.shape ?? resolveShape(options?.model
|
|
1109
|
+
const shape = options?.shape ?? resolveShape(options?.model);
|
|
683
1110
|
const frameSize = options?.frameSize ?? shape.frameSize;
|
|
684
1111
|
const maxFrames = Math.max(1, options?.maxFrames ?? MAX_FRAMES);
|
|
685
1112
|
const geo = geometry(shape, frameSize);
|
|
@@ -697,14 +1124,49 @@ export async function compact<TMessage = Message>(
|
|
|
697
1124
|
|
|
698
1125
|
let truncatedChars = previousArchive?.truncatedChars ?? 0;
|
|
699
1126
|
|
|
1127
|
+
// The previous compaction's unframed text tail is the oldest part of this
|
|
1128
|
+
// archive slice — prepend it so it ages into frames first.
|
|
1129
|
+
if (previousArchive?.textTail) {
|
|
1130
|
+
archiveText =
|
|
1131
|
+
archiveText.length > 0
|
|
1132
|
+
? `${previousArchive.textTail}${NEWLINE_GLYPH}${archiveText}`
|
|
1133
|
+
: previousArchive.textTail;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const pages: string[] = [];
|
|
1137
|
+
if (shape.columns === 2) {
|
|
1138
|
+
pages.push(...docPages(archiveText, geo));
|
|
1139
|
+
} else {
|
|
1140
|
+
for (let offset = 0; offset < archiveText.length; offset += geo.capacity) {
|
|
1141
|
+
pages.push(archiveText.slice(offset, offset + geo.capacity));
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Fit the merged archive into the frame budget BEFORE rendering: pages
|
|
1146
|
+
// that cannot ship are never rasterized. Old unpinned frames evict first
|
|
1147
|
+
// (the archive fades oldest-first, as before); new pages that still do
|
|
1148
|
+
// not fit stay behind as a verbatim text tail instead of being dropped.
|
|
1149
|
+
const prevFrames = previousArchive?.frames ?? [];
|
|
1150
|
+
let keptPrev = prevFrames;
|
|
1151
|
+
if (prevFrames.length + pages.length > maxFrames) {
|
|
1152
|
+
// Pin the earliest frame: it anchors the session head (the original
|
|
1153
|
+
// request, or the filmed summary of even older history) the way the
|
|
1154
|
+
// LLM-summary strategies keep the original goal alive across rounds.
|
|
1155
|
+
// With a budget of one frame the pin is moot.
|
|
1156
|
+
const pinCount = maxFrames >= 2 && prevFrames.length > 0 ? 1 : 0;
|
|
1157
|
+
const evictable = prevFrames.slice(pinCount);
|
|
1158
|
+
const surviving = Math.min(evictable.length, Math.max(0, maxFrames - pages.length - pinCount));
|
|
1159
|
+
const dropped = evictable.slice(0, evictable.length - surviving);
|
|
1160
|
+
for (const frame of dropped) truncatedChars += frame.chars;
|
|
1161
|
+
keptPrev = [...prevFrames.slice(0, pinCount), ...evictable.slice(evictable.length - surviving)];
|
|
1162
|
+
}
|
|
1163
|
+
const renderPages = pages.slice(0, maxFrames - keptPrev.length);
|
|
1164
|
+
const tailPages = pages.slice(renderPages.length);
|
|
1165
|
+
|
|
700
1166
|
const newFrames: Frame[] = [];
|
|
701
|
-
|
|
702
|
-
for (
|
|
703
|
-
|
|
704
|
-
// Re-open a dim span that the previous frame boundary cut through.
|
|
705
|
-
if (dimOpen) chunk = DIM_ON + chunk;
|
|
706
|
-
dimOpen = chunk.lastIndexOf(DIM_ON) > chunk.lastIndexOf(DIM_OFF);
|
|
707
|
-
const rendered = render(chunk, shape, frameSize);
|
|
1167
|
+
const finish = pageFinisher(shape);
|
|
1168
|
+
for (const page of renderPages) {
|
|
1169
|
+
const rendered = render(finish(page), shape, frameSize);
|
|
708
1170
|
newFrames.push({
|
|
709
1171
|
data: rendered.data,
|
|
710
1172
|
mimeType: "image/png",
|
|
@@ -714,31 +1176,39 @@ export async function compact<TMessage = Message>(
|
|
|
714
1176
|
font: shape.font,
|
|
715
1177
|
variant: shape.variant,
|
|
716
1178
|
lineRepeat: shape.lineRepeat,
|
|
1179
|
+
...(shape.columns === 2 ? { columns: 2 } : {}),
|
|
1180
|
+
...(shape.stopwordDim ? { stopwordDim: true } : {}),
|
|
717
1181
|
...(shape.imageDetail ? { detail: shape.imageDetail } : {}),
|
|
718
1182
|
});
|
|
719
1183
|
// Keep the event loop responsive between native render passes.
|
|
720
1184
|
await Bun.sleep(0);
|
|
721
1185
|
}
|
|
722
1186
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1187
|
+
// Pages past the budget survive as text, capped at two frames' capacity
|
|
1188
|
+
// (middle-elided) so an oversized archive cannot blow the context back up.
|
|
1189
|
+
let textTail = "";
|
|
1190
|
+
if (tailPages.length > 0) {
|
|
1191
|
+
const raw =
|
|
1192
|
+
shape.columns === 2 ? tailPages.map(page => page.replaceAll("\n", " ")).join(" ") : tailPages.join("");
|
|
1193
|
+
const tailCap = TEXT_TAIL_MAX_PAGES * geo.capacity;
|
|
1194
|
+
if (raw.length > tailCap) truncatedChars += raw.length - tailCap;
|
|
1195
|
+
// Re-open a dim span the render boundary cut through, so the carried
|
|
1196
|
+
// tail keeps tool output dim when it lands on frames next compaction.
|
|
1197
|
+
const renderedText = shape.columns === 2 ? renderPages.join("\n") : renderPages.join("");
|
|
1198
|
+
const dimOpen = renderedText.lastIndexOf(DIM_ON) > renderedText.lastIndexOf(DIM_OFF);
|
|
1199
|
+
textTail = (dimOpen ? DIM_ON : "") + truncateForSummary(raw, tailCap, TRUNCATE_HEAD_RATIO);
|
|
734
1200
|
}
|
|
1201
|
+
|
|
1202
|
+
const frames = [...keptPrev, ...newFrames];
|
|
735
1203
|
const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0);
|
|
736
1204
|
const mixedShapes = frames.some(
|
|
737
1205
|
frame =>
|
|
738
1206
|
frame.cols !== geo.cols ||
|
|
739
1207
|
frame.rows !== geo.rows ||
|
|
740
1208
|
(frame.variant ?? "sent") !== shape.variant ||
|
|
741
|
-
(frame.lineRepeat ?? 1) !== shape.lineRepeat
|
|
1209
|
+
(frame.lineRepeat ?? 1) !== shape.lineRepeat ||
|
|
1210
|
+
(frame.columns ?? 1) !== (shape.columns ?? 1) ||
|
|
1211
|
+
(frame.stopwordDim ?? false) !== (shape.stopwordDim ?? false),
|
|
742
1212
|
);
|
|
743
1213
|
|
|
744
1214
|
let summary: string;
|
|
@@ -753,11 +1223,14 @@ export async function compact<TMessage = Message>(
|
|
|
753
1223
|
rows: geo.rows,
|
|
754
1224
|
sentenceInk: shape.variant === "sent",
|
|
755
1225
|
lineRepeated: shape.lineRepeat > 1,
|
|
1226
|
+
docColumns: shape.columns === 2,
|
|
1227
|
+
stopwordDimmed: shape.stopwordDim === true,
|
|
756
1228
|
dimmedToolResults: options?.dimToolResults !== false,
|
|
757
1229
|
mixedShapes,
|
|
758
1230
|
totalChars,
|
|
759
1231
|
truncatedChars,
|
|
760
1232
|
includedPreviousSummary,
|
|
1233
|
+
textTail: textTail.length > 0 ? toPlainText(textTail) : undefined,
|
|
761
1234
|
});
|
|
762
1235
|
}
|
|
763
1236
|
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
@@ -766,11 +1239,12 @@ export async function compact<TMessage = Message>(
|
|
|
766
1239
|
// A snapcompact pass replaces any provider-side replacement history; strip the
|
|
767
1240
|
// OpenAI remote-compaction payload like the default summarizer path does.
|
|
768
1241
|
const basePreserve = stripOpenAiRemoteCompactionPreserveData(previousPreserveData) ?? {};
|
|
769
|
-
const archive: Archive = { frames, totalChars, truncatedChars };
|
|
1242
|
+
const archive: Archive = { frames, totalChars, truncatedChars, ...(textTail ? { textTail } : {}) };
|
|
770
1243
|
|
|
1244
|
+
const textTailNote = textTail ? ` (+${textTail.length.toLocaleString()} chars as text)` : "";
|
|
771
1245
|
return {
|
|
772
1246
|
summary,
|
|
773
|
-
shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}`,
|
|
1247
|
+
shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}${textTailNote}`,
|
|
774
1248
|
firstKeptEntryId,
|
|
775
1249
|
tokensBefore,
|
|
776
1250
|
details: { readFiles, modifiedFiles },
|