@oh-my-pi/snapcompact 16.0.11 → 16.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
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
+
5
26
  ## [16.0.11] - 2026-06-19
6
27
 
7
28
  ### Changed
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. */
@@ -504,22 +512,22 @@ export declare function frames(text: string, options?: Pick<RenderManyOptions, "
504
512
  export declare function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined;
505
513
  /** Convert archive frames into LLM image blocks (oldest first). */
506
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)[];
507
520
  /**
508
521
  * Run a snapcompact compaction over prepared messages. Fully local: serializes
509
- * the discarded history, prints it onto PNG frames in the provider-optimal
510
- * shape, merges previously archived frames (oldest dropped beyond the
511
- * budget), and produces a deterministic summary explaining how to read the
512
- * frames. Pages past the frame budget are never rendered (providers with
513
- * hard image caps silently drop excess frames on the wire) — the newest
514
- * unrendered slice survives verbatim as a text tail on the summary and is
515
- * 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.
516
526
  *
517
- * Frames archived under a different shape (provider switches, legacy 5x8
518
- * sessions) are kept as-is each frame carries its own geometry, and the
519
- * summary describes the newest shape while noting that older frames may
520
- * 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.
521
529
  *
522
- * If the previous compaction was text-based, its summary is printed at the
523
- * 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.
524
532
  */
525
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.11",
4
+ "version": "16.1.1",
5
5
  "description": "Bitmap-frame context compression for vision-capable LLMs",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,10 +31,10 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-ai": "16.0.11",
