@oh-my-pi/snapcompact 16.0.10 → 16.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.0] - 2026-06-19
6
+
7
+ ### Added
8
+
9
+ - Added `historyBlocks(archive)` to reconstruct ordered history blocks from archive data
10
+
11
+ ### Changed
12
+
13
+ - Refactored compaction to be text-sourced, re-rendering from unified `Archive.text` source
14
+ - Implemented foveated archive layout (HQ edges, dense LQ middle) for optimized context usage
15
+ - Raised `MAX_FRAMES_DEFAULT` to 80 and consolidated `PROVIDER_IMAGE_BUDGETS`
16
+ - Updated OpenRouter to use standard 90-image budget
17
+ - Updated prompt instructions to clearly distinguish between plain-text and image history regions
18
+ - `Options.maxFrames` is now an upper limit clamped to `MAX_FRAMES_DEFAULT`, not a per-call default
19
+ - Rewrote the resume summary prompt into a structured reading guide (turn headings, grid/two-column layout, ink notes) and render file operations inline as a `FILES` section instead of a spliced `<files>` tag
20
+
21
+ ### Fixed
22
+
23
+ - Fixed context budget undercounting by raising `FRAME_TOKEN_ESTIMATE` to 5024
24
+ - Improved file list formatting in compaction summaries
25
+
26
+ ## [16.0.11] - 2026-06-19
27
+
28
+ ### Changed
29
+
30
+ - Refined elision markers for file operations and truncated text for better display consistency
31
+ - Updated summary text for consistent descriptions of archived tool output
32
+ - Folded a much wider range of Unicode to ASCII in `normalize()` before native rendering: added a per-character Unicode NFKD decomposition fallback (fullwidth forms, super/subscripts, ligatures, circled and math-styled alphanumerics, Roman numerals, vulgar fractions) and expanded the `CHAR_FOLD` punctuation table (more quotes/primes, hyphens, the fraction slash, dot leaders, bullets, and arrows) so undrawable glyphs land on close ASCII equivalents instead of `?`
33
+
5
34
  ## [16.0.8] - 2026-06-18
6
35
 
7
36
  ### Added
package/README.md CHANGED
@@ -49,18 +49,18 @@ Run a full compaction pass over prepared messages:
49
49
  ```ts
50
50
  import { compact } from "@oh-my-pi/snapcompact";
51
51
 
52
- const result = await compact(preparation, { model, maxFrames: 8 });
53
- // result.summary — text summary with <files> operations block
54
- // result.preserveData — frame archive, re-attachable via getPreservedArchive() + images()
52
+ const result = await compact(preparation, { model });
53
+ // result.summary — short "resume prior conversation" lead-in, reading guide, and FILES section
54
+ // result.preserveData — bounded archive source + rendered image middle
55
55
  ```
56
56
 
57
57
  ## API surface
58
58
 
59
- - **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images`
59
+ - **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images`, `historyBlocks`
60
60
  - **Rendering**: `render`, `renderMany`, `frames`, `geometry`
61
61
  - **Shapes**: `SHAPES`, `SHAPE_VARIANTS`, `resolveShape`, `idealShapeVariant`, `isShape`, `isShapeVariantName`
62
62
  - **Text**: `serializeConversation`, `normalize`, `dimStopwords`, `wrap`
63
- - **Budgets**: `providerImageBudget`, `providerFrameBudget`, `MAX_FRAMES`, `FRAME_TOKEN_ESTIMATE`
63
+ - **Budgets**: `providerImageBudget`, `MAX_FRAMES_DEFAULT`, `FRAME_TOKEN_ESTIMATE`, `HQ_EDGE_FRAMES`
64
64
  - **File ops**: `createFileOps`, `computeFileLists`, `upsertFileOperations`
65
65
 
66
66
  ## References
@@ -31,14 +31,11 @@
31
31
  * billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
32
32
  * area-proportional, so resolution cannot improve chars/$ — 1568 stays.
