@oh-my-pi/snapcompact 15.11.6 → 15.11.8

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