35
- "@oh-my-pi/pi-natives": "16.0.11",
36
- "@oh-my-pi/pi-utils": "16.0.11",
37
- "@oh-my-pi/pi-wire": "16.0.11"
34
+ "@oh-my-pi/pi-ai": "16.1.1",
35
+ "@oh-my-pi/pi-natives": "16.1.1",
36
+ "@oh-my-pi/pi-utils": "16.1.1",
37
+ "@oh-my-pi/pi-wire": "16.1.1"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/bun": "^1.3.14"
@@ -1,32 +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: a solid black cell marks a newline and runs of spaces collapse to one; each turn opens with a heading — # User ¶, # Assistant ¶, or # Tool call with assistant reasoning in _italics_ and tool output inside <out>…</out>.
4
- {{#if docColumns}}- Two side-by-side text columns, each {{cols}} characters wide and up to {{rows}} rows tall: read the left column top to bottom, then the right.
5
- {{else}}- A single grid {{cols}} characters wide and up to {{rows}} rows tall: read left to right, top to bottom — no word wrap, so words may break across rows.
6
- {{/if}}
7
- {{#if sentenceInk}}- Ink cycles six colors, one per sentence.
8
- {{/if}}{{#if stopwordDimmed}}- Function words are dim gray; content words keep full ink.
9
- {{/if}}{{#if dimmedToolResults}}- Text inside <out> is dim gray; that gray is archived tool output, not conversation.
10
- {{/if}}{{#if lineRepeated}}- Each line is printed twice (white, then a pale-yellow band); the copies are identical.
11
- {{/if}}
12
- {{#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>`.
13
4
 
14
- 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).
15
- {{/if}}
16
- {{#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.
17
18
 
18
- The earliest frame begins with "[Summary of earlier history]" — a condensed digest of context that predates the archived conversation.
19
- {{/if}}
20
- {{#if truncatedChars}}
19
+ {{#if files}}FILES
20
+ ===================
21
+ {{files}}
21
22
 
22
- {{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.
23
- {{/if}}
24
-
25
- 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.
26
- {{#if textTail}}
27
-
28
- 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.
29
-
30
- [Archived history, continued as text]
31
- {{textTail}}
32
- {{/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,7 +44,7 @@
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";
53
50
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
@@ -301,6 +298,16 @@ const FAMILY_VARIANT: Record<BillingFamily, ShapeVariantName> = {
301
298
  openai: "8on22-bw",
302
299
  };
303
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
+
304
311
  const FAMILY_SHAPE: Record<BillingFamily, Shape> = {
305
312
  anthropic: SHAPES.anthropic,
306
313
  google: SHAPES.google,
@@ -377,22 +384,32 @@ export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "
377
384
  * shapes carry their own `frameSize`. */
378
385
  export const FRAME_SIZE = 2576;
379
386
 
380
- /** Maximum frames carried on a compaction entry. Oldest frames are dropped
381
- * first once the budget is exceeded (mirrors how iterative text summaries
382
- * fade the oldest detail). */
383
- export const MAX_FRAMES = 8;
384
-
385
- /** Conservative per-frame token estimate used for context budgeting
386
- * (upper bound across shapes: Anthropic bills 1568*1568/750 ≈ 3,278). */
387
- 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;
388
406
 
389
407
  /**
390
- * Per-request image-count budgets by provider id. Routers and smaller
391
- * providers enforce hard caps and silently DROP images past them (measured:
392
- * OpenRouter caps at 8 images 9+ vanish with no error and billed tokens
393
- * plateau at 8x frame cost). First-party APIs allow far more; their values
394
- * are conservative policy caps well under the measured hard limits
395
- * (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.
396
413
  */
397
414
  export const PROVIDER_IMAGE_BUDGETS: Record<string, number> = {
398
415
  anthropic: 90,
@@ -402,7 +419,7 @@ export const PROVIDER_IMAGE_BUDGETS: Record<string, number> = {
402
419
  google: 200,
403
420
  "google-vertex": 200,
404
421
  "google-gemini-cli": 200,
405
- openrouter: 8,
422
+ openrouter: 90,
406
423
  };
407
424
 
408
425
  /** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
@@ -413,11 +430,6 @@ export function providerImageBudget(provider: string | undefined): number {
413
430
  return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET;
414
431
  }
415
432
 
416
- /** Archive frame budget for `provider`: its image budget clamped to {@link MAX_FRAMES}. */
417
- export function providerFrameBudget(provider: string | undefined): number {
418
- return Math.min(MAX_FRAMES, providerImageBudget(provider));
419
- }
420
-
421
433
  /** Key under `CompactionEntry.preserveData` holding the frame archive. */
422
434
  export const PRESERVE_KEY = "snapcompact";
423
435
 
@@ -450,16 +462,20 @@ export interface Frame {
450
462
 
451
463
  /** Frame archive persisted under `preserveData[PRESERVE_KEY]`. */
452
464
  export interface Archive {
453
- /** 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. */
454
468
  frames: Frame[];
455
- /** Characters currently readable across all frames. */
469
+ /** Characters currently readable across all frames plus the text regions. */
456
470
  totalChars: number;
457
- /** Characters dropped so far to respect the frame budget. */
471
+ /** Characters dropped so far to respect the archive budget. */
458
472
  truncatedChars: number;
459
- /** Most recent slice of archived history that exceeded the frame budget,
460
- * kept verbatim as normalized text (dim markers and newline glyphs
461
- * included). Shipped as plain text in the compaction summary and folded
462
- * 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. */
463
479
  textTail?: string;
464
480
  }
465
481
 
@@ -481,7 +497,7 @@ export interface Options<TMessage = Message> extends SerializeOptions {
481
497
  shape?: Shape;
482
498
  /** Frame edge in pixels. Defaults to the shape's `frameSize`. */
483
499
  frameSize?: number;
484
- /** Frame budget. Defaults to {@link MAX_FRAMES}. */
500
+ /** Upper limit on archive frames; clamped to (and defaulting to) {@link MAX_FRAMES_DEFAULT}. */
485
501
  maxFrames?: number;
486
502
  }
487
503
 
@@ -585,7 +601,7 @@ function stripFileOperationTags(summary: string): string {
585
601
  .trimEnd();
586
602
  }
587
603
 
588
- function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string {
604
+ function formatFileList(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string {
589
605
  if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
590
606
  const mode = new Map<string, "Read" | "Write" | "RW">();
591
607
  for (const file of readFiles) mode.set(file, "Read");
@@ -595,7 +611,12 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[], read
595
611
  if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
596
612
  files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`;
597
613
  }
598
- 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 }) : "";
599
620
  }
600
621
 
601
622
  export function upsertFileOperations(
@@ -663,10 +684,11 @@ function truncateForSummary(text: string, maxChars: number, headRatio: number):
663
684
 
664
685
  const DIM_MARKERS = /[\u000e\u000f]/g;
665
686
 
666
- /** Cap on the unrendered archive text tail, in frame-capacity units: enough
667
- * to keep the newest discarded history readable without re-inflating the
668
- * context a compaction just shrank. */
669
- 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;
670
692
 
671
693
  /** Normalized archive text → plain text: drop zero-width dim toggles and
672
694
  * print newline glyphs as real newlines. */
@@ -1189,23 +1211,31 @@ export function getPreservedArchive(preserveData: Record<string, unknown> | unde
1189
1211
  const candidate = preserveData?.[PRESERVE_KEY];
1190
1212
  if (!candidate || typeof candidate !== "object") return undefined;
1191
1213
  const archive = candidate as Archive;
1192
- if (!Array.isArray(archive.frames)) return undefined;
1193
- const frames = archive.frames.filter(
1194
- frame =>
1195
- !!frame &&
1196
- typeof frame.data === "string" &&
1197
- frame.data.length > 0 &&
1198
- typeof frame.mimeType === "string" &&
1199
- typeof frame.cols === "number" &&
1200
- typeof frame.rows === "number" &&
1201
- typeof frame.chars === "number",
1202
- );
1203
- 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;
1204
1232
  return {
1205
1233
  frames,
1206
1234
  totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0,
1207
1235
  truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0,
1208
- ...(typeof archive.textTail === "string" && archive.textTail.length > 0 ? { textTail: archive.textTail } : {}),
1236
+ ...(text !== undefined ? { text } : {}),
1237
+ ...(textHead !== undefined ? { textHead } : {}),
1238
+ ...(textTail !== undefined ? { textTail } : {}),
1209
1239
  };
1210
1240
  }
1211
1241
 
@@ -1218,28 +1248,176 @@ export function images(archive: Archive): ImageContent[] {
1218
1248
  ...(frame.detail ? { detail: frame.detail } : {}),
1219
1249
  }));
1220
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
+ }
1221
1278
 
1222
1279
  // ============================================================================
1223
1280
  // Compaction entry point
1224
1281
  // ============================================================================
1225
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
+
1226
1409
  /**
1227
1410
  * Run a snapcompact compaction over prepared messages. Fully local: serializes
1228
- * the discarded history, prints it onto PNG frames in the provider-optimal
1229
- * shape, merges previously archived frames (oldest dropped beyond the
1230
- * budget), and produces a deterministic summary explaining how to read the
1231
- * frames. Pages past the frame budget are never rendered (providers with
1232
- * hard image caps silently drop excess frames on the wire) — the newest
1233
- * unrendered slice survives verbatim as a text tail on the summary and is
1234
- * 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.
1235
1415
  *
1236
- * Frames archived under a different shape (provider switches, legacy 5x8
1237
- * sessions) are kept as-is each frame carries its own geometry, and the
1238
- * summary describes the newest shape while noting that older frames may
1239
- * 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.
1240
1418
  *
1241
- * If the previous compaction was text-based, its summary is printed at the
1242
- * 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.
1243
1421
  */
1244
1422
  export async function compact<TMessage = Message>(
1245
1423
  preparation: CompactionPreparation<TMessage>,
@@ -1249,10 +1427,14 @@ export async function compact<TMessage = Message>(
1249
1427
  if (!firstKeptEntryId) {
1250
1428
  throw new Error("First kept entry has no ID - session may need migration");
1251
1429
  }
1252
- const shape = options?.shape ?? resolveShape(options?.model);
1253
- const frameSize = options?.frameSize ?? shape.frameSize;
1254
- const maxFrames = Math.max(1, options?.maxFrames ?? MAX_FRAMES);
1255
- 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));
1256
1438
 
1257
1439
  const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
1258
1440
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
@@ -1267,127 +1449,103 @@ export async function compact<TMessage = Message>(
1267
1449
 
1268
1450
  let truncatedChars = previousArchive?.truncatedChars ?? 0;
1269
1451
 
1270
- // The previous compaction's unframed text tail is the oldest part of this
1271
- // archive slice prepend it so it ages into frames first.
1272
- if (previousArchive?.textTail) {
1273
- archiveText =
1274
- archiveText.length > 0
1275
- ? `${previousArchive.textTail}${NEWLINE_GLYPH}${archiveText}`
1276
- : previousArchive.textTail;
1277
- }
1278
-
1279
- const pages: string[] = [];
1280
- if (shape.columns === 2) {
1281
- pages.push(...docPages(archiveText, geo));
1282
- } else {
1283
- for (let offset = 0; offset < archiveText.length; offset += geo.capacity) {
1284
- pages.push(archiveText.slice(offset, offset + geo.capacity));
1285
- }
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;
1286
1458
  }
1287
1459
 
1288
- // Fit the merged archive into the frame budget BEFORE rendering: pages
1289
- // that cannot ship are never rasterized. Old unpinned frames evict first
1290
- // (the archive fades oldest-first, as before); new pages that still do
1291
- // not fit stay behind as a verbatim text tail instead of being dropped.
1292
- const prevFrames = previousArchive?.frames ?? [];
1293
- let keptPrev = prevFrames;
1294
- if (prevFrames.length + pages.length > maxFrames) {
1295
- // Pin the earliest frame: it anchors the session head (the original
1296
- // request, or the filmed summary of even older history) the way the
1297
- // LLM-summary strategies keep the original goal alive across rounds.
1298
- // With a budget of one frame the pin is moot.
1299
- const pinCount = maxFrames >= 2 && prevFrames.length > 0 ? 1 : 0;
1300
- const evictable = prevFrames.slice(pinCount);
1301
- const surviving = Math.min(evictable.length, Math.max(0, maxFrames - pages.length - pinCount));
1302
- const dropped = evictable.slice(0, evictable.length - surviving);
1303
- for (const frame of dropped) truncatedChars += frame.chars;
1304
- keptPrev = [...prevFrames.slice(0, pinCount), ...evictable.slice(evictable.length - surviving)];
1305
- }
1306
- const renderPages = pages.slice(0, maxFrames - keptPrev.length);
1307
- const tailPages = pages.slice(renderPages.length);
1460
+ const layout = planArchive(archiveText, high, low, maxFrames);
1461
+ truncatedChars += layout.truncatedChars;
1308
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);
1309
1466
  const newFrames: Frame[] = [];
1310
- const finish = pageFinisher(shape);
1311
- for (const page of renderPages) {
1312
- 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);
1313
1472
  newFrames.push({
1314
1473
  data: rendered.data,
1315
1474
  mimeType: "image/png",
1316
1475
  cols: rendered.cols,
1317
1476
  rows: rendered.rows,
1318
1477
  chars: rendered.chars,
1319
- font: shape.font,
1320
- variant: shape.variant,
1321
- lineRepeat: shape.lineRepeat,
1322
- ...(shape.columns === 2 ? { columns: 2 } : {}),
1323
- ...(shape.stopwordDim ? { stopwordDim: true } : {}),
1324
- ...(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 } : {}),
1325
1484
  });
1326
1485
  // Keep the event loop responsive between native render passes.
1327
1486
  await Bun.sleep(0);
1328
1487
  }
1329
1488
 
1330
- // Pages past the budget survive as text, capped at two frames' capacity
1331
- // (middle-elided) so an oversized archive cannot blow the context back up.
1332
- let textTail = "";
1333
- if (tailPages.length > 0) {
1334
- const raw =
1335
- shape.columns === 2 ? tailPages.map(page => page.replaceAll("\n", " ")).join(" ") : tailPages.join("");
1336
- const tailCap = TEXT_TAIL_MAX_PAGES * geo.capacity;
1337
- if (raw.length > tailCap) truncatedChars += raw.length - tailCap;
1338
- // Re-open a dim span the render boundary cut through, so the carried
1339
- // tail keeps tool output dim when it lands on frames next compaction.
1340
- const renderedText = shape.columns === 2 ? renderPages.join("\n") : renderPages.join("");
1341
- const dimOpen = renderedText.lastIndexOf(DIM_ON) > renderedText.lastIndexOf(DIM_OFF);
1342
- textTail = (dimOpen ? DIM_ON : "") + truncateForSummary(raw, tailCap, TRUNCATE_HEAD_RATIO);
1343
- }
1489
+ const textHead = layout.textHead;
1490
+ const textTail = layout.textTail.length > 0 ? (dimOpen ? DIM_ON : "") + layout.textTail : "";
1491
+ const textChars = textHead.length + textTail.length;
1344
1492
 
1345
- const frames = [...keptPrev, ...newFrames];
1346
- 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;
1347
1495
  const mixedShapes = frames.some(
1348
1496
  frame =>
1349
1497
  frame.cols !== geo.cols ||
1350
1498
  frame.rows !== geo.rows ||
1351
- (frame.variant ?? "sent") !== shape.variant ||
1352
- (frame.lineRepeat ?? 1) !== shape.lineRepeat ||
1353
- (frame.columns ?? 1) !== (shape.columns ?? 1) ||
1354
- (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),
1355
1503
  );
1356
1504
 
1505
+ const { readFiles, modifiedFiles } = computeFileLists(fileOps);
1506
+ const files = formatFileList(readFiles, modifiedFiles, fileOps.read);
1507
+
1357
1508
  let summary: string;
1358
- if (frames.length === 0) {
1509
+ if (frames.length === 0 && textHead.length === 0 && textTail.length === 0 && files.length === 0) {
1359
1510
  summary = "No prior history.";
1360
1511
  } else {
1361
1512
  summary = prompt.render(snapcompactSummaryPrompt, {
1362
1513
  frameCount: frames.length,
1363
1514
  multipleFrames: frames.length > 1,
1364
- fontCell: `${shape.cellWidth}x${shape.cellHeight}`,
1515
+ docColumns: high.columns === 2,
1365
1516
  cols: geo.cols,
1366
1517
  rows: geo.rows,
1367
- sentenceInk: shape.variant === "sent",
1368
- lineRepeated: shape.lineRepeat > 1,
1369
- docColumns: shape.columns === 2,
1370
- stopwordDimmed: shape.stopwordDim === true,
1518
+ sentenceInk: high.variant === "sent",
1519
+ stopwordDimmed: high.stopwordDim === true,
1371
1520
  dimmedToolResults: options?.dimToolResults !== false,
1521
+ lineRepeated: high.lineRepeat > 1,
1372
1522
  mixedShapes,
1373
- totalChars,
1374
1523
  truncatedChars,
1375
1524
  includedPreviousSummary,
1376
- textTail: textTail.length > 0 ? toPlainText(textTail) : undefined,
1525
+ files: files.length > 0 ? files : undefined,
1377
1526
  });
1378
1527
  }
1379
- const { readFiles, modifiedFiles } = computeFileLists(fileOps);
1380
- summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
1381
1528
 
1382
1529
  // A snapcompact pass replaces any provider-side replacement history; strip the
1383
1530
  // OpenAI remote-compaction payload like the default summarizer path does.
1384
1531
  const basePreserve = stripOpenAiRemoteCompactionPreserveData(previousPreserveData) ?? {};
1385
- 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
+ };
1386
1544
 
1387
- const textTailNote = textTail ? ` (+${textTail.length.toLocaleString()} chars as text)` : "";
1545
+ const textNote = textChars > 0 ? ` (+${textChars.toLocaleString()} chars as text)` : "";
1388
1546
  return {
1389
1547
  summary,
1390
- 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}`,
1391
1549
  firstKeptEntryId,
1392
1550
  tokensBefore,
1393
1551
  details: { readFiles, modifiedFiles },