33
33
  * `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
34
- * - **Unknown providers** default to the Anthropic shape. Gateways can
35
- * defeat any shape silently: OpenRouter enforces a per-model image cap
36
- * (measured: 8 images for glm-4.6v frames past the cap are dropped with
37
- * no error, billed tokens plateau exactly at 8x frame cost). The same
38
- * frames routed direct to the vendor read fine (glm f1 .20 -> .78), so
39
- * `providerImageBudget` caps per-request images per provider (OpenRouter
40
- * 8, unknown 5) and `compact()` keeps any archive overflow as a text tail
41
- * on the summary instead of rendering frames that would be dropped.
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.
42
39
  *
43
40
  * The whole pass is local and deterministic — no LLM call, no API key, no
44
41
  * latency beyond rendering. Rasterization and PNG encoding happen in native
@@ -46,7 +43,7 @@
46
43
  * Frames persist in the compaction entry's `preserveData` and are
47
44
  * re-attached to the compaction summary message on every context rebuild.
48
45
  */
49
- import type { Api, ImageContent, Message, Model } from "@oh-my-pi/pi-ai";
46
+ import type { Api, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
50
47
  /** One eval-validated frame shape: font, cell, ink, repetition, and size. */
51
48
  export interface Shape {
52
49
  /** Bundled font in the native renderer. */
@@ -286,28 +283,35 @@ export declare function resolveShape(model?: ShapeTarget, variant?: ShapeVariant
286
283
  /** Legacy frame edge in pixels (the 5x8 shape's eval-validated size). New
287
284
  * shapes carry their own `frameSize`. */
288
285
  export declare const FRAME_SIZE = 2576;
289
- /** Maximum frames carried on a compaction entry. Oldest frames are dropped
290
- * first once the budget is exceeded (mirrors how iterative text summaries
291
- * fade the oldest detail). */
292
- export declare const MAX_FRAMES = 8;
293
- /** Conservative per-frame token estimate used for context budgeting
294
- * (upper bound across shapes: Anthropic bills 1568*1568/750 ≈ 3,278). */
295
- export declare const FRAME_TOKEN_ESTIMATE = 3300;
286
+ /** Default upper bound on archive frames carried per compaction. Sized to hold
287
+ * ~400k tokens of the high-res Anthropic frame Opus reads (1932px 5,000
288
+ * billed tokens each → 80 frames) while staying under the ~100-image
289
+ * per-request wire cap. Oldest frames are dropped first once the budget is
290
+ * exceeded (mirrors how iterative text summaries fade the oldest detail); a
291
+ * caller may pass a lower `maxFrames` upper limit, and per-model context
292
+ * fitting is handled by the caller's overflow guard. */
293
+ export declare const MAX_FRAMES_DEFAULT = 80;
294
+ /** High-quality (legible) frames rendered at each chronological edge of a
295
+ * foveated archive — the session head (oldest) and the slice just before the
296
+ * text region (newest) — with the denser low-quality tier filling the middle. */
297
+ export declare const HQ_EDGE_FRAMES = 3;
298
+ /** Conservative per-frame token estimate used for context budgeting — the
299
+ * upper bound across shapes: high-res Claude frames hit the 4,784 visual-token
300
+ * cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
301
+ * undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
302
+ export declare const FRAME_TOKEN_ESTIMATE = 5024;
296
303
  /**
297
- * Per-request image-count budgets by provider id. Routers and smaller
298
- * providers enforce hard caps and silently DROP images past them (measured:
299
- * OpenRouter caps at 8 images 9+ vanish with no error and billed tokens
300
- * plateau at 8x frame cost). First-party APIs allow far more; their values
301
- * are conservative policy caps well under the measured hard limits
302
- * (Anthropic 100, OpenAI 500, Gemini ~2500).
304
+ * Per-request image-count budgets by provider id. These cap how many images an
305
+ * entire request may carry (archive/system-prompt/tool-result imaging combined).
306
+ * The values are conservative policy caps under the vendor hard limits
307
+ * (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe
308
+ * floor rather than sending unbounded attachments.
303
309
  */
304
310
  export declare const PROVIDER_IMAGE_BUDGETS: Record<string, number>;
305
311
  /** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
306
312
  export declare const DEFAULT_PROVIDER_IMAGE_BUDGET = 5;
307
313
  /** Per-request image budget for `provider`; unknown providers get the floor. */
308
314
  export declare function providerImageBudget(provider: string | undefined): number;
309
- /** Archive frame budget for `provider`: its image budget clamped to {@link MAX_FRAMES}. */
310
- export declare function providerFrameBudget(provider: string | undefined): number;
311
315
  /** Key under `CompactionEntry.preserveData` holding the frame archive. */
312
316
  export declare const PRESERVE_KEY = "snapcompact";
313
317
  /** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
@@ -334,16 +338,20 @@ export interface Frame {
334
338
  }
335
339
  /** Frame archive persisted under `preserveData[PRESERVE_KEY]`. */
336
340
  export interface Archive {
337
- /** Frames ordered oldest to newest. */
341
+ /** Rendered frames ordered oldest to newest, re-derived from {@link text}
342
+ * each compaction with foveated quality tiers (HQ/LQ/HQ inside the imaged
343
+ * middle). May be empty when the whole archive fits in text. */
338
344
  frames: Frame[];
339
- /** Characters currently readable across all frames. */
345
+ /** Characters currently readable across all frames plus the text regions. */
340
346
  totalChars: number;
341
- /** Characters dropped so far to respect the frame budget. */
347
+ /** Characters dropped so far to respect the archive budget. */
342
348
  truncatedChars: number;
343
- /** Most recent slice of archived history that exceeded the frame budget,
344
- * kept verbatim as normalized text (dim markers and newline glyphs
345
- * included). Shipped as plain text in the compaction summary and folded
346
- * back into frames by the next compaction. */
349
+ /** Full kept archive source (oldest to newest, normalized, bounded to the
350
+ * rendered budget) the single source re-rendered each compaction. */
351
+ text?: string;
352
+ /** Oldest text region kept verbatim around the imaged middle. */
353
+ textHead?: string;
354
+ /** Newest text region kept verbatim around the imaged middle. */
347
355
  textTail?: string;
348
356
  }
349
357
  export interface Geometry {
@@ -363,7 +371,7 @@ export interface Options<TMessage = Message> extends SerializeOptions {
363
371
  shape?: Shape;
364
372
  /** Frame edge in pixels. Defaults to the shape's `frameSize`. */
365
373
  frameSize?: number;
366
- /** Frame budget. Defaults to {@link MAX_FRAMES}. */
374
+ /** Upper limit on archive frames; clamped to (and defaulting to) {@link MAX_FRAMES_DEFAULT}. */
367
375
  maxFrames?: number;
368
376
  }
369
377
  /** Result of rendering one frame. */
@@ -450,11 +458,14 @@ export declare const NEWLINE_GLYPH = "\u2588";
450
458
  * Prepare text for printing: strip ANSI escape sequences, collapse horizontal
451
459
  * whitespace runs to single spaces and newline-bearing runs to one
452
460
  * {@link NEWLINE_GLYPH} (drawn as a pitch-black cell), then fold everything
453
- * outside the fonts' ASCII + Latin-1 coverage to ASCII approximations.
454
- * Unrenderable control/format/combining characters are dropped without
455
- * occupying a cell; `?` remains the fallback for unsupported graphic
456
- * characters. The zero-width ink toggles {@link DIM_ON}/{@link DIM_OFF} pass
457
- * through untouched.
461
+ * outside the fonts' ASCII + Latin-1 coverage to ASCII approximations — first
462
+ * through the {@link CHAR_FOLD} punctuation table, then via an NFKD
463
+ * decomposition that recovers the ASCII skeleton of compatibility characters
464
+ * (fullwidth, super/subscripts, ligatures, circled/math-styled alphanumerics,
465
+ * Roman numerals, vulgar fractions). Unrenderable control/format/combining
466
+ * characters are dropped without occupying a cell; `?` remains the fallback
467
+ * for unsupported graphic characters. The zero-width ink toggles
468
+ * {@link DIM_ON}/{@link DIM_OFF} pass through untouched.
458
469
  */
459
470
  export declare function normalize(text: string): string;
460
471
  /**
@@ -501,22 +512,22 @@ export declare function frames(text: string, options?: Pick<RenderManyOptions, "
501
512
  export declare function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined;
502
513
  /** Convert archive frames into LLM image blocks (oldest first). */
503
514
  export declare function images(archive: Archive): ImageContent[];
515
+ /** Ordered archive blocks for a compaction summary message, oldest to newest:
516
+ * the oldest text region, the imaged middle, then the newest text region.
517
+ * Runtime-only; reconstructed from {@link Archive} on each context rebuild
518
+ * instead of persisted on the session entry. */
519
+ export declare function historyBlocks(archive: Archive): (TextContent | ImageContent)[];
504
520
  /**
505
521
  * Run a snapcompact compaction over prepared messages. Fully local: serializes
506
- * the discarded history, prints it onto PNG frames in the provider-optimal
507
- * shape, merges previously archived frames (oldest dropped beyond the
508
- * budget), and produces a deterministic summary explaining how to read the
509
- * frames. Pages past the frame budget are never rendered (providers with
510
- * hard image caps silently drop excess frames on the wire) — the newest
511
- * unrendered slice survives verbatim as a text tail on the summary and is
512
- * folded back into frames by the next compaction.
522
+ * the discarded history, appends it to the accumulated archive source text, and
523
+ * re-renders that source into an ordered history layout: plain text at the
524
+ * oldest edge, imaged middle, then plain text at the newest edge. The imaged
525
+ * middle itself foveates (HQ/LQ/HQ) when it grows large.
513
526
  *
514
- * Frames archived under a different shape (provider switches, legacy 5x8
515
- * sessions) are kept as-is each frame carries its own geometry, and the
516
- * summary describes the newest shape while noting that older frames may
517
- * differ.
527
+ * The full kept source persists on the archive (`text`) so each later compaction
528
+ * unfolds and re-renders it coherently alongside the newly archived history.
518
529
  *
519
- * If the previous compaction was text-based, its summary is printed at the
520
- * head of the frame archive as `[Summary of earlier history]` so no continuity is lost.
530
+ * If the previous compaction was text-based, its summary is printed at the head
531
+ * of the archive as `[Summary of earlier history]` so no continuity is lost.
521
532
  */
522
533
  export declare function compact<TMessage = Message>(preparation: CompactionPreparation<TMessage>, options?: Options<TMessage>): Promise<CompactionResult>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/snapcompact",
4
- "version": "16.0.10",
4
+ "version": "16.1.0",
5
5
  "description": "Bitmap-frame context compression for vision-capable LLMs",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,9 +31,10 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-ai": "16.0.10",
35
- "@oh-my-pi/pi-natives": "16.0.10",
36
- "@oh-my-pi/pi-utils": "16.0.10"
34
+ "@oh-my-pi/pi-ai": "16.1.0",
35
+ "@oh-my-pi/pi-natives": "16.1.0",
36
+ "@oh-my-pi/pi-utils": "16.1.0",
37
+ "@oh-my-pi/pi-wire": "16.1.0"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@types/bun": "^1.3.14"
@@ -1,24 +1,24 @@
1
- Prior conversation history has been archived verbatim onto {{frameCount}} snapcompact frame{{#if multipleFrames}}s{{/if}} the bitmap image{{#if multipleFrames}}s{{/if}} attached below{{#if multipleFrames}}, ordered oldest to newest{{/if}}.
1
+ You are resuming a prior conversation. Its earlier turns were archived to reclaim context and are reproduced under HISTORY below, oldest to newest. Read HISTORY in full, then continue from the live conversation that follows it.
2
2
 
3
- Reading a frame: monospace {{fontCell}} pixel font on a white background, {{#if docColumns}}typeset as two word-wrapped newspaper columns of {{cols}} characters by {{rows}} lines each read the left column top to bottom, then the right column{{else}}{{cols}} characters per row, {{rows}} text rows per frame; read left to right, top to bottom. Text flows continuously with no word wrap, so words may break across row ends{{/if}}. Horizontal whitespace runs were collapsed to single spaces; line breaks print as a solid black cell (one character wide) treat each as a newline. {{#if sentenceInk}}Ink color cycles through six colors, advancing at sentence boundaries — a color change marks a new sentence.{{else}}Glyphs are plain black ink.{{/if}}{{#if stopwordDimmed}} Common function words (the, of, and, …) are printed in dim gray; content words carry the full ink.{{/if}}{{#if dimmedToolResults}} Tool output is printed in dim gray ink gray text is archived tool output, not conversation.{{/if}}{{#if lineRepeated}} Every text line is printed twice in a row — first on the white background, then repeated on a pale yellow band. The copies are identical: read each line once and use the duplicate only to double-check hard glyphs.{{/if}} Roles are tagged inline as [User]:, [Assistant]:, [Think]:, [Tool Call]:, and [Tool Result]:.
4
- {{#if mixedShapes}}
3
+ The archived transcript is compact: each turn opens with a heading`# User ¶`, `# Assistant ¶`, or `# Tool call ¶`assistant reasoning is wrapped in _italics_, and tool output sits inside `<out>…</out>`.
5
4
 
