@prometheus-ai/snapcompact 0.5.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 +50 -0
- package/README.md +70 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/snapcompact.d.ts +523 -0
- package/package.json +64 -0
- package/src/index.ts +1 -0
- package/src/prompts/file-operations.md +5 -0
- package/src/prompts/snapcompact-summary.md +24 -0
- package/src/snapcompact.ts +1290 -0
|
@@ -0,0 +1,1290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapcompact compaction: archive conversation history as dense bitmap images.
|
|
3
|
+
*
|
|
4
|
+
* Instead of asking an LLM to summarize discarded history, the serialized
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* The frame shape is provider-aware. Original choices came from the SQuAD
|
|
11
|
+
* prose evals (`packages/snapcompact`, 200k-token monolithic runs); the
|
|
12
|
+
* spacing choices below come from the tool-result legibility bench
|
|
13
|
+
* (`research/toolbench.py`, real search/read/find output with structure QA),
|
|
14
|
+
* which exposed that the prose-tuned dense cells erase the line numbers and
|
|
15
|
+
* indentation that code/search output depends on:
|
|
16
|
+
*
|
|
17
|
+
* - **Anthropic** (`11on16-bw`): 8x13 glyphs on an 11px advance (extra
|
|
18
|
+
* letter-spacing), black ink. On the tool-result bench, tracking the
|
|
19
|
+
* readable cell beat plain `8on16-bw` (opus-4.8 f1 .806 vs .755) and far
|
|
20
|
+
* beat the prior dense `6x12-dim` (.351, which fell below the OCR ~16px/char
|
|
21
|
+
* floor and abstained). Opus 4.7+/Fable/Mythos ingest high-res natively
|
|
22
|
+
* (2576px edge, 4,784 visual-token cap), so those lines get 1932px frames:
|
|
23
|
+
* same bill, fewer frames. Older Claude lines downscale past 1568px.
|
|
24
|
+
* - **Google** (`8on22-bw` @2048): 8x13 glyphs on a 22px pitch (extra line
|
|
25
|
+
* spacing), black ink. Leading lifted gemini-3.5-flash to f1 .934 vs .807
|
|
26
|
+
* for `8on16-bw` and .287 for the prior `doc-8on16-sent-dim`. Gemini 3.x
|
|
27
|
+
* bills a fixed `media_resolution` budget per image (default 1,120 tokens)
|
|
28
|
+
* regardless of pixels, so the 2048px frame carries more chars at the same
|
|
29
|
+
* bill.
|
|
30
|
+
* - **OpenAI** (`8on22-bw`): same leading win (gpt-5.5/gpt-5.4-mini). Patch
|
|
31
|
+
* billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
|
|
32
|
+
* area-proportional, so resolution cannot improve chars/$ — 1568 stays.
|
|
33
|
+
* `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
|
|
34
|
+
* - **Unknown providers** default to the Anthropic shape. Gateways can
|
|
35
|
+
* defeat any shape silently: OpenRouter enforces a per-model image cap
|
|
36
|
+
* (measured: 8 images for glm-4.6v — frames past the cap are dropped with
|
|
37
|
+
* no error, billed tokens plateau exactly at 8x frame cost). The same
|
|
38
|
+
* frames routed direct to the vendor read fine (glm f1 .20 -> .78), so
|
|
39
|
+
* `providerImageBudget` caps per-request images per provider (OpenRouter
|
|
40
|
+
* 8, unknown 5) and `compact()` keeps any archive overflow as a text tail
|
|
41
|
+
* on the summary instead of rendering frames that would be dropped.
|
|
42
|
+
*
|
|
43
|
+
* The whole pass is local and deterministic — no LLM call, no API key, no
|
|
44
|
+
* latency beyond rendering. Rasterization and PNG encoding happen in native
|
|
45
|
+
* code (`renderSnapcompactPng` in `crates/prometheus-natives/src/snapcompact.rs`).
|
|
46
|
+
* Frames persist in the compaction entry's `preserveData` and are
|
|
47
|
+
* re-attached to the compaction summary message on every context rebuild.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import type { Api, ImageContent, Message, Model } from "@prometheus-ai/ai";
|
|
51
|
+
import { renderSnapcompactPng } from "@prometheus-ai/natives";
|
|
52
|
+
import { formatGroupedPaths, prompt } from "@prometheus-ai/utils";
|
|
53
|
+
import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
|
|
54
|
+
import snapcompactSummaryPrompt from "./prompts/snapcompact-summary.md" with { type: "text" };
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Shapes
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
/** One eval-validated frame shape: font, cell, ink, repetition, and size. */
|
|
61
|
+
export interface Shape {
|
|
62
|
+
/** Bundled font in the native renderer. */
|
|
63
|
+
font: "5x8" | "8x8" | "6x12" | "8x13";
|
|
64
|
+
/** Target cell advance in pixels; differing from the font's natural cell
|
|
65
|
+
* renders via Lanczos stretch (anti-aliased RGB frame). */
|
|
66
|
+
cellWidth: number;
|
|
67
|
+
/** Target cell pitch in pixels. */
|
|
68
|
+
cellHeight: number;
|
|
69
|
+
/** `false` → glyphs drawn at natural size on the cell pitch (8on16);
|
|
70
|
+
* `true`/`undefined` → legacy auto Lanczos stretch when cell ≠ natural. */
|
|
71
|
+
stretch?: boolean;
|
|
72
|
+
/** Ink: `sent` cycles six hues at sentence boundaries; `bw` is black. */
|
|
73
|
+
variant: "sent" | "bw";
|
|
74
|
+
/** Print stopwords in dim ink (research `dim`/`sent-dim` variants). */
|
|
75
|
+
stopwordDim?: boolean;
|
|
76
|
+
/** 1/undefined = row-major grid; 2 = two word-wrapped newspaper columns
|
|
77
|
+
* (research `doc`). */
|
|
78
|
+
columns?: number;
|
|
79
|
+
/** Each text line is printed this many times; copies after the first sit
|
|
80
|
+
* on a pale highlight band (redundancy coding). */
|
|
81
|
+
lineRepeat: number;
|
|
82
|
+
/** Frame edge in pixels. */
|
|
83
|
+
frameSize: number;
|
|
84
|
+
/** Per-frame billed-token estimate for the shape's target provider. */
|
|
85
|
+
frameTokenEstimate: number;
|
|
86
|
+
/** Resolution hint attached to frame images (OpenAI-only). */
|
|
87
|
+
imageDetail?: ImageContent["detail"];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Geometry half of a {@link Shape}: everything except provider billing. */
|
|
91
|
+
export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "imageDetail">;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Frame variants exercised by the SQuAD evals in `research/` that the native
|
|
95
|
+
* renderer reproduces faithfully, keyed by their research names. Font codes:
|
|
96
|
+
* `8x8u` unscii square cell, `8x8r` unscii with every line printed twice
|
|
97
|
+
* (redundancy coding), `6x6u` unscii Lanczos-squeezed to 6x6 (densest
|
|
98
|
+
* readable cell), `5x8` the X.org legacy font on its 2576px frame, `6x12`
|
|
99
|
+
* and `8x13` the X.org misc fonts, `8on16` 8x13 glyphs on an 8x16 cell pitch
|
|
100
|
+
* (no stretch, extra leading), `8on22` the same glyphs on a 22px pitch (more
|
|
101
|
+
* leading), `11on16` the same glyphs on an 11px advance (more tracking),
|
|
102
|
+
* `doc-` prefixed shapes a two-column word-wrapped newspaper layout. Ink:
|
|
103
|
+
* `sent` cycles six hues at sentence boundaries, `bw` is plain black, `-dim`
|
|
104
|
+
* suffix prints stopwords in gray.
|
|
105
|
+
*/
|
|
106
|
+
export const SHAPE_VARIANTS = {
|
|
107
|
+
"8x8r-bw": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "bw", lineRepeat: 2, frameSize: 1568 },
|
|
108
|
+
"8x8r-sent": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "sent", lineRepeat: 2, frameSize: 1568 },
|
|
109
|
+
"8x8u-bw": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "bw", lineRepeat: 1, frameSize: 1568 },
|
|
110
|
+
"8x8u-sent": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "sent", lineRepeat: 1, frameSize: 1568 },
|
|
111
|
+
"6x6u-bw": { font: "8x8", cellWidth: 6, cellHeight: 6, variant: "bw", lineRepeat: 1, frameSize: 1568 },
|
|
112
|
+
"6x6u-sent": { font: "8x8", cellWidth: 6, cellHeight: 6, variant: "sent", lineRepeat: 1, frameSize: 1568 },
|
|
113
|
+
"5x8-bw": { font: "5x8", cellWidth: 5, cellHeight: 8, variant: "bw", lineRepeat: 1, frameSize: 2576 },
|
|
114
|
+
"5x8-sent": { font: "5x8", cellWidth: 5, cellHeight: 8, variant: "sent", lineRepeat: 1, frameSize: 2576 },
|
|
115
|
+
"6x12-dim": {
|
|
116
|
+
font: "6x12",
|
|
117
|
+
cellWidth: 6,
|
|
118
|
+
cellHeight: 12,
|
|
119
|
+
variant: "bw",
|
|
120
|
+
stopwordDim: true,
|
|
121
|
+
lineRepeat: 1,
|
|
122
|
+
frameSize: 1568,
|
|
123
|
+
},
|
|
124
|
+
"8x13-bw": { font: "8x13", cellWidth: 8, cellHeight: 13, variant: "bw", lineRepeat: 1, frameSize: 1568 },
|
|
125
|
+
"8on16-bw": {
|
|
126
|
+
font: "8x13",
|
|
127
|
+
cellWidth: 8,
|
|
128
|
+
cellHeight: 16,
|
|
129
|
+
stretch: false,
|
|
130
|
+
variant: "bw",
|
|
131
|
+
lineRepeat: 1,
|
|
132
|
+
frameSize: 1568,
|
|
133
|
+
},
|
|
134
|
+
"8on22-bw": {
|
|
135
|
+
font: "8x13",
|
|
136
|
+
cellWidth: 8,
|
|
137
|
+
cellHeight: 22,
|
|
138
|
+
stretch: false,
|
|
139
|
+
variant: "bw",
|
|
140
|
+
lineRepeat: 1,
|
|
141
|
+
frameSize: 1568,
|
|
142
|
+
},
|
|
143
|
+
"11on16-bw": {
|
|
144
|
+
font: "8x13",
|
|
145
|
+
cellWidth: 11,
|
|
146
|
+
cellHeight: 16,
|
|
147
|
+
stretch: false,
|
|
148
|
+
variant: "bw",
|
|
149
|
+
lineRepeat: 1,
|
|
150
|
+
frameSize: 1568,
|
|
151
|
+
},
|
|
152
|
+
"doc-8on16-bw": {
|
|
153
|
+
font: "8x13",
|
|
154
|
+
cellWidth: 8,
|
|
155
|
+
cellHeight: 16,
|
|
156
|
+
stretch: false,
|
|
157
|
+
variant: "bw",
|
|
158
|
+
columns: 2,
|
|
159
|
+
lineRepeat: 1,
|
|
160
|
+
frameSize: 1568,
|
|
161
|
+
},
|
|
162
|
+
"doc-8on16-sent": {
|
|
163
|
+
font: "8x13",
|
|
164
|
+
cellWidth: 8,
|
|
165
|
+
cellHeight: 16,
|
|
166
|
+
stretch: false,
|
|
167
|
+
variant: "sent",
|
|
168
|
+
columns: 2,
|
|
169
|
+
lineRepeat: 1,
|
|
170
|
+
frameSize: 1568,
|
|
171
|
+
},
|
|
172
|
+
"doc-8on16-sent-dim": {
|
|
173
|
+
font: "8x13",
|
|
174
|
+
cellWidth: 8,
|
|
175
|
+
cellHeight: 16,
|
|
176
|
+
stretch: false,
|
|
177
|
+
variant: "sent",
|
|
178
|
+
stopwordDim: true,
|
|
179
|
+
columns: 2,
|
|
180
|
+
lineRepeat: 1,
|
|
181
|
+
frameSize: 1568,
|
|
182
|
+
},
|
|
183
|
+
} as const satisfies Record<string, ShapeGeometry>;
|
|
184
|
+
|
|
185
|
+
/** Research name of one renderable frame variant. */
|
|
186
|
+
export type ShapeVariantName = keyof typeof SHAPE_VARIANTS;
|
|
187
|
+
|
|
188
|
+
/** All variant names, in declaration order (for settings enums). */
|
|
189
|
+
export const SHAPE_VARIANT_NAMES = Object.keys(SHAPE_VARIANTS) as readonly ShapeVariantName[];
|
|
190
|
+
|
|
191
|
+
/** Runtime guard for variant names loaded from config. */
|
|
192
|
+
export function isShapeVariantName(value: unknown): value is ShapeVariantName {
|
|
193
|
+
return typeof value === "string" && value in SHAPE_VARIANTS;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Provider families with distinct image billing. */
|
|
197
|
+
type BillingFamily = "anthropic" | "google" | "openai";
|
|
198
|
+
|
|
199
|
+
function billingFamily(api?: Api): BillingFamily {
|
|
200
|
+
switch (api) {
|
|
201
|
+
case "openai-completions":
|
|
202
|
+
case "openai-responses":
|
|
203
|
+
case "openai-codex-responses":
|
|
204
|
+
case "azure-openai-responses":
|
|
205
|
+
return "openai";
|
|
206
|
+
case "google-generative-ai":
|
|
207
|
+
case "google-gemini-cli":
|
|
208
|
+
case "google-vertex":
|
|
209
|
+
return "google";
|
|
210
|
+
default:
|
|
211
|
+
// anthropic-messages, bedrock-converse-stream, and anything unknown
|
|
212
|
+
// share Anthropic's pixel-area pricing as the safe ceiling.
|
|
213
|
+
return "anthropic";
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Per-frame billing for a square frame of edge `frameSize`, by family.
|
|
219
|
+
* Formulas verified against live bills in the resolution benchmarks:
|
|
220
|
+
* - Anthropic: 28px patches, capped at 4,784 visual tokens (the API
|
|
221
|
+
* downscales past the cap; 1568 → 3,136 measured) + 5% margin.
|
|
222
|
+
* - Google: Gemini 3.x bills a fixed `media_resolution` budget per image —
|
|
223
|
+
* default HIGH = 1,120 tokens — regardless of pixel size.
|
|
224
|
+
* - OpenAI: 32px patches × 1.2 flagship multiplier, 10,000-patch budget at
|
|
225
|
+
* `detail: "original"` (1568 → 2,881 measured).
|
|
226
|
+
*/
|
|
227
|
+
function familyBilling(family: BillingFamily, frameSize: number): Pick<Shape, "frameTokenEstimate" | "imageDetail"> {
|
|
228
|
+
switch (family) {
|
|
229
|
+
case "google":
|
|
230
|
+
return { frameTokenEstimate: 1120 };
|
|
231
|
+
case "openai": {
|
|
232
|
+
const patches = Math.min(Math.ceil(frameSize / 32) ** 2, 10_000);
|
|
233
|
+
return { frameTokenEstimate: Math.ceil(patches * 1.2), imageDetail: "original" };
|
|
234
|
+
}
|
|
235
|
+
default: {
|
|
236
|
+
const patches = Math.min(Math.ceil(frameSize / 28) ** 2, 4784);
|
|
237
|
+
return { frameTokenEstimate: Math.ceil(patches * 1.05) };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Attach a provider family's billing to a variant geometry. */
|
|
243
|
+
function priceShape(base: ShapeGeometry, family: BillingFamily): Shape {
|
|
244
|
+
return { ...base, ...familyBilling(family, base.frameSize) };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Eval-validated shapes, keyed by the provider family they won on. */
|
|
248
|
+
export const SHAPES = {
|
|
249
|
+
/** `11on16-bw`: 8x13 glyphs on an 11px advance (extra tracking), black ink.
|
|
250
|
+
* Tool-result legibility bench (real search/read/find output, structure QA)
|
|
251
|
+
* on opus-4.8: f1 .806 vs .755 for plain `8on16-bw` and .351 for the prior
|
|
252
|
+
* `6x12-dim` default — letter-spacing the readable cell wins; the dense
|
|
253
|
+
* 6x12 was below the OCR ~16px/char floor and abstained. */
|
|
254
|
+
anthropic: priceShape(SHAPE_VARIANTS["11on16-bw"], "anthropic"),
|
|
255
|
+
/** `8on22-bw`: 8x13 glyphs on a 22px pitch (extra leading), black ink.
|
|
256
|
+
* Tool-result legibility bench on gemini-3.5-flash: f1 .934 vs .807 for
|
|
257
|
+
* plain `8on16-bw` and .287 for the prior `doc-8on16-sent-dim`; the
|
|
258
|
+
* line-spacing reduces row crowding so line numbers stay legible. */
|
|
259
|
+
google: priceShape(SHAPE_VARIANTS["8on22-bw"], "google"),
|
|
260
|
+
/** `8on22-bw`: 8x13 glyphs on a 22px pitch (extra leading), black ink.
|
|
261
|
+
* Same line-spacing win for OpenAI; bench on gpt-5.5/gpt-5.4-mini showed
|
|
262
|
+
* leading lifts recall on the readable cell over plain `8on16-bw`. */
|
|
263
|
+
openai: priceShape(SHAPE_VARIANTS["8on22-bw"], "openai"),
|
|
264
|
+
/** Original 5x8 X.org shape (pre-shape-table sessions rendered this). */
|
|
265
|
+
legacy: priceShape(SHAPE_VARIANTS["5x8-sent"], "anthropic"),
|
|
266
|
+
} satisfies Record<string, Shape>;
|
|
267
|
+
|
|
268
|
+
/** Runtime guard for shape overrides loaded from config or preserve data. */
|
|
269
|
+
export function isShape(value: unknown): value is Shape {
|
|
270
|
+
if (!value || typeof value !== "object") return false;
|
|
271
|
+
const shape = value as Record<string, unknown>;
|
|
272
|
+
const font = shape.font;
|
|
273
|
+
const variant = shape.variant;
|
|
274
|
+
const detail = shape.imageDetail;
|
|
275
|
+
return (
|
|
276
|
+
(font === "5x8" || font === "8x8" || font === "6x12" || font === "8x13") &&
|
|
277
|
+
typeof shape.cellWidth === "number" &&
|
|
278
|
+
shape.cellWidth > 0 &&
|
|
279
|
+
typeof shape.cellHeight === "number" &&
|
|
280
|
+
shape.cellHeight > 0 &&
|
|
281
|
+
(shape.stretch === undefined || typeof shape.stretch === "boolean") &&
|
|
282
|
+
(variant === "sent" || variant === "bw") &&
|
|
283
|
+
(shape.stopwordDim === undefined || typeof shape.stopwordDim === "boolean") &&
|
|
284
|
+
(shape.columns === undefined || shape.columns === 1 || shape.columns === 2) &&
|
|
285
|
+
typeof shape.lineRepeat === "number" &&
|
|
286
|
+
shape.lineRepeat > 0 &&
|
|
287
|
+
typeof shape.frameSize === "number" &&
|
|
288
|
+
shape.frameSize > 0 &&
|
|
289
|
+
typeof shape.frameTokenEstimate === "number" &&
|
|
290
|
+
shape.frameTokenEstimate > 0 &&
|
|
291
|
+
(detail === undefined || detail === "auto" || detail === "low" || detail === "high" || detail === "original")
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Eval-winning variant per provider family (billing fallback when the
|
|
296
|
+
* model id matches no known reader line). */
|
|
297
|
+
const FAMILY_VARIANT: Record<BillingFamily, ShapeVariantName> = {
|
|
298
|
+
anthropic: "11on16-bw",
|
|
299
|
+
google: "8on22-bw",
|
|
300
|
+
openai: "8on22-bw",
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const FAMILY_SHAPE: Record<BillingFamily, Shape> = {
|
|
304
|
+
anthropic: SHAPES.anthropic,
|
|
305
|
+
google: SHAPES.google,
|
|
306
|
+
openai: SHAPES.openai,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** One model line's ideal format: variant plus an optional frame-size
|
|
310
|
+
* override when the line reads larger frames at no extra cost. */
|
|
311
|
+
export interface IdealShape {
|
|
312
|
+
variant: ShapeVariantName;
|
|
313
|
+
frameSize?: number;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Eval-winning format per model line, matched against the model id. The
|
|
317
|
+
* wire API only identifies the gateway — a Claude served through Vertex or
|
|
318
|
+
* OpenRouter still reads best with its own shape. Patterns cover the model
|
|
319
|
+
* lines the mono evals measured; everything else falls back to the API
|
|
320
|
+
* family's winner at the standard 1568px frame. First match wins. */
|
|
321
|
+
const MODEL_VARIANTS: readonly (readonly [RegExp, IdealShape])[] = [
|
|
322
|
+
// Opus 4.7+ and Fable/Mythos read high-res natively (2576px edge under a
|
|
323
|
+
// 4,784 visual-token cap → 1932px square sweet spot): same recall and
|
|
324
|
+
// cost as 1568, a third fewer frames.
|
|
325
|
+
[/claude.*(fable|mythos)/i, { variant: "11on16-bw", frameSize: 1932 }],
|
|
326
|
+
[/claude-?opus-?4[.-][7-9]/i, { variant: "11on16-bw", frameSize: 1932 }],
|
|
327
|
+
// Older Claude lines downscale past 1568px — keep the safe size.
|
|
328
|
+
[/claude/i, { variant: "11on16-bw" }],
|
|
329
|
+
// Gemini 3.x bills a fixed 1,120-token budget per image regardless of
|
|
330
|
+
// pixels: 2048px packs more chars per frame at the same bill.
|
|
331
|
+
[/gemini/i, { variant: "8on22-bw", frameSize: 2048 }],
|
|
332
|
+
// gpt-5.5 patch billing is area-proportional; 1568 is already optimal.
|
|
333
|
+
[/gpt|codex/i, { variant: "8on22-bw" }],
|
|
334
|
+
// kimi's image processor downscales past 1792px (64×64 28px patches);
|
|
335
|
+
// 1568 wins on chars/$ and reads at f1 .973 (≤8 frames per request).
|
|
336
|
+
[/kimi/i, { variant: "8on16-bw" }],
|
|
337
|
+
// glm-4.6v .780 mono via direct vendor routing.
|
|
338
|
+
[/glm/i, { variant: "8on16-bw" }],
|
|
339
|
+
];
|
|
340
|
+
|
|
341
|
+
/** Eval-ideal format for a model id, or undefined when unmeasured. */
|
|
342
|
+
export function idealShapeVariant(modelId: string): IdealShape | undefined {
|
|
343
|
+
return MODEL_VARIANTS.find(([pattern]) => pattern.test(modelId))?.[1];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** What will read the frames: the wire API (billing) and model id (shape). */
|
|
347
|
+
export interface ShapeTarget {
|
|
348
|
+
api?: Api;
|
|
349
|
+
id?: string;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Pick the frame shape for a reader. An explicit `variant` (anything but
|
|
354
|
+
* `"auto"`) forces that geometry; otherwise the model id selects the
|
|
355
|
+
* eval-winning shape — and frame size — for its model line, falling back to
|
|
356
|
+
* the API family's winner when the model is unmeasured. Billing (token
|
|
357
|
+
* estimate, detail hint) always follows the API family actually carrying
|
|
358
|
+
* the request, computed for the resolved frame size. Accepts a full Prometheus AI
|
|
359
|
+
* `Model` or any `{ api, id }` subset.
|
|
360
|
+
*/
|
|
361
|
+
export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape {
|
|
362
|
+
const family = billingFamily(model?.api);
|
|
363
|
+
if (variant && variant !== "auto") return priceShape(SHAPE_VARIANTS[variant], family);
|
|
364
|
+
const ideal = model?.id ? idealShapeVariant(model.id) : undefined;
|
|
365
|
+
const name = ideal?.variant ?? FAMILY_VARIANT[family];
|
|
366
|
+
if (name === FAMILY_VARIANT[family] && ideal?.frameSize === undefined) return FAMILY_SHAPE[family];
|
|
367
|
+
const base = SHAPE_VARIANTS[name];
|
|
368
|
+
return priceShape(ideal?.frameSize ? { ...base, frameSize: ideal.frameSize } : base, family);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ============================================================================
|
|
372
|
+
// Constants
|
|
373
|
+
// ============================================================================
|
|
374
|
+
|
|
375
|
+
/** Legacy frame edge in pixels (the 5x8 shape's eval-validated size). New
|
|
376
|
+
* shapes carry their own `frameSize`. */
|
|
377
|
+
export const FRAME_SIZE = 2576;
|
|
378
|
+
|
|
379
|
+
/** Maximum frames carried on a compaction entry. Oldest frames are dropped
|
|
380
|
+
* first once the budget is exceeded (mirrors how iterative text summaries
|
|
381
|
+
* fade the oldest detail). */
|
|
382
|
+
export const MAX_FRAMES = 8;
|
|
383
|
+
|
|
384
|
+
/** Conservative per-frame token estimate used for context budgeting
|
|
385
|
+
* (upper bound across shapes: Anthropic bills 1568*1568/750 ≈ 3,278). */
|
|
386
|
+
export const FRAME_TOKEN_ESTIMATE = 3300;
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Per-request image-count budgets by provider id. Routers and smaller
|
|
390
|
+
* providers enforce hard caps and silently DROP images past them (measured:
|
|
391
|
+
* OpenRouter caps at 8 — images 9+ vanish with no error and billed tokens
|
|
392
|
+
* plateau at 8x frame cost). First-party APIs allow far more; their values
|
|
393
|
+
* are conservative policy caps well under the measured hard limits
|
|
394
|
+
* (Anthropic 100, OpenAI 500, Gemini ~2500).
|
|
395
|
+
*/
|
|
396
|
+
export const PROVIDER_IMAGE_BUDGETS: Record<string, number> = {
|
|
397
|
+
anthropic: 90,
|
|
398
|
+
"amazon-bedrock": 90,
|
|
399
|
+
openai: 200,
|
|
400
|
+
google: 200,
|
|
401
|
+
"google-vertex": 200,
|
|
402
|
+
"google-gemini-cli": 200,
|
|
403
|
+
openrouter: 8,
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
|
|
407
|
+
export const DEFAULT_PROVIDER_IMAGE_BUDGET = 5;
|
|
408
|
+
|
|
409
|
+
/** Per-request image budget for `provider`; unknown providers get the floor. */
|
|
410
|
+
export function providerImageBudget(provider: string | undefined): number {
|
|
411
|
+
return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Archive frame budget for `provider`: its image budget clamped to {@link MAX_FRAMES}. */
|
|
415
|
+
export function providerFrameBudget(provider: string | undefined): number {
|
|
416
|
+
return Math.min(MAX_FRAMES, providerImageBudget(provider));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Key under `CompactionEntry.preserveData` holding the frame archive. */
|
|
420
|
+
export const PRESERVE_KEY = "snapcompact";
|
|
421
|
+
|
|
422
|
+
// ============================================================================
|
|
423
|
+
// Types
|
|
424
|
+
// ============================================================================
|
|
425
|
+
|
|
426
|
+
/** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
|
|
427
|
+
export interface Frame {
|
|
428
|
+
/** Base64-encoded PNG. */
|
|
429
|
+
data: string;
|
|
430
|
+
mimeType: string;
|
|
431
|
+
/** Characters per row in the frame grid (per-column width on doc frames). */
|
|
432
|
+
cols: number;
|
|
433
|
+
/** Text rows in the frame grid (unique lines, not repeated copies). */
|
|
434
|
+
rows: number;
|
|
435
|
+
/** Characters actually printed onto this frame. */
|
|
436
|
+
chars: number;
|
|
437
|
+
/** Shape metadata (absent on legacy frames, which are 5x8 `sent`). */
|
|
438
|
+
font?: Shape["font"];
|
|
439
|
+
variant?: Shape["variant"];
|
|
440
|
+
lineRepeat?: number;
|
|
441
|
+
/** 2 on two-column doc frames; absent on row-major grid frames. */
|
|
442
|
+
columns?: number;
|
|
443
|
+
/** True when stopwords were printed in dim ink. */
|
|
444
|
+
stopwordDim?: boolean;
|
|
445
|
+
/** Resolution hint forwarded to the provider when re-attaching. */
|
|
446
|
+
detail?: ImageContent["detail"];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Frame archive persisted under `preserveData[PRESERVE_KEY]`. */
|
|
450
|
+
export interface Archive {
|
|
451
|
+
/** Frames ordered oldest to newest. */
|
|
452
|
+
frames: Frame[];
|
|
453
|
+
/** Characters currently readable across all frames. */
|
|
454
|
+
totalChars: number;
|
|
455
|
+
/** Characters dropped so far to respect the frame budget. */
|
|
456
|
+
truncatedChars: number;
|
|
457
|
+
/** Most recent slice of archived history that exceeded the frame budget,
|
|
458
|
+
* kept verbatim as normalized text (dim markers and newline glyphs
|
|
459
|
+
* included). Shipped as plain text in the compaction summary and folded
|
|
460
|
+
* back into frames by the next compaction. */
|
|
461
|
+
textTail?: string;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export interface Geometry {
|
|
465
|
+
/** Characters per row (per-column line width when `columns === 2`). */
|
|
466
|
+
cols: number;
|
|
467
|
+
rows: number;
|
|
468
|
+
/** Characters that fit one frame (nominal upper bound on doc shapes,
|
|
469
|
+
* where real consumption is wrap-dependent). */
|
|
470
|
+
capacity: number;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export interface Options<TMessage = Message> extends SerializeOptions {
|
|
474
|
+
/** App-level message transformer (same contract as agent-core's `SummaryOptions.convertToLlm`). */
|
|
475
|
+
convertToLlm?: ConvertToLlm<TMessage>;
|
|
476
|
+
/** Model whose provider API selects the frame shape. */
|
|
477
|
+
model?: Pick<Model, "api">;
|
|
478
|
+
/** Caller-owned reasoning/thinking level metadata; currently preserved for compatibility. */
|
|
479
|
+
thinkingLevel?: unknown;
|
|
480
|
+
/** Explicit shape override; wins over `model`. */
|
|
481
|
+
shape?: Shape;
|
|
482
|
+
/** Frame edge in pixels. Defaults to the shape's `frameSize`. */
|
|
483
|
+
frameSize?: number;
|
|
484
|
+
/** Frame budget. Defaults to {@link MAX_FRAMES}. */
|
|
485
|
+
maxFrames?: number;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Result of rendering one frame. */
|
|
489
|
+
export interface RenderedFrame {
|
|
490
|
+
/** Base64-encoded PNG, as returned by the native renderer. */
|
|
491
|
+
data: string;
|
|
492
|
+
cols: number;
|
|
493
|
+
rows: number;
|
|
494
|
+
/** Characters printed (ink toggles excluded; input may be shorter than capacity). */
|
|
495
|
+
chars: number;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ============================================================================
|
|
499
|
+
// Compaction data contracts
|
|
500
|
+
// ============================================================================
|
|
501
|
+
|
|
502
|
+
export interface FileOperations {
|
|
503
|
+
read: Set<string>;
|
|
504
|
+
written: Set<string>;
|
|
505
|
+
edited: Set<string>;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export interface CompactionDetails {
|
|
509
|
+
readFiles: string[];
|
|
510
|
+
modifiedFiles: string[];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export interface CompactionPreparation<TMessage = Message> {
|
|
514
|
+
/** UUID of first entry to keep. */
|
|
515
|
+
firstKeptEntryId: string;
|
|
516
|
+
/** Messages that will be archived and discarded. */
|
|
517
|
+
messagesToSummarize: TMessage[];
|
|
518
|
+
/** Messages that will be archived as the split-turn prefix, if any. */
|
|
519
|
+
turnPrefixMessages: TMessage[];
|
|
520
|
+
tokensBefore: number;
|
|
521
|
+
/** Summary from previous compaction, for continuity when no prior snapcompact archive exists. */
|
|
522
|
+
previousSummary?: string;
|
|
523
|
+
/** Preserved opaque compaction payload from the previous compaction, if any. */
|
|
524
|
+
previousPreserveData?: Record<string, unknown>;
|
|
525
|
+
/** File operations extracted by the host agent. */
|
|
526
|
+
fileOps: FileOperations;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export interface CompactionResult<T = CompactionDetails> {
|
|
530
|
+
summary: string;
|
|
531
|
+
shortSummary?: string;
|
|
532
|
+
firstKeptEntryId: string;
|
|
533
|
+
tokensBefore: number;
|
|
534
|
+
details?: T;
|
|
535
|
+
preserveData?: Record<string, unknown>;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export type ConvertToLlm<TMessage = Message> = (messages: TMessage[]) => Message[];
|
|
539
|
+
|
|
540
|
+
function defaultConvertToLlm<TMessage>(messages: TMessage[]): Message[] {
|
|
541
|
+
return messages as unknown as Message[];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ============================================================================
|
|
545
|
+
// File operation helpers
|
|
546
|
+
// ============================================================================
|
|
547
|
+
|
|
548
|
+
export function createFileOps(): FileOperations {
|
|
549
|
+
return {
|
|
550
|
+
read: new Set(),
|
|
551
|
+
written: new Set(),
|
|
552
|
+
edited: new Set(),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function computeFileLists(fileOps: FileOperations): CompactionDetails {
|
|
557
|
+
const modified = new Set([...fileOps.edited, ...fileOps.written]);
|
|
558
|
+
const readFiles = [...fileOps.read].filter(file => !modified.has(file)).sort();
|
|
559
|
+
const modifiedFiles = [...modified].sort();
|
|
560
|
+
return { readFiles, modifiedFiles };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Format file operations as one `<files>` tag: a grouped, prefix-folded
|
|
565
|
+
* directory tree (find-tool shape) with a ` (Read)` / ` (Write)` / ` (RW)`
|
|
566
|
+
* marker per file. `readSet` is the cumulative read set (`fileOps.read`),
|
|
567
|
+
* used to tell modified files that were also read (RW) from blind writes.
|
|
568
|
+
*/
|
|
569
|
+
const FILE_OPERATION_SUMMARY_LIMIT = 20;
|
|
570
|
+
|
|
571
|
+
function stripFileOperationTags(summary: string): string {
|
|
572
|
+
// Legacy <read-files>/<modified-files> tags are still stripped so summaries
|
|
573
|
+
// written before the combined <files> tag self-heal on the next compaction.
|
|
574
|
+
return summary
|
|
575
|
+
.replace(/<files>[\s\S]*?<\/files>\s*/g, "")
|
|
576
|
+
.replace(/<read-files>[\s\S]*?<\/read-files>\s*/g, "")
|
|
577
|
+
.replace(/<modified-files>[\s\S]*?<\/modified-files>\s*/g, "")
|
|
578
|
+
.trimEnd();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string {
|
|
582
|
+
if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
|
|
583
|
+
const mode = new Map<string, "Read" | "Write" | "RW">();
|
|
584
|
+
for (const file of readFiles) mode.set(file, "Read");
|
|
585
|
+
for (const file of modifiedFiles) mode.set(file, readSet?.has(file) ? "RW" : "Write");
|
|
586
|
+
const all = [...mode.keys()].sort();
|
|
587
|
+
let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
|
|
588
|
+
if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
|
|
589
|
+
files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`;
|
|
590
|
+
}
|
|
591
|
+
return prompt.render(fileOperationsTemplate, { files });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function upsertFileOperations(
|
|
595
|
+
summary: string,
|
|
596
|
+
readFiles: string[],
|
|
597
|
+
modifiedFiles: string[],
|
|
598
|
+
readSet?: ReadonlySet<string>,
|
|
599
|
+
): string {
|
|
600
|
+
const baseSummary = stripFileOperationTags(summary);
|
|
601
|
+
const fileOperations = formatFileOperations(readFiles, modifiedFiles, readSet);
|
|
602
|
+
if (!fileOperations) return baseSummary;
|
|
603
|
+
if (!baseSummary) return fileOperations;
|
|
604
|
+
return `${baseSummary}\n\n${fileOperations}`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ============================================================================
|
|
608
|
+
// Message serialization
|
|
609
|
+
// ============================================================================
|
|
610
|
+
|
|
611
|
+
/** Default per-tool-result character cap in serialized history. */
|
|
612
|
+
export const TOOL_RESULT_MAX_CHARS = 2000;
|
|
613
|
+
|
|
614
|
+
/** Default per-argument-value character cap inside serialized tool calls
|
|
615
|
+
* (write/edit bodies otherwise dump whole files into the archive). */
|
|
616
|
+
export const TOOL_ARG_MAX_CHARS = 500;
|
|
617
|
+
|
|
618
|
+
/** Default character cap across one tool call's full serialized argument list. */
|
|
619
|
+
export const TOOL_CALL_MAX_CHARS = 2000;
|
|
620
|
+
|
|
621
|
+
/** Default fraction of a truncation budget spent on the head; the remainder
|
|
622
|
+
* keeps the tail, where command errors and test failures usually land. */
|
|
623
|
+
export const TRUNCATE_HEAD_RATIO = 0.6;
|
|
624
|
+
|
|
625
|
+
/** Zero-width ink toggles understood by the native renderer (shift-out/in):
|
|
626
|
+
* text between them prints in dim gray ink without occupying a cell. */
|
|
627
|
+
export const DIM_ON = "\u000e";
|
|
628
|
+
export const DIM_OFF = "\u000f";
|
|
629
|
+
|
|
630
|
+
/** Character budgets applied while serializing discarded history for frame
|
|
631
|
+
* rendering. Pass `Infinity` to disable an individual cap. */
|
|
632
|
+
export interface SerializeOptions {
|
|
633
|
+
/** Per-tool-result cap. Defaults to {@link TOOL_RESULT_MAX_CHARS}. */
|
|
634
|
+
toolResultMaxChars?: number;
|
|
635
|
+
/** Per-argument-value cap. Defaults to {@link TOOL_ARG_MAX_CHARS}. */
|
|
636
|
+
toolArgMaxChars?: number;
|
|
637
|
+
/** Whole-argument-list cap per call. Defaults to {@link TOOL_CALL_MAX_CHARS}. */
|
|
638
|
+
toolCallMaxChars?: number;
|
|
639
|
+
/** Head share of each budget, clamped to [0, 1]. Defaults to {@link TRUNCATE_HEAD_RATIO}. */
|
|
640
|
+
truncateHeadRatio?: number;
|
|
641
|
+
/** Print tool-result text in dim gray ink so archived conversation reads
|
|
642
|
+
* louder than archived tool noise. Defaults to `true`. */
|
|
643
|
+
dimToolResults?: boolean;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Keep the head and tail of `text`, eliding the middle beyond `maxChars`. */
|
|
647
|
+
function truncateForSummary(text: string, maxChars: number, headRatio: number): string {
|
|
648
|
+
if (text.length <= maxChars) return text;
|
|
649
|
+
const ratio = Math.min(Math.max(headRatio, 0), 1);
|
|
650
|
+
const headChars = Math.round(maxChars * ratio);
|
|
651
|
+
const tailChars = maxChars - headChars;
|
|
652
|
+
const elided = text.length - maxChars;
|
|
653
|
+
const tail = tailChars > 0 ? text.slice(-tailChars) : "";
|
|
654
|
+
return `${text.slice(0, headChars)} [... ${elided} chars elided ...] ${tail}`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const DIM_MARKERS = /[\u000e\u000f]/g;
|
|
658
|
+
|
|
659
|
+
/** Cap on the unrendered archive text tail, in frame-capacity units: enough
|
|
660
|
+
* to keep the newest discarded history readable without re-inflating the
|
|
661
|
+
* context a compaction just shrank. */
|
|
662
|
+
const TEXT_TAIL_MAX_PAGES = 2;
|
|
663
|
+
|
|
664
|
+
/** Normalized archive text → plain text: drop zero-width dim toggles and
|
|
665
|
+
* print newline glyphs as real newlines. */
|
|
666
|
+
function toPlainText(text: string): string {
|
|
667
|
+
return stripDimMarkers(text).replaceAll(NEWLINE_GLYPH, "\n");
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Strip stray ink toggles from raw content so it cannot forge dim spans. */
|
|
671
|
+
function stripDimMarkers(text: string): string {
|
|
672
|
+
return text.replace(DIM_MARKERS, "");
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export function serializeConversation(messages: Message[], options?: SerializeOptions): string {
|
|
676
|
+
const toolResultMaxChars = options?.toolResultMaxChars ?? TOOL_RESULT_MAX_CHARS;
|
|
677
|
+
const toolArgMaxChars = options?.toolArgMaxChars ?? TOOL_ARG_MAX_CHARS;
|
|
678
|
+
const toolCallMaxChars = options?.toolCallMaxChars ?? TOOL_CALL_MAX_CHARS;
|
|
679
|
+
const headRatio = options?.truncateHeadRatio ?? TRUNCATE_HEAD_RATIO;
|
|
680
|
+
const dimToolResults = options?.dimToolResults !== false;
|
|
681
|
+
const parts: string[] = [];
|
|
682
|
+
|
|
683
|
+
// Tool results flagged contextually useless (and their paired calls) carry
|
|
684
|
+
// no information worth archiving — skip the whole pair.
|
|
685
|
+
const uselessCallIds = new Set<string>();
|
|
686
|
+
for (const msg of messages) {
|
|
687
|
+
if (msg.role === "toolResult" && msg.useless === true && msg.isError !== true) {
|
|
688
|
+
uselessCallIds.add(msg.toolCallId);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
for (const msg of messages) {
|
|
693
|
+
if (msg.role === "user") {
|
|
694
|
+
const content =
|
|
695
|
+
typeof msg.content === "string"
|
|
696
|
+
? msg.content
|
|
697
|
+
: msg.content
|
|
698
|
+
.filter((content): content is { type: "text"; text: string } => content.type === "text")
|
|
699
|
+
.map(content => content.text)
|
|
700
|
+
.join("");
|
|
701
|
+
if (content) parts.push(`[User]: ${stripDimMarkers(content)}`);
|
|
702
|
+
} else if (msg.role === "assistant") {
|
|
703
|
+
const textParts: string[] = [];
|
|
704
|
+
const thinkingParts: string[] = [];
|
|
705
|
+
const toolCalls: string[] = [];
|
|
706
|
+
|
|
707
|
+
for (const block of msg.content) {
|
|
708
|
+
if (block.type === "text") {
|
|
709
|
+
textParts.push(stripDimMarkers(block.text));
|
|
710
|
+
} else if (block.type === "thinking") {
|
|
711
|
+
thinkingParts.push(stripDimMarkers(block.thinking));
|
|
712
|
+
} else if (block.type === "toolCall") {
|
|
713
|
+
if (uselessCallIds.has(block.id)) continue;
|
|
714
|
+
const args = block.arguments as Record<string, unknown>;
|
|
715
|
+
const argsStr = truncateForSummary(
|
|
716
|
+
Object.entries(args)
|
|
717
|
+
.map(
|
|
718
|
+
([key, value]) =>
|
|
719
|
+
`${key}=${truncateForSummary(JSON.stringify(value) ?? "undefined", toolArgMaxChars, headRatio)}`,
|
|
720
|
+
)
|
|
721
|
+
.join(", "),
|
|
722
|
+
toolCallMaxChars,
|
|
723
|
+
headRatio,
|
|
724
|
+
);
|
|
725
|
+
toolCalls.push(`${block.name}(${argsStr})`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (thinkingParts.length > 0) {
|
|
730
|
+
parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
|
|
731
|
+
}
|
|
732
|
+
if (textParts.length > 0) {
|
|
733
|
+
parts.push(`[Assistant]: ${textParts.join("\n")}`);
|
|
734
|
+
}
|
|
735
|
+
if (toolCalls.length > 0) {
|
|
736
|
+
parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
|
|
737
|
+
}
|
|
738
|
+
} else if (msg.role === "toolResult") {
|
|
739
|
+
if (uselessCallIds.has(msg.toolCallId)) continue;
|
|
740
|
+
const content = msg.content
|
|
741
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
742
|
+
.map(block => block.text)
|
|
743
|
+
.join("");
|
|
744
|
+
if (content) {
|
|
745
|
+
// Args above are JSON-escaped, so only raw result text can carry toggles.
|
|
746
|
+
const body = truncateForSummary(stripDimMarkers(content), toolResultMaxChars, headRatio);
|
|
747
|
+
parts.push(dimToolResults ? `[Tool result]: ${DIM_ON}${body}${DIM_OFF}` : `[Tool result]: ${body}`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return parts.join("\n\n");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ============================================================================
|
|
756
|
+
// Preserve-data helpers
|
|
757
|
+
// ============================================================================
|
|
758
|
+
|
|
759
|
+
const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
|
|
760
|
+
|
|
761
|
+
function stripOpenAiRemoteCompactionPreserveData(
|
|
762
|
+
preserveData: Record<string, unknown> | undefined,
|
|
763
|
+
): Record<string, unknown> | undefined {
|
|
764
|
+
if (!preserveData || !(OPENAI_REMOTE_COMPACTION_PRESERVE_KEY in preserveData)) {
|
|
765
|
+
return preserveData;
|
|
766
|
+
}
|
|
767
|
+
const { [OPENAI_REMOTE_COMPACTION_PRESERVE_KEY]: _removed, ...rest } = preserveData;
|
|
768
|
+
return Object.keys(rest).length > 0 ? rest : undefined;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ============================================================================
|
|
772
|
+
// Text normalization
|
|
773
|
+
// ============================================================================
|
|
774
|
+
|
|
775
|
+
/** Folds for common non-Latin-1 characters the bundled fonts cannot draw. */
|
|
776
|
+
const CHAR_FOLD: Record<string, string> = {
|
|
777
|
+
"\u2018": "'",
|
|
778
|
+
"\u2019": "'",
|
|
779
|
+
"\u201a": "'",
|
|
780
|
+
"\u201b": "'",
|
|
781
|
+
"\u201c": '"',
|
|
782
|
+
"\u201d": '"',
|
|
783
|
+
"\u201e": '"',
|
|
784
|
+
"\u2013": "-",
|
|
785
|
+
"\u2014": "-",
|
|
786
|
+
"\u2015": "-",
|
|
787
|
+
"\u2212": "-",
|
|
788
|
+
"\u2026": "...",
|
|
789
|
+
"\u2022": "*",
|
|
790
|
+
"\u25cf": "*",
|
|
791
|
+
"\u25a0": "*",
|
|
792
|
+
"\u25aa": "*",
|
|
793
|
+
"\u2190": "<-",
|
|
794
|
+
"\u2192": "->",
|
|
795
|
+
"\u21d2": "=>",
|
|
796
|
+
"\u2713": "v",
|
|
797
|
+
"\u2714": "v",
|
|
798
|
+
"\u2717": "x",
|
|
799
|
+
"\u2718": "x",
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
/** Printed in place of newline runs: the native renderer fills this cell
|
|
803
|
+
* entirely with pitch-black ink, so line structure survives whitespace
|
|
804
|
+
* collapsing at a one-cell cost. */
|
|
805
|
+
export const NEWLINE_GLYPH = "\u2588";
|
|
806
|
+
|
|
807
|
+
/** Collapsed in one pass: whitespace plus zero-width format characters (ZWSP,
|
|
808
|
+
* BOM, directional marks — JS `\s` already counts BOM as whitespace, so they
|
|
809
|
+
* must fold here, before the per-character pass). */
|
|
810
|
+
const COLLAPSIBLE = /[\s\p{Cf}]+/gu;
|
|
811
|
+
|
|
812
|
+
/** Runs carrying one of these collapse to {@link NEWLINE_GLYPH}. */
|
|
813
|
+
const LINE_BREAK = /[\n\r\u2028\u2029]/;
|
|
814
|
+
|
|
815
|
+
/** Leading/trailing spaces or newline glyphs add no information to a frame. */
|
|
816
|
+
const EDGE_RUNS = /^[ \u2588]+|[ \u2588]+$/g;
|
|
817
|
+
|
|
818
|
+
/** Glyph-less code points skipped outright instead of printing `?`: controls
|
|
819
|
+
* (bare ESC/BEL/NUL — full ANSI sequences are stripped beforehand),
|
|
820
|
+
* combining marks the fonts cannot compose, and lone surrogates. */
|
|
821
|
+
const UNRENDERABLE = /[\p{Cc}\p{Mn}\p{Me}\p{Cs}]/u;
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Prepare text for printing: strip ANSI escape sequences, collapse horizontal
|
|
825
|
+
* whitespace runs to single spaces and newline-bearing runs to one
|
|
826
|
+
* {@link NEWLINE_GLYPH} (drawn as a pitch-black cell), then fold everything
|
|
827
|
+
* outside the fonts' ASCII + Latin-1 coverage to ASCII approximations.
|
|
828
|
+
* Unrenderable control/format/combining characters are dropped without
|
|
829
|
+
* occupying a cell; `?` remains the fallback for unsupported graphic
|
|
830
|
+
* characters. The zero-width ink toggles {@link DIM_ON}/{@link DIM_OFF} pass
|
|
831
|
+
* through untouched.
|
|
832
|
+
*/
|
|
833
|
+
export function normalize(text: string): string {
|
|
834
|
+
const stripped = text.includes("\u001b") ? Bun.stripANSI(text) : text;
|
|
835
|
+
const collapsed = stripped
|
|
836
|
+
// A run of pure format chars (BOM is both \s and Cf) vanishes; only a
|
|
837
|
+
// run containing genuine whitespace separates words.
|
|
838
|
+
.replace(COLLAPSIBLE, run => (LINE_BREAK.test(run) ? NEWLINE_GLYPH : /[^\p{Cf}]/u.test(run) ? " " : ""))
|
|
839
|
+
.replace(EDGE_RUNS, "");
|
|
840
|
+
let out = "";
|
|
841
|
+
for (const ch of collapsed) {
|
|
842
|
+
const cp = ch.codePointAt(0) as number;
|
|
843
|
+
if ((cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff)) {
|
|
844
|
+
out += ch;
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
if (ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) {
|
|
848
|
+
out += ch;
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
const fold = CHAR_FOLD[ch];
|
|
852
|
+
if (fold !== undefined) {
|
|
853
|
+
out += fold;
|
|
854
|
+
} else if (cp >= 0x2500 && cp <= 0x257f) {
|
|
855
|
+
// Box drawing: keep table skeletons legible.
|
|
856
|
+
out += cp === 0x2502 || cp === 0x2503 ? "|" : cp === 0x2500 || cp === 0x2501 ? "-" : "+";
|
|
857
|
+
} else if (!UNRENDERABLE.test(ch)) {
|
|
858
|
+
out += "?";
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return out;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// ============================================================================
|
|
865
|
+
// Stopword dimming
|
|
866
|
+
// ============================================================================
|
|
867
|
+
|
|
868
|
+
/** High-frequency function words a reader can reconstruct from context; the
|
|
869
|
+
* dim shapes render them in light gray so content words carry the contrast
|
|
870
|
+
* (verbatim from `research/bdf.py` `_STOPWORDS`). */
|
|
871
|
+
const STOPWORDS: ReadonlySet<string> = new Set(
|
|
872
|
+
(
|
|
873
|
+
"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 " +
|
|
874
|
+
"he she his her they their them which also who whom when where while will would could should there then than " +
|
|
875
|
+
"into over under about after before between during each such these those some most more other only same so"
|
|
876
|
+
).split(" "),
|
|
877
|
+
);
|
|
878
|
+
|
|
879
|
+
/** Maximal alphabetic runs (ASCII + Latin-1 letters, the fonts' coverage). */
|
|
880
|
+
const ALPHA_RUN = /[a-zA-Z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff]+/g;
|
|
881
|
+
|
|
882
|
+
/** Splitter that keeps the zero-width ink toggles as their own segments. */
|
|
883
|
+
const DIM_MARKER_SPLIT = /([\u000e\u000f])/;
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Wrap each maximal alphabetic run that is a stopword in {@link DIM_ON} /
|
|
887
|
+
* {@link DIM_OFF} so it prints in dim gray ink. Spans that are already dim
|
|
888
|
+
* (e.g. archived tool output) pass through untouched — wrapping there would
|
|
889
|
+
* terminate the enclosing dim span early. Markers are zero-width, so the
|
|
890
|
+
* visible glyph count is unchanged.
|
|
891
|
+
*/
|
|
892
|
+
export function dimStopwords(text: string): string {
|
|
893
|
+
const parts = text.split(DIM_MARKER_SPLIT);
|
|
894
|
+
let dim = false;
|
|
895
|
+
let out = "";
|
|
896
|
+
for (const part of parts) {
|
|
897
|
+
if (part === DIM_ON) {
|
|
898
|
+
dim = true;
|
|
899
|
+
out += part;
|
|
900
|
+
} else if (part === DIM_OFF) {
|
|
901
|
+
dim = false;
|
|
902
|
+
out += part;
|
|
903
|
+
} else if (dim) {
|
|
904
|
+
out += part;
|
|
905
|
+
} else {
|
|
906
|
+
out += part.replace(ALPHA_RUN, word => (STOPWORDS.has(word.toLowerCase()) ? DIM_ON + word + DIM_OFF : word));
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return out;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// ============================================================================
|
|
913
|
+
// Doc layout (two word-wrapped newspaper columns)
|
|
914
|
+
// ============================================================================
|
|
915
|
+
|
|
916
|
+
/** Char cells between the two doc columns (research exp14 `GUTTER`). */
|
|
917
|
+
const DOC_GUTTER = 3;
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Greedy word-wrap, no mid-word breaks (hard split only for width+ words) —
|
|
921
|
+
* ported verbatim from `research/exp14_bestgpt.py` `wrap()`. Zero-width dim
|
|
922
|
+
* markers count toward word length here; serialized history places them at
|
|
923
|
+
* word boundaries, so the drift is at most one cell per affected line.
|
|
924
|
+
*/
|
|
925
|
+
export function wrap(text: string, width: number): string[] {
|
|
926
|
+
const lines: string[] = [];
|
|
927
|
+
let cur = "";
|
|
928
|
+
for (const token of text.split(/\s+/)) {
|
|
929
|
+
if (token.length === 0) continue;
|
|
930
|
+
let word = token;
|
|
931
|
+
while (word.length > width) {
|
|
932
|
+
// Pathological; never hit on prose.
|
|
933
|
+
if (cur) {
|
|
934
|
+
lines.push(cur);
|
|
935
|
+
cur = "";
|
|
936
|
+
}
|
|
937
|
+
lines.push(word.slice(0, width));
|
|
938
|
+
word = word.slice(width);
|
|
939
|
+
}
|
|
940
|
+
if (!cur) {
|
|
941
|
+
cur = word;
|
|
942
|
+
} else if (cur.length + 1 + word.length <= width) {
|
|
943
|
+
cur += ` ${word}`;
|
|
944
|
+
} else {
|
|
945
|
+
lines.push(cur);
|
|
946
|
+
cur = word;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
if (cur) lines.push(cur);
|
|
950
|
+
return lines;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Paginate already-normalized text for a doc shape: wrap once at the column
|
|
955
|
+
* width, then slice into pages of `2 * rows` lines, each page `\n`-joined.
|
|
956
|
+
* Every input character lands on exactly one page (whitespace becomes the
|
|
957
|
+
* wrap points).
|
|
958
|
+
*/
|
|
959
|
+
function docPages(normalized: string, geo: Geometry): string[] {
|
|
960
|
+
const lines = wrap(normalized, geo.cols);
|
|
961
|
+
const perPage = 2 * geo.rows;
|
|
962
|
+
const pages: string[] = [];
|
|
963
|
+
for (let offset = 0; offset < lines.length; offset += perPage) {
|
|
964
|
+
pages.push(lines.slice(offset, offset + perPage).join("\n"));
|
|
965
|
+
}
|
|
966
|
+
return pages;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ============================================================================
|
|
970
|
+
// Rendering
|
|
971
|
+
// ============================================================================
|
|
972
|
+
|
|
973
|
+
export function geometry(shape: Shape, size: number = shape.frameSize): Geometry {
|
|
974
|
+
const gridCols = Math.floor(size / shape.cellWidth);
|
|
975
|
+
const rows = Math.floor(size / shape.cellHeight / shape.lineRepeat);
|
|
976
|
+
if (shape.columns === 2) {
|
|
977
|
+
const cols = Math.floor((gridCols - DOC_GUTTER) / 2);
|
|
978
|
+
return { cols, rows, capacity: 2 * cols * rows };
|
|
979
|
+
}
|
|
980
|
+
return { cols: gridCols, rows, capacity: gridCols * rows };
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const NEWLINES = /\n/g;
|
|
984
|
+
|
|
985
|
+
/** Render one snapcompact frame from already-normalized text. Doc shapes
|
|
986
|
+
* (`columns === 2`) expect one page of `\n`-joined pre-wrapped lines. */
|
|
987
|
+
export function render(text: string, shape: Shape, size: number = shape.frameSize): RenderedFrame {
|
|
988
|
+
const { cols, rows, capacity } = geometry(shape, size);
|
|
989
|
+
let visible = text.length - (text.match(DIM_MARKERS)?.length ?? 0);
|
|
990
|
+
// Doc line separators consume no cell; in the grid they print as a blank.
|
|
991
|
+
if (shape.columns === 2) visible -= text.match(NEWLINES)?.length ?? 0;
|
|
992
|
+
const chars = Math.min(visible, capacity);
|
|
993
|
+
const data = renderSnapcompactPng(text, {
|
|
994
|
+
size,
|
|
995
|
+
font: shape.font,
|
|
996
|
+
cellWidth: shape.cellWidth,
|
|
997
|
+
cellHeight: shape.cellHeight,
|
|
998
|
+
stretch: shape.stretch,
|
|
999
|
+
variant: shape.variant,
|
|
1000
|
+
lineRepeat: shape.lineRepeat,
|
|
1001
|
+
columns: shape.columns,
|
|
1002
|
+
});
|
|
1003
|
+
return { data, cols, rows, chars };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** Stateful per-page text finisher: re-opens a dim span the previous page
|
|
1007
|
+
* boundary cut through, then applies stopword dimming when the shape asks
|
|
1008
|
+
* for it (after pagination, so capacity math never sees the markers). */
|
|
1009
|
+
function pageFinisher(shape: Shape): (page: string) => string {
|
|
1010
|
+
let dimOpen = false;
|
|
1011
|
+
return page => {
|
|
1012
|
+
const text = dimOpen ? DIM_ON + page : page;
|
|
1013
|
+
dimOpen = text.lastIndexOf(DIM_ON) > text.lastIndexOf(DIM_OFF);
|
|
1014
|
+
return shape.stopwordDim ? dimStopwords(text) : text;
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Options for {@link renderMany} and {@link frames}. */
|
|
1019
|
+
export interface RenderManyOptions {
|
|
1020
|
+
/** Explicit shape; wins over `model`. */
|
|
1021
|
+
shape?: Shape;
|
|
1022
|
+
/** Model whose `api` selects the eval-optimal shape. */
|
|
1023
|
+
model?: Pick<Model, "api">;
|
|
1024
|
+
/** Frame edge in px; defaults to the shape's `frameSize`. */
|
|
1025
|
+
frameSize?: number;
|
|
1026
|
+
/** Hard cap on frames produced; omit for unbounded (caller decides usage). */
|
|
1027
|
+
maxFrames?: number;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Render arbitrary text into snapcompact PNG frames as LLM image blocks
|
|
1032
|
+
* (first page first). Synchronous: safe to call from per-request transforms.
|
|
1033
|
+
* Empty/whitespace-only input yields no frames.
|
|
1034
|
+
*/
|
|
1035
|
+
export function renderMany(text: string, options?: RenderManyOptions): ImageContent[] {
|
|
1036
|
+
const shape = options?.shape ?? resolveShape(options?.model);
|
|
1037
|
+
const frameSize = options?.frameSize ?? shape.frameSize;
|
|
1038
|
+
const geo = geometry(shape, frameSize);
|
|
1039
|
+
const normalized = normalize(text);
|
|
1040
|
+
const frames: ImageContent[] = [];
|
|
1041
|
+
const push = (rendered: RenderedFrame): void => {
|
|
1042
|
+
frames.push({
|
|
1043
|
+
type: "image",
|
|
1044
|
+
data: rendered.data,
|
|
1045
|
+
mimeType: "image/png",
|
|
1046
|
+
...(shape.imageDetail ? { detail: shape.imageDetail } : {}),
|
|
1047
|
+
});
|
|
1048
|
+
};
|
|
1049
|
+
if (shape.columns === 2) {
|
|
1050
|
+
const finish = pageFinisher(shape);
|
|
1051
|
+
for (const page of docPages(normalized, geo)) {
|
|
1052
|
+
if (options?.maxFrames !== undefined && frames.length >= options.maxFrames) break;
|
|
1053
|
+
push(render(finish(page), shape, frameSize));
|
|
1054
|
+
}
|
|
1055
|
+
return frames;
|
|
1056
|
+
}
|
|
1057
|
+
for (let offset = 0; offset < normalized.length; offset += geo.capacity) {
|
|
1058
|
+
if (options?.maxFrames !== undefined && frames.length >= options.maxFrames) break;
|
|
1059
|
+
let chunk = normalized.slice(offset, offset + geo.capacity);
|
|
1060
|
+
if (shape.stopwordDim) chunk = dimStopwords(chunk);
|
|
1061
|
+
push(render(chunk, shape, frameSize));
|
|
1062
|
+
}
|
|
1063
|
+
return frames;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** Frames needed to hold `text` at the given shape/size, without rendering.
|
|
1067
|
+
* For doc shapes this wraps the text once and counts pages of `2 * rows`
|
|
1068
|
+
* lines; for grid shapes it divides by the frame capacity. */
|
|
1069
|
+
export function frames(text: string, options?: Pick<RenderManyOptions, "shape" | "model" | "frameSize">): number {
|
|
1070
|
+
const shape = options?.shape ?? resolveShape(options?.model);
|
|
1071
|
+
const geo = geometry(shape, options?.frameSize ?? shape.frameSize);
|
|
1072
|
+
const normalized = normalize(text);
|
|
1073
|
+
if (shape.columns === 2) return Math.ceil(wrap(normalized, geo.cols).length / (2 * geo.rows));
|
|
1074
|
+
return Math.ceil(normalized.length / geo.capacity);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// ============================================================================
|
|
1078
|
+
// Archive helpers
|
|
1079
|
+
// ============================================================================
|
|
1080
|
+
|
|
1081
|
+
/** Validate and extract a persisted frame archive from `preserveData`. */
|
|
1082
|
+
export function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined {
|
|
1083
|
+
const candidate = preserveData?.[PRESERVE_KEY];
|
|
1084
|
+
if (!candidate || typeof candidate !== "object") return undefined;
|
|
1085
|
+
const archive = candidate as Archive;
|
|
1086
|
+
if (!Array.isArray(archive.frames)) return undefined;
|
|
1087
|
+
const frames = archive.frames.filter(
|
|
1088
|
+
frame =>
|
|
1089
|
+
!!frame &&
|
|
1090
|
+
typeof frame.data === "string" &&
|
|
1091
|
+
frame.data.length > 0 &&
|
|
1092
|
+
typeof frame.mimeType === "string" &&
|
|
1093
|
+
typeof frame.cols === "number" &&
|
|
1094
|
+
typeof frame.rows === "number" &&
|
|
1095
|
+
typeof frame.chars === "number",
|
|
1096
|
+
);
|
|
1097
|
+
if (frames.length === 0) return undefined;
|
|
1098
|
+
return {
|
|
1099
|
+
frames,
|
|
1100
|
+
totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0,
|
|
1101
|
+
truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0,
|
|
1102
|
+
...(typeof archive.textTail === "string" && archive.textTail.length > 0 ? { textTail: archive.textTail } : {}),
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Convert archive frames into LLM image blocks (oldest first). */
|
|
1107
|
+
export function images(archive: Archive): ImageContent[] {
|
|
1108
|
+
return archive.frames.map(frame => ({
|
|
1109
|
+
type: "image",
|
|
1110
|
+
data: frame.data,
|
|
1111
|
+
mimeType: frame.mimeType,
|
|
1112
|
+
...(frame.detail ? { detail: frame.detail } : {}),
|
|
1113
|
+
}));
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// ============================================================================
|
|
1117
|
+
// Compaction entry point
|
|
1118
|
+
// ============================================================================
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Run a snapcompact compaction over prepared messages. Fully local: serializes
|
|
1122
|
+
* the discarded history, prints it onto PNG frames in the provider-optimal
|
|
1123
|
+
* shape, merges previously archived frames (oldest dropped beyond the
|
|
1124
|
+
* budget), and produces a deterministic summary explaining how to read the
|
|
1125
|
+
* frames. Pages past the frame budget are never rendered (providers with
|
|
1126
|
+
* hard image caps silently drop excess frames on the wire) — the newest
|
|
1127
|
+
* unrendered slice survives verbatim as a text tail on the summary and is
|
|
1128
|
+
* folded back into frames by the next compaction.
|
|
1129
|
+
*
|
|
1130
|
+
* Frames archived under a different shape (provider switches, legacy 5x8
|
|
1131
|
+
* sessions) are kept as-is — each frame carries its own geometry, and the
|
|
1132
|
+
* summary describes the newest shape while noting that older frames may
|
|
1133
|
+
* differ.
|
|
1134
|
+
*
|
|
1135
|
+
* If the previous compaction was text-based, its summary is printed at the
|
|
1136
|
+
* head of the frame archive as `[Summary of earlier history]` so no continuity is lost.
|
|
1137
|
+
*/
|
|
1138
|
+
export async function compact<TMessage = Message>(
|
|
1139
|
+
preparation: CompactionPreparation<TMessage>,
|
|
1140
|
+
options?: Options<TMessage>,
|
|
1141
|
+
): Promise<CompactionResult> {
|
|
1142
|
+
const { firstKeptEntryId, tokensBefore, previousSummary, previousPreserveData, fileOps } = preparation;
|
|
1143
|
+
if (!firstKeptEntryId) {
|
|
1144
|
+
throw new Error("First kept entry has no ID - session may need migration");
|
|
1145
|
+
}
|
|
1146
|
+
const shape = options?.shape ?? resolveShape(options?.model);
|
|
1147
|
+
const frameSize = options?.frameSize ?? shape.frameSize;
|
|
1148
|
+
const maxFrames = Math.max(1, options?.maxFrames ?? MAX_FRAMES);
|
|
1149
|
+
const geo = geometry(shape, frameSize);
|
|
1150
|
+
|
|
1151
|
+
const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
|
1152
|
+
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
|
|
1153
|
+
let archiveText = normalize(serializeConversation(llmMessages, options));
|
|
1154
|
+
|
|
1155
|
+
const previousArchive = getPreservedArchive(previousPreserveData);
|
|
1156
|
+
const includedPreviousSummary = !previousArchive && !!previousSummary;
|
|
1157
|
+
if (includedPreviousSummary && previousSummary) {
|
|
1158
|
+
const head = `[Summary of earlier history] ${normalize(previousSummary)}`;
|
|
1159
|
+
archiveText = archiveText.length > 0 ? `${head} [Recent conversation] ${archiveText}` : head;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
let truncatedChars = previousArchive?.truncatedChars ?? 0;
|
|
1163
|
+
|
|
1164
|
+
// The previous compaction's unframed text tail is the oldest part of this
|
|
1165
|
+
// archive slice — prepend it so it ages into frames first.
|
|
1166
|
+
if (previousArchive?.textTail) {
|
|
1167
|
+
archiveText =
|
|
1168
|
+
archiveText.length > 0
|
|
1169
|
+
? `${previousArchive.textTail}${NEWLINE_GLYPH}${archiveText}`
|
|
1170
|
+
: previousArchive.textTail;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const pages: string[] = [];
|
|
1174
|
+
if (shape.columns === 2) {
|
|
1175
|
+
pages.push(...docPages(archiveText, geo));
|
|
1176
|
+
} else {
|
|
1177
|
+
for (let offset = 0; offset < archiveText.length; offset += geo.capacity) {
|
|
1178
|
+
pages.push(archiveText.slice(offset, offset + geo.capacity));
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Fit the merged archive into the frame budget BEFORE rendering: pages
|
|
1183
|
+
// that cannot ship are never rasterized. Old unpinned frames evict first
|
|
1184
|
+
// (the archive fades oldest-first, as before); new pages that still do
|
|
1185
|
+
// not fit stay behind as a verbatim text tail instead of being dropped.
|
|
1186
|
+
const prevFrames = previousArchive?.frames ?? [];
|
|
1187
|
+
let keptPrev = prevFrames;
|
|
1188
|
+
if (prevFrames.length + pages.length > maxFrames) {
|
|
1189
|
+
// Pin the earliest frame: it anchors the session head (the original
|
|
1190
|
+
// request, or the filmed summary of even older history) the way the
|
|
1191
|
+
// LLM-summary strategies keep the original goal alive across rounds.
|
|
1192
|
+
// With a budget of one frame the pin is moot.
|
|
1193
|
+
const pinCount = maxFrames >= 2 && prevFrames.length > 0 ? 1 : 0;
|
|
1194
|
+
const evictable = prevFrames.slice(pinCount);
|
|
1195
|
+
const surviving = Math.min(evictable.length, Math.max(0, maxFrames - pages.length - pinCount));
|
|
1196
|
+
const dropped = evictable.slice(0, evictable.length - surviving);
|
|
1197
|
+
for (const frame of dropped) truncatedChars += frame.chars;
|
|
1198
|
+
keptPrev = [...prevFrames.slice(0, pinCount), ...evictable.slice(evictable.length - surviving)];
|
|
1199
|
+
}
|
|
1200
|
+
const renderPages = pages.slice(0, maxFrames - keptPrev.length);
|
|
1201
|
+
const tailPages = pages.slice(renderPages.length);
|
|
1202
|
+
|
|
1203
|
+
const newFrames: Frame[] = [];
|
|
1204
|
+
const finish = pageFinisher(shape);
|
|
1205
|
+
for (const page of renderPages) {
|
|
1206
|
+
const rendered = render(finish(page), shape, frameSize);
|
|
1207
|
+
newFrames.push({
|
|
1208
|
+
data: rendered.data,
|
|
1209
|
+
mimeType: "image/png",
|
|
1210
|
+
cols: rendered.cols,
|
|
1211
|
+
rows: rendered.rows,
|
|
1212
|
+
chars: rendered.chars,
|
|
1213
|
+
font: shape.font,
|
|
1214
|
+
variant: shape.variant,
|
|
1215
|
+
lineRepeat: shape.lineRepeat,
|
|
1216
|
+
...(shape.columns === 2 ? { columns: 2 } : {}),
|
|
1217
|
+
...(shape.stopwordDim ? { stopwordDim: true } : {}),
|
|
1218
|
+
...(shape.imageDetail ? { detail: shape.imageDetail } : {}),
|
|
1219
|
+
});
|
|
1220
|
+
// Keep the event loop responsive between native render passes.
|
|
1221
|
+
await Bun.sleep(0);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Pages past the budget survive as text, capped at two frames' capacity
|
|
1225
|
+
// (middle-elided) so an oversized archive cannot blow the context back up.
|
|
1226
|
+
let textTail = "";
|
|
1227
|
+
if (tailPages.length > 0) {
|
|
1228
|
+
const raw =
|
|
1229
|
+
shape.columns === 2 ? tailPages.map(page => page.replaceAll("\n", " ")).join(" ") : tailPages.join("");
|
|
1230
|
+
const tailCap = TEXT_TAIL_MAX_PAGES * geo.capacity;
|
|
1231
|
+
if (raw.length > tailCap) truncatedChars += raw.length - tailCap;
|
|
1232
|
+
// Re-open a dim span the render boundary cut through, so the carried
|
|
1233
|
+
// tail keeps tool output dim when it lands on frames next compaction.
|
|
1234
|
+
const renderedText = shape.columns === 2 ? renderPages.join("\n") : renderPages.join("");
|
|
1235
|
+
const dimOpen = renderedText.lastIndexOf(DIM_ON) > renderedText.lastIndexOf(DIM_OFF);
|
|
1236
|
+
textTail = (dimOpen ? DIM_ON : "") + truncateForSummary(raw, tailCap, TRUNCATE_HEAD_RATIO);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const frames = [...keptPrev, ...newFrames];
|
|
1240
|
+
const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0);
|
|
1241
|
+
const mixedShapes = frames.some(
|
|
1242
|
+
frame =>
|
|
1243
|
+
frame.cols !== geo.cols ||
|
|
1244
|
+
frame.rows !== geo.rows ||
|
|
1245
|
+
(frame.variant ?? "sent") !== shape.variant ||
|
|
1246
|
+
(frame.lineRepeat ?? 1) !== shape.lineRepeat ||
|
|
1247
|
+
(frame.columns ?? 1) !== (shape.columns ?? 1) ||
|
|
1248
|
+
(frame.stopwordDim ?? false) !== (shape.stopwordDim ?? false),
|
|
1249
|
+
);
|
|
1250
|
+
|
|
1251
|
+
let summary: string;
|
|
1252
|
+
if (frames.length === 0) {
|
|
1253
|
+
summary = "No prior history.";
|
|
1254
|
+
} else {
|
|
1255
|
+
summary = prompt.render(snapcompactSummaryPrompt, {
|
|
1256
|
+
frameCount: frames.length,
|
|
1257
|
+
multipleFrames: frames.length > 1,
|
|
1258
|
+
fontCell: `${shape.cellWidth}x${shape.cellHeight}`,
|
|
1259
|
+
cols: geo.cols,
|
|
1260
|
+
rows: geo.rows,
|
|
1261
|
+
sentenceInk: shape.variant === "sent",
|
|
1262
|
+
lineRepeated: shape.lineRepeat > 1,
|
|
1263
|
+
docColumns: shape.columns === 2,
|
|
1264
|
+
stopwordDimmed: shape.stopwordDim === true,
|
|
1265
|
+
dimmedToolResults: options?.dimToolResults !== false,
|
|
1266
|
+
mixedShapes,
|
|
1267
|
+
totalChars,
|
|
1268
|
+
truncatedChars,
|
|
1269
|
+
includedPreviousSummary,
|
|
1270
|
+
textTail: textTail.length > 0 ? toPlainText(textTail) : undefined,
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
1274
|
+
summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
|
|
1275
|
+
|
|
1276
|
+
// A snapcompact pass replaces any provider-side replacement history; strip the
|
|
1277
|
+
// OpenAI remote-compaction payload like the default summarizer path does.
|
|
1278
|
+
const basePreserve = stripOpenAiRemoteCompactionPreserveData(previousPreserveData) ?? {};
|
|
1279
|
+
const archive: Archive = { frames, totalChars, truncatedChars, ...(textTail ? { textTail } : {}) };
|
|
1280
|
+
|
|
1281
|
+
const textTailNote = textTail ? ` (+${textTail.length.toLocaleString()} chars as text)` : "";
|
|
1282
|
+
return {
|
|
1283
|
+
summary,
|
|
1284
|
+
shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}${textTailNote}`,
|
|
1285
|
+
firstKeptEntryId,
|
|
1286
|
+
tokensBefore,
|
|
1287
|
+
details: { readFiles, modifiedFiles },
|
|
1288
|
+
preserveData: { ...basePreserve, [PRESERVE_KEY]: archive },
|
|
1289
|
+
};
|
|
1290
|
+
}
|