/** * Snapcompact compaction: archive conversation history as dense bitmap images. * * Instead of asking an LLM to summarize discarded history, the serialized * conversation is rendered into PNG frames of pixel-font text that vision * models read back directly, like an archivist at a snapcompact frame * reader. Frames are `frameSize` wide; their height hugs the text rows * actually printed, so a partially filled frame never bills blank rows. * * The frame shape is provider-aware. Original choices came from the SQuAD * prose evals (`packages/snapcompact`, 200k-token monolithic runs); the * spacing choices below come from the tool-result legibility bench * (`research/toolbench.py`, real search/read/find output with structure QA), * which exposed that the prose-tuned dense cells erase the line numbers and * indentation that code/search output depends on: * * - **Anthropic** (`11on16-bw`): 8x13 glyphs on an 11px advance (extra * letter-spacing), black ink. On the tool-result bench, tracking the * readable cell beat plain `8on16-bw` (opus-4.8 f1 .806 vs .755) and far * beat the prior dense `6x12-dim` (.351, which fell below the OCR ~16px/char * floor and abstained). Opus 4.7+/Fable/Mythos ingest high-res natively * (2576px edge, 4,784 visual-token cap), so those lines get 1932px frames: * same bill, fewer frames. Older Claude lines downscale past 1568px. * - **Google** (`8on22-bw` @2048): 8x13 glyphs on a 22px pitch (extra line * spacing), black ink. Leading lifted gemini-3.5-flash to f1 .934 vs .807 * for `8on16-bw` and .287 for the prior `doc-8on16-sent-dim`. Gemini 3.x * bills a fixed `media_resolution` budget per image (default 1,120 tokens) * regardless of pixels, so the 2048px frame carries more chars at the same * bill. * - **OpenAI** (`8on22-bw`): same leading win (gpt-5.5/gpt-5.4-mini). Patch * 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. `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 * code (`renderSnapcompactPng` in `crates/pi-natives/src/snapcompact.rs`). * Frames persist in the compaction entry's `preserveData` and are * re-attached to the compaction summary message on every context rebuild. */ import type { Api, ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai"; import { renderSnapcompactPng, snapcompactSupportedChars } from "@oh-my-pi/pi-natives"; import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils"; import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" }; import snapcompactSummaryPrompt from "./prompts/snapcompact-summary.md" with { type: "text" }; // ============================================================================ // Shapes // ============================================================================ /** One eval-validated frame shape: font, cell, ink, repetition, and size. */ export interface Shape { /** Bundled font in the native renderer. */ font: "5x8" | "8x8" | "6x12" | "8x13" | "silver"; /** Target cell advance in pixels; differing from the font's natural cell * renders via Lanczos stretch (anti-aliased RGB frame). */ cellWidth: number; /** Target cell pitch in pixels. */ cellHeight: number; /** `false` → glyphs drawn at natural size on the cell pitch (8on16); * `true`/`undefined` → legacy auto Lanczos stretch when cell ≠ natural. */ stretch?: boolean; /** Ink: `sent` cycles six hues at sentence boundaries; `bw` is black. */ variant: "sent" | "bw"; /** Print stopwords in dim ink (research `dim`/`sent-dim` variants). */ stopwordDim?: boolean; /** 1/undefined = row-major grid; 2 = two word-wrapped newspaper columns * (research `doc`). */ columns?: number; /** Each text line is printed this many times; copies after the first sit * on a pale highlight band (redundancy coding). */ lineRepeat: number; /** Frame edge in pixels. */ frameSize: number; /** Per-frame billed-token estimate for the shape's target provider. */ frameTokenEstimate: number; /** Resolution hint attached to frame images (OpenAI-only). */ imageDetail?: ImageContent["detail"]; } /** Geometry half of a {@link Shape}: everything except provider billing. */ export type ShapeGeometry = Omit; /** * Frame variants exercised by the SQuAD evals in `research/` that the native * renderer reproduces faithfully, keyed by their research names. Font codes: * `8x8u` unscii square cell, `8x8r` unscii with every line printed twice * (redundancy coding), `6x6u` unscii Lanczos-squeezed to 6x6 (densest * readable cell), `5x8` the X.org legacy font on its 2576px frame, `6x12` * and `8x13` the X.org misc fonts, `8on16` 8x13 glyphs on an 8x16 cell pitch * (no stretch, extra leading), `8on22` the same glyphs on a 22px pitch (more * leading), `11on16` the same glyphs on an 11px advance (more tracking), * `silver16` the embedded Silver TrueType font on a 16px grid for CJK and * other non-Latin text, and `doc-` prefixed shapes a two-column word-wrapped * newspaper layout. Ink: `sent` cycles six hues at sentence boundaries, `bw` * is plain black, `-dim` suffix prints stopwords in gray. */ export const SHAPE_VARIANTS = { "8x8r-bw": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "bw", lineRepeat: 2, frameSize: 1568 }, "8x8r-sent": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "sent", lineRepeat: 2, frameSize: 1568 }, "8x8u-bw": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "bw", lineRepeat: 1, frameSize: 1568 }, "8x8u-sent": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "sent", lineRepeat: 1, frameSize: 1568 }, "6x6u-bw": { font: "8x8", cellWidth: 6, cellHeight: 6, variant: "bw", lineRepeat: 1, frameSize: 1568 }, "6x6u-sent": { font: "8x8", cellWidth: 6, cellHeight: 6, variant: "sent", lineRepeat: 1, frameSize: 1568 }, "5x8-bw": { font: "5x8", cellWidth: 5, cellHeight: 8, variant: "bw", lineRepeat: 1, frameSize: 2576 }, "5x8-sent": { font: "5x8", cellWidth: 5, cellHeight: 8, variant: "sent", lineRepeat: 1, frameSize: 2576 }, "6x12-dim": { font: "6x12", cellWidth: 6, cellHeight: 12, variant: "bw", stopwordDim: true, lineRepeat: 1, frameSize: 1568, }, "8x13-bw": { font: "8x13", cellWidth: 8, cellHeight: 13, variant: "bw", lineRepeat: 1, frameSize: 1568 }, "8on16-bw": { font: "8x13", cellWidth: 8, cellHeight: 16, stretch: false, variant: "bw", lineRepeat: 1, frameSize: 1568, }, "8on22-bw": { font: "8x13", cellWidth: 8, cellHeight: 22, stretch: false, variant: "bw", lineRepeat: 1, frameSize: 1568, }, "11on16-bw": { font: "8x13", cellWidth: 11, cellHeight: 16, stretch: false, variant: "bw", lineRepeat: 1, frameSize: 1568, }, "silver16-bw": { font: "silver", cellWidth: 16, cellHeight: 16, variant: "bw", lineRepeat: 1, frameSize: 1568, }, "doc-8on16-bw": { font: "8x13", cellWidth: 8, cellHeight: 16, stretch: false, variant: "bw", columns: 2, lineRepeat: 1, frameSize: 1568, }, "doc-8on16-sent": { font: "8x13", cellWidth: 8, cellHeight: 16, stretch: false, variant: "sent", columns: 2, lineRepeat: 1, frameSize: 1568, }, "doc-8on16-sent-dim": { font: "8x13", cellWidth: 8, cellHeight: 16, stretch: false, variant: "sent", stopwordDim: true, columns: 2, lineRepeat: 1, frameSize: 1568, }, } as const satisfies Record; /** Research name of one renderable frame variant. */ export type ShapeVariantName = keyof typeof SHAPE_VARIANTS; /** All variant names, in declaration order (for settings enums). */ export const SHAPE_VARIANT_NAMES = Object.keys(SHAPE_VARIANTS) as readonly ShapeVariantName[]; /** Runtime guard for variant names loaded from config. */ export function isShapeVariantName(value: unknown): value is ShapeVariantName { return typeof value === "string" && value in SHAPE_VARIANTS; } /** Provider families with distinct image billing. */ type BillingFamily = "anthropic" | "google" | "openai"; function billingFamily(api?: Api): BillingFamily { switch (api) { case "openai-completions": case "openai-responses": case "openai-codex-responses": case "azure-openai-responses": return "openai"; case "google-generative-ai": case "google-gemini-cli": case "google-vertex": return "google"; default: // anthropic-messages, bedrock-converse-stream, and anything unknown // share Anthropic's pixel-area pricing as the safe ceiling. return "anthropic"; } } /** * Per-frame billing for a square frame of edge `frameSize`, by family. * Formulas verified against live bills in the resolution benchmarks: * - Anthropic: 28px patches, capped at 4,784 visual tokens (the API * downscales past the cap; 1568 → 3,136 measured) + 5% margin. * - Google: Gemini 3.x bills a fixed `media_resolution` budget per image — * default HIGH = 1,120 tokens — regardless of pixel size. * - OpenAI: 32px patches × 1.2 flagship multiplier, 10,000-patch budget at * `detail: "original"` (1568 → 2,881 measured). */ function familyBilling(family: BillingFamily, frameSize: number): Pick { switch (family) { case "google": return { frameTokenEstimate: 1120 }; case "openai": { const patches = Math.min(Math.ceil(frameSize / 32) ** 2, 10_000); return { frameTokenEstimate: Math.ceil(patches * 1.2), imageDetail: "original" }; } default: { const patches = Math.min(Math.ceil(frameSize / 28) ** 2, 4784); return { frameTokenEstimate: Math.ceil(patches * 1.05) }; } } } /** Attach a provider family's billing to a variant geometry. */ function priceShape(base: ShapeGeometry, family: BillingFamily): Shape { return { ...base, ...familyBilling(family, base.frameSize) }; } /** Eval-validated shapes, keyed by the provider family they won on. */ export const SHAPES = { /** `11on16-bw`: 8x13 glyphs on an 11px advance (extra tracking), black ink. * Tool-result legibility bench (real search/read/find output, structure QA) * on opus-4.8: f1 .806 vs .755 for plain `8on16-bw` and .351 for the prior * `6x12-dim` default — letter-spacing the readable cell wins; the dense * 6x12 was below the OCR ~16px/char floor and abstained. */ anthropic: priceShape(SHAPE_VARIANTS["11on16-bw"], "anthropic"), /** `8on22-bw`: 8x13 glyphs on a 22px pitch (extra leading), black ink. * Tool-result legibility bench on gemini-3.5-flash: f1 .934 vs .807 for * plain `8on16-bw` and .287 for the prior `doc-8on16-sent-dim`; the * line-spacing reduces row crowding so line numbers stay legible. */ google: priceShape(SHAPE_VARIANTS["8on22-bw"], "google"), /** `8on22-bw`: 8x13 glyphs on a 22px pitch (extra leading), black ink. * Same line-spacing win for OpenAI; bench on gpt-5.5/gpt-5.4-mini showed * leading lifts recall on the readable cell over plain `8on16-bw`. */ openai: priceShape(SHAPE_VARIANTS["8on22-bw"], "openai"), /** Original 5x8 X.org shape (pre-shape-table sessions rendered this). */ legacy: priceShape(SHAPE_VARIANTS["5x8-sent"], "anthropic"), } satisfies Record; /** Runtime guard for shape overrides loaded from config or preserve data. */ export function isShape(value: unknown): value is Shape { if (!value || typeof value !== "object") return false; const shape = value as Record; const font = shape.font; const variant = shape.variant; const detail = shape.imageDetail; return ( (font === "5x8" || font === "8x8" || font === "6x12" || font === "8x13" || font === "silver") && typeof shape.cellWidth === "number" && shape.cellWidth > 0 && typeof shape.cellHeight === "number" && shape.cellHeight > 0 && (shape.stretch === undefined || typeof shape.stretch === "boolean") && (variant === "sent" || variant === "bw") && (shape.stopwordDim === undefined || typeof shape.stopwordDim === "boolean") && (shape.columns === undefined || shape.columns === 1 || shape.columns === 2) && typeof shape.lineRepeat === "number" && shape.lineRepeat > 0 && typeof shape.frameSize === "number" && shape.frameSize > 0 && typeof shape.frameTokenEstimate === "number" && shape.frameTokenEstimate > 0 && (detail === undefined || detail === "auto" || detail === "low" || detail === "high" || detail === "original") ); } /** Eval-winning variant per provider family (billing fallback when the * model id matches no known reader line). */ const FAMILY_VARIANT: Record = { anthropic: "11on16-bw", google: "8on22-bw", 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, openai: SHAPES.openai, }; /** One model line's ideal format: variant plus an optional frame-size * override when the line reads larger frames at no extra cost. */ export interface IdealShape { variant: ShapeVariantName; frameSize?: number; } /** Eval-winning format per model line, matched against the model id. The * wire API only identifies the gateway — a Claude served through Vertex or * OpenRouter still reads best with its own shape. Patterns cover the model * lines the mono evals measured; everything else falls back to the API * family's winner at the standard 1568px frame. First match wins. */ const MODEL_VARIANTS: readonly (readonly [RegExp, IdealShape])[] = [ // Opus 4.7+ and Fable/Mythos read high-res natively (2576px edge under a // 4,784 visual-token cap → 1932px square sweet spot): same recall and // cost as 1568, a third fewer frames. [/claude.*(fable|mythos)/i, { variant: "11on16-bw", frameSize: 1932 }], [/claude-?opus-?4[.-][7-9]/i, { variant: "11on16-bw", frameSize: 1932 }], // Older Claude lines downscale past 1568px — keep the safe size. [/claude/i, { variant: "11on16-bw" }], // Gemini 3.x bills a fixed 1,120-token budget per image regardless of // pixels: 2048px packs more chars per frame at the same bill. [/gemini/i, { variant: "8on22-bw", frameSize: 2048 }], // gpt-5.5 patch billing is area-proportional; 1568 is already optimal. [/gpt|codex/i, { variant: "8on22-bw" }], // kimi's image processor downscales past 1792px (64×64 28px patches); // 1568 wins on chars/$ and reads at f1 .973 (≤8 frames per request). [/kimi/i, { variant: "8on16-bw" }], // glm-4.6v .780 mono via direct vendor routing. [/glm/i, { variant: "8on16-bw" }], ]; /** Eval-ideal format for a model id, or undefined when unmeasured. */ export function idealShapeVariant(modelId: string): IdealShape | undefined { return MODEL_VARIANTS.find(([pattern]) => pattern.test(modelId))?.[1]; } /** What will read the frames: the wire API (billing) and model id (shape). */ export interface ShapeTarget { api?: Api; id?: string; } /** * Pick the frame shape for a reader. An explicit `variant` (anything but * `"auto"`) forces that geometry; otherwise the model id selects the * eval-winning shape — and frame size — for its model line, falling back to * the API family's winner when the model is unmeasured. Billing (token * estimate, detail hint) always follows the API family actually carrying * the request, computed for the resolved frame size. Accepts a full pi-ai * `Model` or any `{ api, id }` subset. */ export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape { const family = billingFamily(model?.api); if (variant && variant !== "auto") return priceShape(SHAPE_VARIANTS[variant], family); const ideal = model?.id ? idealShapeVariant(model.id) : undefined; const name = ideal?.variant ?? FAMILY_VARIANT[family]; if (name === FAMILY_VARIANT[family] && ideal?.frameSize === undefined) return FAMILY_SHAPE[family]; const base = SHAPE_VARIANTS[name]; return priceShape(ideal?.frameSize ? { ...base, frameSize: ideal.frameSize } : base, family); } const CJK_HEAVY_MIN_WIDE_CHARS = 8; const CJK_HEAVY_WIDE_RATIO = 0.25; function isCjkHeavyText(text: string): boolean { const chars = normalizedInputChars(text); let graphicChars = 0; let wideChars = 0; for (const ch of chars) { if (ch === " " || ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) continue; const cp = ch.codePointAt(0); if (cp === undefined || UNRENDERABLE.test(ch)) continue; graphicChars++; if (isWideCodePoint(cp)) wideChars++; } return wideChars >= CJK_HEAVY_MIN_WIDE_CHARS && wideChars / graphicChars >= CJK_HEAVY_WIDE_RATIO; } /** * Pick the frame shape for `text`. Explicit variants remain forced. Auto first * resolves the model/provider default, then selects the Silver CJK grid when * the default font cannot safely render the text or wide CJK glyphs dominate * the transcript and Silver can render it safely. */ export function resolveShapeForText(text: string, model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape { const shape = resolveShape(model, variant); if (variant && variant !== "auto") return shape; const silver = resolveShape(model, "silver16-bw"); if (!scanRenderability(text, { shape }).isSafe) { return scanRenderability(text, { shape: silver }).isSafe ? silver : shape; } return shape.font !== "silver" && isCjkHeavyText(text) && scanRenderability(text, { shape: silver }).isSafe ? silver : shape; } // ============================================================================ // Constants // ============================================================================ /** Legacy frame edge in pixels (the 5x8 shape's eval-validated size). New * shapes carry their own `frameSize`. */ export const FRAME_SIZE = 2576; /** Default upper bound on archive frames carried per compaction. Sized to hold * ~400k tokens of the high-res Anthropic frame Opus reads (1932px ≈ 5,000 * billed tokens each → 80 frames) while staying under the ~100-image * per-request wire cap. Oldest frames are dropped first once the budget is * exceeded (mirrors how iterative text summaries fade the oldest detail); a * caller may pass a lower `maxFrames` upper limit, and per-model context * fitting is handled by the caller's overflow guard. */ export const MAX_FRAMES_DEFAULT = 80; /** 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; /** Conservative upper bound for one persisted frame's base64 payload. The * measured high-res Anthropic `8x13`/`11on16` PNG frames sit around 159 KB; * 170 KB leaves margin for denser glyph pages without permitting multi-MB * standing request bodies at large context windows. */ export const FRAME_DATA_BYTES_ESTIMATE = 170_000; /** Maximum snapcompact image base64 carried in every rebuilt provider request. * Above this, provider backends can accept the HTTP body but fail mid-stream * with opaque 5xx errors. Keep this independent from visual-token budgeting: * a 1M-token model can afford 70 images on paper, but not the resulting * ~11 MB JSON payload on every turn. */ export const FRAME_DATA_BYTES_BUDGET = 3_000_000; /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET}. */ export function maxFramesForDataBudget(maxFrameDataBytes: number = FRAME_DATA_BYTES_BUDGET): number { return Math.max(1, Math.floor(maxFrameDataBytes / FRAME_DATA_BYTES_ESTIMATE)); } /** Base64 byte length for persisted snapcompact frames. */ export function frameDataBytes(frames: readonly Pick[]): number { return frames.reduce((sum, frame) => sum + frame.data.length, 0); } /** * 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, "amazon-bedrock": 90, openai: 200, "openai-codex": 200, google: 200, "google-vertex": 200, "google-gemini-cli": 200, openrouter: 90, umans: 10, }; /** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */ export const DEFAULT_PROVIDER_IMAGE_BUDGET = 5; /** Per-request image budget for `provider`; unknown providers get the floor. */ export function providerImageBudget(provider: string | undefined): number { return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET; } /** Key under `CompactionEntry.preserveData` holding the frame archive. */ export const PRESERVE_KEY = "snapcompact"; // ============================================================================ // Types // ============================================================================ /** One developed snapcompact frame: a base64 PNG plus its reading geometry. */ export interface Frame { /** Base64-encoded PNG. */ data: string; mimeType: string; /** Characters per row in the frame grid (per-column width on doc frames). */ cols: number; /** Text rows in the frame grid (unique lines, not repeated copies). */ rows: number; /** Characters actually printed onto this frame. */ chars: number; /** Shape metadata (absent on legacy frames, which are 5x8 `sent`). */ font?: Shape["font"]; variant?: Shape["variant"]; lineRepeat?: number; /** 2 on two-column doc frames; absent on row-major grid frames. */ columns?: number; /** True when stopwords were printed in dim ink. */ stopwordDim?: boolean; /** Resolution hint forwarded to the provider when re-attaching. */ detail?: ImageContent["detail"]; } /** Frame archive persisted under `preserveData[PRESERVE_KEY]`. */ export interface Archive { /** 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 plus the text regions. */ totalChars: number; /** Characters dropped so far to respect the archive budget. */ truncatedChars: number; /** Full kept archive source (oldest to newest, normalized, bounded to the * rendered budget) — the single source re-rendered each compaction. */ text?: string; /** Oldest text region kept verbatim around the imaged middle. */ textHead?: string; /** Newest text region kept verbatim around the imaged middle. */ textTail?: string; } export interface Geometry { /** Characters per row (per-column line width when `columns === 2`). */ cols: number; rows: number; /** Characters that fit one frame (nominal upper bound on doc shapes, * where real consumption is wrap-dependent). */ capacity: number; } export interface Options extends SerializeOptions { /** App-level message transformer (same contract as agent-core's `SummaryOptions.convertToLlm`). */ convertToLlm?: ConvertToLlm; /** Model whose provider API and id select the frame shape. */ model?: ShapeTarget; /** Explicit shape override; wins over `model`. */ shape?: Shape; /** Frame edge in pixels. Defaults to the shape's `frameSize`. */ frameSize?: number; /** Upper limit on archive frames; clamped to (and defaulting to) {@link MAX_FRAMES_DEFAULT}. */ maxFrames?: number; } /** Result of rendering one frame. */ export interface RenderedFrame { /** Base64-encoded PNG, as returned by the native renderer. */ data: string; cols: number; rows: number; /** Characters printed (ink toggles excluded; input may be shorter than capacity). */ chars: number; } // ============================================================================ // Compaction data contracts // ============================================================================ export interface FileOperations { read: Set; written: Set; edited: Set; } export interface CompactionDetails { readFiles: string[]; modifiedFiles: string[]; } export interface CompactionPreparation { /** UUID of first entry to keep. */ firstKeptEntryId: string; /** Messages that will be archived and discarded. */ messagesToSummarize: TMessage[]; /** Messages that will be archived as the split-turn prefix, if any. */ turnPrefixMessages: TMessage[]; tokensBefore: number; /** Summary from previous compaction, for continuity when no prior snapcompact archive exists. */ previousSummary?: string; /** Preserved opaque compaction payload from the previous compaction, if any. */ previousPreserveData?: Record; /** File operations extracted by the host agent. */ fileOps: FileOperations; } export interface CompactionResult { summary: string; shortSummary?: string; firstKeptEntryId: string; tokensBefore: number; details?: T; preserveData?: Record; } export type ConvertToLlm = (messages: TMessage[]) => Message[]; function defaultConvertToLlm(messages: TMessage[]): Message[] { return messages as unknown as Message[]; } // ============================================================================ // File operation helpers // ============================================================================ export function createFileOps(): FileOperations { return { read: new Set(), written: new Set(), edited: new Set(), }; } const URL_SCHEME_RE = /[a-z][a-z0-9+.-]*:\/\//i; export function isUrlSchemePath(path: string): boolean { return URL_SCHEME_RE.test(path); } export function computeFileLists(fileOps: FileOperations): CompactionDetails { const modified = new Set([...fileOps.edited, ...fileOps.written].filter(file => !isUrlSchemePath(file))); const readFiles = [...fileOps.read].filter(file => !isUrlSchemePath(file) && !modified.has(file)).sort(); const modifiedFiles = [...modified].sort(); return { readFiles, modifiedFiles }; } /** * Format file operations as one `` tag: a grouped, prefix-folded * directory tree (find-tool shape) with a ` (Read)` / ` (Write)` / ` (RW)` * marker per file. `readSet` is the cumulative read set (`fileOps.read`), * used to tell modified files that were also read (RW) from blind writes. */ const FILE_OPERATION_SUMMARY_LIMIT = 20; function stripFileOperationTags(summary: string): string { // Legacy / tags are still stripped so summaries // written before the combined tag self-heal on the next compaction. return summary .replace(/[\s\S]*?<\/files>\s*/g, "") .replace(/[\s\S]*?<\/read-files>\s*/g, "") .replace(/[\s\S]*?<\/modified-files>\s*/g, "") .trimEnd(); } 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"); for (const file of modifiedFiles) mode.set(file, readSet?.has(file) ? "RW" : "Write"); const all = [...mode.keys()].sort(); let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`); if (all.length > FILE_OPERATION_SUMMARY_LIMIT) { files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`; } 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( summary: string, readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet, ): string { const baseSummary = stripFileOperationTags(summary); const fileOperations = formatFileOperations(readFiles, modifiedFiles, readSet); if (!fileOperations) return baseSummary; if (!baseSummary) return fileOperations; return `${baseSummary}\n\n${fileOperations}`; } // ============================================================================ // Message serialization // ============================================================================ /** Default per-tool-result character cap in serialized history. */ export const TOOL_RESULT_MAX_CHARS = 2000; /** Default per-argument-value character cap inside serialized tool calls * (write/edit bodies otherwise dump whole files into the archive). */ export const TOOL_ARG_MAX_CHARS = 500; /** Default character cap across one tool call's full serialized argument list. */ export const TOOL_CALL_MAX_CHARS = 2000; /** Default fraction of a truncation budget spent on the head; the remainder * keeps the tail, where command errors and test failures usually land. */ export const TRUNCATE_HEAD_RATIO = 0.6; /** Zero-width ink toggles understood by the native renderer (shift-out/in): * text between them prints in dim gray ink without occupying a cell. */ export const DIM_ON = "\u000e"; export const DIM_OFF = "\u000f"; /** Character budgets applied while serializing discarded history for frame * rendering. Pass `Infinity` to disable an individual cap. */ export interface SerializeOptions { /** Per-tool-result cap. Defaults to {@link TOOL_RESULT_MAX_CHARS}. */ toolResultMaxChars?: number; /** Per-argument-value cap. Defaults to {@link TOOL_ARG_MAX_CHARS}. */ toolArgMaxChars?: number; /** Whole-argument-list cap per call. Defaults to {@link TOOL_CALL_MAX_CHARS}. */ toolCallMaxChars?: number; /** Head share of each budget, clamped to [0, 1]. Defaults to {@link TRUNCATE_HEAD_RATIO}. */ truncateHeadRatio?: number; /** Print tool-result text in dim gray ink so archived conversation reads * louder than archived tool noise. Defaults to `true`. */ dimToolResults?: boolean; /** Serialize assistant reasoning as `¶think:` sections. Defaults to `true`. * Callers archiving for a Claude/Anthropic-dialect model set this `false`: * the archive frames are replayed as text into every later request, and * reasoning rendered back to Claude trips its `reasoning_extraction` * classifier (issue #6093). */ includeThinking?: boolean; } /** Keep the head and tail of `text`, eliding the middle beyond `maxChars`. */ function truncateForSummary(text: string, maxChars: number, headRatio: number): string { if (text.length <= maxChars) return text; const ratio = Math.min(Math.max(headRatio, 0), 1); const headChars = Math.round(maxChars * ratio); const tailChars = maxChars - headChars; const elided = text.length - maxChars; const tail = tailChars > 0 ? text.slice(-tailChars) : ""; return `${text.slice(0, headChars)} […${elided}ch elided…] ${tail}`; } const DIM_MARKERS = /[\u000e\u000f]/g; /** 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. */ function toPlainText(text: string): string { return stripDimMarkers(text).replaceAll(NEWLINE_GLYPH, "\n"); } /** Strip stray ink toggles from raw content so it cannot forge dim spans. */ function stripDimMarkers(text: string): string { return text.replace(DIM_MARKERS, ""); } export function serializeConversation(messages: Message[], options?: SerializeOptions): string { const toolResultMaxChars = options?.toolResultMaxChars ?? TOOL_RESULT_MAX_CHARS; const toolArgMaxChars = options?.toolArgMaxChars ?? TOOL_ARG_MAX_CHARS; const toolCallMaxChars = options?.toolCallMaxChars ?? TOOL_CALL_MAX_CHARS; const headRatio = options?.truncateHeadRatio ?? TRUNCATE_HEAD_RATIO; const dimToolResults = options?.dimToolResults !== false; const includeThinking = options?.includeThinking !== false; const parts: string[] = []; let lastPrefix: string | null = null; const pushPart = (prefix: string, content: string) => { const lastIndex = parts.length - 1; if (lastIndex >= 0 && lastPrefix === prefix) { const sep = parts[lastIndex].endsWith("\n") || content.startsWith("\n") ? "" : "\n"; parts[lastIndex] += sep + content; } else { parts.push(prefix + content); lastPrefix = prefix; } }; // Tool results flagged contextually useless (and their paired calls) carry no // information worth archiving — skip the whole pair. Surviving results are // indexed by tool-call id so each merges into its originating `¶call:` scope. const uselessCallIds = new Set(); const resultTextByCallId = new Map(); for (const msg of messages) { if (msg.role !== "toolResult") continue; if (msg.useless === true && msg.isError !== true) { uselessCallIds.add(msg.toolCallId); continue; } const text = msg.content .filter((block): block is { type: "text"; text: string } => block.type === "text") .map(block => block.text) .join(""); if (text) resultTextByCallId.set(msg.toolCallId, text); } // Wrap a raw tool-result body in an `` block, dimming only the body so // the frame coloring keeps scope markers and calls loud. const renderResultBlock = (rawText: string): string => { const body = truncateForSummary(stripDimMarkers(rawText), toolResultMaxChars, headRatio); return `\n${dimToolResults ? `${DIM_ON}${body}${DIM_OFF}` : body}\n`; }; const mergedCallIds = new Set(); for (const msg of messages) { if (msg.role === "user") { const content = typeof msg.content === "string" ? msg.content : msg.content .filter((content): content is { type: "text"; text: string } => content.type === "text") .map(content => content.text) .join(""); if (content) pushPart("¶user:", stripDimMarkers(content)); } else if (msg.role === "assistant") { // Stream blocks in content order: buffer thinking/text, then flush a // separate section for each block type right before each tool call. let pendingThinking: string[] = []; let pendingText: string[] = []; const flushAssistant = () => { if (pendingThinking.length > 0) { pushPart("¶think:", pendingThinking.join("\n")); } if (pendingText.length > 0) { pushPart("¶ai:", pendingText.join("\n")); } pendingThinking = []; pendingText = []; }; for (const block of msg.content) { if (block.type === "text") { const text = stripDimMarkers(block.text); if (text.trim()) pendingText.push(text); } else if (block.type === "thinking") { if (!includeThinking) continue; const thinking = stripDimMarkers(block.thinking); if (thinking.trim()) pendingThinking.push(thinking); } else if (block.type === "toolCall") { if (uselessCallIds.has(block.id)) continue; flushAssistant(); const args = block.arguments as Record; // Prefer the harness-derived intent, else the raw intent arg; render it as // a one-line `//comment` and drop it from the args below. const rawIntent = typeof block.intent === "string" ? block.intent : typeof args[INTENT_FIELD] === "string" ? (args[INTENT_FIELD] as string) : ""; const intent = stripDimMarkers(rawIntent).replace(/\s+/g, " ").trim(); const argsStr = truncateForSummary( Object.entries(args) .filter(([key]) => key !== INTENT_FIELD) .map( ([key, value]) => `${key}=${truncateForSummary(JSON.stringify(value) ?? "undefined", toolArgMaxChars, headRatio)}`, ) .join(", "), toolCallMaxChars, headRatio, ); const lines: string[] = []; let firstLine = `${block.name}(${argsStr})`; if (intent) { firstLine += `//${intent}`; } lines.push(firstLine); const resultText = resultTextByCallId.get(block.id); if (resultText !== undefined) { mergedCallIds.add(block.id); lines.push(renderResultBlock(resultText)); } pushPart("¶call:", lines.join("\n")); } } flushAssistant(); } else if (msg.role === "toolResult") { // Paired results already merged into their tool call block above; // only orphans (call archived outside this window) render standalone. if (uselessCallIds.has(msg.toolCallId) || mergedCallIds.has(msg.toolCallId)) continue; const resultText = resultTextByCallId.get(msg.toolCallId); if (resultText !== undefined) pushPart("¶call:", `\n${renderResultBlock(resultText)}`); } } return parts.join("\n\n"); } // ============================================================================ // Preserve-data helpers // ============================================================================ const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction"; function stripOpenAiRemoteCompactionPreserveData( preserveData: Record | undefined, ): Record | undefined { if (!preserveData || !(OPENAI_REMOTE_COMPACTION_PRESERVE_KEY in preserveData)) { return preserveData; } const { [OPENAI_REMOTE_COMPACTION_PRESERVE_KEY]: _removed, ...rest } = preserveData; return Object.keys(rest).length > 0 ? rest : undefined; } // ============================================================================ // Text normalization // ============================================================================ /** Punctuation and symbol folds applied before the NFKD fallback in * {@link normalize}: quotes, dashes, bullets, arrows, and dot leaders that * have no compatibility decomposition (or one that is itself non-ASCII). */ const CHAR_FOLD: Record = { // Quotation marks and primes. "\u2018": "'", "\u2019": "'", "\u201a": "'", "\u201b": "'", "\u201c": '"', "\u201d": '"', "\u201e": '"', "\u2032": "'", "\u2033": '"', "\u2035": "'", "\u2036": '"', "\u2039": "<", "\u203a": ">", // Dashes, hyphens, and the fraction slash NFKD leaves in vulgar fractions. "\u2010": "-", "\u2011": "-", "\u2012": "-", "\u2013": "-", "\u2014": "-", "\u2015": "-", "\u2212": "-", "\u2044": "/", // Dot leaders and ellipses. "\u2024": ".", "\u2025": "..", "\u2026": "...", "\u22ef": "...", // Bullets. "\u2022": "*", "\u2023": "*", "\u2043": "-", "\u2219": "*", "\u25cf": "*", "\u25a0": "*", "\u25aa": "*", // Arrows. "\u2190": "<-", "\u2191": "^", "\u2192": "->", "\u2193": "v", "\u2194": "<->", "\u21d0": "<=", "\u21d2": "=>", "\u21d4": "<=>", // Check marks and crosses. "\u2713": "v", "\u2714": "v", "\u2717": "x", "\u2718": "x", }; /** Printed in place of newline runs: the native renderer fills this cell * entirely with pitch-black ink, so line structure survives whitespace * collapsing at a one-cell cost. */ export const NEWLINE_GLYPH = "\u2588"; /** Collapsed in one pass: whitespace plus zero-width format characters (ZWSP, * BOM, directional marks — JS `\s` already counts BOM as whitespace, so they * must fold here, before the per-character pass). */ const COLLAPSIBLE = /[\s\p{Cf}]+/gu; /** Runs carrying one of these collapse to {@link NEWLINE_GLYPH}. */ const LINE_BREAK = /[\n\r\u2028\u2029]/; /** Leading/trailing spaces or newline glyphs add no information to a frame. */ const EDGE_RUNS = /^[ \u2588]+|[ \u2588]+$/g; /** Glyph-less code points skipped outright instead of printing `?`: controls * (bare ESC/BEL/NUL — full ANSI sequences are stripped beforehand), * combining marks the fonts cannot compose, and lone surrogates. */ const UNRENDERABLE = /[\p{Cc}\p{Mn}\p{Me}\p{Cs}]/u; /** Combining marks NFKD splits off accented letters; dropped so the base * letter prints without the diacritic the bundled fonts cannot compose. */ const COMBINING_MARKS = /\p{M}+/gu; /** Status-like pictographs that carry meaning in tool output; all other emoji * pictographs drop instead of burning cells as `?`. */ const EMOJI_FOLD: Record = { "✅": "[OK]", "☑": "[OK]", "✔": "[OK]", "❌": "[FAIL]", "❎": "[FAIL]", "✖": "[FAIL]", "⚠": "[WARN]", "🚨": "[ALERT]", ℹ: "[INFO]", "🐛": "[BUG]", "💥": "[CRASH]", "🔥": "[HOT]", "🔒": "[LOCK]", "🔓": "[UNLOCK]", "📁": "[DIR]", "📂": "[DIR]", "📄": "[FILE]", "📝": "[NOTE]", "🧪": "[TEST]", "⏳": "[WAIT]", "⌛": "[WAIT]", "🚀": "[RUN]", }; const EMOJI_PICTOGRAPH = /\p{Extended_Pictographic}/u; export interface NormalizeOptions { /** Shape whose font is tried before the embedded Silver fallback. */ shape?: Pick; /** Native font name when a full shape is not available. */ font?: Shape["font"]; } interface NormalizedText { text: string; totalGraphics: number; fallbackCount: number; } /** * Aggressive single-code-point ASCII fold via Unicode NFKD: decompose the * compatibility form (fullwidth, super/subscripts, ligatures, circled and * math-styled alphanumerics, Roman numerals, vulgar fractions, …), strip the * combining marks, and keep the ASCII/Latin-1 skeleton — routing any residual * punctuation back through {@link CHAR_FOLD}. Returns `undefined` when the code * point has no decomposition or still leaves an undrawable glyph, so the * caller falls back to `?`. */ function isAsciiOrLatin1(cp: number): boolean { return (cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff); } function foldToAscii(ch: string): string | undefined { const decomposed = ch.normalize("NFKD").replace(COMBINING_MARKS, ""); if (decomposed === ch) return undefined; let out = ""; for (const part of decomposed) { const cp = part.codePointAt(0); if (cp !== undefined && isAsciiOrLatin1(cp)) { out += part; continue; } const fold = CHAR_FOLD[part]; if (fold === undefined) return undefined; out += fold; } return out; } function renderableUnicodeChars(chars: readonly string[], font: Shape["font"] | undefined): ReadonlySet { if (chars.length === 0) return new Set(); const text = chars.join(""); const primaryFont = font ?? "5x8"; const supported = new Set(snapcompactSupportedChars(primaryFont, text)); if (primaryFont !== "silver") { for (const ch of snapcompactSupportedChars("silver", text)) supported.add(ch); } return supported; } function normalizedInputChars(text: string): string[] { const stripped = text.includes("\u001b") ? Bun.stripANSI(text) : text; const collapsed = stripped // A run of pure format chars (BOM is both \s and Cf) vanishes; only a // run containing genuine whitespace separates words. .replace(COLLAPSIBLE, run => (LINE_BREAK.test(run) ? NEWLINE_GLYPH : /[^\p{Cf}]/u.test(run) ? " " : "")) .replace(EDGE_RUNS, ""); return [...collapsed]; } function candidateUnicodeChars(chars: readonly string[]): string[] { const unique = new Set(); for (const ch of chars) { const cp = ch.codePointAt(0); if (cp === undefined || isAsciiOrLatin1(cp) || ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) { continue; } if ( CHAR_FOLD[ch] !== undefined || (cp >= 0x2500 && cp <= 0x257f) || EMOJI_FOLD[ch] !== undefined || EMOJI_PICTOGRAPH.test(ch) || foldToAscii(ch) !== undefined || UNRENDERABLE.test(ch) ) { continue; } unique.add(ch); } return [...unique]; } function normalizeWithStats(text: string, options?: NormalizeOptions): NormalizedText { const chars = normalizedInputChars(text); const font = options?.font ?? options?.shape?.font; const supported = renderableUnicodeChars(candidateUnicodeChars(chars), font); const out: string[] = []; let totalGraphics = 0; let fallbackCount = 0; for (const ch of chars) { const cp = ch.codePointAt(0); if (cp === undefined) continue; if (isAsciiOrLatin1(cp)) { out.push(ch); totalGraphics++; continue; } if (ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) { out.push(ch); continue; } const emoji = EMOJI_FOLD[ch]; if (emoji !== undefined) { out.push(emoji); totalGraphics++; continue; } const fold = CHAR_FOLD[ch]; if (fold !== undefined) { out.push(fold); totalGraphics++; continue; } if (cp >= 0x2500 && cp <= 0x257f) { out.push(cp === 0x2502 || cp === 0x2503 ? "|" : cp === 0x2500 || cp === 0x2501 ? "-" : "+"); totalGraphics++; continue; } if (!EMOJI_PICTOGRAPH.test(ch) && supported.has(ch)) { out.push(ch); totalGraphics++; continue; } const folded = foldToAscii(ch); if (folded !== undefined) { out.push(folded); totalGraphics++; } else if (EMOJI_PICTOGRAPH.test(ch)) { } else if (!UNRENDERABLE.test(ch)) { out.push("?"); totalGraphics++; fallbackCount++; } } return { text: out.join("").replace(/ +/g, " ").replace(EDGE_RUNS, ""), totalGraphics, fallbackCount }; } /** * Prepare text for printing: strip ANSI escape sequences, collapse horizontal * whitespace runs, fold unsupported symbols (including box drawing to ASCII), * preserve Unicode glyphs that either the selected font or embedded Silver * fallback can render, and drop decorative emoji instead of printing `?`. */ export function normalize(text: string, options?: NormalizeOptions): string { return normalizeWithStats(text, options).text; } /** * Scan text with the same font-aware path as {@link normalize}; unsafe means * more than 5% of graphic characters would hit the `?` fallback. */ export function scanRenderability( text: string, options?: NormalizeOptions, ): { isSafe: boolean; unrenderableRatio: number } { const normalized = normalizeWithStats(text, options); const unrenderableRatio = normalized.totalGraphics > 0 ? normalized.fallbackCount / normalized.totalGraphics : 0; return { isSafe: unrenderableRatio <= 0.05, unrenderableRatio }; } // ============================================================================ // Stopword dimming // ============================================================================ /** High-frequency function words a reader can reconstruct from context; the * dim shapes render them in light gray so content words carry the contrast * (verbatim from `research/bdf.py` `_STOPWORDS`). */ const STOPWORDS: ReadonlySet = new Set( ( "the a an and or of to in on at as is are was were be been by for with that this it its from had has have not but " + "he she his her they their them which also who whom when where while will would could should there then than " + "into over under about after before between during each such these those some most more other only same so" ).split(" "), ); /** Maximal alphabetic runs (ASCII + Latin-1 letters, the fonts' coverage). */ const ALPHA_RUN = /[a-zA-Z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff]+/g; /** Splitter that keeps the zero-width ink toggles as their own segments. */ const DIM_MARKER_SPLIT = /([\u000e\u000f])/; /** * Wrap each maximal alphabetic run that is a stopword in {@link DIM_ON} / * {@link DIM_OFF} so it prints in dim gray ink. Spans that are already dim * (e.g. archived tool output) pass through untouched — wrapping there would * terminate the enclosing dim span early. Markers are zero-width, so the * visible glyph count is unchanged. */ export function dimStopwords(text: string): string { const parts = text.split(DIM_MARKER_SPLIT); let dim = false; let out = ""; for (const part of parts) { if (part === DIM_ON) { dim = true; out += part; } else if (part === DIM_OFF) { dim = false; out += part; } else if (dim) { out += part; } else { out += part.replace(ALPHA_RUN, word => (STOPWORDS.has(word.toLowerCase()) ? DIM_ON + word + DIM_OFF : word)); } } return out; } // ============================================================================ // Doc layout (two word-wrapped newspaper columns) // ============================================================================ /** Char cells between the two doc columns (research exp14 `GUTTER`). */ const DOC_GUTTER = 3; /** East Asian Wide / Fullwidth code points that occupy two grid cells when a * narrow bitmap shape draws them through the Silver fallback. Mirrors * `is_wide` in `crates/pi-natives/src/snapcompact.rs`; the two MUST stay in * sync or native layout and this capacity math disagree on cell counts. */ function isWideCodePoint(cp: number): boolean { return ( (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0x2eff) || (cp >= 0x2f00 && cp <= 0x2fdf) || (cp >= 0x3000 && cp <= 0x303e) || (cp >= 0x3041 && cp <= 0x33ff) || (cp >= 0x3400 && cp <= 0x4dbf) || (cp >= 0x4e00 && cp <= 0x9fff) || (cp >= 0xa000 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x20000 && cp <= 0x2fffd) || (cp >= 0x30000 && cp <= 0x3fffd) ); } /** Cells one character occupies: 0 for the zero-width dim toggles, 2 for wide * code points in narrow bitmap shapes, 1 otherwise. Mirrors native * `cell_units`. */ function charCells(ch: string, wideCells: boolean): number { if (ch === DIM_ON || ch === DIM_OFF) return 0; const cp = ch.codePointAt(0); return wideCells && cp !== undefined && isWideCodePoint(cp) ? 2 : 1; } /** Wide code points span two cells in every shape except the square-celled * Silver shape, which sizes each cell for a full-width glyph already. */ function usesWideCells(shape: Pick): boolean { return shape.font !== "silver"; } /** Total grid cells a string occupies (ignoring row wrapping/pads). */ function cellLength(text: string, wideCells: boolean): number { let cells = 0; for (const ch of text) cells += charCells(ch, wideCells); return cells; } /** Longest prefix of `text` that fits `width` cells (at least one char). */ function sliceCells(text: string, width: number, wideCells: boolean): string { let cells = 0; let out = ""; let placed = false; for (const ch of text) { const w = charCells(ch, wideCells); if (placed && cells + w > width) break; out += ch; cells += w; if (w > 0) placed = true; } return out; } /** Split `text` into pages that each fill at most `capacity` grid cells, * inserting a one-cell pad before a wide glyph that would straddle the right * edge (mirrors native `place_cell`). Pages are contiguous substrings, so each * renders independently starting at cell 0. A single char wider than the whole * budget still rides its page; the native renderer clips it. */ function paginateCells(text: string, capacity: number, cols: number, wideCells: boolean): string[] { const chars = [...text]; const pages: string[] = []; let start = 0; let cell = 0; let hasCell = false; for (let i = 0; i < chars.length; i++) { const w = charCells(chars[i] ?? "", wideCells); if (w === 0) continue; let at = cell; if (w === 2 && cols >= 2 && at % cols === cols - 1) at += 1; if (hasCell && at + w > capacity) { pages.push(chars.slice(start, i).join("")); start = i; at = 0; } cell = at + w; hasCell = true; } if (hasCell) pages.push(chars.slice(start).join("")); return pages; } /** * Greedy word-wrap, no mid-word breaks (hard split only for width+ words) — * ported verbatim from `research/exp14_bestgpt.py` `wrap()`. Zero-width dim * markers count toward word length here; serialized history places them at * word boundaries, so the drift is at most one cell per affected line. */ export function wrap(text: string, width: number, wideCells = false): string[] { const lines: string[] = []; let cur = ""; let curCells = 0; for (const token of text.split(/\s+/)) { if (token.length === 0) continue; let word = token; let wordCells = cellLength(word, wideCells); while (wordCells > width) { // Pathological; never hit on prose. if (cur) { lines.push(cur); cur = ""; curCells = 0; } const head = sliceCells(word, width, wideCells); lines.push(head); word = word.slice(head.length); wordCells = cellLength(word, wideCells); } if (!cur) { cur = word; curCells = wordCells; } else if (curCells + 1 + wordCells <= width) { cur += ` ${word}`; curCells += 1 + wordCells; } else { lines.push(cur); cur = word; curCells = wordCells; } } if (cur) lines.push(cur); return lines; } /** * Paginate already-normalized text for a doc shape: wrap once at the column * width, then slice into pages of `2 * rows` lines, each page `\n`-joined. * Every input character lands on exactly one page (whitespace becomes the * wrap points). */ function docPages(normalized: string, geo: Geometry, wideCells: boolean): string[] { const lines = wrap(normalized, geo.cols, wideCells); const perPage = 2 * geo.rows; const pages: string[] = []; for (let offset = 0; offset < lines.length; offset += perPage) { pages.push(lines.slice(offset, offset + perPage).join("\n")); } return pages; } // ============================================================================ // Rendering // ============================================================================ export function geometry(shape: Shape, size: number = shape.frameSize): Geometry { const gridCols = Math.floor(size / shape.cellWidth); const rows = Math.floor(size / shape.cellHeight / shape.lineRepeat); if (shape.columns === 2) { const cols = Math.floor((gridCols - DOC_GUTTER) / 2); return { cols, rows, capacity: 2 * cols * rows }; } return { cols: gridCols, rows, capacity: gridCols * rows }; } const NEWLINES = /\n/g; function nativeRenderOptions(shape: Shape, size: number) { return { size, font: shape.font, cellWidth: shape.cellWidth, cellHeight: shape.cellHeight, stretch: shape.stretch, variant: shape.variant, lineRepeat: shape.lineRepeat, columns: shape.columns, }; } function renderedChars(text: string, shape: Shape, geo: Geometry): number { if (shape.columns === 2) { let visible = [...text].length - (text.match(DIM_MARKERS)?.length ?? 0); visible -= text.match(NEWLINES)?.length ?? 0; return Math.min(visible, geo.capacity); } // Grid: count visible chars that fit within the frame's cell budget, with // wide glyphs taking two cells (and a straddle pad) exactly as the renderer. const wideCells = usesWideCells(shape); let cell = 0; let count = 0; for (const ch of text) { const w = charCells(ch, wideCells); if (w === 0) continue; let at = cell; if (w === 2 && geo.cols >= 2 && at % geo.cols === geo.cols - 1) at += 1; if (at + w > geo.capacity) break; cell = at + w; count++; } return count; } /** Render one snapcompact frame from already-normalized text. Doc shapes * (`columns === 2`) expect one page of `\n`-joined pre-wrapped lines. */ export async function render(text: string, shape: Shape, size: number = shape.frameSize): Promise { const geo = geometry(shape, size); const { cols, rows } = geo; const chars = renderedChars(text, shape, geo); const data = await renderSnapcompactPng(text, nativeRenderOptions(shape, size)); return { data, cols, rows, chars }; } /** Stateful per-page text finisher: re-opens a dim span the previous page * boundary cut through, then applies stopword dimming when the shape asks * for it (after pagination, so capacity math never sees the markers). */ function pageFinisher(shape: Shape): (page: string) => string { let dimOpen = false; return page => { const text = dimOpen ? DIM_ON + page : page; dimOpen = text.lastIndexOf(DIM_ON) > text.lastIndexOf(DIM_OFF); return shape.stopwordDim ? dimStopwords(text) : text; }; } /** Options for {@link renderMany} and {@link frames}. */ export interface RenderManyOptions { /** Explicit shape; wins over `model`. */ shape?: Shape; /** Model whose provider API and id select the frame shape. */ model?: ShapeTarget; /** Frame edge in px; defaults to the shape's `frameSize`. */ frameSize?: number; /** Hard cap on frames produced; omit for unbounded (caller decides usage). */ maxFrames?: number; } /** * Render arbitrary text into snapcompact PNG frames as LLM image blocks * (first page first). Empty/whitespace-only input yields no frames. */ export async function renderMany(text: string, options?: RenderManyOptions): Promise { const shape = options?.shape ?? resolveShapeForText(text, options?.model); const frameSize = options?.frameSize ?? shape.frameSize; const geo = geometry(shape, frameSize); const normalized = normalize(text, { shape }); const cap = options?.maxFrames; // Build the per-frame texts in order first (cheap, synchronous), then fan // the native PNG renders out concurrently — render() is async/off-thread, // so awaiting each before starting the next leaves throughput on the table. const pageTexts: string[] = []; const wideCells = usesWideCells(shape); if (shape.columns === 2) { const finish = pageFinisher(shape); for (const page of docPages(normalized, geo, wideCells)) { if (cap !== undefined && pageTexts.length >= cap) break; pageTexts.push(finish(page)); } } else { for (const page of paginateCells(normalized, geo.capacity, geo.cols, wideCells)) { if (cap !== undefined && pageTexts.length >= cap) break; pageTexts.push(shape.stopwordDim ? dimStopwords(page) : page); } } const rendered = await Promise.all(pageTexts.map(page => render(page, shape, frameSize))); return rendered.map(frame => ({ type: "image", data: frame.data, mimeType: "image/png", ...(shape.imageDetail ? { detail: shape.imageDetail } : {}), })); } /** Frames needed to hold `text` at the given shape/size, without rendering. * For doc shapes this wraps the text once and counts pages of `2 * rows` * lines; for grid shapes it divides by the frame capacity. */ export function frames(text: string, options?: Pick): number { const shape = options?.shape ?? resolveShapeForText(text, options?.model); const geo = geometry(shape, options?.frameSize ?? shape.frameSize); const normalized = normalize(text, { shape }); const wideCells = usesWideCells(shape); if (shape.columns === 2) return Math.ceil(wrap(normalized, geo.cols, wideCells).length / (2 * geo.rows)); return paginateCells(normalized, geo.capacity, geo.cols, wideCells).length; } // ============================================================================ // Archive helpers // ============================================================================ /** Validate and extract a persisted frame archive from `preserveData`. */ export function getPreservedArchive(preserveData: Record | undefined): Archive | undefined { const candidate = preserveData?.[PRESERVE_KEY]; if (!candidate || typeof candidate !== "object") return undefined; const archive = candidate as Archive; 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; return { frames, totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0, truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0, ...(text !== undefined ? { text } : {}), ...(textHead !== undefined ? { textHead } : {}), ...(textTail !== undefined ? { textTail } : {}), }; } /** Drop the persisted frame archive ({@link PRESERVE_KEY}) from `preserveData`, * returning the remaining state — or `undefined` when nothing else remains, so * an empty `{}` is never persisted. Callers strip the archive once its frames * have been migrated into a new compaction's text, preventing the stale frames * from leaking back into the rebuilt context. */ export function stripPreservedArchive( preserveData: Record | undefined, ): Record | undefined { if (!preserveData || !(PRESERVE_KEY in preserveData)) return preserveData; const { [PRESERVE_KEY]: _removed, ...rest } = preserveData; return Object.keys(rest).length > 0 ? rest : undefined; } /** Extract persisted archive source text as plain text for LLM summarization. */ export function archiveSourceText(archive: Archive): string | undefined { const text = archive.text ?? [archive.textHead, archive.textTail] .filter((part): part is string => typeof part === "string" && part.length > 0) .join(NEWLINE_GLYPH); return text.length > 0 ? toPlainText(text) : undefined; } /** Build the text used to choose and preflight a font-aware snapcompact shape. */ export function renderabilityProbeText( serialized: string, previousPreserveData?: Record, previousSummary?: string, ): string { const previousArchive = getPreservedArchive(previousPreserveData); const previousText = previousArchive ? (archiveSourceText(previousArchive) ?? "") : ""; if (previousText.length > 0) return `${previousText}${NEWLINE_GLYPH}${serialized}`; if (previousSummary) return `${previousSummary}${NEWLINE_GLYPH}${serialized}`; return serialized; } /** Options for reconstructing a persisted snapcompact archive into prompt blocks. */ export interface HistoryBlockOptions { /** Hard cap on image base64 bytes attached to one rebuilt provider request. */ maxFrameDataBytes?: number; } function formatFrameDataBytes(bytes: number): string { if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`; if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`; return `${bytes} B`; } function imagesWithinBudget( archive: Archive, maxFrameDataBytes: number | undefined, ): { images: ImageContent[]; omittedFrames: number; omittedBytes: number } { if (maxFrameDataBytes === undefined) { return { images: images(archive), omittedFrames: 0, omittedBytes: 0 }; } let usedBytes = 0; let omittedFrames = 0; let omittedBytes = 0; const keptNewestFirst: Frame[] = []; for (let index = archive.frames.length - 1; index >= 0; index--) { const frame = archive.frames[index]; if (!frame) continue; const bytes = frame.data.length; if (usedBytes + bytes > maxFrameDataBytes) { omittedFrames++; omittedBytes += bytes; continue; } usedBytes += bytes; keptNewestFirst.push(frame); } keptNewestFirst.reverse(); return { images: images({ ...archive, frames: keptNewestFirst }), omittedFrames, omittedBytes }; } function omittedFrameNotice(omittedFrames: number, omittedBytes: number): string { return [ "-------------- snapcompact image middle omitted", `${omittedFrames.toLocaleString()} archived image frame${omittedFrames === 1 ? "" : "s"} (${formatFrameDataBytes(omittedBytes)} base64) exceeded the per-request snapcompact payload budget. The compacted summary and visible text edges remain available.`, "--------------", ].join("\n"); } /** Convert archive frames into LLM image blocks (oldest first). */ export function images(archive: Archive): ImageContent[] { return archive.frames.map(frame => ({ type: "image", data: frame.data, mimeType: frame.mimeType, ...(frame.detail ? { detail: frame.detail } : {}), })); } /** Ordered archive blocks for a compaction summary message, oldest to newest: * the oldest text region, the imaged middle, then the newest text region. * Runtime-only; reconstructed from {@link Archive} on each context rebuild * instead of persisted on the session entry. */ export function historyBlocks(archive: Archive, options: HistoryBlockOptions = {}): (TextContent | ImageContent)[] { const blocks: (TextContent | ImageContent)[] = []; const budgeted = imagesWithinBudget(archive, options.maxFrameDataBytes); const hasImages = budgeted.images.length > 0; const hasOmittedImages = budgeted.omittedFrames > 0; if (archive.textHead) { const suffix = hasImages ? "\n-------------- imaged middle below\n" : hasOmittedImages ? `\n${omittedFrameNotice(budgeted.omittedFrames, budgeted.omittedBytes)}\n` : ""; blocks.push({ type: "text", text: toPlainText(archive.textHead) + suffix }); } else if (hasOmittedImages && !hasImages) { blocks.push({ type: "text", text: omittedFrameNotice(budgeted.omittedFrames, budgeted.omittedBytes) }); } // Omitted frames are the OLDEST archived images: the byte budget keeps the // newest tail frames, so the gap notice precedes the kept images to keep the // reconstructed blocks oldest-to-newest. if (hasImages && hasOmittedImages) { blocks.push({ type: "text", text: omittedFrameNotice(budgeted.omittedFrames, budgeted.omittedBytes) }); } blocks.push(...budgeted.images); if (archive.textTail) { const prefix = hasImages ? "-------------- imaged middle above\n" : archive.truncatedChars > 0 || hasOmittedImages ? "\n-------------- middle history omitted above\n" : ""; const tail = prefix + toPlainText(archive.textTail); const lastBlock = blocks[blocks.length - 1]; if (lastBlock?.type === "text") { lastBlock.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, TrueType Unicode shapes, or when no denser * variant exists (foveation off). */ function denseCompanion(high: Shape, api: Api | undefined): Shape { if (high.columns === 2 || high.font === "silver") 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; } /** Wrap each page string as a planned frame at one shape (tier). */ function planFrames(pages: readonly string[], shape: Shape): PlanFrame[] { return pages.map(text => ({ text, shape })); } /** * 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), usesWideCells(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: planFrames(kept, high), textHead, textTail, keptText: textHead + flat + textTail, truncatedChars, }; } // Grid: paginate the imaged region into HQ frames (cell-aware, so wide CJK // glyphs spanning two cells never overflow a frame's capacity). const hiPages = paginateCells(imageText, capHi, geometry(high).cols, usesWideCells(high)); if (hiPages.length <= maxFrames) { return { frames: planFrames(hiPages, 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 headPages = hiPages.slice(0, imageEdgeFrames); const tailPages = imageEdgeFrames > 0 ? hiPages.slice(hiPages.length - imageEdgeFrames) : []; const imageHead = headPages.join(""); const imageTail = tailPages.join(""); const middleSource = imageText.slice(imageHead.length, imageText.length - imageTail.length); let middlePages = paginateCells(middleSource, capLo, geometry(low).cols, usesWideCells(low)); const middleBudget = maxFrames - 2 * imageEdgeFrames; let truncatedChars = 0; let middleText = middleSource; if (middlePages.length > middleBudget) { const dropped = middlePages.slice(0, middlePages.length - middleBudget).join(""); truncatedChars = dropped.length; middleText = middleSource.slice(dropped.length); middlePages = middlePages.slice(middlePages.length - middleBudget); } return { frames: [...planFrames(headPages, high), ...planFrames(middlePages, low), ...planFrames(tailPages, high)], textHead, textTail, keptText: textHead + imageHead + middleText + imageTail + textTail, truncatedChars, }; } /** * Drop `¶think:` sections from serialized archive source text. * * Archives written before {@link SerializeOptions.includeThinking} existed bake * reasoning into their kept source; replaying it to Claude trips the * `reasoning_extraction` classifier (issue #6093). Re-compaction re-renders the * whole unfolded source, so scrubbing the prior text heals a poisoned session * at its next compaction. Conservative by construction: only sections that * start with `¶think:` at a section boundary are dropped. */ function stripThinkingSections(text: string): string { return text .split(NEWLINE_GLYPH) .map(segment => segment .split(/\n\n(?=¶(?:user|think|ai|call):)/) .filter(section => !section.startsWith("¶think:")) .join("\n\n"), ) .filter(segment => segment.length > 0) .join(NEWLINE_GLYPH); } /** * Run a snapcompact compaction over prepared messages. Fully local: serializes * 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. * * The full kept source persists on the archive (`text`) so each later compaction * unfolds and re-renders it coherently alongside the newly archived history. * * 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, options?: Options, ): Promise { const { firstKeptEntryId, tokensBefore, previousSummary, previousPreserveData, fileOps } = preparation; if (!firstKeptEntryId) { throw new Error("First kept entry has no ID - session may need migration"); } const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages); const serialized = serializeConversation(llmMessages, options); const previousArchive = getPreservedArchive(previousPreserveData); const previousTextRaw = previousArchive?.text ?? [previousArchive?.textHead, previousArchive?.textTail] .filter((part): part is string => typeof part === "string" && part.length > 0) .join(NEWLINE_GLYPH); // Legacy archives may carry `¶think:` sections from before includeThinking // existed; scrub them when this compaction excludes thinking so the // re-rendered archive stops replaying reasoning (issue #6093). const previousText = options?.includeThinking === false && previousTextRaw.length > 0 ? stripThinkingSections(previousTextRaw) : previousTextRaw; const hasPreviousText = previousText.length > 0; const includedPreviousSummary = !hasPreviousText && !!previousSummary; const shapeProbeText = renderabilityProbeText(serialized, previousPreserveData, previousSummary); const baseShape = options?.shape ?? resolveShapeForText(shapeProbeText, 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)); let archiveText = normalize(serialized, { shape: high }); if (includedPreviousSummary && previousSummary) { const head = `[Summary of earlier history] ${normalize(previousSummary, { shape: high })}`; archiveText = archiveText.length > 0 ? `${head} [Recent conversation] ${archiveText}` : head; } let truncatedChars = previousArchive?.truncatedChars ?? 0; // Re-compacting a snapcompacted history unfolds the prior archive's source // text and treats it as one coherent transcript: the previous kept source // ages in ahead of the new history, then the whole thing is re-rendered. if (hasPreviousText) { archiveText = archiveText.length > 0 ? `${previousText}${NEWLINE_GLYPH}${archiveText}` : previousText; } const layout = planArchive(archiveText, high, low, maxFrames); 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: Promise[] = []; 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); newFrames.push( render(pageText, planned.shape).then(rendered => ({ data: rendered.data, mimeType: "image/png", cols: rendered.cols, rows: rendered.rows, chars: rendered.chars, 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 } : {}), })), ); } const textHead = layout.textHead; const textTail = layout.textTail.length > 0 ? (dimOpen ? DIM_ON : "") + layout.textTail : ""; const textChars = textHead.length + textTail.length; const frames = await Promise.all(newFrames); const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0) + textChars; const { readFiles, modifiedFiles } = computeFileLists(fileOps); const files = formatFileList(readFiles, modifiedFiles, fileOps.read); let summary: string; 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, docColumns: high.columns === 2, cols: geo.cols, rows: geo.rows, sentenceInk: high.variant === "sent", stopwordDimmed: high.stopwordDim === true, lineRepeated: high.lineRepeat > 1, truncatedChars, includedPreviousSummary, files: files.length > 0 ? files : undefined, }); } // 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 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 } : {}), ...(textHead ? { textHead } : {}), ...(textTail ? { textTail } : {}), }; 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"}${textNote}`, firstKeptEntryId, tokensBefore, details: { readFiles, modifiedFiles }, preserveData: { ...basePreserve, [PRESERVE_KEY]: archive }, }; }