6
- Older frames may use a different font, grid, or ink coloring than described above; the reading order is always the same (left to right, top to bottom, oldest frame first).
7
- {{/if}}
8
- {{#if includedPreviousSummary}}
5
+ Reading HISTORY:
6
+ - Plain-text sections are the verbatim transcript — rely on them exactly.
7
+ {{#if frameCount}}- Some middle sections are attached as images instead of text. Each image is a page of that same transcript and belongs at its place in the reading order, between marked delimiters. Within an image, a solid black cell marks a newline and runs of spaces collapse to one.
8
+ {{#if docColumns}} - A frame holds two side-by-side columns, each {{cols}} characters wide and up to {{rows}} rows tall: read the left column top to bottom, then the right.
9
+ {{else}} - A frame is one grid {{cols}} characters wide and up to {{rows}} rows tall: read left to right, top to bottom — there is no word wrap, so a word may break across rows.
10
+ {{/if}}{{#if sentenceInk}} - Ink cycles through six colors, one per sentence.
11
+ {{/if}}{{#if stopwordDimmed}} - Function words are dim gray; content words keep full ink.
12
+ {{/if}}{{#if dimmedToolResults}} - Text inside `<out>` is dim gray — that gray is archived tool output, not conversation.
13
+ {{/if}}{{#if lineRepeated}} - Each line is printed twice (white, then a pale-yellow band); the two copies are identical.
14
+ {{/if}}{{#if mixedShapes}} - The compressed middle frames use a smaller, denser font than the edge frames; the reading order is unchanged.
15
+ {{/if}}{{/if}}{{#if includedPreviousSummary}}- HISTORY opens with a condensed digest of still-older context that predates the archived turns.
16
+ {{/if}}{{#if truncatedChars}}- About {{truncatedChars}} characters of older middle history were dropped to fit the archive budget.
17
+ {{/if}}- When an exact earlier detail matters and a section reads unclearly, re-derive it from the workspace (re-read files, re-run commands) rather than guessing.
9
18
 
10
- The earliest frame begins with "[Summary of earlier history]" — a condensed digest of context that predates the archived conversation.
11
- {{/if}}
12
- {{#if truncatedChars}}
19
+ {{#if files}}FILES
20
+ ===================
21
+ {{files}}
13
22
 
14
- {{truncatedChars}} characters of older history were dropped to respect the frame budget. The first frame (session start) is always kept, so the missing span sits between the first frame and the next.
15
- {{/if}}
16
-
17
- Total archived: {{totalChars}} characters. Consult the frames whenever you need exact earlier details (user wording, decisions, file paths, tool output). If a region is hard to read, re-derive the fact from the workspace (re-read files, re-run commands) rather than guessing.
18
- {{#if textTail}}
19
-
20
- The frame budget ran out before the newest part of the archive. That remainder continues below as plain text — it is newer than every frame and ends where the live conversation resumes.
21
-
22
- [Archived history, continued as text]
23
- {{textTail}}
24
- {{/if}}
23
+ {{/if}}HISTORY
24
+ ===================
@@ -31,14 +31,11 @@
31
31
  * billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
32
32
  * area-proportional, so resolution cannot improve chars/$ — 1568 stays.
33
33
  * `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
34
- * - **Unknown providers** default to the Anthropic shape. Gateways can
35
- * defeat any shape silently: OpenRouter enforces a per-model image cap
36
- * (measured: 8 images for glm-4.6v frames past the cap are dropped with
37
- * no error, billed tokens plateau exactly at 8x frame cost). The same
38
- * frames routed direct to the vendor read fine (glm f1 .20 -> .78), so
39
- * `providerImageBudget` caps per-request images per provider (OpenRouter
40
- * 8, unknown 5) and `compact()` keeps any archive overflow as a text tail
41
- * on the summary instead of rendering frames that would be dropped.
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.
42
39
  *
43
40
  * The whole pass is local and deterministic — no LLM call, no API key, no
44
41
  * latency beyond rendering. Rasterization and PNG encoding happen in native
@@ -47,9 +44,10 @@
47
44
  * re-attached to the compaction summary message on every context rebuild.
48
45
  */
49
46
 
50
- import type { Api, ImageContent, Message, Model } from "@oh-my-pi/pi-ai";
47
+ import type { Api, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
51
48
  import { renderSnapcompactPng } from "@oh-my-pi/pi-natives";
52
49
  import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils";
50
+ import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
53
51
  import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
54
52
  import snapcompactSummaryPrompt from "./prompts/snapcompact-summary.md" with { type: "text" };
55
53
 
@@ -300,6 +298,16 @@ const FAMILY_VARIANT: Record<BillingFamily, ShapeVariantName> = {
300
298
  openai: "8on22-bw",
301
299
  };
302
300
 
301
+ /** Denser companion variant per family for the foveated archive middle: same
302
+ * pixels (identical per-frame bill) but a tighter 8px cell, trading some
303
+ * legibility for ~40% more chars per frame so the least-important middle of a
304
+ * long archive compresses into fewer frames. */
305
+ const FAMILY_VARIANT_LOW: Record<BillingFamily, ShapeVariantName> = {
306
+ anthropic: "8on16-bw",
307
+ google: "8on16-bw",
308
+ openai: "8on16-bw",
309
+ };
310
+
303
311
  const FAMILY_SHAPE: Record<BillingFamily, Shape> = {
304
312
  anthropic: SHAPES.anthropic,
305
313
  google: SHAPES.google,
@@ -376,22 +384,32 @@ export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "
376
384
  * shapes carry their own `frameSize`. */
377
385
  export const FRAME_SIZE = 2576;
378
386
 
379
- /** Maximum frames carried on a compaction entry. Oldest frames are dropped
380
- * first once the budget is exceeded (mirrors how iterative text summaries
381
- * fade the oldest detail). */
382
- export const MAX_FRAMES = 8;
383
-
384
- /** Conservative per-frame token estimate used for context budgeting
385
- * (upper bound across shapes: Anthropic bills 1568*1568/750 ≈ 3,278). */
386
- export const FRAME_TOKEN_ESTIMATE = 3300;
387
+ /** Default upper bound on archive frames carried per compaction. Sized to hold
388
+ * ~400k tokens of the high-res Anthropic frame Opus reads (1932px 5,000
389
+ * billed tokens each → 80 frames) while staying under the ~100-image
390
+ * per-request wire cap. Oldest frames are dropped first once the budget is
391
+ * exceeded (mirrors how iterative text summaries fade the oldest detail); a
392
+ * caller may pass a lower `maxFrames` upper limit, and per-model context
393
+ * fitting is handled by the caller's overflow guard. */
394
+ export const MAX_FRAMES_DEFAULT = 80;
395
+
396
+ /** High-quality (legible) frames rendered at each chronological edge of a
397
+ * foveated archive — the session head (oldest) and the slice just before the
398
+ * text region (newest) — with the denser low-quality tier filling the middle. */
399
+ export const HQ_EDGE_FRAMES = 3;
400
+
401
+ /** Conservative per-frame token estimate used for context budgeting — the
402
+ * upper bound across shapes: high-res Claude frames hit the 4,784 visual-token
403
+ * cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
404
+ * undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
405
+ export const FRAME_TOKEN_ESTIMATE = 5024;
387
406
 
388
407
  /**
389
- * Per-request image-count budgets by provider id. Routers and smaller
390
- * providers enforce hard caps and silently DROP images past them (measured:
391
- * OpenRouter caps at 8 images 9+ vanish with no error and billed tokens
392
- * plateau at 8x frame cost). First-party APIs allow far more; their values
393
- * are conservative policy caps well under the measured hard limits
394
- * (Anthropic 100, OpenAI 500, Gemini ~2500).
408
+ * Per-request image-count budgets by provider id. These cap how many images an
409
+ * entire request may carry (archive/system-prompt/tool-result imaging combined).
410
+ * The values are conservative policy caps under the vendor hard limits
411
+ * (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe
412
+ * floor rather than sending unbounded attachments.
395
413
  */
396
414
  export const PROVIDER_IMAGE_BUDGETS: Record<string, number> = {
397
415
  anthropic: 90,
@@ -401,7 +419,7 @@ export const PROVIDER_IMAGE_BUDGETS: Record<string, number> = {
401
419
  google: 200,
402
420
  "google-vertex": 200,
403
421
  "google-gemini-cli": 200,
404
- openrouter: 8,
422
+ openrouter: 90,
405
423
  };
406
424
 
407
425
  /** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
@@ -412,11 +430,6 @@ export function providerImageBudget(provider: string | undefined): number {
412
430
  return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET;
413
431
  }
414
432
 
415
- /** Archive frame budget for `provider`: its image budget clamped to {@link MAX_FRAMES}. */
416
- export function providerFrameBudget(provider: string | undefined): number {
417
- return Math.min(MAX_FRAMES, providerImageBudget(provider));
418
- }
419
-
420
433
  /** Key under `CompactionEntry.preserveData` holding the frame archive. */
421
434
  export const PRESERVE_KEY = "snapcompact";
422
435
 
@@ -449,16 +462,20 @@ export interface Frame {
449
462
 
450
463
  /** Frame archive persisted under `preserveData[PRESERVE_KEY]`. */
451
464
  export interface Archive {
452
- /** Frames ordered oldest to newest. */
465
+ /** Rendered frames ordered oldest to newest, re-derived from {@link text}
466
+ * each compaction with foveated quality tiers (HQ/LQ/HQ inside the imaged
467
+ * middle). May be empty when the whole archive fits in text. */
453
468
  frames: Frame[];
454
- /** Characters currently readable across all frames. */
469
+ /** Characters currently readable across all frames plus the text regions. */
455
470
  totalChars: number;
456
- /** Characters dropped so far to respect the frame budget. */
471
+ /** Characters dropped so far to respect the archive budget. */
457
472
  truncatedChars: number;
458
- /** Most recent slice of archived history that exceeded the frame budget,
459
- * kept verbatim as normalized text (dim markers and newline glyphs
460
- * included). Shipped as plain text in the compaction summary and folded
461
- * back into frames by the next compaction. */
473
+ /** Full kept archive source (oldest to newest, normalized, bounded to the
474
+ * rendered budget) the single source re-rendered each compaction. */
475
+ text?: string;
476
+ /** Oldest text region kept verbatim around the imaged middle. */
477
+ textHead?: string;
478
+ /** Newest text region kept verbatim around the imaged middle. */
462
479
  textTail?: string;
463
480
  }
464
481
 
@@ -480,7 +497,7 @@ export interface Options<TMessage = Message> extends SerializeOptions {
480
497
  shape?: Shape;
481
498
  /** Frame edge in pixels. Defaults to the shape's `frameSize`. */
482
499
  frameSize?: number;
483
- /** Frame budget. Defaults to {@link MAX_FRAMES}. */
500
+ /** Upper limit on archive frames; clamped to (and defaulting to) {@link MAX_FRAMES_DEFAULT}. */
484
501
  maxFrames?: number;
485
502
  }
486
503
 
@@ -584,7 +601,7 @@ function stripFileOperationTags(summary: string): string {
584
601
  .trimEnd();
585
602
  }
586
603
 
587
- function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string {
604
+ function formatFileList(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string {
588
605
  if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
589
606
  const mode = new Map<string, "Read" | "Write" | "RW">();
590
607
  for (const file of readFiles) mode.set(file, "Read");
@@ -592,9 +609,14 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[], read
592
609
  const all = [...mode.keys()].sort();
593
610
  let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
594
611
  if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
595
- files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`;
612
+ files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`;
596
613
  }
597
- return prompt.render(fileOperationsTemplate, { files });
614
+ return files;
615
+ }
616
+
617
+ function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string {
618
+ const files = formatFileList(readFiles, modifiedFiles, readSet);
619
+ return files.length > 0 ? prompt.render(fileOperationsTemplate, { files }) : "";
598
620
  }
599
621
 
600
622
  export function upsertFileOperations(
@@ -657,15 +679,16 @@ function truncateForSummary(text: string, maxChars: number, headRatio: number):
657
679
  const tailChars = maxChars - headChars;
658
680
  const elided = text.length - maxChars;
659
681
  const tail = tailChars > 0 ? text.slice(-tailChars) : "";
660
- return `${text.slice(0, headChars)} [... ${elided} chars elided ...] ${tail}`;
682
+ return `${text.slice(0, headChars)} […${elided}ch elided] ${tail}`;
661
683
  }
662
684
 
663
685
  const DIM_MARKERS = /[\u000e\u000f]/g;
664
686
 
665
- /** Cap on the unrendered archive text tail, in frame-capacity units: enough
666
- * to keep the newest discarded history readable without re-inflating the
667
- * context a compaction just shrank. */
668
- const TEXT_TAIL_MAX_PAGES = 2;
687
+ /** Plain-text history kept verbatim at each chronological edge, in HQ-frame-
688
+ * capacity units per edge. One page at the start and one at the end preserves
689
+ * high-fidelity context around the imaged middle while keeping the total text
690
+ * budget equal to the prior 2-page tail-only scheme. */
691
+ const TEXT_EDGE_PAGES = 1;
669
692
 
670
693
  /** Normalized archive text → plain text: drop zero-width dim toggles and
671
694
  * print newline glyphs as real newlines. */
@@ -740,9 +763,11 @@ export function serializeConversation(messages: Message[], options?: SerializeOp
740
763
 
741
764
  for (const block of msg.content) {
742
765
  if (block.type === "text") {
743
- pendingText.push(stripDimMarkers(block.text));
766
+ const text = stripDimMarkers(block.text);
767
+ if (text.trim()) pendingText.push(text);
744
768
  } else if (block.type === "thinking") {
745
- pendingThinking.push(stripDimMarkers(block.thinking));
769
+ const thinking = stripDimMarkers(block.thinking);
770
+ if (thinking.trim()) pendingThinking.push(thinking);
746
771
  } else if (block.type === "toolCall") {
747
772
  if (uselessCallIds.has(block.id)) continue;
748
773
  flushAssistant();
@@ -750,11 +775,15 @@ export function serializeConversation(messages: Message[], options?: SerializeOp
750
775
  // Prefer the harness-derived intent, else the raw `_i` arg; render it as
751
776
  // a one-line `//comment` and drop `_i` from the args below.
752
777
  const rawIntent =
753
- typeof block.intent === "string" ? block.intent : typeof args._i === "string" ? args._i : "";
778
+ typeof block.intent === "string"
779
+ ? block.intent
780
+ : typeof args[INTENT_FIELD] === "string"
781
+ ? (args[INTENT_FIELD] as string)
782
+ : "";
754
783
  const intent = stripDimMarkers(rawIntent).replace(/\s+/g, " ").trim();
755
784
  const argsStr = truncateForSummary(
756
785
  Object.entries(args)
757
- .filter(([key]) => key !== "_i")
786
+ .filter(([key]) => key !== INTENT_FIELD)
758
787
  .map(
759
788
  ([key, value]) =>
760
789
  `${key}=${truncateForSummary(JSON.stringify(value) ?? "undefined", toolArgMaxChars, headRatio)}`,
@@ -807,8 +836,11 @@ function stripOpenAiRemoteCompactionPreserveData(
807
836
  // Text normalization
808
837
  // ============================================================================
809
838
 
810
- /** Folds for common non-Latin-1 characters the bundled fonts cannot draw. */
839
+ /** Punctuation and symbol folds applied before the NFKD fallback in
840
+ * {@link normalize}: quotes, dashes, bullets, arrows, and dot leaders that
841
+ * have no compatibility decomposition (or one that is itself non-ASCII). */
811
842
  const CHAR_FOLD: Record<string, string> = {
843
+ // Quotation marks and primes.
812
844
  "\u2018": "'",
813
845
  "\u2019": "'",
814
846
  "\u201a": "'",
@@ -816,18 +848,44 @@ const CHAR_FOLD: Record<string, string> = {
816
848
  "\u201c": '"',
817
849
  "\u201d": '"',
818
850
  "\u201e": '"',
851
+ "\u2032": "'",
852
+ "\u2033": '"',
853
+ "\u2035": "'",
854
+ "\u2036": '"',
855
+ "\u2039": "<",
856
+ "\u203a": ">",
857
+ // Dashes, hyphens, and the fraction slash NFKD leaves in vulgar fractions.
858
+ "\u2010": "-",
859
+ "\u2011": "-",
860
+ "\u2012": "-",
819
861
  "\u2013": "-",
820
862
  "\u2014": "-",
821
863
  "\u2015": "-",
822
864
  "\u2212": "-",
865
+ "\u2044": "/",
866
+ // Dot leaders and ellipses.
867
+ "\u2024": ".",
868
+ "\u2025": "..",
823
869
  "\u2026": "...",
870
+ "\u22ef": "...",
871
+ // Bullets.
824
872
  "\u2022": "*",
873
+ "\u2023": "*",
874
+ "\u2043": "-",
875
+ "\u2219": "*",
825
876
  "\u25cf": "*",
826
877
  "\u25a0": "*",
827
878
  "\u25aa": "*",
879
+ // Arrows.
828
880
  "\u2190": "<-",
881
+ "\u2191": "^",
829
882
  "\u2192": "->",
883
+ "\u2193": "v",
884
+ "\u2194": "<->",
885
+ "\u21d0": "<=",
830
886
  "\u21d2": "=>",
887
+ "\u21d4": "<=>",
888
+ // Check marks and crosses.
831
889
  "\u2713": "v",
832
890
  "\u2714": "v",
833
891
  "\u2717": "x",
@@ -855,15 +913,48 @@ const EDGE_RUNS = /^[ \u2588]+|[ \u2588]+$/g;
855
913
  * combining marks the fonts cannot compose, and lone surrogates. */
856
914
  const UNRENDERABLE = /[\p{Cc}\p{Mn}\p{Me}\p{Cs}]/u;
857
915
 
916
+ /** Combining marks NFKD splits off accented letters; dropped so the base
917
+ * letter prints without the diacritic the bundled fonts cannot compose. */
918
+ const COMBINING_MARKS = /\p{M}+/gu;
919
+
920
+ /**
921
+ * Aggressive single-code-point ASCII fold via Unicode NFKD: decompose the
922
+ * compatibility form (fullwidth, super/subscripts, ligatures, circled and
923
+ * math-styled alphanumerics, Roman numerals, vulgar fractions, …), strip the
924
+ * combining marks, and keep the ASCII/Latin-1 skeleton — routing any residual
925
+ * punctuation back through {@link CHAR_FOLD}. Returns `undefined` when the code
926
+ * point has no decomposition or still leaves an undrawable glyph, so the
927
+ * caller falls back to `?`.
928
+ */
929
+ function foldToAscii(ch: string): string | undefined {
930
+ const decomposed = ch.normalize("NFKD").replace(COMBINING_MARKS, "");
931
+ if (decomposed === ch) return undefined;
932
+ let out = "";
933
+ for (const part of decomposed) {
934
+ const cp = part.codePointAt(0) as number;
935
+ if ((cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff)) {
936
+ out += part;
937
+ continue;
938
+ }
939
+ const fold = CHAR_FOLD[part];
940
+ if (fold === undefined) return undefined;
941
+ out += fold;
942
+ }
943
+ return out;
944
+ }
945
+
858
946
  /**
859
947
  * Prepare text for printing: strip ANSI escape sequences, collapse horizontal
860
948
  * whitespace runs to single spaces and newline-bearing runs to one
861
949
  * {@link NEWLINE_GLYPH} (drawn as a pitch-black cell), then fold everything
862
- * outside the fonts' ASCII + Latin-1 coverage to ASCII approximations.
863
- * Unrenderable control/format/combining characters are dropped without
864
- * occupying a cell; `?` remains the fallback for unsupported graphic
865
- * characters. The zero-width ink toggles {@link DIM_ON}/{@link DIM_OFF} pass
866
- * through untouched.
950
+ * outside the fonts' ASCII + Latin-1 coverage to ASCII approximations — first
951
+ * through the {@link CHAR_FOLD} punctuation table, then via an NFKD
952
+ * decomposition that recovers the ASCII skeleton of compatibility characters
953
+ * (fullwidth, super/subscripts, ligatures, circled/math-styled alphanumerics,
954
+ * Roman numerals, vulgar fractions). Unrenderable control/format/combining
955
+ * characters are dropped without occupying a cell; `?` remains the fallback
956
+ * for unsupported graphic characters. The zero-width ink toggles
957
+ * {@link DIM_ON}/{@link DIM_OFF} pass through untouched.
867
958
  */
868
959
  export function normalize(text: string): string {
869
960
  const stripped = text.includes("\u001b") ? Bun.stripANSI(text) : text;
@@ -889,8 +980,10 @@ export function normalize(text: string): string {
889
980
  } else if (cp >= 0x2500 && cp <= 0x257f) {
890
981
  // Box drawing: keep table skeletons legible.
891
982
  out += cp === 0x2502 || cp === 0x2503 ? "|" : cp === 0x2500 || cp === 0x2501 ? "-" : "+";
892
- } else if (!UNRENDERABLE.test(ch)) {
893
- out += "?";
983
+ } else {
984
+ const folded = foldToAscii(ch);
985
+ if (folded !== undefined) out += folded;
986
+ else if (!UNRENDERABLE.test(ch)) out += "?";
894
987
  }
895
988
  }
896
989
  return out;
@@ -1118,23 +1211,31 @@ export function getPreservedArchive(preserveData: Record<string, unknown> | unde
1118
1211
  const candidate = preserveData?.[PRESERVE_KEY];
1119
1212
  if (!candidate || typeof candidate !== "object") return undefined;
1120
1213
  const archive = candidate as Archive;
1121
- if (!Array.isArray(archive.frames)) return undefined;
1122
- const frames = archive.frames.filter(
1123
- frame =>
1124
- !!frame &&
1125
- typeof frame.data === "string" &&
1126
- frame.data.length > 0 &&
1127
- typeof frame.mimeType === "string" &&
1128
- typeof frame.cols === "number" &&
1129
- typeof frame.rows === "number" &&
1130
- typeof frame.chars === "number",
1131
- );
1132
- if (frames.length === 0) return undefined;
1214
+ const frames = Array.isArray(archive.frames)
1215
+ ? archive.frames.filter(
1216
+ frame =>
1217
+ !!frame &&
1218
+ typeof frame.data === "string" &&
1219
+ frame.data.length > 0 &&
1220
+ typeof frame.mimeType === "string" &&
1221
+ typeof frame.cols === "number" &&
1222
+ typeof frame.rows === "number" &&
1223
+ typeof frame.chars === "number",
1224
+ )
1225
+ : [];
1226
+ const text = typeof archive.text === "string" && archive.text.length > 0 ? archive.text : undefined;
1227
+ const textHead = typeof archive.textHead === "string" && archive.textHead.length > 0 ? archive.textHead : undefined;
1228
+ const textTail = typeof archive.textTail === "string" && archive.textTail.length > 0 ? archive.textTail : undefined;
1229
+ // A text-only archive (everything fit in the plain-text regions) is valid;
1230
+ // only an archive carrying neither frames nor text is empty.
1231
+ if (frames.length === 0 && text === undefined && textHead === undefined && textTail === undefined) return undefined;
1133
1232
  return {
1134
1233
  frames,
1135
1234
  totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0,
1136
1235
  truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0,
1137
- ...(typeof archive.textTail === "string" && archive.textTail.length > 0 ? { textTail: archive.textTail } : {}),
1236
+ ...(text !== undefined ? { text } : {}),
1237
+ ...(textHead !== undefined ? { textHead } : {}),
1238
+ ...(textTail !== undefined ? { textTail } : {}),
1138
1239
  };
1139
1240
  }
1140
1241
 
@@ -1147,28 +1248,176 @@ export function images(archive: Archive): ImageContent[] {
1147
1248
  ...(frame.detail ? { detail: frame.detail } : {}),
1148
1249
  }));
1149
1250
  }
1251
+ /** Ordered archive blocks for a compaction summary message, oldest to newest:
1252
+ * the oldest text region, the imaged middle, then the newest text region.
1253
+ * Runtime-only; reconstructed from {@link Archive} on each context rebuild
1254
+ * instead of persisted on the session entry. */
1255
+ export function historyBlocks(archive: Archive): (TextContent | ImageContent)[] {
1256
+ const blocks: (TextContent | ImageContent)[] = [];
1257
+ const hasImages = archive.frames.length > 0;
1258
+ if (archive.textHead) {
1259
+ const suffix = hasImages ? "\n-------------- imaged middle below\n" : "";
1260
+ blocks.push({ type: "text", text: toPlainText(archive.textHead) + suffix });
1261
+ }
1262
+ blocks.push(...images(archive));
1263
+ if (archive.textTail) {
1264
+ const prefix = hasImages
1265
+ ? "-------------- imaged middle above\n"
1266
+ : archive.truncatedChars > 0
1267
+ ? "\n-------------- middle history omitted above\n"
1268
+ : "";
1269
+ const tail = prefix + toPlainText(archive.textTail);
1270
+ if (blocks.length > 0 && blocks[blocks.length - 1]?.type === "text") {
1271
+ (blocks[blocks.length - 1] as TextContent).text += tail;
1272
+ } else {
1273
+ blocks.push({ type: "text", text: tail });
1274
+ }
1275
+ }
1276
+ return blocks;
1277
+ }
1150
1278
 
1151
1279
  // ============================================================================
1152
1280
  // Compaction entry point
1153
1281
  // ============================================================================
1154
1282
 
1283
+ /** Denser companion of `high` for the foveated archive middle: same family and
1284
+ * frame size (identical per-frame bill) but a tighter cell. Returns `high`
1285
+ * unchanged for doc layouts or when no denser variant exists (foveation off). */
1286
+ function denseCompanion(high: Shape, api: Api | undefined): Shape {
1287
+ if (high.columns === 2) return high;
1288
+ const family = billingFamily(api);
1289
+ const low = priceShape({ ...SHAPE_VARIANTS[FAMILY_VARIANT_LOW[family]], frameSize: high.frameSize }, family);
1290
+ return geometry(low).capacity > geometry(high).capacity ? low : high;
1291
+ }
1292
+
1293
+ /** One planned frame: the source slice and the shape (quality tier) to render. */
1294
+ interface PlanFrame {
1295
+ text: string;
1296
+ shape: Shape;
1297
+ }
1298
+
1299
+ /** A foveated archive layout: frames oldest→newest for the imaged middle, the
1300
+ * verbatim text kept at both chronological edges, the flat kept source to
1301
+ * persist, and the chars dropped this round to fit the budget. */
1302
+ interface ArchiveLayout {
1303
+ frames: PlanFrame[];
1304
+ textHead: string;
1305
+ textTail: string;
1306
+ keptText: string;
1307
+ truncatedChars: number;
1308
+ }
1309
+
1310
+ /** Slice `text` into `capacity`-char frames at one shape (tier). */
1311
+ function sliceFrames(text: string, capacity: number, shape: Shape): PlanFrame[] {
1312
+ const out: PlanFrame[] = [];
1313
+ for (let offset = 0; offset < text.length; offset += capacity) {
1314
+ out.push({ text: text.slice(offset, offset + capacity), shape });
1315
+ }
1316
+ return out;
1317
+ }
1318
+
1319
+ /**
1320
+ * Lay out the accumulated archive `text` (oldest→newest) with text at both
1321
+ * chronological edges and images in the middle. One HQ-capacity stays verbatim
1322
+ * at the oldest edge, one at the newest edge, and the middle between them is
1323
+ * imaged. If the imaged middle itself overflows `maxFrames`, foveate it
1324
+ * internally (HQ/LQ/HQ) and drop the oldest slice of its dense center.
1325
+ */
1326
+ function planArchive(text: string, high: Shape, low: Shape, maxFrames: number): ArchiveLayout {
1327
+ const capHi = geometry(high).capacity;
1328
+ const edgeCap = TEXT_EDGE_PAGES * capHi;
1329
+ if (text.length <= 2 * edgeCap) {
1330
+ return { frames: [], textHead: text, textTail: "", keptText: text, truncatedChars: 0 };
1331
+ }
1332
+ if (maxFrames < 1) {
1333
+ const textHead = text.slice(0, edgeCap);
1334
+ const textTail = text.slice(text.length - edgeCap);
1335
+ return {
1336
+ frames: [],
1337
+ textHead,
1338
+ textTail,
1339
+ keptText: textHead + textTail,
1340
+ truncatedChars: text.length - textHead.length - textTail.length,
1341
+ };
1342
+ }
1343
+
1344
+ const textHead = text.slice(0, edgeCap);
1345
+ const textTail = text.slice(text.length - edgeCap);
1346
+ const imageText = text.slice(edgeCap, text.length - edgeCap);
1347
+ if (imageText.length === 0) {
1348
+ return { frames: [], textHead: text, textTail: "", keptText: text, truncatedChars: 0 };
1349
+ }
1350
+
1351
+ // Doc layouts wrap (no char-slicing) and don't foveate: one tier, keep the
1352
+ // newest pages with the session head pinned, drop the oldest middle.
1353
+ if (high.columns === 2) {
1354
+ const pages = docPages(imageText, geometry(high));
1355
+ let kept = pages;
1356
+ let truncatedChars = 0;
1357
+ if (pages.length > maxFrames) {
1358
+ const dropped = pages.slice(1, pages.length - (maxFrames - 1));
1359
+ truncatedChars = dropped.reduce((sum, page) => sum + page.length, 0);
1360
+ kept = [...pages.slice(0, 1), ...pages.slice(pages.length - (maxFrames - 1))];
1361
+ }
1362
+ const flat = kept.map(page => page.replaceAll("\n", " ")).join(" ");
1363
+ return {
1364
+ frames: kept.map(page => ({ text: page, shape: high })),
1365
+ textHead,
1366
+ textTail,
1367
+ keptText: textHead + flat + textTail,
1368
+ truncatedChars,
1369
+ };
1370
+ }
1371
+
1372
+ // Grid: render all-HQ when the image region fits the budget outright.
1373
+ if (Math.ceil(imageText.length / capHi) <= maxFrames) {
1374
+ return {
1375
+ frames: sliceFrames(imageText, capHi, high),
1376
+ textHead,
1377
+ textTail,
1378
+ keptText: textHead + imageText + textTail,
1379
+ truncatedChars: 0,
1380
+ };
1381
+ }
1382
+
1383
+ // Foveate the imaged middle: HQ edges, dense center, drop the oldest dense slice.
1384
+ const capLo = geometry(low).capacity;
1385
+ const imageEdgeFrames = Math.min(HQ_EDGE_FRAMES, Math.floor((maxFrames - 1) / 2));
1386
+ const imageEdgeCap = imageEdgeFrames * capHi;
1387
+ const imageHead = imageText.slice(0, imageEdgeCap);
1388
+ const imageTail = imageEdgeCap > 0 ? imageText.slice(imageText.length - imageEdgeCap) : "";
1389
+ let middleText = imageText.slice(imageEdgeCap, imageText.length - imageEdgeCap);
1390
+ let truncatedChars = 0;
1391
+ const middleCap = (maxFrames - 2 * imageEdgeFrames) * capLo;
1392
+ if (middleText.length > middleCap) {
1393
+ truncatedChars = middleText.length - middleCap;
1394
+ middleText = middleText.slice(truncatedChars);
1395
+ }
1396
+ return {
1397
+ frames: [
1398
+ ...sliceFrames(imageHead, capHi, high),
1399
+ ...sliceFrames(middleText, capLo, low),
1400
+ ...sliceFrames(imageTail, capHi, high),
1401
+ ],
1402
+ textHead,
1403
+ textTail,
1404
+ keptText: textHead + imageHead + middleText + imageTail + textTail,
1405
+ truncatedChars,
1406
+ };
1407
+ }
1408
+
1155
1409
  /**
1156
1410
  * Run a snapcompact compaction over prepared messages. Fully local: serializes
1157
- * the discarded history, prints it onto PNG frames in the provider-optimal
1158
- * shape, merges previously archived frames (oldest dropped beyond the
1159
- * budget), and produces a deterministic summary explaining how to read the
1160
- * frames. Pages past the frame budget are never rendered (providers with
1161
- * hard image caps silently drop excess frames on the wire) — the newest
1162
- * unrendered slice survives verbatim as a text tail on the summary and is
1163
- * folded back into frames by the next compaction.
1411
+ * the discarded history, appends it to the accumulated archive source text, and
1412
+ * re-renders that source into an ordered history layout: plain text at the
1413
+ * oldest edge, imaged middle, then plain text at the newest edge. The imaged
1414
+ * middle itself foveates (HQ/LQ/HQ) when it grows large.
1164
1415
  *
1165
- * Frames archived under a different shape (provider switches, legacy 5x8
1166
- * sessions) are kept as-is each frame carries its own geometry, and the
1167
- * summary describes the newest shape while noting that older frames may
1168
- * differ.
1416
+ * The full kept source persists on the archive (`text`) so each later compaction
1417
+ * unfolds and re-renders it coherently alongside the newly archived history.
1169
1418
  *
1170
- * If the previous compaction was text-based, its summary is printed at the
1171
- * head of the frame archive as `[Summary of earlier history]` so no continuity is lost.
1419
+ * If the previous compaction was text-based, its summary is printed at the head
1420
+ * of the archive as `[Summary of earlier history]` so no continuity is lost.
1172
1421
  */
1173
1422
  export async function compact<TMessage = Message>(
1174
1423
  preparation: CompactionPreparation<TMessage>,
@@ -1178,10 +1427,14 @@ export async function compact<TMessage = Message>(
1178
1427
  if (!firstKeptEntryId) {
1179
1428
  throw new Error("First kept entry has no ID - session may need migration");
1180
1429
  }
1181
- const shape = options?.shape ?? resolveShape(options?.model);
1182
- const frameSize = options?.frameSize ?? shape.frameSize;
1183
- const maxFrames = Math.max(1, options?.maxFrames ?? MAX_FRAMES);
1184
- const geo = geometry(shape, frameSize);
1430
+ const baseShape = options?.shape ?? resolveShape(options?.model);
1431
+ const frameSize = options?.frameSize ?? baseShape.frameSize;
1432
+ const high = frameSize === baseShape.frameSize ? baseShape : { ...baseShape, frameSize };
1433
+ const low = denseCompanion(high, options?.model?.api);
1434
+ const geo = geometry(high);
1435
+ // The engine default caps archive growth; a caller-supplied maxFrames only
1436
+ // lowers it further (an upper limit), never raising it past the default.
1437
+ const maxFrames = Math.max(1, Math.min(options?.maxFrames ?? MAX_FRAMES_DEFAULT, MAX_FRAMES_DEFAULT));
1185
1438
 
1186
1439
  const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
1187
1440
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
@@ -1196,127 +1449,103 @@ export async function compact<TMessage = Message>(
1196
1449
 
1197
1450
  let truncatedChars = previousArchive?.truncatedChars ?? 0;
1198
1451
 
1199
- // The previous compaction's unframed text tail is the oldest part of this
1200
- // archive slice prepend it so it ages into frames first.
1201
- if (previousArchive?.textTail) {
1202
- archiveText =
1203
- archiveText.length > 0
1204
- ? `${previousArchive.textTail}${NEWLINE_GLYPH}${archiveText}`
1205
- : previousArchive.textTail;
1206
- }
1207
-
1208
- const pages: string[] = [];
1209
- if (shape.columns === 2) {
1210
- pages.push(...docPages(archiveText, geo));
1211
- } else {
1212
- for (let offset = 0; offset < archiveText.length; offset += geo.capacity) {
1213
- pages.push(archiveText.slice(offset, offset + geo.capacity));
1214
- }
1452
+ // Re-compacting a snapcompacted history unfolds the prior archive's source
1453
+ // text and treats it as one coherent transcript: the previous kept source
1454
+ // ages in ahead of the new history, then the whole thing is re-rendered.
1455
+ const previousText = previousArchive?.text;
1456
+ if (previousText) {
1457
+ archiveText = archiveText.length > 0 ? `${previousText}${NEWLINE_GLYPH}${archiveText}` : previousText;
1215
1458
  }
1216
1459
 
1217
- // Fit the merged archive into the frame budget BEFORE rendering: pages
1218
- // that cannot ship are never rasterized. Old unpinned frames evict first
1219
- // (the archive fades oldest-first, as before); new pages that still do
1220
- // not fit stay behind as a verbatim text tail instead of being dropped.
1221
- const prevFrames = previousArchive?.frames ?? [];
1222
- let keptPrev = prevFrames;
1223
- if (prevFrames.length + pages.length > maxFrames) {
1224
- // Pin the earliest frame: it anchors the session head (the original
1225
- // request, or the filmed summary of even older history) the way the
1226
- // LLM-summary strategies keep the original goal alive across rounds.
1227
- // With a budget of one frame the pin is moot.
1228
- const pinCount = maxFrames >= 2 && prevFrames.length > 0 ? 1 : 0;
1229
- const evictable = prevFrames.slice(pinCount);
1230
- const surviving = Math.min(evictable.length, Math.max(0, maxFrames - pages.length - pinCount));
1231
- const dropped = evictable.slice(0, evictable.length - surviving);
1232
- for (const frame of dropped) truncatedChars += frame.chars;
1233
- keptPrev = [...prevFrames.slice(0, pinCount), ...evictable.slice(evictable.length - surviving)];
1234
- }
1235
- const renderPages = pages.slice(0, maxFrames - keptPrev.length);
1236
- const tailPages = pages.slice(renderPages.length);
1460
+ const layout = planArchive(archiveText, high, low, maxFrames);
1461
+ truncatedChars += layout.truncatedChars;
1237
1462
 
1463
+ // Re-render the planned frames, carrying any open dim span across every
1464
+ // boundary: textHead → frames → textTail.
1465
+ let dimOpen = layout.textHead.lastIndexOf(DIM_ON) > layout.textHead.lastIndexOf(DIM_OFF);
1238
1466
  const newFrames: Frame[] = [];
1239
- const finish = pageFinisher(shape);
1240
- for (const page of renderPages) {
1241
- const rendered = render(finish(page), shape, frameSize);
1467
+ for (const planned of layout.frames) {
1468
+ let pageText: string = dimOpen ? DIM_ON + planned.text : planned.text;
1469
+ dimOpen = pageText.lastIndexOf(DIM_ON) > pageText.lastIndexOf(DIM_OFF);
1470
+ if (planned.shape.stopwordDim) pageText = dimStopwords(pageText);
1471
+ const rendered = render(pageText, planned.shape);
1242
1472
  newFrames.push({
1243
1473
  data: rendered.data,
1244
1474
  mimeType: "image/png",
1245
1475
  cols: rendered.cols,
1246
1476
  rows: rendered.rows,
1247
1477
  chars: rendered.chars,
1248
- font: shape.font,
1249
- variant: shape.variant,
1250
- lineRepeat: shape.lineRepeat,
1251
- ...(shape.columns === 2 ? { columns: 2 } : {}),
1252
- ...(shape.stopwordDim ? { stopwordDim: true } : {}),
1253
- ...(shape.imageDetail ? { detail: shape.imageDetail } : {}),
1478
+ font: planned.shape.font,
1479
+ variant: planned.shape.variant,
1480
+ lineRepeat: planned.shape.lineRepeat,
1481
+ ...(planned.shape.columns === 2 ? { columns: 2 } : {}),
1482
+ ...(planned.shape.stopwordDim ? { stopwordDim: true } : {}),
1483
+ ...(planned.shape.imageDetail ? { detail: planned.shape.imageDetail } : {}),
1254
1484
  });
1255
1485
  // Keep the event loop responsive between native render passes.
1256
1486
  await Bun.sleep(0);
1257
1487
  }
1258
1488
 
1259
- // Pages past the budget survive as text, capped at two frames' capacity
1260
- // (middle-elided) so an oversized archive cannot blow the context back up.
1261
- let textTail = "";
1262
- if (tailPages.length > 0) {
1263
- const raw =
1264
- shape.columns === 2 ? tailPages.map(page => page.replaceAll("\n", " ")).join(" ") : tailPages.join("");
1265
- const tailCap = TEXT_TAIL_MAX_PAGES * geo.capacity;
1266
- if (raw.length > tailCap) truncatedChars += raw.length - tailCap;
1267
- // Re-open a dim span the render boundary cut through, so the carried
1268
- // tail keeps tool output dim when it lands on frames next compaction.
1269
- const renderedText = shape.columns === 2 ? renderPages.join("\n") : renderPages.join("");
1270
- const dimOpen = renderedText.lastIndexOf(DIM_ON) > renderedText.lastIndexOf(DIM_OFF);
1271
- textTail = (dimOpen ? DIM_ON : "") + truncateForSummary(raw, tailCap, TRUNCATE_HEAD_RATIO);
1272
- }
1489
+ const textHead = layout.textHead;
1490
+ const textTail = layout.textTail.length > 0 ? (dimOpen ? DIM_ON : "") + layout.textTail : "";
1491
+ const textChars = textHead.length + textTail.length;
1273
1492
 
1274
- const frames = [...keptPrev, ...newFrames];
1275
- const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0);
1493
+ const frames = newFrames;
1494
+ const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0) + textChars;
1276
1495
  const mixedShapes = frames.some(
1277
1496
  frame =>
1278
1497
  frame.cols !== geo.cols ||
1279
1498
  frame.rows !== geo.rows ||
1280
- (frame.variant ?? "sent") !== shape.variant ||
1281
- (frame.lineRepeat ?? 1) !== shape.lineRepeat ||
1282
- (frame.columns ?? 1) !== (shape.columns ?? 1) ||
1283
- (frame.stopwordDim ?? false) !== (shape.stopwordDim ?? false),
1499
+ (frame.variant ?? "sent") !== high.variant ||
1500
+ (frame.lineRepeat ?? 1) !== high.lineRepeat ||
1501
+ (frame.columns ?? 1) !== (high.columns ?? 1) ||
1502
+ (frame.stopwordDim ?? false) !== (high.stopwordDim ?? false),
1284
1503
  );
1285
1504
 
1505
+ const { readFiles, modifiedFiles } = computeFileLists(fileOps);
1506
+ const files = formatFileList(readFiles, modifiedFiles, fileOps.read);
1507
+
1286
1508
  let summary: string;
1287
- if (frames.length === 0) {
1509
+ if (frames.length === 0 && textHead.length === 0 && textTail.length === 0 && files.length === 0) {
1288
1510
  summary = "No prior history.";
1289
1511
  } else {
1290
1512
  summary = prompt.render(snapcompactSummaryPrompt, {
1291
1513
  frameCount: frames.length,
1292
1514
  multipleFrames: frames.length > 1,
1293
- fontCell: `${shape.cellWidth}x${shape.cellHeight}`,
1515
+ docColumns: high.columns === 2,
1294
1516
  cols: geo.cols,
1295
1517
  rows: geo.rows,
1296
- sentenceInk: shape.variant === "sent",
1297
- lineRepeated: shape.lineRepeat > 1,
1298
- docColumns: shape.columns === 2,
1299
- stopwordDimmed: shape.stopwordDim === true,
1518
+ sentenceInk: high.variant === "sent",
1519
+ stopwordDimmed: high.stopwordDim === true,
1300
1520
  dimmedToolResults: options?.dimToolResults !== false,
1521
+ lineRepeated: high.lineRepeat > 1,
1301
1522
  mixedShapes,
1302
- totalChars,
1303
1523
  truncatedChars,
1304
1524
  includedPreviousSummary,
1305
- textTail: textTail.length > 0 ? toPlainText(textTail) : undefined,
1525
+ files: files.length > 0 ? files : undefined,
1306
1526
  });
1307
1527
  }
1308
- const { readFiles, modifiedFiles } = computeFileLists(fileOps);
1309
- summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
1310
1528
 
1311
1529
  // A snapcompact pass replaces any provider-side replacement history; strip the
1312
1530
  // OpenAI remote-compaction payload like the default summarizer path does.
1313
1531
  const basePreserve = stripOpenAiRemoteCompactionPreserveData(previousPreserveData) ?? {};
1314
- const archive: Archive = { frames, totalChars, truncatedChars, ...(textTail ? { textTail } : {}) };
1532
+ const persistedText =
1533
+ layout.keptText.length > 0 && layout.textTail.length > 0
1534
+ ? `${layout.keptText.slice(0, layout.keptText.length - layout.textTail.length)}${textTail}`
1535
+ : layout.keptText;
1536
+ const archive: Archive = {
1537
+ frames,
1538
+ totalChars,
1539
+ truncatedChars,
1540
+ ...(persistedText.length > 0 ? { text: persistedText } : {}),
1541
+ ...(textHead ? { textHead } : {}),
1542
+ ...(textTail ? { textTail } : {}),
1543
+ };
1315
1544
 
1316
- const textTailNote = textTail ? ` (+${textTail.length.toLocaleString()} chars as text)` : "";
1545
+ const textNote = textChars > 0 ? ` (+${textChars.toLocaleString()} chars as text)` : "";
1317
1546
  return {
1318
1547
  summary,
1319
- shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}${textTailNote}`,
1548
+ shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}${textNote}`,
1320
1549
  firstKeptEntryId,
1321
1550
  tokensBefore,
1322
1551
  details: { readFiles, modifiedFiles },