From b137764f0ce8bccdc52b941cf666dcc44e9ce1d9 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 19 Jun 2026 07:38:54 +0200 Subject: [PATCH] feat: implemented text-first foveated snapcompact archive layout - Implemented text-first archive structure that incorporates bounded source text with foveated image frames (HQ edges, LQ middle) to improve context quality. - Updated snapcompact core logic to use `historyBlocks` for reconstruction, transitioning away from reliance on previous PNG frame inheritance. - Increased default frame limits to 80 and adjusted token estimation constants (5024) to enhance context budget accuracy. - Added `pruneToolDescriptions` and improved multi-block summary token estimation to optimize tool spec usage. --- docs/compaction.md | 4 +- packages/agent/CHANGELOG.md | 5 + packages/agent/src/compaction/compaction.ts | 13 +- packages/coding-agent/CHANGELOG.md | 3 + .../coding-agent/src/session/agent-session.ts | 5 +- .../src/session/session-context.ts | 6 +- .../src/session/snapcompact-inline.ts | 4 +- packages/snapcompact/CHANGELOG.md | 23 +- packages/snapcompact/README.md | 10 +- .../src/prompts/snapcompact-summary.md | 52 +- packages/snapcompact/src/snapcompact.ts | 462 ++++++++++++------ packages/snapcompact/test/snapcompact.test.ts | 224 +++------ 12 files changed, 476 insertions(+), 335 deletions(-) diff --git a/docs/compaction.md b/docs/compaction.md index f3a172ba8..fbf507343 100644 --- a/docs/compaction.md +++ b/docs/compaction.md @@ -134,8 +134,8 @@ The automatic paths are intentionally different: - The discarded history is serialized, whitespace-collapsed, and printed onto model-aware PNG frames (frame width fixed per shape; frame height hugs the rows actually printed) using bundled public-domain pixel fonts. The shape — and frame size — resolve from the **model id** when the model line was measured: Claude reads X.org `8x13` glyphs on an 11px advance (extra letter-spacing, black ink — `11on16-bw`; high-res lines — Opus 4.7+, Fable, Mythos — get 1932px frames under Anthropic's 4,784 visual-token cap, older lines stay at 1568px), Gemini reads `8x13` glyphs on a 22px pitch (extra leading, black ink — `8on22-bw` at 2048px, since Gemini 3.x bills a fixed 1,120-token budget per image at any pixel size), GPT/Codex read the same `8on22-bw` shape at 1568px (patch billing is area-proportional, so larger frames cannot improve chars per token), and Kimi/GLM read `8x13` glyphs on a 16px pitch (`8on16-bw` at 1568px — kimi's processor downscales past 1792px). A Claude routed through Vertex or OpenRouter keeps its Claude shape. Unmeasured models fall back to their wire API family (Anthropic-family/unknown → `11on16-bw`, Google → `8on22-bw`, OpenAI-compatible → `8on22-bw`); billing (per-family patch/budget formulas, OpenAI's `detail: "original"` hint) always follows the API carrying the request, computed for the resolved frame size. The `snapcompact.shape` setting (default `auto`) forces one of the research-eval variants instead: square grids (`8x8r`/`8x8u`/`6x6u`/`5x8` × sentence-hue/black ink) or the per-model eval winners (`6x12-dim`, `8x13-bw`, `8on16-bw`, `8on22-bw`, `11on16-bw`, and the two-column word-wrapped `doc-8on16-bw`/`-sent`/`-sent-dim`, where `dim` prints stopwords in gray). A forced variant keeps its geometry but is re-priced for the target provider's image billing. The same setting governs inline system-prompt/tool-result imaging (`snapcompact.systemPrompt`, `snapcompact.toolResults`). - Serialization keeps the archive conversation-dense: tool results are truncated head+tail (default 2,000 chars at a 0.6 head ratio), tool-call argument values are capped per value (500) and per call (2,000), and tool output is printed in dim gray ink so conversation reads louder than tool noise. All budgets and the dimming are configurable via `SerializeOptions` (`toolResultMaxChars`, `toolArgMaxChars`, `toolCallMaxChars`, `truncateHeadRatio`, `dimToolResults`). -- Frames persist under `CompactionEntry.preserveData.snapcompact` and are re-attached to the `compactionSummary` message as image blocks on every context rebuild; the entry's `summary` is a deterministic reading guide (grid geometry, role tags, truncation notes) plus the usual file-operation lists. -- Later compactions carry earlier frames forward. The frame budget is provider-aware (`providerFrameBudget`): the per-provider image cap clamped to 8 (`MAX_FRAMES`) — OpenRouter hard-caps requests at 8 images and silently drops the excess, unknown providers get a safe floor of 5. Beyond the budget the archive fades from the middle out: the earliest frame (session head — the original request, or the filmed summary of older history) is pinned, and the oldest *unpinned* frames are evicted. Pages of the *current* compaction that no longer fit are never rendered or dropped — the newest unframed slice survives verbatim as a text tail on the summary (`Archive.textTail`, capped at two frame capacities with middle elision) and is folded back into frames by the next compaction. If the previous compaction was text-based, its summary is printed at the head of the frame archive as `[Summary of earlier history]`. +- The snapcompact archive persists under `CompactionEntry.preserveData.snapcompact` as bounded source text plus rendered frames. On each context rebuild it is reconstructed into ordered compaction blocks: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. The entry's `summary` is just the short resume lead-in plus the usual file-operation list. +- Later compactions re-render from that bounded source text (`Archive.text`), not by carrying old PNGs forward blindly. `maxFrames` now defaults to `MAX_FRAMES_DEFAULT` (80) and acts only as an upper limit; when the imaged middle is large it foveates internally (HQ/LQ/HQ), while both chronological edges stay verbatim text. - No model, API key, or network is involved, so snapcompact is also safe for overflow recovery. It requires a vision-capable current model (`model.input` includes `"image"`); otherwise the run falls back to context-full and emits a warning notice (auto and manual paths). Manual `/compact` honors the strategy unless custom instructions are given (those imply a directed LLM summary). - Rationale: the shape table comes from the snapcompact 200k-token evals in `packages/snapcompact`, where bitmap frames preserved QA recall at lower billed-token cost than raw text for vision-capable models. diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index d857258cf..af031c425 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,10 +1,15 @@ # Changelog ## [Unreleased] + ### Added - Added `pruneToolDescriptions` option to reduce token usage by stripping tool descriptions from provider-bound specs +### Fixed + +- Improved token estimation accuracy for compaction summaries containing multi-block content + ## [16.0.11] - 2026-06-19 ### Changed diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 3be7f751b..99366e0f6 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -326,9 +326,16 @@ export function estimateTokens(message: AgentMessage): number { case "branchSummary": case "compactionSummary": { fragments.push(message.summary); - if (message.role === "compactionSummary" && message.images) { - // Snapcompact frames render at ≥1568px; providers bill the downscaled cap. - extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE; + if (message.role === "compactionSummary") { + if (message.blocks) { + for (const block of message.blocks) { + if (block.type === "text") fragments.push(block.text); + else extra += snapcompact.FRAME_TOKEN_ESTIMATE; + } + } else if (message.images) { + // Snapcompact frames render at ≥1568px; providers bill the downscaled cap. + extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE; + } } break; } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ce6df5157..5d0c04d04 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,12 @@ # Changelog ## [Unreleased] + ### Changed +- Refined session context to utilize history blocks instead of raw images for snapcompact summaries - Optimized network traffic by stripping tool descriptions from provider tool schemas +- Snapcompact compaction summaries now reach the model as ordered history blocks instead of one lead-in text block plus appended images: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. This matches the new text-first snapcompact archive layout and preserves chronological order in the provider prompt. ## [16.0.11] - 2026-06-19 diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 299010d27..6f6cd0b31 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -9197,14 +9197,15 @@ export class AgentSession { */ #projectSnapcompactContextTokens(preparation: CompactionPreparation, result: snapcompact.CompactionResult): number { const archive = snapcompact.getPreservedArchive(result.preserveData); - const frames = archive ? snapcompact.images(archive) : undefined; + const blocks = archive ? snapcompact.historyBlocks(archive) : undefined; const summaryMessage = createCompactionSummaryMessage( result.summary, result.tokensBefore, new Date().toISOString(), result.shortSummary, undefined, - frames, + undefined, + blocks, ); let tokens = computeNonMessageTokens(this) + estimateTokens(summaryMessage); for (const message of preparation.recentMessages) { diff --git a/packages/coding-agent/src/session/session-context.ts b/packages/coding-agent/src/session/session-context.ts index dd46f079d..c113c6bb4 100644 --- a/packages/coding-agent/src/session/session-context.ts +++ b/packages/coding-agent/src/session/session-context.ts @@ -226,7 +226,8 @@ export function buildSessionContext( entry.timestamp, entry.shortSummary, undefined, - snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined, + undefined, + snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined, ), ); } else { @@ -258,7 +259,8 @@ export function buildSessionContext( compaction.timestamp, compaction.shortSummary, providerPayload, - snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined, + undefined, + snapcompactArchive ? snapcompact.historyBlocks(snapcompactArchive) : undefined, ), ); diff --git a/packages/coding-agent/src/session/snapcompact-inline.ts b/packages/coding-agent/src/session/snapcompact-inline.ts index a18417f60..10f8588ac 100644 --- a/packages/coding-agent/src/session/snapcompact-inline.ts +++ b/packages/coding-agent/src/session/snapcompact-inline.ts @@ -46,8 +46,8 @@ export type SnapcompactSavingsSink = ( // Per-provider image-count budgets live in @oh-my-pi/snapcompact // (`providerImageBudget`): snapcompact frames are 1568px (<2000px) so // dimension/size limits never bind; only COUNT does. Once the budget is -// spent (e.g. OpenRouter's hard 8-image cap, already consumed by archive -// frames), tool results ship verbatim as text. +// spent by already-attached archive/system-prompt images, tool results ship +// verbatim as text. const MAX_SYSTEM_PROMPT_FRAMES = 6; /** Tool results under this many tokens are never rasterized — the swap can't * save enough to justify trading crisp text for an image. */ diff --git a/packages/snapcompact/CHANGELOG.md b/packages/snapcompact/CHANGELOG.md index 5f1867ebe..20ebe04f7 100644 --- a/packages/snapcompact/CHANGELOG.md +++ b/packages/snapcompact/CHANGELOG.md @@ -1,6 +1,27 @@ # Changelog ## [Unreleased] +### Added + +- Added `historyBlocks(archive)` to reconstruct ordered history blocks from archive data +- Added `historyBlocks(archive)`, which reconstructs the ordered prompt blocks for a snapcompact archive at rebuild time: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. This keeps the message ordering reconstructible from `preserveData` without duplicating image payloads onto compaction entries. + +### Changed + +- Refactored compaction to be text-sourced, re-rendering from unified `Archive.text` source +- Implemented foveated archive layout (HQ edges, dense LQ middle) for optimized context usage +- Raised `MAX_FRAMES_DEFAULT` to 80 and consolidated `PROVIDER_IMAGE_BUDGETS` +- Updated OpenRouter to use standard 90-image budget +- Updated prompt instructions to clearly distinguish between plain-text and image history regions +- Reworked snapcompact compaction to be text-sourced and text-first: the archive now persists bounded source text (`Archive.text`) and re-renders from it every compaction instead of blindly carrying PNGs forward. History is laid out middle-out as `text head → imaged middle → text tail`, and the imaged middle foveates internally (HQ/LQ/HQ) when it grows large. +- Renamed `MAX_FRAMES` to `MAX_FRAMES_DEFAULT`, raised the default cap to 80 (enough for ~400k tokens of high-res Opus image budget while staying under Anthropic's wire cap), and made `Options.maxFrames` a pure upper limit rather than a caller-supplied default. +- Raised `FRAME_TOKEN_ESTIMATE` to the true high-res Claude upper bound (5,024) so coding-agent overflow checks no longer undercount large snapcompact archives. +- Removed the old OpenRouter-specific 8-image special case from `PROVIDER_IMAGE_BUDGETS`; OpenRouter now uses the same permissive 90-image budget as Anthropic/Bedrock instead of being artificially clamped. + +### Fixed + +- Fixed context budget undercounting by raising `FRAME_TOKEN_ESTIMATE` to 5024 +- Improved file list formatting in compaction summaries ## [16.0.11] - 2026-06-19 @@ -110,4 +131,4 @@ ### Fixed - Fixed frame rendering at archive chunk boundaries to reopen dim spans when a chunk ends inside a dimmed tool-result segment -- Fixed message serialization to strip user- and assistant-provided dim markers so only renderer-generated dim spans can be applied +- Fixed message serialization to strip user- and assistant-provided dim markers so only renderer-generated dim spans can be applied \ No newline at end of file diff --git a/packages/snapcompact/README.md b/packages/snapcompact/README.md index d6a4968ec..aa6893369 100644 --- a/packages/snapcompact/README.md +++ b/packages/snapcompact/README.md @@ -49,18 +49,18 @@ Run a full compaction pass over prepared messages: ```ts import { compact } from "@oh-my-pi/snapcompact"; -const result = await compact(preparation, { model, maxFrames: 8 }); -// result.summary — text summary with operations block -// result.preserveData — frame archive, re-attachable via getPreservedArchive() + images() +const result = await compact(preparation, { model }); +// result.summary — short "resume prior conversation" lead-in + block +// result.preserveData — bounded archive source + rendered image middle ``` ## API surface -- **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images` +- **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images`, `historyBlocks` - **Rendering**: `render`, `renderMany`, `frames`, `geometry` - **Shapes**: `SHAPES`, `SHAPE_VARIANTS`, `resolveShape`, `idealShapeVariant`, `isShape`, `isShapeVariantName` - **Text**: `serializeConversation`, `normalize`, `dimStopwords`, `wrap` -- **Budgets**: `providerImageBudget`, `providerFrameBudget`, `MAX_FRAMES`, `FRAME_TOKEN_ESTIMATE` +- **Budgets**: `providerImageBudget`, `MAX_FRAMES_DEFAULT`, `FRAME_TOKEN_ESTIMATE`, `HQ_EDGE_FRAMES` - **File ops**: `createFileOps`, `computeFileLists`, `upsertFileOperations` ## References diff --git a/packages/snapcompact/src/prompts/snapcompact-summary.md b/packages/snapcompact/src/prompts/snapcompact-summary.md index f2074b331..397786c16 100644 --- a/packages/snapcompact/src/prompts/snapcompact-summary.md +++ b/packages/snapcompact/src/prompts/snapcompact-summary.md @@ -1,32 +1,24 @@ -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}}. +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. -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 …. -{{#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. -{{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. -{{/if}} -{{#if sentenceInk}}- Ink cycles six colors, one per sentence. -{{/if}}{{#if stopwordDimmed}}- Function words are dim gray; content words keep full ink. -{{/if}}{{#if dimmedToolResults}}- Text inside is dim gray; that gray is archived tool output, not conversation. -{{/if}}{{#if lineRepeated}}- Each line is printed twice (white, then a pale-yellow band); the copies are identical. -{{/if}} -{{#if mixedShapes}} - -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). -{{/if}} -{{#if includedPreviousSummary}} - -The earliest frame begins with "[Summary of earlier history]" — a condensed digest of context that predates the archived conversation. -{{/if}} -{{#if truncatedChars}} - -{{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. -{{/if}} - -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. -{{#if textTail}} - -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. - -[Archived history, continued as text] -{{textTail}} +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 `…`. + +Reading HISTORY: +- Plain-text sections are the verbatim transcript — rely on them exactly. +{{#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. +{{#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. +{{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. +{{/if}}{{#if sentenceInk}} - Ink cycles through six colors, one per sentence. +{{/if}}{{#if stopwordDimmed}} - Function words are dim gray; content words keep full ink. +{{/if}}{{#if dimmedToolResults}} - Text inside `` is dim gray — that gray is archived tool output, not conversation. +{{/if}}{{#if lineRepeated}} - Each line is printed twice (white, then a pale-yellow band); the two copies are identical. +{{/if}}{{#if mixedShapes}} - The compressed middle frames use a smaller, denser font than the edge frames; the reading order is unchanged. +{{/if}}{{/if}}{{#if includedPreviousSummary}}- HISTORY opens with a condensed digest of still-older context that predates the archived turns. +{{/if}}{{#if truncatedChars}}- About {{truncatedChars}} characters of older middle history were dropped to fit the archive budget. +{{/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. +{{#if files}} +FILES +=================== +{{files}} {{/if}} +HISTORY +=================== diff --git a/packages/snapcompact/src/snapcompact.ts b/packages/snapcompact/src/snapcompact.ts index 5697a7744..9ba52a173 100644 --- a/packages/snapcompact/src/snapcompact.ts +++ b/packages/snapcompact/src/snapcompact.ts @@ -31,14 +31,11 @@ * billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is * area-proportional, so resolution cannot improve chars/$ — 1568 stays. * `detail: "high"` would downgrade (2,500-patch cap); `original` is sent. - * - **Unknown providers** default to the Anthropic shape. Gateways can - * defeat any shape silently: OpenRouter enforces a per-model image cap - * (measured: 8 images for glm-4.6v — frames past the cap are dropped with - * no error, billed tokens plateau exactly at 8x frame cost). The same - * frames routed direct to the vendor read fine (glm f1 .20 -> .78), so - * `providerImageBudget` caps per-request images per provider (OpenRouter - * 8, unknown 5) and `compact()` keeps any archive overflow as a text tail - * on the summary instead of rendering frames that would be dropped. + * - **Unknown providers** default to the Anthropic shape. `providerImageBudget` + * still caps per-request images per provider so inline imaging cannot flood a + * request with attachments, but the old OpenRouter-specific 8-image cap is + * gone; routers now use the same permissive budget as direct Anthropic/Claude + * lines unless configured otherwise upstream. * * The whole pass is local and deterministic — no LLM call, no API key, no * latency beyond rendering. Rasterization and PNG encoding happen in native @@ -47,7 +44,7 @@ * re-attached to the compaction summary message on every context rebuild. */ -import type { Api, ImageContent, Message, Model } from "@oh-my-pi/pi-ai"; +import type { Api, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai"; import { renderSnapcompactPng } from "@oh-my-pi/pi-natives"; import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils"; import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; @@ -301,6 +298,16 @@ const FAMILY_VARIANT: Record = { openai: "8on22-bw", }; +/** Denser companion variant per family for the foveated archive middle: same + * pixels (identical per-frame bill) but a tighter 8px cell, trading some + * legibility for ~40% more chars per frame so the least-important middle of a + * long archive compresses into fewer frames. */ +const FAMILY_VARIANT_LOW: Record = { + anthropic: "8on16-bw", + google: "8on16-bw", + openai: "8on16-bw", +}; + const FAMILY_SHAPE: Record = { anthropic: SHAPES.anthropic, google: SHAPES.google, @@ -386,17 +393,23 @@ export const FRAME_SIZE = 2576; * fitting is handled by the caller's overflow guard. */ export const MAX_FRAMES_DEFAULT = 80; -/** Conservative per-frame token estimate used for context budgeting - * (upper bound across shapes: Anthropic bills 1568*1568/750 ≈ 3,278). */ -export const FRAME_TOKEN_ESTIMATE = 3300; +/** High-quality (legible) frames rendered at each chronological edge of a + * foveated archive — the session head (oldest) and the slice just before the + * text region (newest) — with the denser low-quality tier filling the middle. */ +export const HQ_EDGE_FRAMES = 3; + +/** Conservative per-frame token estimate used for context budgeting — the + * upper bound across shapes: high-res Claude frames hit the 4,784 visual-token + * cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from + * undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */ +export const FRAME_TOKEN_ESTIMATE = 5024; /** - * Per-request image-count budgets by provider id. Routers and smaller - * providers enforce hard caps and silently DROP images past them (measured: - * OpenRouter caps at 8 — images 9+ vanish with no error and billed tokens - * plateau at 8x frame cost). First-party APIs allow far more; their values - * are conservative policy caps well under the measured hard limits - * (Anthropic 100, OpenAI 500, Gemini ~2500). + * Per-request image-count budgets by provider id. These cap how many images an + * entire request may carry (archive/system-prompt/tool-result imaging combined). + * The values are conservative policy caps under the vendor hard limits + * (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe + * floor rather than sending unbounded attachments. */ export const PROVIDER_IMAGE_BUDGETS: Record = { anthropic: 90, @@ -406,7 +419,7 @@ export const PROVIDER_IMAGE_BUDGETS: Record = { google: 200, "google-vertex": 200, "google-gemini-cli": 200, - openrouter: 8, + openrouter: 90, }; /** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */ @@ -449,16 +462,25 @@ export interface Frame { /** Frame archive persisted under `preserveData[PRESERVE_KEY]`. */ export interface Archive { - /** Frames ordered oldest to newest. */ + /** Rendered frames ordered oldest to newest, re-derived from {@link text} + * each compaction with foveated quality tiers (HQ/LQ/HQ inside the imaged + * middle). May be empty when the whole archive fits in text. */ frames: Frame[]; - /** Characters currently readable across all frames. */ + /** Characters currently readable across all frames plus the text regions. */ totalChars: number; - /** Characters dropped so far to respect the frame budget. */ + /** Characters dropped so far to respect the archive budget. */ truncatedChars: number; - /** Most recent slice of archived history that exceeded the frame budget, - * kept verbatim as normalized text (dim markers and newline glyphs - * included). Shipped as plain text in the compaction summary and folded - * back into frames by the next compaction. */ + /** Full kept archive source (oldest to newest, normalized, bounded to the + * rendered budget) — the single source re-rendered each compaction. Absent + * on legacy archives persisted before text-sourced rendering, whose frames + * are carried verbatim (see {@link pinnedFrames}). */ + text?: string; + /** Count of leading {@link frames} carried verbatim from a legacy archive + * (one with no source {@link text}); never re-rendered. */ + pinnedFrames?: number; + /** Oldest text region kept verbatim around the imaged middle. */ + textHead?: string; + /** Newest text region kept verbatim around the imaged middle. */ textTail?: string; } @@ -584,7 +606,7 @@ function stripFileOperationTags(summary: string): string { .trimEnd(); } -function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet): string { +function formatFileList(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet): string { if (readFiles.length === 0 && modifiedFiles.length === 0) return ""; const mode = new Map(); for (const file of readFiles) mode.set(file, "Read"); @@ -594,7 +616,12 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[], read if (all.length > FILE_OPERATION_SUMMARY_LIMIT) { files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`; } - return prompt.render(fileOperationsTemplate, { files }); + return files; +} + +function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet): string { + const files = formatFileList(readFiles, modifiedFiles, readSet); + return files.length > 0 ? prompt.render(fileOperationsTemplate, { files }) : ""; } export function upsertFileOperations( @@ -662,10 +689,11 @@ function truncateForSummary(text: string, maxChars: number, headRatio: number): const DIM_MARKERS = /[\u000e\u000f]/g; -/** Cap on the unrendered archive text tail, in frame-capacity units: enough - * to keep the newest discarded history readable without re-inflating the - * context a compaction just shrank. */ -const TEXT_TAIL_MAX_PAGES = 2; +/** Plain-text history kept verbatim at each chronological edge, in HQ-frame- + * capacity units per edge. One page at the start and one at the end preserves + * high-fidelity context around the imaged middle while keeping the total text + * budget equal to the prior 2-page tail-only scheme. */ +const TEXT_EDGE_PAGES = 1; /** Normalized archive text → plain text: drop zero-width dim toggles and * print newline glyphs as real newlines. */ @@ -1188,23 +1216,34 @@ export function getPreservedArchive(preserveData: Record | unde const candidate = preserveData?.[PRESERVE_KEY]; if (!candidate || typeof candidate !== "object") return undefined; const archive = candidate as Archive; - if (!Array.isArray(archive.frames)) return undefined; - const frames = archive.frames.filter( - frame => - !!frame && - typeof frame.data === "string" && - frame.data.length > 0 && - typeof frame.mimeType === "string" && - typeof frame.cols === "number" && - typeof frame.rows === "number" && - typeof frame.chars === "number", - ); - if (frames.length === 0) return undefined; + const frames = Array.isArray(archive.frames) + ? archive.frames.filter( + frame => + !!frame && + typeof frame.data === "string" && + frame.data.length > 0 && + typeof frame.mimeType === "string" && + typeof frame.cols === "number" && + typeof frame.rows === "number" && + typeof frame.chars === "number", + ) + : []; + const text = typeof archive.text === "string" && archive.text.length > 0 ? archive.text : undefined; + const textHead = typeof archive.textHead === "string" && archive.textHead.length > 0 ? archive.textHead : undefined; + const textTail = typeof archive.textTail === "string" && archive.textTail.length > 0 ? archive.textTail : undefined; + // A text-only archive (everything fit in the plain-text regions) is valid; + // only an archive carrying neither frames nor text is empty. + if (frames.length === 0 && text === undefined && textHead === undefined && textTail === undefined) return undefined; + const pinnedFrames = + typeof archive.pinnedFrames === "number" ? Math.max(0, Math.min(frames.length, archive.pinnedFrames)) : undefined; return { frames, totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0, truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0, - ...(typeof archive.textTail === "string" && archive.textTail.length > 0 ? { textTail: archive.textTail } : {}), + ...(text !== undefined ? { text } : {}), + ...(pinnedFrames !== undefined ? { pinnedFrames } : {}), + ...(textHead !== undefined ? { textHead } : {}), + ...(textTail !== undefined ? { textTail } : {}), }; } @@ -1217,28 +1256,177 @@ export function images(archive: Archive): ImageContent[] { ...(frame.detail ? { detail: frame.detail } : {}), })); } +/** Ordered archive blocks for a compaction summary message: old text region, + * imaged middle, then new text region. Runtime-only; reconstructed from + * {@link Archive} on each context rebuild instead of persisted on the session + * entry. */ +export function historyBlocks(archive: Archive): (TextContent | ImageContent)[] { + const blocks: (TextContent | ImageContent)[] = []; + const hasImages = archive.frames.length > 0; + if (archive.textHead) { + const suffix = hasImages ? "\n-------------- imaged middle below\n" : ""; + blocks.push({ type: "text", text: toPlainText(archive.textHead) + suffix }); + } + blocks.push(...images(archive)); + if (archive.textTail) { + const prefix = hasImages + ? "-------------- imaged middle above\n" + : archive.truncatedChars > 0 + ? "\n-------------- middle history omitted above\n" + : ""; + const tail = prefix + toPlainText(archive.textTail); + if (blocks.length > 0 && blocks[blocks.length - 1]?.type === "text") { + (blocks[blocks.length - 1] as TextContent).text += tail; + } else { + blocks.push({ type: "text", text: tail }); + } + } + return blocks; +} // ============================================================================ // Compaction entry point // ============================================================================ +/** Denser companion of `high` for the foveated archive middle: same family and + * frame size (identical per-frame bill) but a tighter cell. Returns `high` + * unchanged for doc layouts or when no denser variant exists (foveation off). */ +function denseCompanion(high: Shape, api: Api | undefined): Shape { + if (high.columns === 2) return high; + const family = billingFamily(api); + const low = priceShape({ ...SHAPE_VARIANTS[FAMILY_VARIANT_LOW[family]], frameSize: high.frameSize }, family); + return geometry(low).capacity > geometry(high).capacity ? low : high; +} + +/** One planned frame: the source slice and the shape (quality tier) to render. */ +interface PlanFrame { + text: string; + shape: Shape; +} + +/** A foveated archive layout: frames oldest→newest for the imaged middle, the + * verbatim text kept at both chronological edges, the flat kept source to + * persist, and the chars dropped this round to fit the budget. */ +interface ArchiveLayout { + frames: PlanFrame[]; + textHead: string; + textTail: string; + keptText: string; + truncatedChars: number; +} + +/** Slice `text` into `capacity`-char frames at one shape (tier). */ +function sliceFrames(text: string, capacity: number, shape: Shape): PlanFrame[] { + const out: PlanFrame[] = []; + for (let offset = 0; offset < text.length; offset += capacity) { + out.push({ text: text.slice(offset, offset + capacity), shape }); + } + return out; +} + +/** + * Lay out the accumulated archive `text` (oldest→newest) with text at both + * chronological edges and images in the middle. One HQ-capacity stays verbatim + * at the oldest edge, one at the newest edge, and the middle between them is + * imaged. If the imaged middle itself overflows `maxFrames`, foveate it + * internally (HQ/LQ/HQ) and drop the oldest slice of its dense center. + */ +function planArchive(text: string, high: Shape, low: Shape, maxFrames: number): ArchiveLayout { + const capHi = geometry(high).capacity; + const edgeCap = TEXT_EDGE_PAGES * capHi; + if (text.length <= 2 * edgeCap) { + return { frames: [], textHead: text, textTail: "", keptText: text, truncatedChars: 0 }; + } + if (maxFrames < 1) { + const textHead = text.slice(0, edgeCap); + const textTail = text.slice(text.length - edgeCap); + return { + frames: [], + textHead, + textTail, + keptText: textHead + textTail, + truncatedChars: text.length - textHead.length - textTail.length, + }; + } + + const textHead = text.slice(0, edgeCap); + const textTail = text.slice(text.length - edgeCap); + const imageText = text.slice(edgeCap, text.length - edgeCap); + if (imageText.length === 0) { + return { frames: [], textHead: text, textTail: "", keptText: text, truncatedChars: 0 }; + } + + // Doc layouts wrap (no char-slicing) and don't foveate: one tier, keep the + // newest pages with the session head pinned, drop the oldest middle. + if (high.columns === 2) { + const pages = docPages(imageText, geometry(high)); + let kept = pages; + let truncatedChars = 0; + if (pages.length > maxFrames) { + const dropped = pages.slice(1, pages.length - (maxFrames - 1)); + truncatedChars = dropped.reduce((sum, page) => sum + page.length, 0); + kept = [...pages.slice(0, 1), ...pages.slice(pages.length - (maxFrames - 1))]; + } + const flat = kept.map(page => page.replaceAll("\n", " ")).join(" "); + return { + frames: kept.map(page => ({ text: page, shape: high })), + textHead, + textTail, + keptText: textHead + flat + textTail, + truncatedChars, + }; + } + + // Grid: render all-HQ when the image region fits the budget outright. + if (Math.ceil(imageText.length / capHi) <= maxFrames) { + return { + frames: sliceFrames(imageText, capHi, high), + textHead, + textTail, + keptText: textHead + imageText + textTail, + truncatedChars: 0, + }; + } + + // Foveate the imaged middle: HQ edges, dense center, drop the oldest dense slice. + const capLo = geometry(low).capacity; + const imageEdgeFrames = Math.min(HQ_EDGE_FRAMES, Math.floor((maxFrames - 1) / 2)); + const imageEdgeCap = imageEdgeFrames * capHi; + const imageHead = imageText.slice(0, imageEdgeCap); + const imageTail = imageEdgeCap > 0 ? imageText.slice(imageText.length - imageEdgeCap) : ""; + let middleText = imageText.slice(imageEdgeCap, imageText.length - imageEdgeCap); + let truncatedChars = 0; + const middleCap = (maxFrames - 2 * imageEdgeFrames) * capLo; + if (middleText.length > middleCap) { + truncatedChars = middleText.length - middleCap; + middleText = middleText.slice(truncatedChars); + } + return { + frames: [ + ...sliceFrames(imageHead, capHi, high), + ...sliceFrames(middleText, capLo, low), + ...sliceFrames(imageTail, capHi, high), + ], + textHead, + textTail, + keptText: textHead + imageHead + middleText + imageTail + textTail, + truncatedChars, + }; +} + /** * Run a snapcompact compaction over prepared messages. Fully local: serializes - * the discarded history, prints it onto PNG frames in the provider-optimal - * shape, merges previously archived frames (oldest dropped beyond the - * budget), and produces a deterministic summary explaining how to read the - * frames. Pages past the frame budget are never rendered (providers with - * hard image caps silently drop excess frames on the wire) — the newest - * unrendered slice survives verbatim as a text tail on the summary and is - * folded back into frames by the next compaction. + * the discarded history, appends it to the accumulated archive source text, and + * re-renders that source into an ordered history layout: plain text at the + * oldest edge, imaged middle, then plain text at the newest edge. The imaged + * middle itself foveates (HQ/LQ/HQ) when it grows large. * - * Frames archived under a different shape (provider switches, legacy 5x8 - * sessions) are kept as-is — each frame carries its own geometry, and the - * summary describes the newest shape while noting that older frames may - * differ. + * The full kept source persists on the archive (`text`) so each compaction can + * re-tier it; legacy archives predating text-sourced rendering keep their frames + * verbatim as a pinned head (`pinnedFrames`). * - * If the previous compaction was text-based, its summary is printed at the - * head of the frame archive as `[Summary of earlier history]` so no continuity is lost. + * If the previous compaction was text-based, its summary is printed at the head + * of the archive as `[Summary of earlier history]` so no continuity is lost. */ export async function compact( preparation: CompactionPreparation, @@ -1248,12 +1436,14 @@ export async function compact( if (!firstKeptEntryId) { throw new Error("First kept entry has no ID - session may need migration"); } - const shape = options?.shape ?? resolveShape(options?.model); - const frameSize = options?.frameSize ?? shape.frameSize; + const baseShape = options?.shape ?? resolveShape(options?.model); + const frameSize = options?.frameSize ?? baseShape.frameSize; + const high = frameSize === baseShape.frameSize ? baseShape : { ...baseShape, frameSize }; + const low = denseCompanion(high, options?.model?.api); + const geo = geometry(high); // The engine default caps archive growth; a caller-supplied maxFrames only // lowers it further (an upper limit), never raising it past the default. const maxFrames = Math.max(1, Math.min(options?.maxFrames ?? MAX_FRAMES_DEFAULT, MAX_FRAMES_DEFAULT)); - const geo = geometry(shape, frameSize); const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages); @@ -1268,127 +1458,125 @@ export async function compact( let truncatedChars = previousArchive?.truncatedChars ?? 0; - // The previous compaction's unframed text tail is the oldest part of this - // archive slice — prepend it so it ages into frames first. - if (previousArchive?.textTail) { + // Older archive source ages into this slice ahead of the new history. A + // text-sourced archive replays its full kept source; a legacy one (frames, + // no source text) keeps its frames verbatim as a pinned head and contributes + // only its recoverable text tail. + let pinned: Frame[] = []; + if (previousArchive?.text !== undefined) { + pinned = previousArchive.frames.slice(0, previousArchive.pinnedFrames ?? 0); archiveText = - archiveText.length > 0 - ? `${previousArchive.textTail}${NEWLINE_GLYPH}${archiveText}` - : previousArchive.textTail; - } - - const pages: string[] = []; - if (shape.columns === 2) { - pages.push(...docPages(archiveText, geo)); - } else { - for (let offset = 0; offset < archiveText.length; offset += geo.capacity) { - pages.push(archiveText.slice(offset, offset + geo.capacity)); + archiveText.length > 0 ? `${previousArchive.text}${NEWLINE_GLYPH}${archiveText}` : previousArchive.text; + } else if (previousArchive) { + const cap = Math.max(1, Math.floor(maxFrames / 2)); + if (previousArchive.frames.length <= cap) { + pinned = previousArchive.frames; + } else { + const dropped = previousArchive.frames.slice(1, previousArchive.frames.length - (cap - 1)); + for (const frame of dropped) truncatedChars += frame.chars; + pinned = [ + ...previousArchive.frames.slice(0, 1), + ...previousArchive.frames.slice(previousArchive.frames.length - (cap - 1)), + ]; + } + if (previousArchive.textTail) { + archiveText = + archiveText.length > 0 + ? `${previousArchive.textTail}${NEWLINE_GLYPH}${archiveText}` + : previousArchive.textTail; } } - // Fit the merged archive into the frame budget BEFORE rendering: pages - // that cannot ship are never rasterized. Old unpinned frames evict first - // (the archive fades oldest-first, as before); new pages that still do - // not fit stay behind as a verbatim text tail instead of being dropped. - const prevFrames = previousArchive?.frames ?? []; - let keptPrev = prevFrames; - if (prevFrames.length + pages.length > maxFrames) { - // Pin the earliest frame: it anchors the session head (the original - // request, or the filmed summary of even older history) the way the - // LLM-summary strategies keep the original goal alive across rounds. - // With a budget of one frame the pin is moot. - const pinCount = maxFrames >= 2 && prevFrames.length > 0 ? 1 : 0; - const evictable = prevFrames.slice(pinCount); - const surviving = Math.min(evictable.length, Math.max(0, maxFrames - pages.length - pinCount)); - const dropped = evictable.slice(0, evictable.length - surviving); - for (const frame of dropped) truncatedChars += frame.chars; - keptPrev = [...prevFrames.slice(0, pinCount), ...evictable.slice(evictable.length - surviving)]; - } - const renderPages = pages.slice(0, maxFrames - keptPrev.length); - const tailPages = pages.slice(renderPages.length); + const layout = planArchive(archiveText, high, low, Math.max(0, maxFrames - pinned.length)); + truncatedChars += layout.truncatedChars; + // Re-render the planned frames, carrying any open dim span across every + // boundary: textHead → frames → textTail. + let dimOpen = layout.textHead.lastIndexOf(DIM_ON) > layout.textHead.lastIndexOf(DIM_OFF); const newFrames: Frame[] = []; - const finish = pageFinisher(shape); - for (const page of renderPages) { - const rendered = render(finish(page), shape, frameSize); + for (const planned of layout.frames) { + let pageText: string = dimOpen ? DIM_ON + planned.text : planned.text; + dimOpen = pageText.lastIndexOf(DIM_ON) > pageText.lastIndexOf(DIM_OFF); + if (planned.shape.stopwordDim) pageText = dimStopwords(pageText); + const rendered = render(pageText, planned.shape); newFrames.push({ data: rendered.data, mimeType: "image/png", cols: rendered.cols, rows: rendered.rows, chars: rendered.chars, - font: shape.font, - variant: shape.variant, - lineRepeat: shape.lineRepeat, - ...(shape.columns === 2 ? { columns: 2 } : {}), - ...(shape.stopwordDim ? { stopwordDim: true } : {}), - ...(shape.imageDetail ? { detail: shape.imageDetail } : {}), + font: planned.shape.font, + variant: planned.shape.variant, + lineRepeat: planned.shape.lineRepeat, + ...(planned.shape.columns === 2 ? { columns: 2 } : {}), + ...(planned.shape.stopwordDim ? { stopwordDim: true } : {}), + ...(planned.shape.imageDetail ? { detail: planned.shape.imageDetail } : {}), }); // Keep the event loop responsive between native render passes. await Bun.sleep(0); } - // Pages past the budget survive as text, capped at two frames' capacity - // (middle-elided) so an oversized archive cannot blow the context back up. - let textTail = ""; - if (tailPages.length > 0) { - const raw = - shape.columns === 2 ? tailPages.map(page => page.replaceAll("\n", " ")).join(" ") : tailPages.join(""); - const tailCap = TEXT_TAIL_MAX_PAGES * geo.capacity; - if (raw.length > tailCap) truncatedChars += raw.length - tailCap; - // Re-open a dim span the render boundary cut through, so the carried - // tail keeps tool output dim when it lands on frames next compaction. - const renderedText = shape.columns === 2 ? renderPages.join("\n") : renderPages.join(""); - const dimOpen = renderedText.lastIndexOf(DIM_ON) > renderedText.lastIndexOf(DIM_OFF); - textTail = (dimOpen ? DIM_ON : "") + truncateForSummary(raw, tailCap, TRUNCATE_HEAD_RATIO); - } + const textHead = layout.textHead; + const textTail = layout.textTail.length > 0 ? (dimOpen ? DIM_ON : "") + layout.textTail : ""; + const textChars = textHead.length + textTail.length; - const frames = [...keptPrev, ...newFrames]; - const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0); + const frames = [...pinned, ...newFrames]; + const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0) + textChars; const mixedShapes = frames.some( frame => frame.cols !== geo.cols || frame.rows !== geo.rows || - (frame.variant ?? "sent") !== shape.variant || - (frame.lineRepeat ?? 1) !== shape.lineRepeat || - (frame.columns ?? 1) !== (shape.columns ?? 1) || - (frame.stopwordDim ?? false) !== (shape.stopwordDim ?? false), + (frame.variant ?? "sent") !== high.variant || + (frame.lineRepeat ?? 1) !== high.lineRepeat || + (frame.columns ?? 1) !== (high.columns ?? 1) || + (frame.stopwordDim ?? false) !== (high.stopwordDim ?? false), ); + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + const files = formatFileList(readFiles, modifiedFiles, fileOps.read); + let summary: string; - if (frames.length === 0) { + if (frames.length === 0 && textHead.length === 0 && textTail.length === 0 && files.length === 0) { summary = "No prior history."; } else { summary = prompt.render(snapcompactSummaryPrompt, { frameCount: frames.length, multipleFrames: frames.length > 1, - fontCell: `${shape.cellWidth}x${shape.cellHeight}`, + docColumns: high.columns === 2, cols: geo.cols, rows: geo.rows, - sentenceInk: shape.variant === "sent", - lineRepeated: shape.lineRepeat > 1, - docColumns: shape.columns === 2, - stopwordDimmed: shape.stopwordDim === true, + sentenceInk: high.variant === "sent", + stopwordDimmed: high.stopwordDim === true, dimmedToolResults: options?.dimToolResults !== false, + lineRepeated: high.lineRepeat > 1, mixedShapes, - totalChars, truncatedChars, includedPreviousSummary, - textTail: textTail.length > 0 ? toPlainText(textTail) : undefined, + files: files.length > 0 ? files : undefined, }); } - const { readFiles, modifiedFiles } = computeFileLists(fileOps); - summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read); // A snapcompact pass replaces any provider-side replacement history; strip the // OpenAI remote-compaction payload like the default summarizer path does. const basePreserve = stripOpenAiRemoteCompactionPreserveData(previousPreserveData) ?? {}; - const archive: Archive = { frames, totalChars, truncatedChars, ...(textTail ? { textTail } : {}) }; + const persistedText = + layout.keptText.length > 0 && layout.textTail.length > 0 + ? `${layout.keptText.slice(0, layout.keptText.length - layout.textTail.length)}${textTail}` + : layout.keptText; + const archive: Archive = { + frames, + totalChars, + truncatedChars, + ...(persistedText.length > 0 ? { text: persistedText } : {}), + ...(pinned.length > 0 ? { pinnedFrames: pinned.length } : {}), + ...(textHead ? { textHead } : {}), + ...(textTail ? { textTail } : {}), + }; - const textTailNote = textTail ? ` (+${textTail.length.toLocaleString()} chars as text)` : ""; + const textNote = textChars > 0 ? ` (+${textChars.toLocaleString()} chars as text)` : ""; return { summary, - shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}${textTailNote}`, + shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}${textNote}`, firstKeptEntryId, tokensBefore, details: { readFiles, modifiedFiles }, diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index c276148e7..bbcfccffc 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -665,7 +665,7 @@ describe("serializeConversation", () => { }); describe("compact", () => { - it("archives history onto frames with a self-describing summary", async () => { + it("stores small archives as plain text with no frames", async () => { const fileOps = snapcompact.createFileOps(); fileOps.read.add("src/auth.ts"); fileOps.edited.add("src/login.ts"); @@ -673,163 +673,102 @@ describe("compact", () => { expect(result.firstKeptEntryId).toBe("kept-1"); expect(result.tokensBefore).toBe(99000); - // Reading instructions reflect the default (anthropic 11on16-bw) shape. - expect(result.summary).toContain("29 characters wide"); - expect(result.summary).toContain("dim gray"); - expect(result.summary).toContain("snapcompact frame"); - // File operations are upserted like every other compaction summary: - // one grouped tree with per-file access markers. - expect(result.summary).toContain("\n# src/\nauth.ts (Read)\nlogin.ts (Write)\n"); - expect(result.shortSummary).toContain("snapcompact frame"); + expect(result.summary).toContain("You are resuming a prior conversation."); + expect(result.summary).toContain("HISTORY"); + expect(result.summary).toContain("FILES\n===================\n# src/\nauth.ts (Read)\nlogin.ts (Write)"); const archive = snapcompact.getPreservedArchive(result.preserveData); expect(archive).toBeDefined(); - expect(archive?.frames.length).toBe(1); - expect(archive?.frames[0].mimeType).toBe("image/png"); - expect(archive?.frames[0].chars).toBe(archive?.totalChars); - expect(archive?.frames[0].font).toBe("8x13"); - expect(archive?.frames[0].variant).toBe("bw"); - expect(archive?.frames[0].stopwordDim).toBeUndefined(); + expect(archive?.frames).toHaveLength(0); + expect(archive?.textHead).toBeTruthy(); + expect(archive?.textTail).toBeUndefined(); expect(archive?.truncatedChars).toBe(0); - // Frame data round-trips as a decodable PNG. - const decoded = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); - expect(decoded.width).toBe(TEST_FRAME_SIZE); + + const blocks = archive ? snapcompact.historyBlocks(archive) : []; + expect(blocks).toHaveLength(1); + expect(blocks[0]?.type).toBe("text"); }); - it("prints tool results in dim gray ink, persisting the span across frame boundaries", async () => { - // Anthropic shape at 320px holds 800 chars/frame; a 1650-char tool - // result spans three frames, so the reopened span must dim in each. + it("carries dim tool-output spans from text into the first image frame", async () => { const result = await snapcompact.compact( makePreparation({ messagesToSummarize: [createUserMessage("Run the suite."), createToolResultMessage("FAIL ".repeat(330))], }), - { frameSize: TEST_FRAME_SIZE }, + { frameSize: TEST_FRAME_SIZE, maxFrames: 2 }, ); const archive = snapcompact.getPreservedArchive(result.preserveData); - expect(archive?.frames.length).toBeGreaterThanOrEqual(2); - for (const frame of archive?.frames ?? []) { - const decoded = decodePng(Buffer.from(frame.data, "base64")); - // Palette index 9 is the dim tool-output ink. - expect(new Set(decoded.pixels).has(9)).toBe(true); - } - // Conversation text outside the span stays in black bw ink (frame 1). - const first = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); - expect(new Set(first.pixels).has(7)).toBe(true); - expect(result.summary).toContain("archived tool output"); + expect(archive?.frames.length).toBeGreaterThanOrEqual(1); + const decoded = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); + // Palette index 9 is the dim tool-output ink. + expect(new Set(decoded.pixels).has(9)).toBe(true); }); - it("keeps frames free of dim ink when dimToolResults is false", async () => { + it("keeps image frames free of dim ink when dimToolResults is false", async () => { const result = await snapcompact.compact( makePreparation({ - messagesToSummarize: [createUserMessage("Run."), createToolResultMessage("all good")], + messagesToSummarize: [createUserMessage("Run."), createToolResultMessage("all good ".repeat(200))], }), - { frameSize: TEST_FRAME_SIZE, dimToolResults: false }, + { frameSize: TEST_FRAME_SIZE, maxFrames: 2, dimToolResults: false }, ); const archive = snapcompact.getPreservedArchive(result.preserveData); + expect(archive?.frames.length).toBeGreaterThanOrEqual(1); const decoded = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); expect(new Set(decoded.pixels).has(9)).toBe(false); - expect(result.summary).not.toContain("archived tool output"); }); - it("keeps history past the frame budget as a text tail instead of dropping it", async () => { - const { capacity } = snapcompact.geometry(snapcompact.SHAPES.anthropic, TEST_FRAME_SIZE); - // Sentences avoid whitespace collapse shrinking the payload below 2.5 frames. - const longText = `${"Important fact number one. ".repeat(Math.ceil((capacity * 2.5) / 28))}Tail sentinel QQZZ.`; + it("keeps plain text at both edges and images in the middle", async () => { + const longText = `HEAD sentinel AA. ${"Important fact number one. ".repeat(400)}TAIL sentinel QQZZ.`; const result = await snapcompact.compact( makePreparation({ messagesToSummarize: [createUserMessage(longText)] }), - { - frameSize: TEST_FRAME_SIZE, - maxFrames: 2, - }, + { frameSize: TEST_FRAME_SIZE, maxFrames: 5 }, ); const archive = snapcompact.getPreservedArchive(result.preserveData); - expect(archive?.frames.length).toBe(2); - // Nothing is dropped: the unrendered remainder ships as text. - expect(archive?.truncatedChars).toBe(0); - expect(archive?.textTail).toContain("QQZZ"); - expect(result.summary).toContain("[Archived history, continued as text]"); - expect(result.summary).toContain("Tail sentinel QQZZ."); - expect(result.shortSummary).toContain("chars as text"); + expect(archive?.frames).toHaveLength(5); + expect(archive?.textHead).toContain("HEAD sentinel AA"); + expect(archive?.textTail).toContain("TAIL sentinel QQZZ"); + + const blocks = archive ? snapcompact.historyBlocks(archive) : []; + expect(blocks[0]?.type).toBe("text"); + expect((blocks[0] as { text: string }).text).toContain("imaged middle below"); + expect(blocks.at(-1)?.type).toBe("text"); + expect((blocks.at(-1) as { text: string }).text).toContain("imaged middle above"); + expect(blocks.filter(block => block.type === "image")).toHaveLength(5); }); - it("folds the previous text tail back into frames on the next compaction", async () => { - const { capacity } = snapcompact.geometry(snapcompact.SHAPES.anthropic, TEST_FRAME_SIZE); - const longText = "Important fact number one. ".repeat(Math.ceil((capacity * 2.5) / 28)); - const first = await snapcompact.compact(makePreparation({ messagesToSummarize: [createUserMessage(longText)] }), { - frameSize: TEST_FRAME_SIZE, - maxFrames: 2, - }); - expect(snapcompact.getPreservedArchive(first.preserveData)?.textTail).toBeTruthy(); + it("uses three HQ image frames on each edge when the budget allows", async () => { + const hugeText = `HEAD sentinel. ${"Important fact number one. ".repeat(1000)}TAIL sentinel.`; + const result = await snapcompact.compact( + makePreparation({ messagesToSummarize: [createUserMessage(hugeText)] }), + { frameSize: TEST_FRAME_SIZE, maxFrames: 7 }, + ); + const archive = snapcompact.getPreservedArchive(result.preserveData); + expect(archive?.frames).toHaveLength(7); + const hiCols = snapcompact.geometry(snapcompact.SHAPES.anthropic, TEST_FRAME_SIZE).cols; + const cols = archive?.frames.map(frame => frame.cols) ?? []; + expect(cols.slice(0, 3)).toEqual([hiCols, hiCols, hiCols]); + expect(cols.slice(-3)).toEqual([hiCols, hiCols, hiCols]); + expect(cols[3]).toBeGreaterThan(hiCols); + }); + it("re-renders later compactions from the kept source text", async () => { + const first = await snapcompact.compact( + makePreparation({ + messagesToSummarize: [createUserMessage("A long first turn. ".repeat(500))], + }), + { frameSize: TEST_FRAME_SIZE, maxFrames: 5 }, + ); const second = await snapcompact.compact( makePreparation({ messagesToSummarize: [createUserMessage("A short follow-up turn.")], previousSummary: first.summary, previousPreserveData: first.preserveData, }), - { frameSize: TEST_FRAME_SIZE, maxFrames: 8 }, + { frameSize: TEST_FRAME_SIZE, maxFrames: 5 }, ); const archive = snapcompact.getPreservedArchive(second.preserveData); - // 2 carried frames + 1 new frame holding (old tail + new turn); no tail left. - expect(archive?.frames.length).toBe(3); - expect(archive?.textTail).toBeUndefined(); - expect(second.summary).not.toContain("[Archived history, continued as text]"); - }); - - it("caps the text tail and counts the elided middle as truncated", async () => { - const { capacity } = snapcompact.geometry(snapcompact.SHAPES.anthropic, TEST_FRAME_SIZE); - // 6 frames of payload at a 1-frame budget → tail capped at 2 frame capacities. - const longText = "Important fact number one. ".repeat(Math.ceil((capacity * 6) / 28)); - const result = await snapcompact.compact( - makePreparation({ messagesToSummarize: [createUserMessage(longText)] }), - { frameSize: TEST_FRAME_SIZE, maxFrames: 1 }, - ); - const archive = snapcompact.getPreservedArchive(result.preserveData); - expect(archive?.frames.length).toBe(1); - expect(archive?.textTail).toContain("ch elided"); - expect(archive?.textTail?.length).toBeLessThan(capacity * 2.5); - expect(archive?.truncatedChars).toBeGreaterThan(0); - }); - - it("keeps a text tail on two-column doc shapes (wrapped pages rejoined flat)", async () => { - const geo = snapcompact.geometry(snapcompact.SHAPES.google, TEST_FRAME_SIZE); - const longText = `${"Important fact number one. ".repeat(Math.ceil((geo.capacity * 3.5) / 28))}Tail sentinel QQZZ.`; - const result = await snapcompact.compact( - makePreparation({ messagesToSummarize: [createUserMessage(longText)] }), - { frameSize: TEST_FRAME_SIZE, maxFrames: 2, shape: snapcompact.SHAPES.google }, - ); - const archive = snapcompact.getPreservedArchive(result.preserveData); - expect(archive?.frames.length).toBe(2); - // The sentinel ends the archive, so it survives any tail cap. - expect(archive?.textTail).toContain("QQZZ"); - // Wrap-induced line breaks flatten to spaces in the tail. - expect(archive?.textTail).not.toContain("\n"); - }); - - it("evicts the oldest unpinned frames, keeping the session-head frame alive", async () => { - let previous: snapcompact.CompactionResult | undefined; - let headFrameData = ""; - let secondFrameData = ""; - for (let pass = 1; pass <= 4; pass++) { - previous = await snapcompact.compact( - makePreparation({ - messagesToSummarize: [createUserMessage(`Distinct turn number ${pass}.`)], - previousSummary: previous?.summary, - previousPreserveData: previous?.preserveData, - }), - { frameSize: TEST_FRAME_SIZE, maxFrames: 3 }, - ); - const archive = snapcompact.getPreservedArchive(previous.preserveData); - if (pass === 1) headFrameData = archive?.frames[0].data ?? ""; - if (pass === 2) secondFrameData = archive?.frames[1].data ?? ""; - } - const final = snapcompact.getPreservedArchive(previous?.preserveData); - expect(final?.frames.length).toBe(3); - // The head frame (original request) is pinned through every eviction; - // the archive fades from the middle out. - expect(final?.frames[0].data).toBe(headFrameData); - expect(final?.frames.some(frame => frame.data === secondFrameData)).toBe(false); - expect(final?.truncatedChars).toBeGreaterThan(0); + expect(archive?.text).toContain("A short follow-up turn."); + expect(archive?.textTail ?? archive?.textHead).toContain("A short follow-up turn."); + expect(archive?.frames.length).toBe(5); }); it("includes the previous text summary when the prior compaction was not snapcompact", async () => { @@ -837,13 +776,11 @@ describe("compact", () => { makePreparation({ previousSummary: "Older context: project scaffolding done." }), { frameSize: TEST_FRAME_SIZE }, ); - expect(result.summary).toContain("[Summary of earlier history]"); + expect(result.summary).toContain("condensed digest of still-older context"); }); - it("carries previous frames forward and strips the OpenAI remote payload", async () => { + it("strips the OpenAI remote payload and preserves unrelated preserveData", async () => { const first = await snapcompact.compact(makePreparation(), { frameSize: TEST_FRAME_SIZE }); - const firstArchive = snapcompact.getPreservedArchive(first.preserveData); - const second = await snapcompact.compact( makePreparation({ messagesToSummarize: [createUserMessage("A new turn happened after the first compaction.")], @@ -857,33 +794,10 @@ describe("compact", () => { { frameSize: TEST_FRAME_SIZE }, ); - const archive = snapcompact.getPreservedArchive(second.preserveData); - expect(archive?.frames.length).toBe(2); - // Oldest frame rides along unchanged, new frame appended after it. - expect(archive?.frames[0].data).toBe(firstArchive?.frames[0].data ?? ""); - // Previous archive present → previous summary is snapcompact boilerplate, not re-archived. expect(second.summary).not.toContain("[Summary of earlier history]"); expect(second.preserveData?.openaiRemoteCompaction).toBeUndefined(); expect(second.preserveData?.appKey).toBe("kept"); }); - - it("flags mixed shapes when merged frames disagree with the active shape", async () => { - const first = await snapcompact.compact(makePreparation(), { - frameSize: TEST_FRAME_SIZE, - shape: snapcompact.SHAPES.legacy, - }); - const second = await snapcompact.compact( - makePreparation({ - messagesToSummarize: [createUserMessage("Another turn after a provider switch.")], - previousSummary: first.summary, - previousPreserveData: first.preserveData, - }), - { frameSize: TEST_FRAME_SIZE, model: { api: "anthropic-messages" } }, - ); - expect(second.summary).toContain("Older frames may use a different font"); - // Same-shape merges stay silent. - expect(first.summary).not.toContain("Older frames may use a different font"); - }); }); describe("archive helpers", () => { @@ -899,7 +813,16 @@ describe("archive helpers", () => { expect(snapcompact.getPreservedArchive({ [snapcompact.PRESERVE_KEY]: valid })).toEqual(valid); }); - it("getPreservedArchive round-trips a persisted text tail", () => { + it("getPreservedArchive round-trips text-only and text-tail archives", () => { + const textOnly: snapcompact.Archive = { + frames: [], + totalChars: 21, + truncatedChars: 0, + text: "older history newer history", + textHead: "older history newer history", + }; + expect(snapcompact.getPreservedArchive({ [snapcompact.PRESERVE_KEY]: textOnly })).toEqual(textOnly); + const archive: snapcompact.Archive = { frames: [{ data: "ZmFrZQ==", mimeType: "image/png", cols: 64, rows: 40, chars: 10 }], totalChars: 10, @@ -909,9 +832,8 @@ describe("archive helpers", () => { expect(snapcompact.getPreservedArchive({ [snapcompact.PRESERVE_KEY]: archive })).toEqual(archive); }); - it("provider image budgets respect hard image caps", () => { - // OpenRouter silently drops images past 8 — the budget must match. - expect(snapcompact.providerImageBudget("openrouter")).toBe(8); + it("provider image budgets stay permissive while unknown providers keep the safe floor", () => { + expect(snapcompact.providerImageBudget("openrouter")).toBe(90); // Unknown providers fall to the safe floor. expect(snapcompact.providerImageBudget(undefined)).toBe(snapcompact.DEFAULT_PROVIDER_IMAGE_BUDGET); expect(snapcompact.providerImageBudget("some-new-router")).toBe(snapcompact.DEFAULT_PROVIDER_IMAGE_BUDGET);