@oh-my-pi-zen/snapcompact 16.3.6-zen.1
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 +172 -0
- package/README.md +72 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/snapcompact.d.ts +586 -0
- package/package.json +65 -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 +1949 -0
|
@@ -0,0 +1,1949 @@
|
|
|
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. `providerImageBudget`
|
|
35
|
+
* still caps per-request images per provider so inline imaging cannot flood a
|
|
36
|
+
* request with attachments, but the old OpenRouter-specific 8-image cap is
|
|
37
|
+
* gone; routers now use the same permissive budget as direct Anthropic/Claude
|
|
38
|
+
* lines unless configured otherwise upstream.
|
|
39
|
+
*
|
|
40
|
+
* The whole pass is local and deterministic — no LLM call, no API key, no
|
|
41
|
+
* latency beyond rendering. Rasterization and PNG encoding happen in native
|
|
42
|
+
* code (`renderSnapcompactPng` in `crates/pi-natives/src/snapcompact.rs`).
|
|
43
|
+
* Frames persist in the compaction entry's `preserveData` and are
|
|
44
|
+
* re-attached to the compaction summary message on every context rebuild.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import type { Api, ImageContent, Message, TextContent } from "@oh-my-pi-zen/pi-ai";
|
|
48
|
+
import { renderSnapcompactPng, snapcompactSupportedChars } from "@oh-my-pi-zen/pi-natives";
|
|
49
|
+
import { formatGroupedPaths, prompt } from "@oh-my-pi-zen/pi-utils";
|
|
50
|
+
import { INTENT_FIELD } from "@oh-my-pi-zen/pi-wire";
|
|
51
|
+
import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
|
|
52
|
+
import snapcompactSummaryPrompt from "./prompts/snapcompact-summary.md" with { type: "text" };
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// Shapes
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
/** One eval-validated frame shape: font, cell, ink, repetition, and size. */
|
|
59
|
+
export interface Shape {
|
|
60
|
+
/** Bundled font in the native renderer. */
|
|
61
|
+
font: "5x8" | "8x8" | "6x12" | "8x13" | "silver";
|
|
62
|
+
/** Target cell advance in pixels; differing from the font's natural cell
|
|
63
|
+
* renders via Lanczos stretch (anti-aliased RGB frame). */
|
|
64
|
+
cellWidth: number;
|
|
65
|
+
/** Target cell pitch in pixels. */
|
|
66
|
+
cellHeight: number;
|
|
67
|
+
/** `false` → glyphs drawn at natural size on the cell pitch (8on16);
|
|
68
|
+
* `true`/`undefined` → legacy auto Lanczos stretch when cell ≠ natural. */
|
|
69
|
+
stretch?: boolean;
|
|
70
|
+
/** Ink: `sent` cycles six hues at sentence boundaries; `bw` is black. */
|
|
71
|
+
variant: "sent" | "bw";
|
|
72
|
+
/** Print stopwords in dim ink (research `dim`/`sent-dim` variants). */
|
|
73
|
+
stopwordDim?: boolean;
|
|
74
|
+
/** 1/undefined = row-major grid; 2 = two word-wrapped newspaper columns
|
|
75
|
+
* (research `doc`). */
|
|
76
|
+
columns?: number;
|
|
77
|
+
/** Each text line is printed this many times; copies after the first sit
|
|
78
|
+
* on a pale highlight band (redundancy coding). */
|
|
79
|
+
lineRepeat: number;
|
|
80
|
+
/** Frame edge in pixels. */
|
|
81
|
+
frameSize: number;
|
|
82
|
+
/** Per-frame billed-token estimate for the shape's target provider. */
|
|
83
|
+
frameTokenEstimate: number;
|
|
84
|
+
/** Resolution hint attached to frame images (OpenAI-only). */
|
|
85
|
+
imageDetail?: ImageContent["detail"];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Geometry half of a {@link Shape}: everything except provider billing. */
|
|
89
|
+
export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "imageDetail">;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Frame variants exercised by the SQuAD evals in `research/` that the native
|
|
93
|
+
* renderer reproduces faithfully, keyed by their research names. Font codes:
|
|
94
|
+
* `8x8u` unscii square cell, `8x8r` unscii with every line printed twice
|
|
95
|
+
* (redundancy coding), `6x6u` unscii Lanczos-squeezed to 6x6 (densest
|
|
96
|
+
* readable cell), `5x8` the X.org legacy font on its 2576px frame, `6x12`
|
|
97
|
+
* and `8x13` the X.org misc fonts, `8on16` 8x13 glyphs on an 8x16 cell pitch
|
|
98
|
+
* (no stretch, extra leading), `8on22` the same glyphs on a 22px pitch (more
|
|
99
|
+
* leading), `11on16` the same glyphs on an 11px advance (more tracking),
|
|
100
|
+
* `silver16` the embedded Silver TrueType font on a 16px grid for CJK and
|
|
101
|
+
* other non-Latin text, and `doc-` prefixed shapes a two-column word-wrapped
|
|
102
|
+
* newspaper layout. Ink: `sent` cycles six hues at sentence boundaries, `bw`
|
|
103
|
+
* is plain black, `-dim` suffix prints stopwords in gray.
|
|
104
|
+
*/
|
|
105
|
+
export const SHAPE_VARIANTS = {
|
|
106
|
+
"8x8r-bw": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "bw", lineRepeat: 2, frameSize: 1568 },
|
|
107
|
+
"8x8r-sent": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "sent", lineRepeat: 2, frameSize: 1568 },
|
|
108
|
+
"8x8u-bw": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "bw", lineRepeat: 1, frameSize: 1568 },
|
|
109
|
+
"8x8u-sent": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "sent", lineRepeat: 1, frameSize: 1568 },
|
|
110
|
+
"6x6u-bw": { font: "8x8", cellWidth: 6, cellHeight: 6, variant: "bw", lineRepeat: 1, frameSize: 1568 },
|
|
111
|
+
"6x6u-sent": { font: "8x8", cellWidth: 6, cellHeight: 6, variant: "sent", lineRepeat: 1, frameSize: 1568 },
|
|
112
|
+
"5x8-bw": { font: "5x8", cellWidth: 5, cellHeight: 8, variant: "bw", lineRepeat: 1, frameSize: 2576 },
|
|
113
|
+
"5x8-sent": { font: "5x8", cellWidth: 5, cellHeight: 8, variant: "sent", lineRepeat: 1, frameSize: 2576 },
|
|
114
|
+
"6x12-dim": {
|
|
115
|
+
font: "6x12",
|
|
116
|
+
cellWidth: 6,
|
|
117
|
+
cellHeight: 12,
|
|
118
|
+
variant: "bw",
|
|
119
|
+
stopwordDim: true,
|
|
120
|
+
lineRepeat: 1,
|
|
121
|
+
frameSize: 1568,
|
|
122
|
+
},
|
|
123
|
+
"8x13-bw": { font: "8x13", cellWidth: 8, cellHeight: 13, variant: "bw", lineRepeat: 1, frameSize: 1568 },
|
|
124
|
+
"8on16-bw": {
|
|
125
|
+
font: "8x13",
|
|
126
|
+
cellWidth: 8,
|
|
127
|
+
cellHeight: 16,
|
|
128
|
+
stretch: false,
|
|
129
|
+
variant: "bw",
|
|
130
|
+
lineRepeat: 1,
|
|
131
|
+
frameSize: 1568,
|
|
132
|
+
},
|
|
133
|
+
"8on22-bw": {
|
|
134
|
+
font: "8x13",
|
|
135
|
+
cellWidth: 8,
|
|
136
|
+
cellHeight: 22,
|
|
137
|
+
stretch: false,
|
|
138
|
+
variant: "bw",
|
|
139
|
+
lineRepeat: 1,
|
|
140
|
+
frameSize: 1568,
|
|
141
|
+
},
|
|
142
|
+
"11on16-bw": {
|
|
143
|
+
font: "8x13",
|
|
144
|
+
cellWidth: 11,
|
|
145
|
+
cellHeight: 16,
|
|
146
|
+
stretch: false,
|
|
147
|
+
variant: "bw",
|
|
148
|
+
lineRepeat: 1,
|
|
149
|
+
frameSize: 1568,
|
|
150
|
+
},
|
|
151
|
+
"silver16-bw": {
|
|
152
|
+
font: "silver",
|
|
153
|
+
cellWidth: 16,
|
|
154
|
+
cellHeight: 16,
|
|
155
|
+
variant: "bw",
|
|
156
|
+
lineRepeat: 1,
|
|
157
|
+
frameSize: 1568,
|
|
158
|
+
},
|
|
159
|
+
"doc-8on16-bw": {
|
|
160
|
+
font: "8x13",
|
|
161
|
+
cellWidth: 8,
|
|
162
|
+
cellHeight: 16,
|
|
163
|
+
stretch: false,
|
|
164
|
+
variant: "bw",
|
|
165
|
+
columns: 2,
|
|
166
|
+
lineRepeat: 1,
|
|
167
|
+
frameSize: 1568,
|
|
168
|
+
},
|
|
169
|
+
"doc-8on16-sent": {
|
|
170
|
+
font: "8x13",
|
|
171
|
+
cellWidth: 8,
|
|
172
|
+
cellHeight: 16,
|
|
173
|
+
stretch: false,
|
|
174
|
+
variant: "sent",
|
|
175
|
+
columns: 2,
|
|
176
|
+
lineRepeat: 1,
|
|
177
|
+
frameSize: 1568,
|
|
178
|
+
},
|
|
179
|
+
"doc-8on16-sent-dim": {
|
|
180
|
+
font: "8x13",
|
|
181
|
+
cellWidth: 8,
|
|
182
|
+
cellHeight: 16,
|
|
183
|
+
stretch: false,
|
|
184
|
+
variant: "sent",
|
|
185
|
+
stopwordDim: true,
|
|
186
|
+
columns: 2,
|
|
187
|
+
lineRepeat: 1,
|
|
188
|
+
frameSize: 1568,
|
|
189
|
+
},
|
|
190
|
+
} as const satisfies Record<string, ShapeGeometry>;
|
|
191
|
+
|
|
192
|
+
/** Research name of one renderable frame variant. */
|
|
193
|
+
export type ShapeVariantName = keyof typeof SHAPE_VARIANTS;
|
|
194
|
+
|
|
195
|
+
/** All variant names, in declaration order (for settings enums). */
|
|
196
|
+
export const SHAPE_VARIANT_NAMES = Object.keys(SHAPE_VARIANTS) as readonly ShapeVariantName[];
|
|
197
|
+
|
|
198
|
+
/** Runtime guard for variant names loaded from config. */
|
|
199
|
+
export function isShapeVariantName(value: unknown): value is ShapeVariantName {
|
|
200
|
+
return typeof value === "string" && value in SHAPE_VARIANTS;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Provider families with distinct image billing. */
|
|
204
|
+
type BillingFamily = "anthropic" | "google" | "openai";
|
|
205
|
+
|
|
206
|
+
function billingFamily(api?: Api): BillingFamily {
|
|
207
|
+
switch (api) {
|
|
208
|
+
case "openai-completions":
|
|
209
|
+
case "openai-responses":
|
|
210
|
+
case "openai-codex-responses":
|
|
211
|
+
case "azure-openai-responses":
|
|
212
|
+
return "openai";
|
|
213
|
+
case "google-generative-ai":
|
|
214
|
+
case "google-gemini-cli":
|
|
215
|
+
case "google-vertex":
|
|
216
|
+
return "google";
|
|
217
|
+
default:
|
|
218
|
+
// anthropic-messages, bedrock-converse-stream, and anything unknown
|
|
219
|
+
// share Anthropic's pixel-area pricing as the safe ceiling.
|
|
220
|
+
return "anthropic";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Per-frame billing for a square frame of edge `frameSize`, by family.
|
|
226
|
+
* Formulas verified against live bills in the resolution benchmarks:
|
|
227
|
+
* - Anthropic: 28px patches, capped at 4,784 visual tokens (the API
|
|
228
|
+
* downscales past the cap; 1568 → 3,136 measured) + 5% margin.
|
|
229
|
+
* - Google: Gemini 3.x bills a fixed `media_resolution` budget per image —
|
|
230
|
+
* default HIGH = 1,120 tokens — regardless of pixel size.
|
|
231
|
+
* - OpenAI: 32px patches × 1.2 flagship multiplier, 10,000-patch budget at
|
|
232
|
+
* `detail: "original"` (1568 → 2,881 measured).
|
|
233
|
+
*/
|
|
234
|
+
function familyBilling(family: BillingFamily, frameSize: number): Pick<Shape, "frameTokenEstimate" | "imageDetail"> {
|
|
235
|
+
switch (family) {
|
|
236
|
+
case "google":
|
|
237
|
+
return { frameTokenEstimate: 1120 };
|
|
238
|
+
case "openai": {
|
|
239
|
+
const patches = Math.min(Math.ceil(frameSize / 32) ** 2, 10_000);
|
|
240
|
+
return { frameTokenEstimate: Math.ceil(patches * 1.2), imageDetail: "original" };
|
|
241
|
+
}
|
|
242
|
+
default: {
|
|
243
|
+
const patches = Math.min(Math.ceil(frameSize / 28) ** 2, 4784);
|
|
244
|
+
return { frameTokenEstimate: Math.ceil(patches * 1.05) };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Attach a provider family's billing to a variant geometry. */
|
|
250
|
+
function priceShape(base: ShapeGeometry, family: BillingFamily): Shape {
|
|
251
|
+
return { ...base, ...familyBilling(family, base.frameSize) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Eval-validated shapes, keyed by the provider family they won on. */
|
|
255
|
+
export const SHAPES = {
|
|
256
|
+
/** `11on16-bw`: 8x13 glyphs on an 11px advance (extra tracking), black ink.
|
|
257
|
+
* Tool-result legibility bench (real search/read/find output, structure QA)
|
|
258
|
+
* on opus-4.8: f1 .806 vs .755 for plain `8on16-bw` and .351 for the prior
|
|
259
|
+
* `6x12-dim` default — letter-spacing the readable cell wins; the dense
|
|
260
|
+
* 6x12 was below the OCR ~16px/char floor and abstained. */
|
|
261
|
+
anthropic: priceShape(SHAPE_VARIANTS["11on16-bw"], "anthropic"),
|
|
262
|
+
/** `8on22-bw`: 8x13 glyphs on a 22px pitch (extra leading), black ink.
|
|
263
|
+
* Tool-result legibility bench on gemini-3.5-flash: f1 .934 vs .807 for
|
|
264
|
+
* plain `8on16-bw` and .287 for the prior `doc-8on16-sent-dim`; the
|
|
265
|
+
* line-spacing reduces row crowding so line numbers stay legible. */
|
|
266
|
+
google: priceShape(SHAPE_VARIANTS["8on22-bw"], "google"),
|
|
267
|
+
/** `8on22-bw`: 8x13 glyphs on a 22px pitch (extra leading), black ink.
|
|
268
|
+
* Same line-spacing win for OpenAI; bench on gpt-5.5/gpt-5.4-mini showed
|
|
269
|
+
* leading lifts recall on the readable cell over plain `8on16-bw`. */
|
|
270
|
+
openai: priceShape(SHAPE_VARIANTS["8on22-bw"], "openai"),
|
|
271
|
+
/** Original 5x8 X.org shape (pre-shape-table sessions rendered this). */
|
|
272
|
+
legacy: priceShape(SHAPE_VARIANTS["5x8-sent"], "anthropic"),
|
|
273
|
+
} satisfies Record<string, Shape>;
|
|
274
|
+
|
|
275
|
+
/** Runtime guard for shape overrides loaded from config or preserve data. */
|
|
276
|
+
export function isShape(value: unknown): value is Shape {
|
|
277
|
+
if (!value || typeof value !== "object") return false;
|
|
278
|
+
const shape = value as Record<string, unknown>;
|
|
279
|
+
const font = shape.font;
|
|
280
|
+
const variant = shape.variant;
|
|
281
|
+
const detail = shape.imageDetail;
|
|
282
|
+
return (
|
|
283
|
+
(font === "5x8" || font === "8x8" || font === "6x12" || font === "8x13" || font === "silver") &&
|
|
284
|
+
typeof shape.cellWidth === "number" &&
|
|
285
|
+
shape.cellWidth > 0 &&
|
|
286
|
+
typeof shape.cellHeight === "number" &&
|
|
287
|
+
shape.cellHeight > 0 &&
|
|
288
|
+
(shape.stretch === undefined || typeof shape.stretch === "boolean") &&
|
|
289
|
+
(variant === "sent" || variant === "bw") &&
|
|
290
|
+
(shape.stopwordDim === undefined || typeof shape.stopwordDim === "boolean") &&
|
|
291
|
+
(shape.columns === undefined || shape.columns === 1 || shape.columns === 2) &&
|
|
292
|
+
typeof shape.lineRepeat === "number" &&
|
|
293
|
+
shape.lineRepeat > 0 &&
|
|
294
|
+
typeof shape.frameSize === "number" &&
|
|
295
|
+
shape.frameSize > 0 &&
|
|
296
|
+
typeof shape.frameTokenEstimate === "number" &&
|
|
297
|
+
shape.frameTokenEstimate > 0 &&
|
|
298
|
+
(detail === undefined || detail === "auto" || detail === "low" || detail === "high" || detail === "original")
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Eval-winning variant per provider family (billing fallback when the
|
|
303
|
+
* model id matches no known reader line). */
|
|
304
|
+
const FAMILY_VARIANT: Record<BillingFamily, ShapeVariantName> = {
|
|
305
|
+
anthropic: "11on16-bw",
|
|
306
|
+
google: "8on22-bw",
|
|
307
|
+
openai: "8on22-bw",
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/** Denser companion variant per family for the foveated archive middle: same
|
|
311
|
+
* pixels (identical per-frame bill) but a tighter 8px cell, trading some
|
|
312
|
+
* legibility for ~40% more chars per frame so the least-important middle of a
|
|
313
|
+
* long archive compresses into fewer frames. */
|
|
314
|
+
const FAMILY_VARIANT_LOW: Record<BillingFamily, ShapeVariantName> = {
|
|
315
|
+
anthropic: "8on16-bw",
|
|
316
|
+
google: "8on16-bw",
|
|
317
|
+
openai: "8on16-bw",
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const FAMILY_SHAPE: Record<BillingFamily, Shape> = {
|
|
321
|
+
anthropic: SHAPES.anthropic,
|
|
322
|
+
google: SHAPES.google,
|
|
323
|
+
openai: SHAPES.openai,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** One model line's ideal format: variant plus an optional frame-size
|
|
327
|
+
* override when the line reads larger frames at no extra cost. */
|
|
328
|
+
export interface IdealShape {
|
|
329
|
+
variant: ShapeVariantName;
|
|
330
|
+
frameSize?: number;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Eval-winning format per model line, matched against the model id. The
|
|
334
|
+
* wire API only identifies the gateway — a Claude served through Vertex or
|
|
335
|
+
* OpenRouter still reads best with its own shape. Patterns cover the model
|
|
336
|
+
* lines the mono evals measured; everything else falls back to the API
|
|
337
|
+
* family's winner at the standard 1568px frame. First match wins. */
|
|
338
|
+
const MODEL_VARIANTS: readonly (readonly [RegExp, IdealShape])[] = [
|
|
339
|
+
// Opus 4.7+ and Fable/Mythos read high-res natively (2576px edge under a
|
|
340
|
+
// 4,784 visual-token cap → 1932px square sweet spot): same recall and
|
|
341
|
+
// cost as 1568, a third fewer frames.
|
|
342
|
+
[/claude.*(fable|mythos)/i, { variant: "11on16-bw", frameSize: 1932 }],
|
|
343
|
+
[/claude-?opus-?4[.-][7-9]/i, { variant: "11on16-bw", frameSize: 1932 }],
|
|
344
|
+
// Older Claude lines downscale past 1568px — keep the safe size.
|
|
345
|
+
[/claude/i, { variant: "11on16-bw" }],
|
|
346
|
+
// Gemini 3.x bills a fixed 1,120-token budget per image regardless of
|
|
347
|
+
// pixels: 2048px packs more chars per frame at the same bill.
|
|
348
|
+
[/gemini/i, { variant: "8on22-bw", frameSize: 2048 }],
|
|
349
|
+
// gpt-5.5 patch billing is area-proportional; 1568 is already optimal.
|
|
350
|
+
[/gpt|codex/i, { variant: "8on22-bw" }],
|
|
351
|
+
// kimi's image processor downscales past 1792px (64×64 28px patches);
|
|
352
|
+
// 1568 wins on chars/$ and reads at f1 .973 (≤8 frames per request).
|
|
353
|
+
[/kimi/i, { variant: "8on16-bw" }],
|
|
354
|
+
// glm-4.6v .780 mono via direct vendor routing.
|
|
355
|
+
[/glm/i, { variant: "8on16-bw" }],
|
|
356
|
+
];
|
|
357
|
+
|
|
358
|
+
/** Eval-ideal format for a model id, or undefined when unmeasured. */
|
|
359
|
+
export function idealShapeVariant(modelId: string): IdealShape | undefined {
|
|
360
|
+
return MODEL_VARIANTS.find(([pattern]) => pattern.test(modelId))?.[1];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** What will read the frames: the wire API (billing) and model id (shape). */
|
|
364
|
+
export interface ShapeTarget {
|
|
365
|
+
api?: Api;
|
|
366
|
+
id?: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Pick the frame shape for a reader. An explicit `variant` (anything but
|
|
371
|
+
* `"auto"`) forces that geometry; otherwise the model id selects the
|
|
372
|
+
* eval-winning shape — and frame size — for its model line, falling back to
|
|
373
|
+
* the API family's winner when the model is unmeasured. Billing (token
|
|
374
|
+
* estimate, detail hint) always follows the API family actually carrying
|
|
375
|
+
* the request, computed for the resolved frame size. Accepts a full pi-ai
|
|
376
|
+
* `Model` or any `{ api, id }` subset.
|
|
377
|
+
*/
|
|
378
|
+
export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape {
|
|
379
|
+
const family = billingFamily(model?.api);
|
|
380
|
+
if (variant && variant !== "auto") return priceShape(SHAPE_VARIANTS[variant], family);
|
|
381
|
+
const ideal = model?.id ? idealShapeVariant(model.id) : undefined;
|
|
382
|
+
const name = ideal?.variant ?? FAMILY_VARIANT[family];
|
|
383
|
+
if (name === FAMILY_VARIANT[family] && ideal?.frameSize === undefined) return FAMILY_SHAPE[family];
|
|
384
|
+
const base = SHAPE_VARIANTS[name];
|
|
385
|
+
return priceShape(ideal?.frameSize ? { ...base, frameSize: ideal.frameSize } : base, family);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Pick the frame shape for `text` without changing the selected shape.
|
|
390
|
+
*
|
|
391
|
+
* Glyph-level Silver fallback happens during normalization/rendering, so this
|
|
392
|
+
* helper exists for callers that need a text-aware API name while preserving
|
|
393
|
+
* explicit and provider-selected shapes.
|
|
394
|
+
*/
|
|
395
|
+
export function resolveShapeForText(_text: string, model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape {
|
|
396
|
+
return resolveShape(model, variant);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ============================================================================
|
|
400
|
+
// Constants
|
|
401
|
+
// ============================================================================
|
|
402
|
+
|
|
403
|
+
/** Legacy frame edge in pixels (the 5x8 shape's eval-validated size). New
|
|
404
|
+
* shapes carry their own `frameSize`. */
|
|
405
|
+
export const FRAME_SIZE = 2576;
|
|
406
|
+
|
|
407
|
+
/** Default upper bound on archive frames carried per compaction. Sized to hold
|
|
408
|
+
* ~400k tokens of the high-res Anthropic frame Opus reads (1932px ≈ 5,000
|
|
409
|
+
* billed tokens each → 80 frames) while staying under the ~100-image
|
|
410
|
+
* per-request wire cap. Oldest frames are dropped first once the budget is
|
|
411
|
+
* exceeded (mirrors how iterative text summaries fade the oldest detail); a
|
|
412
|
+
* caller may pass a lower `maxFrames` upper limit, and per-model context
|
|
413
|
+
* fitting is handled by the caller's overflow guard. */
|
|
414
|
+
export const MAX_FRAMES_DEFAULT = 80;
|
|
415
|
+
|
|
416
|
+
/** High-quality (legible) frames rendered at each chronological edge of a
|
|
417
|
+
* foveated archive — the session head (oldest) and the slice just before the
|
|
418
|
+
* text region (newest) — with the denser low-quality tier filling the middle. */
|
|
419
|
+
export const HQ_EDGE_FRAMES = 3;
|
|
420
|
+
|
|
421
|
+
/** Conservative per-frame token estimate used for context budgeting — the
|
|
422
|
+
* upper bound across shapes: high-res Claude frames hit the 4,784 visual-token
|
|
423
|
+
* cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
|
|
424
|
+
* undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
|
|
425
|
+
export const FRAME_TOKEN_ESTIMATE = 5024;
|
|
426
|
+
|
|
427
|
+
/** Conservative upper bound for one persisted frame's base64 payload. The
|
|
428
|
+
* measured high-res Anthropic `8x13`/`11on16` PNG frames sit around 159 KB;
|
|
429
|
+
* 170 KB leaves margin for denser glyph pages without permitting multi-MB
|
|
430
|
+
* standing request bodies at large context windows. */
|
|
431
|
+
export const FRAME_DATA_BYTES_ESTIMATE = 170_000;
|
|
432
|
+
|
|
433
|
+
/** Maximum snapcompact image base64 carried in every rebuilt provider request.
|
|
434
|
+
* Above this, provider backends can accept the HTTP body but fail mid-stream
|
|
435
|
+
* with opaque 5xx errors. Keep this independent from visual-token budgeting:
|
|
436
|
+
* a 1M-token model can afford 70 images on paper, but not the resulting
|
|
437
|
+
* ~11 MB JSON payload on every turn. */
|
|
438
|
+
export const FRAME_DATA_BYTES_BUDGET = 3_000_000;
|
|
439
|
+
|
|
440
|
+
/** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET}. */
|
|
441
|
+
export function maxFramesForDataBudget(maxFrameDataBytes: number = FRAME_DATA_BYTES_BUDGET): number {
|
|
442
|
+
return Math.max(1, Math.floor(maxFrameDataBytes / FRAME_DATA_BYTES_ESTIMATE));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Base64 byte length for persisted snapcompact frames. */
|
|
446
|
+
export function frameDataBytes(frames: readonly Pick<Frame, "data">[]): number {
|
|
447
|
+
return frames.reduce((sum, frame) => sum + frame.data.length, 0);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Per-request image-count budgets by provider id. These cap how many images an
|
|
452
|
+
* entire request may carry (archive/system-prompt/tool-result imaging combined).
|
|
453
|
+
* The values are conservative policy caps under the vendor hard limits
|
|
454
|
+
* (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe
|
|
455
|
+
* floor rather than sending unbounded attachments.
|
|
456
|
+
*/
|
|
457
|
+
export const PROVIDER_IMAGE_BUDGETS: Record<string, number> = {
|
|
458
|
+
anthropic: 90,
|
|
459
|
+
"amazon-bedrock": 90,
|
|
460
|
+
openai: 200,
|
|
461
|
+
"openai-codex": 200,
|
|
462
|
+
google: 200,
|
|
463
|
+
"google-vertex": 200,
|
|
464
|
+
"google-gemini-cli": 200,
|
|
465
|
+
openrouter: 90,
|
|
466
|
+
umans: 10,
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
|
|
470
|
+
export const DEFAULT_PROVIDER_IMAGE_BUDGET = 5;
|
|
471
|
+
|
|
472
|
+
/** Per-request image budget for `provider`; unknown providers get the floor. */
|
|
473
|
+
export function providerImageBudget(provider: string | undefined): number {
|
|
474
|
+
return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Key under `CompactionEntry.preserveData` holding the frame archive. */
|
|
478
|
+
export const PRESERVE_KEY = "snapcompact";
|
|
479
|
+
|
|
480
|
+
// ============================================================================
|
|
481
|
+
// Types
|
|
482
|
+
// ============================================================================
|
|
483
|
+
|
|
484
|
+
/** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
|
|
485
|
+
export interface Frame {
|
|
486
|
+
/** Base64-encoded PNG. */
|
|
487
|
+
data: string;
|
|
488
|
+
mimeType: string;
|
|
489
|
+
/** Characters per row in the frame grid (per-column width on doc frames). */
|
|
490
|
+
cols: number;
|
|
491
|
+
/** Text rows in the frame grid (unique lines, not repeated copies). */
|
|
492
|
+
rows: number;
|
|
493
|
+
/** Characters actually printed onto this frame. */
|
|
494
|
+
chars: number;
|
|
495
|
+
/** Shape metadata (absent on legacy frames, which are 5x8 `sent`). */
|
|
496
|
+
font?: Shape["font"];
|
|
497
|
+
variant?: Shape["variant"];
|
|
498
|
+
lineRepeat?: number;
|
|
499
|
+
/** 2 on two-column doc frames; absent on row-major grid frames. */
|
|
500
|
+
columns?: number;
|
|
501
|
+
/** True when stopwords were printed in dim ink. */
|
|
502
|
+
stopwordDim?: boolean;
|
|
503
|
+
/** Resolution hint forwarded to the provider when re-attaching. */
|
|
504
|
+
detail?: ImageContent["detail"];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Frame archive persisted under `preserveData[PRESERVE_KEY]`. */
|
|
508
|
+
export interface Archive {
|
|
509
|
+
/** Rendered frames ordered oldest to newest, re-derived from {@link text}
|
|
510
|
+
* each compaction with foveated quality tiers (HQ/LQ/HQ inside the imaged
|
|
511
|
+
* middle). May be empty when the whole archive fits in text. */
|
|
512
|
+
frames: Frame[];
|
|
513
|
+
/** Characters currently readable across all frames plus the text regions. */
|
|
514
|
+
totalChars: number;
|
|
515
|
+
/** Characters dropped so far to respect the archive budget. */
|
|
516
|
+
truncatedChars: number;
|
|
517
|
+
/** Full kept archive source (oldest to newest, normalized, bounded to the
|
|
518
|
+
* rendered budget) — the single source re-rendered each compaction. */
|
|
519
|
+
text?: string;
|
|
520
|
+
/** Oldest text region kept verbatim around the imaged middle. */
|
|
521
|
+
textHead?: string;
|
|
522
|
+
/** Newest text region kept verbatim around the imaged middle. */
|
|
523
|
+
textTail?: string;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export interface Geometry {
|
|
527
|
+
/** Characters per row (per-column line width when `columns === 2`). */
|
|
528
|
+
cols: number;
|
|
529
|
+
rows: number;
|
|
530
|
+
/** Characters that fit one frame (nominal upper bound on doc shapes,
|
|
531
|
+
* where real consumption is wrap-dependent). */
|
|
532
|
+
capacity: number;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export interface Options<TMessage = Message> extends SerializeOptions {
|
|
536
|
+
/** App-level message transformer (same contract as agent-core's `SummaryOptions.convertToLlm`). */
|
|
537
|
+
convertToLlm?: ConvertToLlm<TMessage>;
|
|
538
|
+
/** Model whose provider API and id select the frame shape. */
|
|
539
|
+
model?: ShapeTarget;
|
|
540
|
+
/** Explicit shape override; wins over `model`. */
|
|
541
|
+
shape?: Shape;
|
|
542
|
+
/** Frame edge in pixels. Defaults to the shape's `frameSize`. */
|
|
543
|
+
frameSize?: number;
|
|
544
|
+
/** Upper limit on archive frames; clamped to (and defaulting to) {@link MAX_FRAMES_DEFAULT}. */
|
|
545
|
+
maxFrames?: number;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Result of rendering one frame. */
|
|
549
|
+
export interface RenderedFrame {
|
|
550
|
+
/** Base64-encoded PNG, as returned by the native renderer. */
|
|
551
|
+
data: string;
|
|
552
|
+
cols: number;
|
|
553
|
+
rows: number;
|
|
554
|
+
/** Characters printed (ink toggles excluded; input may be shorter than capacity). */
|
|
555
|
+
chars: number;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ============================================================================
|
|
559
|
+
// Compaction data contracts
|
|
560
|
+
// ============================================================================
|
|
561
|
+
|
|
562
|
+
export interface FileOperations {
|
|
563
|
+
read: Set<string>;
|
|
564
|
+
written: Set<string>;
|
|
565
|
+
edited: Set<string>;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export interface CompactionDetails {
|
|
569
|
+
readFiles: string[];
|
|
570
|
+
modifiedFiles: string[];
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export interface CompactionPreparation<TMessage = Message> {
|
|
574
|
+
/** UUID of first entry to keep. */
|
|
575
|
+
firstKeptEntryId: string;
|
|
576
|
+
/** Messages that will be archived and discarded. */
|
|
577
|
+
messagesToSummarize: TMessage[];
|
|
578
|
+
/** Messages that will be archived as the split-turn prefix, if any. */
|
|
579
|
+
turnPrefixMessages: TMessage[];
|
|
580
|
+
tokensBefore: number;
|
|
581
|
+
/** Summary from previous compaction, for continuity when no prior snapcompact archive exists. */
|
|
582
|
+
previousSummary?: string;
|
|
583
|
+
/** Preserved opaque compaction payload from the previous compaction, if any. */
|
|
584
|
+
previousPreserveData?: Record<string, unknown>;
|
|
585
|
+
/** File operations extracted by the host agent. */
|
|
586
|
+
fileOps: FileOperations;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export interface CompactionResult<T = CompactionDetails> {
|
|
590
|
+
summary: string;
|
|
591
|
+
shortSummary?: string;
|
|
592
|
+
firstKeptEntryId: string;
|
|
593
|
+
tokensBefore: number;
|
|
594
|
+
details?: T;
|
|
595
|
+
preserveData?: Record<string, unknown>;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export type ConvertToLlm<TMessage = Message> = (messages: TMessage[]) => Message[];
|
|
599
|
+
|
|
600
|
+
function defaultConvertToLlm<TMessage>(messages: TMessage[]): Message[] {
|
|
601
|
+
return messages as unknown as Message[];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ============================================================================
|
|
605
|
+
// File operation helpers
|
|
606
|
+
// ============================================================================
|
|
607
|
+
|
|
608
|
+
export function createFileOps(): FileOperations {
|
|
609
|
+
return {
|
|
610
|
+
read: new Set(),
|
|
611
|
+
written: new Set(),
|
|
612
|
+
edited: new Set(),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
const URL_SCHEME_RE = /[a-z][a-z0-9+.-]*:\/\//i;
|
|
616
|
+
|
|
617
|
+
const HEADING_MARKER = " ¶";
|
|
618
|
+
|
|
619
|
+
export function isUrlSchemePath(path: string): boolean {
|
|
620
|
+
return URL_SCHEME_RE.test(path);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function computeFileLists(fileOps: FileOperations): CompactionDetails {
|
|
624
|
+
const modified = new Set([...fileOps.edited, ...fileOps.written].filter(file => !isUrlSchemePath(file)));
|
|
625
|
+
const readFiles = [...fileOps.read].filter(file => !isUrlSchemePath(file) && !modified.has(file)).sort();
|
|
626
|
+
const modifiedFiles = [...modified].sort();
|
|
627
|
+
return { readFiles, modifiedFiles };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Format file operations as one `<files>` tag: a grouped, prefix-folded
|
|
632
|
+
* directory tree (find-tool shape) with a ` (Read)` / ` (Write)` / ` (RW)`
|
|
633
|
+
* marker per file. `readSet` is the cumulative read set (`fileOps.read`),
|
|
634
|
+
* used to tell modified files that were also read (RW) from blind writes.
|
|
635
|
+
*/
|
|
636
|
+
const FILE_OPERATION_SUMMARY_LIMIT = 20;
|
|
637
|
+
|
|
638
|
+
function stripFileOperationTags(summary: string): string {
|
|
639
|
+
// Legacy <read-files>/<modified-files> tags are still stripped so summaries
|
|
640
|
+
// written before the combined <files> tag self-heal on the next compaction.
|
|
641
|
+
return summary
|
|
642
|
+
.replace(/<files>[\s\S]*?<\/files>\s*/g, "")
|
|
643
|
+
.replace(/<read-files>[\s\S]*?<\/read-files>\s*/g, "")
|
|
644
|
+
.replace(/<modified-files>[\s\S]*?<\/modified-files>\s*/g, "")
|
|
645
|
+
.trimEnd();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function formatFileList(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string {
|
|
649
|
+
if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
|
|
650
|
+
const mode = new Map<string, "Read" | "Write" | "RW">();
|
|
651
|
+
for (const file of readFiles) mode.set(file, "Read");
|
|
652
|
+
for (const file of modifiedFiles) mode.set(file, readSet?.has(file) ? "RW" : "Write");
|
|
653
|
+
const all = [...mode.keys()].sort();
|
|
654
|
+
let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
|
|
655
|
+
if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
|
|
656
|
+
files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`;
|
|
657
|
+
}
|
|
658
|
+
return files;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string {
|
|
662
|
+
const files = formatFileList(readFiles, modifiedFiles, readSet);
|
|
663
|
+
return files.length > 0 ? prompt.render(fileOperationsTemplate, { files }) : "";
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function upsertFileOperations(
|
|
667
|
+
summary: string,
|
|
668
|
+
readFiles: string[],
|
|
669
|
+
modifiedFiles: string[],
|
|
670
|
+
readSet?: ReadonlySet<string>,
|
|
671
|
+
): string {
|
|
672
|
+
const baseSummary = stripFileOperationTags(summary);
|
|
673
|
+
const fileOperations = formatFileOperations(readFiles, modifiedFiles, readSet);
|
|
674
|
+
if (!fileOperations) return baseSummary;
|
|
675
|
+
if (!baseSummary) return fileOperations;
|
|
676
|
+
return `${baseSummary}\n\n${fileOperations}`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ============================================================================
|
|
680
|
+
// Message serialization
|
|
681
|
+
// ============================================================================
|
|
682
|
+
|
|
683
|
+
/** Default per-tool-result character cap in serialized history. */
|
|
684
|
+
export const TOOL_RESULT_MAX_CHARS = 2000;
|
|
685
|
+
|
|
686
|
+
/** Default per-argument-value character cap inside serialized tool calls
|
|
687
|
+
* (write/edit bodies otherwise dump whole files into the archive). */
|
|
688
|
+
export const TOOL_ARG_MAX_CHARS = 500;
|
|
689
|
+
|
|
690
|
+
/** Default character cap across one tool call's full serialized argument list. */
|
|
691
|
+
export const TOOL_CALL_MAX_CHARS = 2000;
|
|
692
|
+
|
|
693
|
+
/** Default fraction of a truncation budget spent on the head; the remainder
|
|
694
|
+
* keeps the tail, where command errors and test failures usually land. */
|
|
695
|
+
export const TRUNCATE_HEAD_RATIO = 0.6;
|
|
696
|
+
|
|
697
|
+
/** Zero-width ink toggles understood by the native renderer (shift-out/in):
|
|
698
|
+
* text between them prints in dim gray ink without occupying a cell. */
|
|
699
|
+
export const DIM_ON = "\u000e";
|
|
700
|
+
export const DIM_OFF = "\u000f";
|
|
701
|
+
|
|
702
|
+
/** Character budgets applied while serializing discarded history for frame
|
|
703
|
+
* rendering. Pass `Infinity` to disable an individual cap. */
|
|
704
|
+
export interface SerializeOptions {
|
|
705
|
+
/** Per-tool-result cap. Defaults to {@link TOOL_RESULT_MAX_CHARS}. */
|
|
706
|
+
toolResultMaxChars?: number;
|
|
707
|
+
/** Per-argument-value cap. Defaults to {@link TOOL_ARG_MAX_CHARS}. */
|
|
708
|
+
toolArgMaxChars?: number;
|
|
709
|
+
/** Whole-argument-list cap per call. Defaults to {@link TOOL_CALL_MAX_CHARS}. */
|
|
710
|
+
toolCallMaxChars?: number;
|
|
711
|
+
/** Head share of each budget, clamped to [0, 1]. Defaults to {@link TRUNCATE_HEAD_RATIO}. */
|
|
712
|
+
truncateHeadRatio?: number;
|
|
713
|
+
/** Print tool-result text in dim gray ink so archived conversation reads
|
|
714
|
+
* louder than archived tool noise. Defaults to `true`. */
|
|
715
|
+
dimToolResults?: boolean;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Keep the head and tail of `text`, eliding the middle beyond `maxChars`. */
|
|
719
|
+
function truncateForSummary(text: string, maxChars: number, headRatio: number): string {
|
|
720
|
+
if (text.length <= maxChars) return text;
|
|
721
|
+
const ratio = Math.min(Math.max(headRatio, 0), 1);
|
|
722
|
+
const headChars = Math.round(maxChars * ratio);
|
|
723
|
+
const tailChars = maxChars - headChars;
|
|
724
|
+
const elided = text.length - maxChars;
|
|
725
|
+
const tail = tailChars > 0 ? text.slice(-tailChars) : "";
|
|
726
|
+
return `${text.slice(0, headChars)} […${elided}ch elided…] ${tail}`;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const DIM_MARKERS = /[\u000e\u000f]/g;
|
|
730
|
+
|
|
731
|
+
/** Plain-text history kept verbatim at each chronological edge, in HQ-frame-
|
|
732
|
+
* capacity units per edge. One page at the start and one at the end preserves
|
|
733
|
+
* high-fidelity context around the imaged middle while keeping the total text
|
|
734
|
+
* budget equal to the prior 2-page tail-only scheme. */
|
|
735
|
+
const TEXT_EDGE_PAGES = 1;
|
|
736
|
+
|
|
737
|
+
/** Normalized archive text → plain text: drop zero-width dim toggles and
|
|
738
|
+
* print newline glyphs as real newlines. */
|
|
739
|
+
function toPlainText(text: string): string {
|
|
740
|
+
return stripDimMarkers(text).replaceAll(NEWLINE_GLYPH, "\n");
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Strip stray ink toggles from raw content so it cannot forge dim spans. */
|
|
744
|
+
function stripDimMarkers(text: string): string {
|
|
745
|
+
return text.replace(DIM_MARKERS, "");
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export function serializeConversation(messages: Message[], options?: SerializeOptions): string {
|
|
749
|
+
const toolResultMaxChars = options?.toolResultMaxChars ?? TOOL_RESULT_MAX_CHARS;
|
|
750
|
+
const toolArgMaxChars = options?.toolArgMaxChars ?? TOOL_ARG_MAX_CHARS;
|
|
751
|
+
const toolCallMaxChars = options?.toolCallMaxChars ?? TOOL_CALL_MAX_CHARS;
|
|
752
|
+
const headRatio = options?.truncateHeadRatio ?? TRUNCATE_HEAD_RATIO;
|
|
753
|
+
const dimToolResults = options?.dimToolResults !== false;
|
|
754
|
+
const parts: string[] = [];
|
|
755
|
+
|
|
756
|
+
// Tool results flagged contextually useless (and their paired calls) carry no
|
|
757
|
+
// information worth archiving — skip the whole pair. Surviving results are
|
|
758
|
+
// indexed by tool-call id so each merges into its originating `# Tool call`.
|
|
759
|
+
const uselessCallIds = new Set<string>();
|
|
760
|
+
const resultTextByCallId = new Map<string, string>();
|
|
761
|
+
for (const msg of messages) {
|
|
762
|
+
if (msg.role !== "toolResult") continue;
|
|
763
|
+
if (msg.useless === true && msg.isError !== true) {
|
|
764
|
+
uselessCallIds.add(msg.toolCallId);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
const text = msg.content
|
|
768
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
769
|
+
.map(block => block.text)
|
|
770
|
+
.join("");
|
|
771
|
+
if (text) resultTextByCallId.set(msg.toolCallId, text);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Wrap a raw tool-result body in an `<out>` block, dimming only the body so
|
|
775
|
+
// the frame coloring keeps structure (headings, calls) loud.
|
|
776
|
+
const renderResultBlock = (rawText: string): string => {
|
|
777
|
+
const body = truncateForSummary(stripDimMarkers(rawText), toolResultMaxChars, headRatio);
|
|
778
|
+
return `<out>\n${dimToolResults ? `${DIM_ON}${body}${DIM_OFF}` : body}\n</out>`;
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const mergedCallIds = new Set<string>();
|
|
782
|
+
|
|
783
|
+
for (const msg of messages) {
|
|
784
|
+
if (msg.role === "user") {
|
|
785
|
+
const content =
|
|
786
|
+
typeof msg.content === "string"
|
|
787
|
+
? msg.content
|
|
788
|
+
: msg.content
|
|
789
|
+
.filter((content): content is { type: "text"; text: string } => content.type === "text")
|
|
790
|
+
.map(content => content.text)
|
|
791
|
+
.join("");
|
|
792
|
+
if (content) parts.push(`# User${HEADING_MARKER}\n${stripDimMarkers(content)}`);
|
|
793
|
+
} else if (msg.role === "assistant") {
|
|
794
|
+
// Stream blocks in content order: buffer thinking/text, then flush a
|
|
795
|
+
// `# Assistant` block (thinking as italics above the text) right before
|
|
796
|
+
// each tool call, so text or thinking after a call stays after it.
|
|
797
|
+
let pendingThinking: string[] = [];
|
|
798
|
+
let pendingText: string[] = [];
|
|
799
|
+
const flushAssistant = () => {
|
|
800
|
+
const sections: string[] = [];
|
|
801
|
+
if (pendingThinking.length > 0) sections.push(`_${pendingThinking.join("\n")}_`);
|
|
802
|
+
if (pendingText.length > 0) sections.push(pendingText.join("\n"));
|
|
803
|
+
if (sections.length > 0) parts.push(`# Assistant${HEADING_MARKER}\n${sections.join("\n\n")}`);
|
|
804
|
+
pendingThinking = [];
|
|
805
|
+
pendingText = [];
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
for (const block of msg.content) {
|
|
809
|
+
if (block.type === "text") {
|
|
810
|
+
const text = stripDimMarkers(block.text);
|
|
811
|
+
if (text.trim()) pendingText.push(text);
|
|
812
|
+
} else if (block.type === "thinking") {
|
|
813
|
+
const thinking = stripDimMarkers(block.thinking);
|
|
814
|
+
if (thinking.trim()) pendingThinking.push(thinking);
|
|
815
|
+
} else if (block.type === "toolCall") {
|
|
816
|
+
if (uselessCallIds.has(block.id)) continue;
|
|
817
|
+
flushAssistant();
|
|
818
|
+
const args = block.arguments as Record<string, unknown>;
|
|
819
|
+
// Prefer the harness-derived intent, else the raw intent arg; render it as
|
|
820
|
+
// a one-line `//comment` and drop it from the args below.
|
|
821
|
+
const rawIntent =
|
|
822
|
+
typeof block.intent === "string"
|
|
823
|
+
? block.intent
|
|
824
|
+
: typeof args[INTENT_FIELD] === "string"
|
|
825
|
+
? (args[INTENT_FIELD] as string)
|
|
826
|
+
: "";
|
|
827
|
+
const intent = stripDimMarkers(rawIntent).replace(/\s+/g, " ").trim();
|
|
828
|
+
const argsStr = truncateForSummary(
|
|
829
|
+
Object.entries(args)
|
|
830
|
+
.filter(([key]) => key !== INTENT_FIELD)
|
|
831
|
+
.map(
|
|
832
|
+
([key, value]) =>
|
|
833
|
+
`${key}=${truncateForSummary(JSON.stringify(value) ?? "undefined", toolArgMaxChars, headRatio)}`,
|
|
834
|
+
)
|
|
835
|
+
.join(", "),
|
|
836
|
+
toolCallMaxChars,
|
|
837
|
+
headRatio,
|
|
838
|
+
);
|
|
839
|
+
const lines = [`# Tool call${HEADING_MARKER}`];
|
|
840
|
+
if (intent) lines.push(`//${intent}`);
|
|
841
|
+
lines.push(`${block.name}(${argsStr})`);
|
|
842
|
+
const resultText = resultTextByCallId.get(block.id);
|
|
843
|
+
if (resultText !== undefined) {
|
|
844
|
+
mergedCallIds.add(block.id);
|
|
845
|
+
lines.push(renderResultBlock(resultText));
|
|
846
|
+
}
|
|
847
|
+
parts.push(lines.join("\n"));
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
flushAssistant();
|
|
851
|
+
} else if (msg.role === "toolResult") {
|
|
852
|
+
// Paired results already merged into their `# Tool call` block above;
|
|
853
|
+
// only orphans (call archived outside this window) render standalone.
|
|
854
|
+
if (uselessCallIds.has(msg.toolCallId) || mergedCallIds.has(msg.toolCallId)) continue;
|
|
855
|
+
const resultText = resultTextByCallId.get(msg.toolCallId);
|
|
856
|
+
if (resultText !== undefined) parts.push(`# Tool call${HEADING_MARKER}\n${renderResultBlock(resultText)}`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return parts.join("\n\n");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// ============================================================================
|
|
864
|
+
// Preserve-data helpers
|
|
865
|
+
// ============================================================================
|
|
866
|
+
|
|
867
|
+
const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
|
|
868
|
+
|
|
869
|
+
function stripOpenAiRemoteCompactionPreserveData(
|
|
870
|
+
preserveData: Record<string, unknown> | undefined,
|
|
871
|
+
): Record<string, unknown> | undefined {
|
|
872
|
+
if (!preserveData || !(OPENAI_REMOTE_COMPACTION_PRESERVE_KEY in preserveData)) {
|
|
873
|
+
return preserveData;
|
|
874
|
+
}
|
|
875
|
+
const { [OPENAI_REMOTE_COMPACTION_PRESERVE_KEY]: _removed, ...rest } = preserveData;
|
|
876
|
+
return Object.keys(rest).length > 0 ? rest : undefined;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// ============================================================================
|
|
880
|
+
// Text normalization
|
|
881
|
+
// ============================================================================
|
|
882
|
+
|
|
883
|
+
/** Punctuation and symbol folds applied before the NFKD fallback in
|
|
884
|
+
* {@link normalize}: quotes, dashes, bullets, arrows, and dot leaders that
|
|
885
|
+
* have no compatibility decomposition (or one that is itself non-ASCII). */
|
|
886
|
+
const CHAR_FOLD: Record<string, string> = {
|
|
887
|
+
// Quotation marks and primes.
|
|
888
|
+
"\u2018": "'",
|
|
889
|
+
"\u2019": "'",
|
|
890
|
+
"\u201a": "'",
|
|
891
|
+
"\u201b": "'",
|
|
892
|
+
"\u201c": '"',
|
|
893
|
+
"\u201d": '"',
|
|
894
|
+
"\u201e": '"',
|
|
895
|
+
"\u2032": "'",
|
|
896
|
+
"\u2033": '"',
|
|
897
|
+
"\u2035": "'",
|
|
898
|
+
"\u2036": '"',
|
|
899
|
+
"\u2039": "<",
|
|
900
|
+
"\u203a": ">",
|
|
901
|
+
// Dashes, hyphens, and the fraction slash NFKD leaves in vulgar fractions.
|
|
902
|
+
"\u2010": "-",
|
|
903
|
+
"\u2011": "-",
|
|
904
|
+
"\u2012": "-",
|
|
905
|
+
"\u2013": "-",
|
|
906
|
+
"\u2014": "-",
|
|
907
|
+
"\u2015": "-",
|
|
908
|
+
"\u2212": "-",
|
|
909
|
+
"\u2044": "/",
|
|
910
|
+
// Dot leaders and ellipses.
|
|
911
|
+
"\u2024": ".",
|
|
912
|
+
"\u2025": "..",
|
|
913
|
+
"\u2026": "...",
|
|
914
|
+
"\u22ef": "...",
|
|
915
|
+
// Bullets.
|
|
916
|
+
"\u2022": "*",
|
|
917
|
+
"\u2023": "*",
|
|
918
|
+
"\u2043": "-",
|
|
919
|
+
"\u2219": "*",
|
|
920
|
+
"\u25cf": "*",
|
|
921
|
+
"\u25a0": "*",
|
|
922
|
+
"\u25aa": "*",
|
|
923
|
+
// Arrows.
|
|
924
|
+
"\u2190": "<-",
|
|
925
|
+
"\u2191": "^",
|
|
926
|
+
"\u2192": "->",
|
|
927
|
+
"\u2193": "v",
|
|
928
|
+
"\u2194": "<->",
|
|
929
|
+
"\u21d0": "<=",
|
|
930
|
+
"\u21d2": "=>",
|
|
931
|
+
"\u21d4": "<=>",
|
|
932
|
+
// Check marks and crosses.
|
|
933
|
+
"\u2713": "v",
|
|
934
|
+
"\u2714": "v",
|
|
935
|
+
"\u2717": "x",
|
|
936
|
+
"\u2718": "x",
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
/** Printed in place of newline runs: the native renderer fills this cell
|
|
940
|
+
* entirely with pitch-black ink, so line structure survives whitespace
|
|
941
|
+
* collapsing at a one-cell cost. */
|
|
942
|
+
export const NEWLINE_GLYPH = "\u2588";
|
|
943
|
+
|
|
944
|
+
/** Collapsed in one pass: whitespace plus zero-width format characters (ZWSP,
|
|
945
|
+
* BOM, directional marks — JS `\s` already counts BOM as whitespace, so they
|
|
946
|
+
* must fold here, before the per-character pass). */
|
|
947
|
+
const COLLAPSIBLE = /[\s\p{Cf}]+/gu;
|
|
948
|
+
|
|
949
|
+
/** Runs carrying one of these collapse to {@link NEWLINE_GLYPH}. */
|
|
950
|
+
const LINE_BREAK = /[\n\r\u2028\u2029]/;
|
|
951
|
+
|
|
952
|
+
/** Leading/trailing spaces or newline glyphs add no information to a frame. */
|
|
953
|
+
const EDGE_RUNS = /^[ \u2588]+|[ \u2588]+$/g;
|
|
954
|
+
|
|
955
|
+
/** Glyph-less code points skipped outright instead of printing `?`: controls
|
|
956
|
+
* (bare ESC/BEL/NUL — full ANSI sequences are stripped beforehand),
|
|
957
|
+
* combining marks the fonts cannot compose, and lone surrogates. */
|
|
958
|
+
const UNRENDERABLE = /[\p{Cc}\p{Mn}\p{Me}\p{Cs}]/u;
|
|
959
|
+
|
|
960
|
+
/** Combining marks NFKD splits off accented letters; dropped so the base
|
|
961
|
+
* letter prints without the diacritic the bundled fonts cannot compose. */
|
|
962
|
+
const COMBINING_MARKS = /\p{M}+/gu;
|
|
963
|
+
|
|
964
|
+
/** Status-like pictographs that carry meaning in tool output; all other emoji
|
|
965
|
+
* pictographs drop instead of burning cells as `?`. */
|
|
966
|
+
const EMOJI_FOLD: Record<string, string> = {
|
|
967
|
+
"✅": "[OK]",
|
|
968
|
+
"☑": "[OK]",
|
|
969
|
+
"✔": "[OK]",
|
|
970
|
+
"❌": "[FAIL]",
|
|
971
|
+
"❎": "[FAIL]",
|
|
972
|
+
"✖": "[FAIL]",
|
|
973
|
+
"⚠": "[WARN]",
|
|
974
|
+
"🚨": "[ALERT]",
|
|
975
|
+
ℹ: "[INFO]",
|
|
976
|
+
"🐛": "[BUG]",
|
|
977
|
+
"💥": "[CRASH]",
|
|
978
|
+
"🔥": "[HOT]",
|
|
979
|
+
"🔒": "[LOCK]",
|
|
980
|
+
"🔓": "[UNLOCK]",
|
|
981
|
+
"📁": "[DIR]",
|
|
982
|
+
"📂": "[DIR]",
|
|
983
|
+
"📄": "[FILE]",
|
|
984
|
+
"📝": "[NOTE]",
|
|
985
|
+
"🧪": "[TEST]",
|
|
986
|
+
"⏳": "[WAIT]",
|
|
987
|
+
"⌛": "[WAIT]",
|
|
988
|
+
"🚀": "[RUN]",
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
const EMOJI_PICTOGRAPH = /\p{Extended_Pictographic}/u;
|
|
992
|
+
|
|
993
|
+
export interface NormalizeOptions {
|
|
994
|
+
/** Shape whose font is tried before the embedded Silver fallback. */
|
|
995
|
+
shape?: Pick<Shape, "font">;
|
|
996
|
+
/** Native font name when a full shape is not available. */
|
|
997
|
+
font?: Shape["font"];
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
interface NormalizedText {
|
|
1001
|
+
text: string;
|
|
1002
|
+
totalGraphics: number;
|
|
1003
|
+
fallbackCount: number;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Aggressive single-code-point ASCII fold via Unicode NFKD: decompose the
|
|
1008
|
+
* compatibility form (fullwidth, super/subscripts, ligatures, circled and
|
|
1009
|
+
* math-styled alphanumerics, Roman numerals, vulgar fractions, …), strip the
|
|
1010
|
+
* combining marks, and keep the ASCII/Latin-1 skeleton — routing any residual
|
|
1011
|
+
* punctuation back through {@link CHAR_FOLD}. Returns `undefined` when the code
|
|
1012
|
+
* point has no decomposition or still leaves an undrawable glyph, so the
|
|
1013
|
+
* caller falls back to `?`.
|
|
1014
|
+
*/
|
|
1015
|
+
function isAsciiOrLatin1(cp: number): boolean {
|
|
1016
|
+
return (cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function foldToAscii(ch: string): string | undefined {
|
|
1020
|
+
const decomposed = ch.normalize("NFKD").replace(COMBINING_MARKS, "");
|
|
1021
|
+
if (decomposed === ch) return undefined;
|
|
1022
|
+
let out = "";
|
|
1023
|
+
for (const part of decomposed) {
|
|
1024
|
+
const cp = part.codePointAt(0);
|
|
1025
|
+
if (cp !== undefined && isAsciiOrLatin1(cp)) {
|
|
1026
|
+
out += part;
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
const fold = CHAR_FOLD[part];
|
|
1030
|
+
if (fold === undefined) return undefined;
|
|
1031
|
+
out += fold;
|
|
1032
|
+
}
|
|
1033
|
+
return out;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function renderableUnicodeChars(chars: readonly string[], font: Shape["font"] | undefined): ReadonlySet<string> {
|
|
1037
|
+
if (chars.length === 0) return new Set();
|
|
1038
|
+
const text = chars.join("");
|
|
1039
|
+
const primaryFont = font ?? "5x8";
|
|
1040
|
+
const supported = new Set(snapcompactSupportedChars(primaryFont, text));
|
|
1041
|
+
if (primaryFont !== "silver") {
|
|
1042
|
+
for (const ch of snapcompactSupportedChars("silver", text)) supported.add(ch);
|
|
1043
|
+
}
|
|
1044
|
+
return supported;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function normalizedInputChars(text: string): string[] {
|
|
1048
|
+
const stripped = text.includes("\u001b") ? Bun.stripANSI(text) : text;
|
|
1049
|
+
const collapsed = stripped
|
|
1050
|
+
// A run of pure format chars (BOM is both \s and Cf) vanishes; only a
|
|
1051
|
+
// run containing genuine whitespace separates words.
|
|
1052
|
+
.replace(COLLAPSIBLE, run => (LINE_BREAK.test(run) ? NEWLINE_GLYPH : /[^\p{Cf}]/u.test(run) ? " " : ""))
|
|
1053
|
+
.replace(EDGE_RUNS, "");
|
|
1054
|
+
return [...collapsed];
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function candidateUnicodeChars(chars: readonly string[]): string[] {
|
|
1058
|
+
const unique = new Set<string>();
|
|
1059
|
+
for (const ch of chars) {
|
|
1060
|
+
const cp = ch.codePointAt(0);
|
|
1061
|
+
if (cp === undefined || isAsciiOrLatin1(cp) || ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) {
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
if (
|
|
1065
|
+
CHAR_FOLD[ch] !== undefined ||
|
|
1066
|
+
(cp >= 0x2500 && cp <= 0x257f) ||
|
|
1067
|
+
EMOJI_FOLD[ch] !== undefined ||
|
|
1068
|
+
EMOJI_PICTOGRAPH.test(ch) ||
|
|
1069
|
+
foldToAscii(ch) !== undefined ||
|
|
1070
|
+
UNRENDERABLE.test(ch)
|
|
1071
|
+
) {
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
unique.add(ch);
|
|
1075
|
+
}
|
|
1076
|
+
return [...unique];
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function normalizeWithStats(text: string, options?: NormalizeOptions): NormalizedText {
|
|
1080
|
+
const chars = normalizedInputChars(text);
|
|
1081
|
+
const font = options?.font ?? options?.shape?.font;
|
|
1082
|
+
const supported = renderableUnicodeChars(candidateUnicodeChars(chars), font);
|
|
1083
|
+
const out: string[] = [];
|
|
1084
|
+
let totalGraphics = 0;
|
|
1085
|
+
let fallbackCount = 0;
|
|
1086
|
+
|
|
1087
|
+
for (const ch of chars) {
|
|
1088
|
+
const cp = ch.codePointAt(0);
|
|
1089
|
+
if (cp === undefined) continue;
|
|
1090
|
+
if (isAsciiOrLatin1(cp)) {
|
|
1091
|
+
out.push(ch);
|
|
1092
|
+
totalGraphics++;
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
if (ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) {
|
|
1096
|
+
out.push(ch);
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
const emoji = EMOJI_FOLD[ch];
|
|
1100
|
+
if (emoji !== undefined) {
|
|
1101
|
+
out.push(emoji);
|
|
1102
|
+
totalGraphics++;
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
const fold = CHAR_FOLD[ch];
|
|
1106
|
+
if (fold !== undefined) {
|
|
1107
|
+
out.push(fold);
|
|
1108
|
+
totalGraphics++;
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
if (cp >= 0x2500 && cp <= 0x257f) {
|
|
1112
|
+
out.push(cp === 0x2502 || cp === 0x2503 ? "|" : cp === 0x2500 || cp === 0x2501 ? "-" : "+");
|
|
1113
|
+
totalGraphics++;
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
if (!EMOJI_PICTOGRAPH.test(ch) && supported.has(ch)) {
|
|
1117
|
+
out.push(ch);
|
|
1118
|
+
totalGraphics++;
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
const folded = foldToAscii(ch);
|
|
1122
|
+
if (folded !== undefined) {
|
|
1123
|
+
out.push(folded);
|
|
1124
|
+
totalGraphics++;
|
|
1125
|
+
} else if (EMOJI_PICTOGRAPH.test(ch)) {
|
|
1126
|
+
} else if (!UNRENDERABLE.test(ch)) {
|
|
1127
|
+
out.push("?");
|
|
1128
|
+
totalGraphics++;
|
|
1129
|
+
fallbackCount++;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
return { text: out.join("").replace(/ +/g, " ").replace(EDGE_RUNS, ""), totalGraphics, fallbackCount };
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Prepare text for printing: strip ANSI escape sequences, collapse horizontal
|
|
1138
|
+
* whitespace runs, fold unsupported symbols (including box drawing to ASCII),
|
|
1139
|
+
* preserve Unicode glyphs that either the selected font or embedded Silver
|
|
1140
|
+
* fallback can render, and drop decorative emoji instead of printing `?`.
|
|
1141
|
+
*/
|
|
1142
|
+
export function normalize(text: string, options?: NormalizeOptions): string {
|
|
1143
|
+
return normalizeWithStats(text, options).text;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Scan text with the same font-aware path as {@link normalize}; unsafe means
|
|
1148
|
+
* more than 5% of graphic characters would hit the `?` fallback.
|
|
1149
|
+
*/
|
|
1150
|
+
export function scanRenderability(
|
|
1151
|
+
text: string,
|
|
1152
|
+
options?: NormalizeOptions,
|
|
1153
|
+
): { isSafe: boolean; unrenderableRatio: number } {
|
|
1154
|
+
const normalized = normalizeWithStats(text, options);
|
|
1155
|
+
const unrenderableRatio = normalized.totalGraphics > 0 ? normalized.fallbackCount / normalized.totalGraphics : 0;
|
|
1156
|
+
return { isSafe: unrenderableRatio <= 0.05, unrenderableRatio };
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// ============================================================================
|
|
1160
|
+
// Stopword dimming
|
|
1161
|
+
// ============================================================================
|
|
1162
|
+
|
|
1163
|
+
/** High-frequency function words a reader can reconstruct from context; the
|
|
1164
|
+
* dim shapes render them in light gray so content words carry the contrast
|
|
1165
|
+
* (verbatim from `research/bdf.py` `_STOPWORDS`). */
|
|
1166
|
+
const STOPWORDS: ReadonlySet<string> = new Set(
|
|
1167
|
+
(
|
|
1168
|
+
"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 " +
|
|
1169
|
+
"he she his her they their them which also who whom when where while will would could should there then than " +
|
|
1170
|
+
"into over under about after before between during each such these those some most more other only same so"
|
|
1171
|
+
).split(" "),
|
|
1172
|
+
);
|
|
1173
|
+
|
|
1174
|
+
/** Maximal alphabetic runs (ASCII + Latin-1 letters, the fonts' coverage). */
|
|
1175
|
+
const ALPHA_RUN = /[a-zA-Z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff]+/g;
|
|
1176
|
+
|
|
1177
|
+
/** Splitter that keeps the zero-width ink toggles as their own segments. */
|
|
1178
|
+
const DIM_MARKER_SPLIT = /([\u000e\u000f])/;
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Wrap each maximal alphabetic run that is a stopword in {@link DIM_ON} /
|
|
1182
|
+
* {@link DIM_OFF} so it prints in dim gray ink. Spans that are already dim
|
|
1183
|
+
* (e.g. archived tool output) pass through untouched — wrapping there would
|
|
1184
|
+
* terminate the enclosing dim span early. Markers are zero-width, so the
|
|
1185
|
+
* visible glyph count is unchanged.
|
|
1186
|
+
*/
|
|
1187
|
+
export function dimStopwords(text: string): string {
|
|
1188
|
+
const parts = text.split(DIM_MARKER_SPLIT);
|
|
1189
|
+
let dim = false;
|
|
1190
|
+
let out = "";
|
|
1191
|
+
for (const part of parts) {
|
|
1192
|
+
if (part === DIM_ON) {
|
|
1193
|
+
dim = true;
|
|
1194
|
+
out += part;
|
|
1195
|
+
} else if (part === DIM_OFF) {
|
|
1196
|
+
dim = false;
|
|
1197
|
+
out += part;
|
|
1198
|
+
} else if (dim) {
|
|
1199
|
+
out += part;
|
|
1200
|
+
} else {
|
|
1201
|
+
out += part.replace(ALPHA_RUN, word => (STOPWORDS.has(word.toLowerCase()) ? DIM_ON + word + DIM_OFF : word));
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
return out;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// ============================================================================
|
|
1208
|
+
// Doc layout (two word-wrapped newspaper columns)
|
|
1209
|
+
// ============================================================================
|
|
1210
|
+
|
|
1211
|
+
/** Char cells between the two doc columns (research exp14 `GUTTER`). */
|
|
1212
|
+
const DOC_GUTTER = 3;
|
|
1213
|
+
|
|
1214
|
+
/** East Asian Wide / Fullwidth code points that occupy two grid cells when a
|
|
1215
|
+
* narrow bitmap shape draws them through the Silver fallback. Mirrors
|
|
1216
|
+
* `is_wide` in `crates/pi-natives/src/snapcompact.rs`; the two MUST stay in
|
|
1217
|
+
* sync or native layout and this capacity math disagree on cell counts. */
|
|
1218
|
+
function isWideCodePoint(cp: number): boolean {
|
|
1219
|
+
return (
|
|
1220
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
1221
|
+
(cp >= 0x2e80 && cp <= 0x2eff) ||
|
|
1222
|
+
(cp >= 0x2f00 && cp <= 0x2fdf) ||
|
|
1223
|
+
(cp >= 0x3000 && cp <= 0x303e) ||
|
|
1224
|
+
(cp >= 0x3041 && cp <= 0x33ff) ||
|
|
1225
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
1226
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
1227
|
+
(cp >= 0xa000 && cp <= 0xa4cf) ||
|
|
1228
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
1229
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
1230
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
1231
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
1232
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
1233
|
+
(cp >= 0x20000 && cp <= 0x2fffd) ||
|
|
1234
|
+
(cp >= 0x30000 && cp <= 0x3fffd)
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** Cells one character occupies: 0 for the zero-width dim toggles, 2 for wide
|
|
1239
|
+
* code points in narrow bitmap shapes, 1 otherwise. Mirrors native
|
|
1240
|
+
* `cell_units`. */
|
|
1241
|
+
function charCells(ch: string, wideCells: boolean): number {
|
|
1242
|
+
if (ch === DIM_ON || ch === DIM_OFF) return 0;
|
|
1243
|
+
const cp = ch.codePointAt(0);
|
|
1244
|
+
return wideCells && cp !== undefined && isWideCodePoint(cp) ? 2 : 1;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** Wide code points span two cells in every shape except the square-celled
|
|
1248
|
+
* Silver shape, which sizes each cell for a full-width glyph already. */
|
|
1249
|
+
function usesWideCells(shape: Pick<Shape, "font">): boolean {
|
|
1250
|
+
return shape.font !== "silver";
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** Total grid cells a string occupies (ignoring row wrapping/pads). */
|
|
1254
|
+
function cellLength(text: string, wideCells: boolean): number {
|
|
1255
|
+
let cells = 0;
|
|
1256
|
+
for (const ch of text) cells += charCells(ch, wideCells);
|
|
1257
|
+
return cells;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** Longest prefix of `text` that fits `width` cells (at least one char). */
|
|
1261
|
+
function sliceCells(text: string, width: number, wideCells: boolean): string {
|
|
1262
|
+
let cells = 0;
|
|
1263
|
+
let out = "";
|
|
1264
|
+
let placed = false;
|
|
1265
|
+
for (const ch of text) {
|
|
1266
|
+
const w = charCells(ch, wideCells);
|
|
1267
|
+
if (placed && cells + w > width) break;
|
|
1268
|
+
out += ch;
|
|
1269
|
+
cells += w;
|
|
1270
|
+
if (w > 0) placed = true;
|
|
1271
|
+
}
|
|
1272
|
+
return out;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** Split `text` into pages that each fill at most `capacity` grid cells,
|
|
1276
|
+
* inserting a one-cell pad before a wide glyph that would straddle the right
|
|
1277
|
+
* edge (mirrors native `place_cell`). Pages are contiguous substrings, so each
|
|
1278
|
+
* renders independently starting at cell 0. A single char wider than the whole
|
|
1279
|
+
* budget still rides its page; the native renderer clips it. */
|
|
1280
|
+
function paginateCells(text: string, capacity: number, cols: number, wideCells: boolean): string[] {
|
|
1281
|
+
const chars = [...text];
|
|
1282
|
+
const pages: string[] = [];
|
|
1283
|
+
let start = 0;
|
|
1284
|
+
let cell = 0;
|
|
1285
|
+
let hasCell = false;
|
|
1286
|
+
for (let i = 0; i < chars.length; i++) {
|
|
1287
|
+
const w = charCells(chars[i] ?? "", wideCells);
|
|
1288
|
+
if (w === 0) continue;
|
|
1289
|
+
let at = cell;
|
|
1290
|
+
if (w === 2 && cols >= 2 && at % cols === cols - 1) at += 1;
|
|
1291
|
+
if (hasCell && at + w > capacity) {
|
|
1292
|
+
pages.push(chars.slice(start, i).join(""));
|
|
1293
|
+
start = i;
|
|
1294
|
+
at = 0;
|
|
1295
|
+
}
|
|
1296
|
+
cell = at + w;
|
|
1297
|
+
hasCell = true;
|
|
1298
|
+
}
|
|
1299
|
+
if (hasCell) pages.push(chars.slice(start).join(""));
|
|
1300
|
+
return pages;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Greedy word-wrap, no mid-word breaks (hard split only for width+ words) —
|
|
1305
|
+
* ported verbatim from `research/exp14_bestgpt.py` `wrap()`. Zero-width dim
|
|
1306
|
+
* markers count toward word length here; serialized history places them at
|
|
1307
|
+
* word boundaries, so the drift is at most one cell per affected line.
|
|
1308
|
+
*/
|
|
1309
|
+
export function wrap(text: string, width: number, wideCells = false): string[] {
|
|
1310
|
+
const lines: string[] = [];
|
|
1311
|
+
let cur = "";
|
|
1312
|
+
let curCells = 0;
|
|
1313
|
+
for (const token of text.split(/\s+/)) {
|
|
1314
|
+
if (token.length === 0) continue;
|
|
1315
|
+
let word = token;
|
|
1316
|
+
let wordCells = cellLength(word, wideCells);
|
|
1317
|
+
while (wordCells > width) {
|
|
1318
|
+
// Pathological; never hit on prose.
|
|
1319
|
+
if (cur) {
|
|
1320
|
+
lines.push(cur);
|
|
1321
|
+
cur = "";
|
|
1322
|
+
curCells = 0;
|
|
1323
|
+
}
|
|
1324
|
+
const head = sliceCells(word, width, wideCells);
|
|
1325
|
+
lines.push(head);
|
|
1326
|
+
word = word.slice(head.length);
|
|
1327
|
+
wordCells = cellLength(word, wideCells);
|
|
1328
|
+
}
|
|
1329
|
+
if (!cur) {
|
|
1330
|
+
cur = word;
|
|
1331
|
+
curCells = wordCells;
|
|
1332
|
+
} else if (curCells + 1 + wordCells <= width) {
|
|
1333
|
+
cur += ` ${word}`;
|
|
1334
|
+
curCells += 1 + wordCells;
|
|
1335
|
+
} else {
|
|
1336
|
+
lines.push(cur);
|
|
1337
|
+
cur = word;
|
|
1338
|
+
curCells = wordCells;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
if (cur) lines.push(cur);
|
|
1342
|
+
return lines;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Paginate already-normalized text for a doc shape: wrap once at the column
|
|
1347
|
+
* width, then slice into pages of `2 * rows` lines, each page `\n`-joined.
|
|
1348
|
+
* Every input character lands on exactly one page (whitespace becomes the
|
|
1349
|
+
* wrap points).
|
|
1350
|
+
*/
|
|
1351
|
+
function docPages(normalized: string, geo: Geometry, wideCells: boolean): string[] {
|
|
1352
|
+
const lines = wrap(normalized, geo.cols, wideCells);
|
|
1353
|
+
const perPage = 2 * geo.rows;
|
|
1354
|
+
const pages: string[] = [];
|
|
1355
|
+
for (let offset = 0; offset < lines.length; offset += perPage) {
|
|
1356
|
+
pages.push(lines.slice(offset, offset + perPage).join("\n"));
|
|
1357
|
+
}
|
|
1358
|
+
return pages;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// ============================================================================
|
|
1362
|
+
// Rendering
|
|
1363
|
+
// ============================================================================
|
|
1364
|
+
|
|
1365
|
+
export function geometry(shape: Shape, size: number = shape.frameSize): Geometry {
|
|
1366
|
+
const gridCols = Math.floor(size / shape.cellWidth);
|
|
1367
|
+
const rows = Math.floor(size / shape.cellHeight / shape.lineRepeat);
|
|
1368
|
+
if (shape.columns === 2) {
|
|
1369
|
+
const cols = Math.floor((gridCols - DOC_GUTTER) / 2);
|
|
1370
|
+
return { cols, rows, capacity: 2 * cols * rows };
|
|
1371
|
+
}
|
|
1372
|
+
return { cols: gridCols, rows, capacity: gridCols * rows };
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const NEWLINES = /\n/g;
|
|
1376
|
+
|
|
1377
|
+
function nativeRenderOptions(shape: Shape, size: number) {
|
|
1378
|
+
return {
|
|
1379
|
+
size,
|
|
1380
|
+
font: shape.font,
|
|
1381
|
+
cellWidth: shape.cellWidth,
|
|
1382
|
+
cellHeight: shape.cellHeight,
|
|
1383
|
+
stretch: shape.stretch,
|
|
1384
|
+
variant: shape.variant,
|
|
1385
|
+
lineRepeat: shape.lineRepeat,
|
|
1386
|
+
columns: shape.columns,
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function renderedChars(text: string, shape: Shape, geo: Geometry): number {
|
|
1391
|
+
if (shape.columns === 2) {
|
|
1392
|
+
let visible = [...text].length - (text.match(DIM_MARKERS)?.length ?? 0);
|
|
1393
|
+
visible -= text.match(NEWLINES)?.length ?? 0;
|
|
1394
|
+
return Math.min(visible, geo.capacity);
|
|
1395
|
+
}
|
|
1396
|
+
// Grid: count visible chars that fit within the frame's cell budget, with
|
|
1397
|
+
// wide glyphs taking two cells (and a straddle pad) exactly as the renderer.
|
|
1398
|
+
const wideCells = usesWideCells(shape);
|
|
1399
|
+
let cell = 0;
|
|
1400
|
+
let count = 0;
|
|
1401
|
+
for (const ch of text) {
|
|
1402
|
+
const w = charCells(ch, wideCells);
|
|
1403
|
+
if (w === 0) continue;
|
|
1404
|
+
let at = cell;
|
|
1405
|
+
if (w === 2 && geo.cols >= 2 && at % geo.cols === geo.cols - 1) at += 1;
|
|
1406
|
+
if (at + w > geo.capacity) break;
|
|
1407
|
+
cell = at + w;
|
|
1408
|
+
count++;
|
|
1409
|
+
}
|
|
1410
|
+
return count;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/** Render one snapcompact frame from already-normalized text. Doc shapes
|
|
1414
|
+
* (`columns === 2`) expect one page of `\n`-joined pre-wrapped lines. */
|
|
1415
|
+
export async function render(text: string, shape: Shape, size: number = shape.frameSize): Promise<RenderedFrame> {
|
|
1416
|
+
const geo = geometry(shape, size);
|
|
1417
|
+
const { cols, rows } = geo;
|
|
1418
|
+
const chars = renderedChars(text, shape, geo);
|
|
1419
|
+
const data = await renderSnapcompactPng(text, nativeRenderOptions(shape, size));
|
|
1420
|
+
return { data, cols, rows, chars };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/** Stateful per-page text finisher: re-opens a dim span the previous page
|
|
1424
|
+
* boundary cut through, then applies stopword dimming when the shape asks
|
|
1425
|
+
* for it (after pagination, so capacity math never sees the markers). */
|
|
1426
|
+
function pageFinisher(shape: Shape): (page: string) => string {
|
|
1427
|
+
let dimOpen = false;
|
|
1428
|
+
return page => {
|
|
1429
|
+
const text = dimOpen ? DIM_ON + page : page;
|
|
1430
|
+
dimOpen = text.lastIndexOf(DIM_ON) > text.lastIndexOf(DIM_OFF);
|
|
1431
|
+
return shape.stopwordDim ? dimStopwords(text) : text;
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/** Options for {@link renderMany} and {@link frames}. */
|
|
1436
|
+
export interface RenderManyOptions {
|
|
1437
|
+
/** Explicit shape; wins over `model`. */
|
|
1438
|
+
shape?: Shape;
|
|
1439
|
+
/** Model whose provider API and id select the frame shape. */
|
|
1440
|
+
model?: ShapeTarget;
|
|
1441
|
+
/** Frame edge in px; defaults to the shape's `frameSize`. */
|
|
1442
|
+
frameSize?: number;
|
|
1443
|
+
/** Hard cap on frames produced; omit for unbounded (caller decides usage). */
|
|
1444
|
+
maxFrames?: number;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Render arbitrary text into snapcompact PNG frames as LLM image blocks
|
|
1449
|
+
* (first page first). Empty/whitespace-only input yields no frames.
|
|
1450
|
+
*/
|
|
1451
|
+
export async function renderMany(text: string, options?: RenderManyOptions): Promise<ImageContent[]> {
|
|
1452
|
+
const shape = options?.shape ?? resolveShapeForText(text, options?.model);
|
|
1453
|
+
const frameSize = options?.frameSize ?? shape.frameSize;
|
|
1454
|
+
const geo = geometry(shape, frameSize);
|
|
1455
|
+
const normalized = normalize(text, { shape });
|
|
1456
|
+
const cap = options?.maxFrames;
|
|
1457
|
+
// Build the per-frame texts in order first (cheap, synchronous), then fan
|
|
1458
|
+
// the native PNG renders out concurrently — render() is async/off-thread,
|
|
1459
|
+
// so awaiting each before starting the next leaves throughput on the table.
|
|
1460
|
+
const pageTexts: string[] = [];
|
|
1461
|
+
const wideCells = usesWideCells(shape);
|
|
1462
|
+
if (shape.columns === 2) {
|
|
1463
|
+
const finish = pageFinisher(shape);
|
|
1464
|
+
for (const page of docPages(normalized, geo, wideCells)) {
|
|
1465
|
+
if (cap !== undefined && pageTexts.length >= cap) break;
|
|
1466
|
+
pageTexts.push(finish(page));
|
|
1467
|
+
}
|
|
1468
|
+
} else {
|
|
1469
|
+
for (const page of paginateCells(normalized, geo.capacity, geo.cols, wideCells)) {
|
|
1470
|
+
if (cap !== undefined && pageTexts.length >= cap) break;
|
|
1471
|
+
pageTexts.push(shape.stopwordDim ? dimStopwords(page) : page);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
const rendered = await Promise.all(pageTexts.map(page => render(page, shape, frameSize)));
|
|
1475
|
+
return rendered.map(frame => ({
|
|
1476
|
+
type: "image",
|
|
1477
|
+
data: frame.data,
|
|
1478
|
+
mimeType: "image/png",
|
|
1479
|
+
...(shape.imageDetail ? { detail: shape.imageDetail } : {}),
|
|
1480
|
+
}));
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/** Frames needed to hold `text` at the given shape/size, without rendering.
|
|
1484
|
+
* For doc shapes this wraps the text once and counts pages of `2 * rows`
|
|
1485
|
+
* lines; for grid shapes it divides by the frame capacity. */
|
|
1486
|
+
export function frames(text: string, options?: Pick<RenderManyOptions, "shape" | "model" | "frameSize">): number {
|
|
1487
|
+
const shape = options?.shape ?? resolveShapeForText(text, options?.model);
|
|
1488
|
+
const geo = geometry(shape, options?.frameSize ?? shape.frameSize);
|
|
1489
|
+
const normalized = normalize(text, { shape });
|
|
1490
|
+
const wideCells = usesWideCells(shape);
|
|
1491
|
+
if (shape.columns === 2) return Math.ceil(wrap(normalized, geo.cols, wideCells).length / (2 * geo.rows));
|
|
1492
|
+
return paginateCells(normalized, geo.capacity, geo.cols, wideCells).length;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// ============================================================================
|
|
1496
|
+
// Archive helpers
|
|
1497
|
+
// ============================================================================
|
|
1498
|
+
|
|
1499
|
+
/** Validate and extract a persisted frame archive from `preserveData`. */
|
|
1500
|
+
export function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined {
|
|
1501
|
+
const candidate = preserveData?.[PRESERVE_KEY];
|
|
1502
|
+
if (!candidate || typeof candidate !== "object") return undefined;
|
|
1503
|
+
const archive = candidate as Archive;
|
|
1504
|
+
const frames = Array.isArray(archive.frames)
|
|
1505
|
+
? archive.frames.filter(
|
|
1506
|
+
frame =>
|
|
1507
|
+
!!frame &&
|
|
1508
|
+
typeof frame.data === "string" &&
|
|
1509
|
+
frame.data.length > 0 &&
|
|
1510
|
+
typeof frame.mimeType === "string" &&
|
|
1511
|
+
typeof frame.cols === "number" &&
|
|
1512
|
+
typeof frame.rows === "number" &&
|
|
1513
|
+
typeof frame.chars === "number",
|
|
1514
|
+
)
|
|
1515
|
+
: [];
|
|
1516
|
+
const text = typeof archive.text === "string" && archive.text.length > 0 ? archive.text : undefined;
|
|
1517
|
+
const textHead = typeof archive.textHead === "string" && archive.textHead.length > 0 ? archive.textHead : undefined;
|
|
1518
|
+
const textTail = typeof archive.textTail === "string" && archive.textTail.length > 0 ? archive.textTail : undefined;
|
|
1519
|
+
// A text-only archive (everything fit in the plain-text regions) is valid;
|
|
1520
|
+
// only an archive carrying neither frames nor text is empty.
|
|
1521
|
+
if (frames.length === 0 && text === undefined && textHead === undefined && textTail === undefined) return undefined;
|
|
1522
|
+
return {
|
|
1523
|
+
frames,
|
|
1524
|
+
totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0,
|
|
1525
|
+
truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0,
|
|
1526
|
+
...(text !== undefined ? { text } : {}),
|
|
1527
|
+
...(textHead !== undefined ? { textHead } : {}),
|
|
1528
|
+
...(textTail !== undefined ? { textTail } : {}),
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/** Drop the persisted frame archive ({@link PRESERVE_KEY}) from `preserveData`,
|
|
1533
|
+
* returning the remaining state — or `undefined` when nothing else remains, so
|
|
1534
|
+
* an empty `{}` is never persisted. Callers strip the archive once its frames
|
|
1535
|
+
* have been migrated into a new compaction's text, preventing the stale frames
|
|
1536
|
+
* from leaking back into the rebuilt context. */
|
|
1537
|
+
export function stripPreservedArchive(
|
|
1538
|
+
preserveData: Record<string, unknown> | undefined,
|
|
1539
|
+
): Record<string, unknown> | undefined {
|
|
1540
|
+
if (!preserveData || !(PRESERVE_KEY in preserveData)) return preserveData;
|
|
1541
|
+
const { [PRESERVE_KEY]: _removed, ...rest } = preserveData;
|
|
1542
|
+
return Object.keys(rest).length > 0 ? rest : undefined;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/** Extract persisted archive source text as plain text for LLM summarization. */
|
|
1546
|
+
export function archiveSourceText(archive: Archive): string | undefined {
|
|
1547
|
+
const text =
|
|
1548
|
+
archive.text ??
|
|
1549
|
+
[archive.textHead, archive.textTail]
|
|
1550
|
+
.filter((part): part is string => typeof part === "string" && part.length > 0)
|
|
1551
|
+
.join(NEWLINE_GLYPH);
|
|
1552
|
+
return text.length > 0 ? toPlainText(text) : undefined;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/** Build the text used to choose and preflight a font-aware snapcompact shape. */
|
|
1556
|
+
export function renderabilityProbeText(
|
|
1557
|
+
serialized: string,
|
|
1558
|
+
previousPreserveData?: Record<string, unknown>,
|
|
1559
|
+
previousSummary?: string,
|
|
1560
|
+
): string {
|
|
1561
|
+
const previousArchive = getPreservedArchive(previousPreserveData);
|
|
1562
|
+
const previousText = previousArchive ? (archiveSourceText(previousArchive) ?? "") : "";
|
|
1563
|
+
if (previousText.length > 0) return `${previousText}${NEWLINE_GLYPH}${serialized}`;
|
|
1564
|
+
if (previousSummary) return `${previousSummary}${NEWLINE_GLYPH}${serialized}`;
|
|
1565
|
+
return serialized;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/** Options for reconstructing a persisted snapcompact archive into prompt blocks. */
|
|
1569
|
+
export interface HistoryBlockOptions {
|
|
1570
|
+
/** Hard cap on image base64 bytes attached to one rebuilt provider request. */
|
|
1571
|
+
maxFrameDataBytes?: number;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function formatFrameDataBytes(bytes: number): string {
|
|
1575
|
+
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
|
1576
|
+
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
|
|
1577
|
+
return `${bytes} B`;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function imagesWithinBudget(
|
|
1581
|
+
archive: Archive,
|
|
1582
|
+
maxFrameDataBytes: number | undefined,
|
|
1583
|
+
): { images: ImageContent[]; omittedFrames: number; omittedBytes: number } {
|
|
1584
|
+
if (maxFrameDataBytes === undefined) {
|
|
1585
|
+
return { images: images(archive), omittedFrames: 0, omittedBytes: 0 };
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
let usedBytes = 0;
|
|
1589
|
+
let omittedFrames = 0;
|
|
1590
|
+
let omittedBytes = 0;
|
|
1591
|
+
const keptNewestFirst: Frame[] = [];
|
|
1592
|
+
for (let index = archive.frames.length - 1; index >= 0; index--) {
|
|
1593
|
+
const frame = archive.frames[index];
|
|
1594
|
+
if (!frame) continue;
|
|
1595
|
+
const bytes = frame.data.length;
|
|
1596
|
+
if (usedBytes + bytes > maxFrameDataBytes) {
|
|
1597
|
+
omittedFrames++;
|
|
1598
|
+
omittedBytes += bytes;
|
|
1599
|
+
continue;
|
|
1600
|
+
}
|
|
1601
|
+
usedBytes += bytes;
|
|
1602
|
+
keptNewestFirst.push(frame);
|
|
1603
|
+
}
|
|
1604
|
+
keptNewestFirst.reverse();
|
|
1605
|
+
return { images: images({ ...archive, frames: keptNewestFirst }), omittedFrames, omittedBytes };
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function omittedFrameNotice(omittedFrames: number, omittedBytes: number): string {
|
|
1609
|
+
return [
|
|
1610
|
+
"-------------- snapcompact image middle omitted",
|
|
1611
|
+
`${omittedFrames.toLocaleString()} archived image frame${omittedFrames === 1 ? "" : "s"} (${formatFrameDataBytes(omittedBytes)} base64) exceeded the per-request snapcompact payload budget. The compacted summary and visible text edges remain available.`,
|
|
1612
|
+
"--------------",
|
|
1613
|
+
].join("\n");
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/** Convert archive frames into LLM image blocks (oldest first). */
|
|
1617
|
+
export function images(archive: Archive): ImageContent[] {
|
|
1618
|
+
return archive.frames.map(frame => ({
|
|
1619
|
+
type: "image",
|
|
1620
|
+
data: frame.data,
|
|
1621
|
+
mimeType: frame.mimeType,
|
|
1622
|
+
...(frame.detail ? { detail: frame.detail } : {}),
|
|
1623
|
+
}));
|
|
1624
|
+
}
|
|
1625
|
+
/** Ordered archive blocks for a compaction summary message, oldest to newest:
|
|
1626
|
+
* the oldest text region, the imaged middle, then the newest text region.
|
|
1627
|
+
* Runtime-only; reconstructed from {@link Archive} on each context rebuild
|
|
1628
|
+
* instead of persisted on the session entry. */
|
|
1629
|
+
export function historyBlocks(archive: Archive, options: HistoryBlockOptions = {}): (TextContent | ImageContent)[] {
|
|
1630
|
+
const blocks: (TextContent | ImageContent)[] = [];
|
|
1631
|
+
const budgeted = imagesWithinBudget(archive, options.maxFrameDataBytes);
|
|
1632
|
+
const hasImages = budgeted.images.length > 0;
|
|
1633
|
+
const hasOmittedImages = budgeted.omittedFrames > 0;
|
|
1634
|
+
if (archive.textHead) {
|
|
1635
|
+
const suffix = hasImages
|
|
1636
|
+
? "\n-------------- imaged middle below\n"
|
|
1637
|
+
: hasOmittedImages
|
|
1638
|
+
? `\n${omittedFrameNotice(budgeted.omittedFrames, budgeted.omittedBytes)}\n`
|
|
1639
|
+
: "";
|
|
1640
|
+
blocks.push({ type: "text", text: toPlainText(archive.textHead) + suffix });
|
|
1641
|
+
} else if (hasOmittedImages && !hasImages) {
|
|
1642
|
+
blocks.push({ type: "text", text: omittedFrameNotice(budgeted.omittedFrames, budgeted.omittedBytes) });
|
|
1643
|
+
}
|
|
1644
|
+
// Omitted frames are the OLDEST archived images: the byte budget keeps the
|
|
1645
|
+
// newest tail frames, so the gap notice precedes the kept images to keep the
|
|
1646
|
+
// reconstructed blocks oldest-to-newest.
|
|
1647
|
+
if (hasImages && hasOmittedImages) {
|
|
1648
|
+
blocks.push({ type: "text", text: omittedFrameNotice(budgeted.omittedFrames, budgeted.omittedBytes) });
|
|
1649
|
+
}
|
|
1650
|
+
blocks.push(...budgeted.images);
|
|
1651
|
+
if (archive.textTail) {
|
|
1652
|
+
const prefix = hasImages
|
|
1653
|
+
? "-------------- imaged middle above\n"
|
|
1654
|
+
: archive.truncatedChars > 0 || hasOmittedImages
|
|
1655
|
+
? "\n-------------- middle history omitted above\n"
|
|
1656
|
+
: "";
|
|
1657
|
+
const tail = prefix + toPlainText(archive.textTail);
|
|
1658
|
+
const lastBlock = blocks[blocks.length - 1];
|
|
1659
|
+
if (lastBlock?.type === "text") {
|
|
1660
|
+
lastBlock.text += tail;
|
|
1661
|
+
} else {
|
|
1662
|
+
blocks.push({ type: "text", text: tail });
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
return blocks;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// ============================================================================
|
|
1669
|
+
// Compaction entry point
|
|
1670
|
+
// ============================================================================
|
|
1671
|
+
|
|
1672
|
+
/** Denser companion of `high` for the foveated archive middle: same family and
|
|
1673
|
+
* frame size (identical per-frame bill) but a tighter cell. Returns `high`
|
|
1674
|
+
* unchanged for doc layouts, TrueType Unicode shapes, or when no denser
|
|
1675
|
+
* variant exists (foveation off). */
|
|
1676
|
+
function denseCompanion(high: Shape, api: Api | undefined): Shape {
|
|
1677
|
+
if (high.columns === 2 || high.font === "silver") return high;
|
|
1678
|
+
const family = billingFamily(api);
|
|
1679
|
+
const low = priceShape({ ...SHAPE_VARIANTS[FAMILY_VARIANT_LOW[family]], frameSize: high.frameSize }, family);
|
|
1680
|
+
return geometry(low).capacity > geometry(high).capacity ? low : high;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/** One planned frame: the source slice and the shape (quality tier) to render. */
|
|
1684
|
+
interface PlanFrame {
|
|
1685
|
+
text: string;
|
|
1686
|
+
shape: Shape;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/** A foveated archive layout: frames oldest→newest for the imaged middle, the
|
|
1690
|
+
* verbatim text kept at both chronological edges, the flat kept source to
|
|
1691
|
+
* persist, and the chars dropped this round to fit the budget. */
|
|
1692
|
+
interface ArchiveLayout {
|
|
1693
|
+
frames: PlanFrame[];
|
|
1694
|
+
textHead: string;
|
|
1695
|
+
textTail: string;
|
|
1696
|
+
keptText: string;
|
|
1697
|
+
truncatedChars: number;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/** Wrap each page string as a planned frame at one shape (tier). */
|
|
1701
|
+
function planFrames(pages: readonly string[], shape: Shape): PlanFrame[] {
|
|
1702
|
+
return pages.map(text => ({ text, shape }));
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/**
|
|
1706
|
+
* Lay out the accumulated archive `text` (oldest→newest) with text at both
|
|
1707
|
+
* chronological edges and images in the middle. One HQ-capacity stays verbatim
|
|
1708
|
+
* at the oldest edge, one at the newest edge, and the middle between them is
|
|
1709
|
+
* imaged. If the imaged middle itself overflows `maxFrames`, foveate it
|
|
1710
|
+
* internally (HQ/LQ/HQ) and drop the oldest slice of its dense center.
|
|
1711
|
+
*/
|
|
1712
|
+
function planArchive(text: string, high: Shape, low: Shape, maxFrames: number): ArchiveLayout {
|
|
1713
|
+
const capHi = geometry(high).capacity;
|
|
1714
|
+
const edgeCap = TEXT_EDGE_PAGES * capHi;
|
|
1715
|
+
if (text.length <= 2 * edgeCap) {
|
|
1716
|
+
return { frames: [], textHead: text, textTail: "", keptText: text, truncatedChars: 0 };
|
|
1717
|
+
}
|
|
1718
|
+
if (maxFrames < 1) {
|
|
1719
|
+
const textHead = text.slice(0, edgeCap);
|
|
1720
|
+
const textTail = text.slice(text.length - edgeCap);
|
|
1721
|
+
return {
|
|
1722
|
+
frames: [],
|
|
1723
|
+
textHead,
|
|
1724
|
+
textTail,
|
|
1725
|
+
keptText: textHead + textTail,
|
|
1726
|
+
truncatedChars: text.length - textHead.length - textTail.length,
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
const textHead = text.slice(0, edgeCap);
|
|
1731
|
+
const textTail = text.slice(text.length - edgeCap);
|
|
1732
|
+
const imageText = text.slice(edgeCap, text.length - edgeCap);
|
|
1733
|
+
if (imageText.length === 0) {
|
|
1734
|
+
return { frames: [], textHead: text, textTail: "", keptText: text, truncatedChars: 0 };
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// Doc layouts wrap (no char-slicing) and don't foveate: one tier, keep the
|
|
1738
|
+
// newest pages with the session head pinned, drop the oldest middle.
|
|
1739
|
+
if (high.columns === 2) {
|
|
1740
|
+
const pages = docPages(imageText, geometry(high), usesWideCells(high));
|
|
1741
|
+
let kept = pages;
|
|
1742
|
+
let truncatedChars = 0;
|
|
1743
|
+
if (pages.length > maxFrames) {
|
|
1744
|
+
const dropped = pages.slice(1, pages.length - (maxFrames - 1));
|
|
1745
|
+
truncatedChars = dropped.reduce((sum, page) => sum + page.length, 0);
|
|
1746
|
+
kept = [...pages.slice(0, 1), ...pages.slice(pages.length - (maxFrames - 1))];
|
|
1747
|
+
}
|
|
1748
|
+
const flat = kept.map(page => page.replaceAll("\n", " ")).join(" ");
|
|
1749
|
+
return {
|
|
1750
|
+
frames: planFrames(kept, high),
|
|
1751
|
+
textHead,
|
|
1752
|
+
textTail,
|
|
1753
|
+
keptText: textHead + flat + textTail,
|
|
1754
|
+
truncatedChars,
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// Grid: paginate the imaged region into HQ frames (cell-aware, so wide CJK
|
|
1759
|
+
// glyphs spanning two cells never overflow a frame's capacity).
|
|
1760
|
+
const hiPages = paginateCells(imageText, capHi, geometry(high).cols, usesWideCells(high));
|
|
1761
|
+
if (hiPages.length <= maxFrames) {
|
|
1762
|
+
return {
|
|
1763
|
+
frames: planFrames(hiPages, high),
|
|
1764
|
+
textHead,
|
|
1765
|
+
textTail,
|
|
1766
|
+
keptText: textHead + imageText + textTail,
|
|
1767
|
+
truncatedChars: 0,
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// Foveate the imaged middle: HQ edges, dense center, drop the oldest dense slice.
|
|
1772
|
+
const capLo = geometry(low).capacity;
|
|
1773
|
+
const imageEdgeFrames = Math.min(HQ_EDGE_FRAMES, Math.floor((maxFrames - 1) / 2));
|
|
1774
|
+
const headPages = hiPages.slice(0, imageEdgeFrames);
|
|
1775
|
+
const tailPages = imageEdgeFrames > 0 ? hiPages.slice(hiPages.length - imageEdgeFrames) : [];
|
|
1776
|
+
const imageHead = headPages.join("");
|
|
1777
|
+
const imageTail = tailPages.join("");
|
|
1778
|
+
const middleSource = imageText.slice(imageHead.length, imageText.length - imageTail.length);
|
|
1779
|
+
let middlePages = paginateCells(middleSource, capLo, geometry(low).cols, usesWideCells(low));
|
|
1780
|
+
const middleBudget = maxFrames - 2 * imageEdgeFrames;
|
|
1781
|
+
let truncatedChars = 0;
|
|
1782
|
+
let middleText = middleSource;
|
|
1783
|
+
if (middlePages.length > middleBudget) {
|
|
1784
|
+
const dropped = middlePages.slice(0, middlePages.length - middleBudget).join("");
|
|
1785
|
+
truncatedChars = dropped.length;
|
|
1786
|
+
middleText = middleSource.slice(dropped.length);
|
|
1787
|
+
middlePages = middlePages.slice(middlePages.length - middleBudget);
|
|
1788
|
+
}
|
|
1789
|
+
return {
|
|
1790
|
+
frames: [...planFrames(headPages, high), ...planFrames(middlePages, low), ...planFrames(tailPages, high)],
|
|
1791
|
+
textHead,
|
|
1792
|
+
textTail,
|
|
1793
|
+
keptText: textHead + imageHead + middleText + imageTail + textTail,
|
|
1794
|
+
truncatedChars,
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/**
|
|
1799
|
+
* Run a snapcompact compaction over prepared messages. Fully local: serializes
|
|
1800
|
+
* the discarded history, appends it to the accumulated archive source text, and
|
|
1801
|
+
* re-renders that source into an ordered history layout: plain text at the
|
|
1802
|
+
* oldest edge, imaged middle, then plain text at the newest edge. The imaged
|
|
1803
|
+
* middle itself foveates (HQ/LQ/HQ) when it grows large.
|
|
1804
|
+
*
|
|
1805
|
+
* The full kept source persists on the archive (`text`) so each later compaction
|
|
1806
|
+
* unfolds and re-renders it coherently alongside the newly archived history.
|
|
1807
|
+
*
|
|
1808
|
+
* If the previous compaction was text-based, its summary is printed at the head
|
|
1809
|
+
* of the archive as `[Summary of earlier history]` so no continuity is lost.
|
|
1810
|
+
*/
|
|
1811
|
+
export async function compact<TMessage = Message>(
|
|
1812
|
+
preparation: CompactionPreparation<TMessage>,
|
|
1813
|
+
options?: Options<TMessage>,
|
|
1814
|
+
): Promise<CompactionResult> {
|
|
1815
|
+
const { firstKeptEntryId, tokensBefore, previousSummary, previousPreserveData, fileOps } = preparation;
|
|
1816
|
+
if (!firstKeptEntryId) {
|
|
1817
|
+
throw new Error("First kept entry has no ID - session may need migration");
|
|
1818
|
+
}
|
|
1819
|
+
const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
|
1820
|
+
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
|
|
1821
|
+
const serialized = serializeConversation(llmMessages, options);
|
|
1822
|
+
const previousArchive = getPreservedArchive(previousPreserveData);
|
|
1823
|
+
const previousText =
|
|
1824
|
+
previousArchive?.text ??
|
|
1825
|
+
[previousArchive?.textHead, previousArchive?.textTail]
|
|
1826
|
+
.filter((part): part is string => typeof part === "string" && part.length > 0)
|
|
1827
|
+
.join(NEWLINE_GLYPH);
|
|
1828
|
+
const hasPreviousText = previousText.length > 0;
|
|
1829
|
+
const includedPreviousSummary = !hasPreviousText && !!previousSummary;
|
|
1830
|
+
const shapeProbeText = renderabilityProbeText(serialized, previousPreserveData, previousSummary);
|
|
1831
|
+
const baseShape = options?.shape ?? resolveShapeForText(shapeProbeText, options?.model);
|
|
1832
|
+
const frameSize = options?.frameSize ?? baseShape.frameSize;
|
|
1833
|
+
const high = frameSize === baseShape.frameSize ? baseShape : { ...baseShape, frameSize };
|
|
1834
|
+
const low = denseCompanion(high, options?.model?.api);
|
|
1835
|
+
const geo = geometry(high);
|
|
1836
|
+
// The engine default caps archive growth; a caller-supplied maxFrames only
|
|
1837
|
+
// lowers it further (an upper limit), never raising it past the default.
|
|
1838
|
+
const maxFrames = Math.max(1, Math.min(options?.maxFrames ?? MAX_FRAMES_DEFAULT, MAX_FRAMES_DEFAULT));
|
|
1839
|
+
|
|
1840
|
+
let archiveText = normalize(serialized, { shape: high });
|
|
1841
|
+
|
|
1842
|
+
if (includedPreviousSummary && previousSummary) {
|
|
1843
|
+
const head = `[Summary of earlier history] ${normalize(previousSummary, { shape: high })}`;
|
|
1844
|
+
archiveText = archiveText.length > 0 ? `${head} [Recent conversation] ${archiveText}` : head;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
let truncatedChars = previousArchive?.truncatedChars ?? 0;
|
|
1848
|
+
|
|
1849
|
+
// Re-compacting a snapcompacted history unfolds the prior archive's source
|
|
1850
|
+
// text and treats it as one coherent transcript: the previous kept source
|
|
1851
|
+
// ages in ahead of the new history, then the whole thing is re-rendered.
|
|
1852
|
+
if (hasPreviousText) {
|
|
1853
|
+
archiveText = archiveText.length > 0 ? `${previousText}${NEWLINE_GLYPH}${archiveText}` : previousText;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
const layout = planArchive(archiveText, high, low, maxFrames);
|
|
1857
|
+
truncatedChars += layout.truncatedChars;
|
|
1858
|
+
|
|
1859
|
+
// Re-render the planned frames, carrying any open dim span across every
|
|
1860
|
+
// boundary: textHead → frames → textTail.
|
|
1861
|
+
let dimOpen = layout.textHead.lastIndexOf(DIM_ON) > layout.textHead.lastIndexOf(DIM_OFF);
|
|
1862
|
+
const newFrames: Promise<Frame>[] = [];
|
|
1863
|
+
for (const planned of layout.frames) {
|
|
1864
|
+
let pageText: string = dimOpen ? DIM_ON + planned.text : planned.text;
|
|
1865
|
+
dimOpen = pageText.lastIndexOf(DIM_ON) > pageText.lastIndexOf(DIM_OFF);
|
|
1866
|
+
if (planned.shape.stopwordDim) pageText = dimStopwords(pageText);
|
|
1867
|
+
newFrames.push(
|
|
1868
|
+
render(pageText, planned.shape).then(rendered => ({
|
|
1869
|
+
data: rendered.data,
|
|
1870
|
+
mimeType: "image/png",
|
|
1871
|
+
cols: rendered.cols,
|
|
1872
|
+
rows: rendered.rows,
|
|
1873
|
+
chars: rendered.chars,
|
|
1874
|
+
font: planned.shape.font,
|
|
1875
|
+
variant: planned.shape.variant,
|
|
1876
|
+
lineRepeat: planned.shape.lineRepeat,
|
|
1877
|
+
...(planned.shape.columns === 2 ? { columns: 2 } : {}),
|
|
1878
|
+
...(planned.shape.stopwordDim ? { stopwordDim: true } : {}),
|
|
1879
|
+
...(planned.shape.imageDetail ? { detail: planned.shape.imageDetail } : {}),
|
|
1880
|
+
})),
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
const textHead = layout.textHead;
|
|
1885
|
+
const textTail = layout.textTail.length > 0 ? (dimOpen ? DIM_ON : "") + layout.textTail : "";
|
|
1886
|
+
const textChars = textHead.length + textTail.length;
|
|
1887
|
+
|
|
1888
|
+
const frames = await Promise.all(newFrames);
|
|
1889
|
+
const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0) + textChars;
|
|
1890
|
+
const mixedShapes = frames.some(
|
|
1891
|
+
frame =>
|
|
1892
|
+
frame.cols !== geo.cols ||
|
|
1893
|
+
frame.rows !== geo.rows ||
|
|
1894
|
+
(frame.variant ?? "sent") !== high.variant ||
|
|
1895
|
+
(frame.lineRepeat ?? 1) !== high.lineRepeat ||
|
|
1896
|
+
(frame.columns ?? 1) !== (high.columns ?? 1) ||
|
|
1897
|
+
(frame.stopwordDim ?? false) !== (high.stopwordDim ?? false),
|
|
1898
|
+
);
|
|
1899
|
+
|
|
1900
|
+
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
1901
|
+
const files = formatFileList(readFiles, modifiedFiles, fileOps.read);
|
|
1902
|
+
|
|
1903
|
+
let summary: string;
|
|
1904
|
+
if (frames.length === 0 && textHead.length === 0 && textTail.length === 0 && files.length === 0) {
|
|
1905
|
+
summary = "No prior history.";
|
|
1906
|
+
} else {
|
|
1907
|
+
summary = prompt.render(snapcompactSummaryPrompt, {
|
|
1908
|
+
frameCount: frames.length,
|
|
1909
|
+
multipleFrames: frames.length > 1,
|
|
1910
|
+
docColumns: high.columns === 2,
|
|
1911
|
+
cols: geo.cols,
|
|
1912
|
+
rows: geo.rows,
|
|
1913
|
+
sentenceInk: high.variant === "sent",
|
|
1914
|
+
stopwordDimmed: high.stopwordDim === true,
|
|
1915
|
+
dimmedToolResults: options?.dimToolResults !== false,
|
|
1916
|
+
lineRepeated: high.lineRepeat > 1,
|
|
1917
|
+
mixedShapes,
|
|
1918
|
+
truncatedChars,
|
|
1919
|
+
includedPreviousSummary,
|
|
1920
|
+
files: files.length > 0 ? files : undefined,
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// A snapcompact pass replaces any provider-side replacement history; strip the
|
|
1925
|
+
// OpenAI remote-compaction payload like the default summarizer path does.
|
|
1926
|
+
const basePreserve = stripOpenAiRemoteCompactionPreserveData(previousPreserveData) ?? {};
|
|
1927
|
+
const persistedText =
|
|
1928
|
+
layout.keptText.length > 0 && layout.textTail.length > 0
|
|
1929
|
+
? `${layout.keptText.slice(0, layout.keptText.length - layout.textTail.length)}${textTail}`
|
|
1930
|
+
: layout.keptText;
|
|
1931
|
+
const archive: Archive = {
|
|
1932
|
+
frames,
|
|
1933
|
+
totalChars,
|
|
1934
|
+
truncatedChars,
|
|
1935
|
+
...(persistedText.length > 0 ? { text: persistedText } : {}),
|
|
1936
|
+
...(textHead ? { textHead } : {}),
|
|
1937
|
+
...(textTail ? { textTail } : {}),
|
|
1938
|
+
};
|
|
1939
|
+
|
|
1940
|
+
const textNote = textChars > 0 ? ` (+${textChars.toLocaleString()} chars as text)` : "";
|
|
1941
|
+
return {
|
|
1942
|
+
summary,
|
|
1943
|
+
shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}${textNote}`,
|
|
1944
|
+
firstKeptEntryId,
|
|
1945
|
+
tokensBefore,
|
|
1946
|
+
details: { readFiles, modifiedFiles },
|
|
1947
|
+
preserveData: { ...basePreserve, [PRESERVE_KEY]: archive },
|
|
1948
|
+
};
|
|
1949
|
+
}
|