@oh-my-pi/snapcompact 15.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [15.11.0] - 2026-06-10
6
+
7
+ ### Breaking Changes
8
+
9
+ - Changed `renderSnapcompactFrame` output from `png: Uint8Array` to `data: string` base64, requiring consumers to read frame payloads from `frame.data`
10
+
11
+ ### Added
12
+
13
+ - Added new serialization options `toolResultMaxChars`, `toolArgMaxChars`, `toolCallMaxChars`, `truncateHeadRatio`, and `dimToolResults` to `snapcompactCompact`/`serializeSnapcompactConversation` so callers can tune how tool results and arguments are archived
14
+ - Added exported default constants `SNAPCOMPACT_TOOL_RESULT_MAX_CHARS`, `SNAPCOMPACT_TOOL_ARG_MAX_CHARS`, `SNAPCOMPACT_TOOL_CALL_MAX_CHARS`, and `SNAPCOMPACT_TRUNCATE_HEAD_RATIO` for reuse when configuring truncation limits
15
+ - Added provider-specific snapcompact frame-shape presets and shape helpers (`SNAPCOMPACT_SHAPES`, `resolveSnapcompactShape`, `isSnapcompactShape`) so callers can consistently select validated image-frame geometry for archive renders
16
+ - Added `file-operations.md` and `snapcompact-summary.md` prompts to preserve file-read/write context and frame metadata in the compaction prompt flow
17
+ - Added a full `packages/snapcompact/research` experiment and visualization suite for running snapcompact SQuAD studies, provider probes, and activation-style analyses
18
+ - Added package-level TypeScript exports and publication config so consumers can import `@oh-my-pi/snapcompact` with typed access to snapcompact APIs
19
+ - Published `@oh-my-pi/snapcompact` as the reusable snapcompact compaction package, including bitmap-frame rendering helpers, archive helpers, and the local `snapcompactCompact()` strategy.
20
+
21
+ ### Changed
22
+
23
+ - Changed truncation in archived tool output to keep both the beginning and end of long text using a configurable head/tail ratio instead of a single hard cut
24
+ - Changed tool-result text rendering so archived tool results are shown in dim gray ink by default and the summary prompt notes that dim text is archived tool output
25
+ - Changed `RenderedFrame` visible-character accounting so `chars` no longer includes invisible dim-control markers
26
+ - Changed the file-operations summary block to a single `<files>` tag: one grouped, prefix-folded directory tree with per-file `(Read)`/`(Write)`/`(RW)` markers, replacing the separate `<read-files>`/`<modified-files>` lists; `upsertSnapcompactFileOperations` takes the cumulative read set to distinguish `(RW)` from blind writes
27
+
28
+ ### Fixed
29
+
30
+ - Fixed frame rendering at archive chunk boundaries to reopen dim spans when a chunk ends inside a dimmed tool-result segment
31
+ - Fixed message serialization to strip user- and assistant-provided dim markers so only renderer-generated dim spans can be applied
@@ -0,0 +1 @@
1
+ export * from "./snapcompact";
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Snapcompact compaction: archive conversation history as dense bitmap images.
3
+ *
4
+ * Instead of asking an LLM to summarize discarded history, the serialized
5
+ * conversation is rendered into square PNG frames of pixel-font text that
6
+ * vision models read back directly, like an archivist at a snapcompact frame
7
+ * reader.
8
+ *
9
+ * The frame shape is provider-aware, following the snapcompact SQuAD evals
10
+ * (`packages/snapcompact`, 200k-token monolithic runs):
11
+ *
12
+ * - **Anthropic** (`8x8r-bw`): unscii-8 square cells, black ink, every line
13
+ * printed twice with the copy on a pale highlight band. Read at F1 parity
14
+ * with raw text at ~2x lower cost; the colored variants drew refusals at
15
+ * scale, the repeated plain shape did not.
16
+ * - **Google** (`8x8r-sent`): same repeated grid with six-hue sentence
17
+ * coloring (0.90 F1 at ~2.9x lower cost on gemini-3.5-flash).
18
+ * - **OpenAI** (`6x6u-sent`): OpenAI bills a flat ~2.9k tokens per image, so
19
+ * image count is the only cost lever — unscii-8 Lanczos-stretched to 6x6
20
+ * cells packs the most readable chars per frame. Frames request
21
+ * `detail: "original"`; the default `auto` downscale destroys 6px glyphs.
22
+ * - **Unknown providers** default to the Anthropic shape (most
23
+ * refusal-robust). Gateways that resize images (e.g. OpenRouter normalizes
24
+ * visual payloads to a fixed token budget) defeat any shape — optical
25
+ * context fails silently there.
26
+ *
27
+ * The whole pass is local and deterministic — no LLM call, no API key, no
28
+ * latency beyond rendering. Rasterization and PNG encoding happen in native
29
+ * code (`renderSnapcompactPng` in `crates/pi-natives/src/snapcompact.rs`).
30
+ * Frames persist in the compaction entry's `preserveData` and are
31
+ * re-attached to the compaction summary message on every context rebuild.
32
+ */
33
+ import type { Api, ImageContent, Message, Model } from "@oh-my-pi/pi-ai";
34
+ /** One eval-validated frame shape: font, cell, ink, repetition, and size. */
35
+ export interface SnapcompactShape {
36
+ /** Bundled font in the native renderer. */
37
+ font: "5x8" | "8x8";
38
+ /** Target cell advance in pixels; differing from the font's natural cell
39
+ * renders via Lanczos stretch (anti-aliased RGB frame). */
40
+ cellWidth: number;
41
+ /** Target cell pitch in pixels. */
42
+ cellHeight: number;
43
+ /** Ink: `sent` cycles six hues at sentence boundaries; `bw` is black. */
44
+ variant: "sent" | "bw";
45
+ /** Each text line is printed this many times; copies after the first sit
46
+ * on a pale highlight band (redundancy coding). */
47
+ lineRepeat: number;
48
+ /** Frame edge in pixels. */
49
+ frameSize: number;
50
+ /** Per-frame billed-token estimate for the shape's target provider. */
51
+ frameTokenEstimate: number;
52
+ /** Resolution hint attached to frame images (OpenAI-only). */
53
+ imageDetail?: ImageContent["detail"];
54
+ }
55
+ /** Eval-validated shapes, keyed by the provider family they won on. */
56
+ export declare const SNAPCOMPACT_SHAPES: {
57
+ /** `8x8r-bw`: unscii square, black ink, lines doubled on highlight bands. */
58
+ readonly anthropic: {
59
+ readonly font: "8x8";
60
+ readonly cellWidth: 8;
61
+ readonly cellHeight: 8;
62
+ readonly variant: "bw";
63
+ readonly lineRepeat: 2;
64
+ readonly frameSize: 1568;
65
+ readonly frameTokenEstimate: 3300;
66
+ };
67
+ /** `8x8r-sent`: the repeated grid with sentence-hue ink. */
68
+ readonly google: {
69
+ readonly font: "8x8";
70
+ readonly cellWidth: 8;
71
+ readonly cellHeight: 8;
72
+ readonly variant: "sent";
73
+ readonly lineRepeat: 2;
74
+ readonly frameSize: 1568;
75
+ readonly frameTokenEstimate: 1100;
76
+ };
77
+ /** `6x6u-sent`: unscii stretched to 6x6 — densest readable cell, fewest
78
+ * frames (OpenAI bills per image, ~2.9k tokens flat). */
79
+ readonly openaiDense: {
80
+ readonly font: "8x8";
81
+ readonly cellWidth: 6;
82
+ readonly cellHeight: 6;
83
+ readonly variant: "sent";
84
+ readonly lineRepeat: 1;
85
+ readonly frameSize: 1568;
86
+ readonly frameTokenEstimate: 2900;
87
+ readonly imageDetail: "original";
88
+ };
89
+ /** Original 5x8 X.org shape (pre-shape-table sessions rendered this). */
90
+ readonly legacy: {
91
+ readonly font: "5x8";
92
+ readonly cellWidth: 5;
93
+ readonly cellHeight: 8;
94
+ readonly variant: "sent";
95
+ readonly lineRepeat: 1;
96
+ readonly frameSize: 2576;
97
+ readonly frameTokenEstimate: 3300;
98
+ };
99
+ };
100
+ /** Runtime guard for shape overrides loaded from config or preserve data. */
101
+ export declare function isSnapcompactShape(value: unknown): value is SnapcompactShape;
102
+ /** Pick the eval-optimal frame shape for a provider API. */
103
+ export declare function resolveSnapcompactShape(api?: Api): SnapcompactShape;
104
+ /** Legacy frame edge in pixels (the 5x8 shape's eval-validated size). New
105
+ * shapes carry their own `frameSize`. */
106
+ export declare const SNAPCOMPACT_FRAME_SIZE = 2576;
107
+ /** Maximum frames carried on a compaction entry. Oldest frames are dropped
108
+ * first once the budget is exceeded (mirrors how iterative text summaries
109
+ * fade the oldest detail). */
110
+ export declare const SNAPCOMPACT_MAX_FRAMES = 8;
111
+ /** Conservative per-frame token estimate used for context budgeting
112
+ * (upper bound across shapes: Anthropic bills 1568*1568/750 ≈ 3,278). */
113
+ export declare const SNAPCOMPACT_FRAME_TOKEN_ESTIMATE = 3300;
114
+ /** Key under `CompactionEntry.preserveData` holding the frame archive. */
115
+ export declare const SNAPCOMPACT_PRESERVE_KEY = "snapcompact";
116
+ /** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
117
+ export interface SnapcompactFrame {
118
+ /** Base64-encoded PNG. */
119
+ data: string;
120
+ mimeType: string;
121
+ /** Characters per row in the frame grid. */
122
+ cols: number;
123
+ /** Text rows in the frame grid (unique lines, not repeated copies). */
124
+ rows: number;
125
+ /** Characters actually printed onto this frame. */
126
+ chars: number;
127
+ /** Shape metadata (absent on legacy frames, which are 5x8 `sent`). */
128
+ font?: SnapcompactShape["font"];
129
+ variant?: SnapcompactShape["variant"];
130
+ lineRepeat?: number;
131
+ /** Resolution hint forwarded to the provider when re-attaching. */
132
+ detail?: ImageContent["detail"];
133
+ }
134
+ /** Frame archive persisted under `preserveData[SNAPCOMPACT_PRESERVE_KEY]`. */
135
+ export interface SnapcompactArchive {
136
+ /** Frames ordered oldest to newest. */
137
+ frames: SnapcompactFrame[];
138
+ /** Characters currently readable across all frames. */
139
+ totalChars: number;
140
+ /** Characters dropped so far to respect the frame budget. */
141
+ truncatedChars: number;
142
+ }
143
+ export interface SnapcompactGeometry {
144
+ cols: number;
145
+ rows: number;
146
+ /** Characters that fit one frame (cols * rows). */
147
+ capacity: number;
148
+ }
149
+ export interface SnapcompactOptions<TMessage = Message> extends SnapcompactSerializeOptions {
150
+ /** App-level message transformer (same contract as agent-core's `SummaryOptions.convertToLlm`). */
151
+ convertToLlm?: SnapcompactConvertToLlm<TMessage>;
152
+ /** Model whose provider API selects the frame shape. */
153
+ model?: Pick<Model, "api">;
154
+ /** Explicit shape override; wins over `model`. */
155
+ shape?: SnapcompactShape;
156
+ /** Frame edge in pixels. Defaults to the shape's `frameSize`. */
157
+ frameSize?: number;
158
+ /** Frame budget. Defaults to {@link SNAPCOMPACT_MAX_FRAMES}. */
159
+ maxFrames?: number;
160
+ }
161
+ /** Result of rendering one frame. */
162
+ export interface RenderedFrame {
163
+ /** Base64-encoded PNG, as returned by the native renderer. */
164
+ data: string;
165
+ cols: number;
166
+ rows: number;
167
+ /** Characters printed (ink toggles excluded; input may be shorter than capacity). */
168
+ chars: number;
169
+ }
170
+ export interface SnapcompactFileOperations {
171
+ read: Set<string>;
172
+ written: Set<string>;
173
+ edited: Set<string>;
174
+ }
175
+ export interface SnapcompactCompactionDetails {
176
+ readFiles: string[];
177
+ modifiedFiles: string[];
178
+ }
179
+ export interface SnapcompactCompactionPreparation<TMessage = Message> {
180
+ /** UUID of first entry to keep. */
181
+ firstKeptEntryId: string;
182
+ /** Messages that will be archived and discarded. */
183
+ messagesToSummarize: TMessage[];
184
+ /** Messages that will be archived as the split-turn prefix, if any. */
185
+ turnPrefixMessages: TMessage[];
186
+ tokensBefore: number;
187
+ /** Summary from previous compaction, for continuity when no prior snapcompact archive exists. */
188
+ previousSummary?: string;
189
+ /** Preserved opaque compaction payload from the previous compaction, if any. */
190
+ previousPreserveData?: Record<string, unknown>;
191
+ /** File operations extracted by the host agent. */
192
+ fileOps: SnapcompactFileOperations;
193
+ }
194
+ export interface SnapcompactCompactionResult<T = SnapcompactCompactionDetails> {
195
+ summary: string;
196
+ shortSummary?: string;
197
+ firstKeptEntryId: string;
198
+ tokensBefore: number;
199
+ details?: T;
200
+ preserveData?: Record<string, unknown>;
201
+ }
202
+ export type SnapcompactConvertToLlm<TMessage = Message> = (messages: TMessage[]) => Message[];
203
+ export declare function createSnapcompactFileOps(): SnapcompactFileOperations;
204
+ export declare function computeSnapcompactFileLists(fileOps: SnapcompactFileOperations): SnapcompactCompactionDetails;
205
+ export declare function upsertSnapcompactFileOperations(summary: string, readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string;
206
+ /** Default per-tool-result character cap in serialized history. */
207
+ export declare const SNAPCOMPACT_TOOL_RESULT_MAX_CHARS = 2000;
208
+ /** Default per-argument-value character cap inside serialized tool calls
209
+ * (write/edit bodies otherwise dump whole files into the archive). */
210
+ export declare const SNAPCOMPACT_TOOL_ARG_MAX_CHARS = 500;
211
+ /** Default character cap across one tool call's full serialized argument list. */
212
+ export declare const SNAPCOMPACT_TOOL_CALL_MAX_CHARS = 2000;
213
+ /** Default fraction of a truncation budget spent on the head; the remainder
214
+ * keeps the tail, where command errors and test failures usually land. */
215
+ export declare const SNAPCOMPACT_TRUNCATE_HEAD_RATIO = 0.6;
216
+ /** Zero-width ink toggles understood by the native renderer (shift-out/in):
217
+ * text between them prints in dim gray ink without occupying a cell. */
218
+ export declare const SNAPCOMPACT_DIM_ON = "\u000E";
219
+ export declare const SNAPCOMPACT_DIM_OFF = "\u000F";
220
+ /** Character budgets applied while serializing discarded history for frame
221
+ * rendering. Pass `Infinity` to disable an individual cap. */
222
+ export interface SnapcompactSerializeOptions {
223
+ /** Per-tool-result cap. Defaults to {@link SNAPCOMPACT_TOOL_RESULT_MAX_CHARS}. */
224
+ toolResultMaxChars?: number;
225
+ /** Per-argument-value cap. Defaults to {@link SNAPCOMPACT_TOOL_ARG_MAX_CHARS}. */
226
+ toolArgMaxChars?: number;
227
+ /** Whole-argument-list cap per call. Defaults to {@link SNAPCOMPACT_TOOL_CALL_MAX_CHARS}. */
228
+ toolCallMaxChars?: number;
229
+ /** Head share of each budget, clamped to [0, 1]. Defaults to {@link SNAPCOMPACT_TRUNCATE_HEAD_RATIO}. */
230
+ truncateHeadRatio?: number;
231
+ /** Print tool-result text in dim gray ink so archived conversation reads
232
+ * louder than archived tool noise. Defaults to `true`. */
233
+ dimToolResults?: boolean;
234
+ }
235
+ export declare function serializeSnapcompactConversation(messages: Message[], options?: SnapcompactSerializeOptions): string;
236
+ /**
237
+ * Prepare text for printing: collapse whitespace runs (incl. newlines) to
238
+ * single spaces — the eval's "paragraph breaks collapsed to spaces" format —
239
+ * then fold everything outside the fonts' ASCII + Latin-1 coverage to ASCII
240
+ * approximations (`?` as the last resort).
241
+ */
242
+ export declare function normalizeForSnapcompact(text: string): string;
243
+ export declare function snapcompactGeometry(shape: SnapcompactShape, size?: number): SnapcompactGeometry;
244
+ /** Render one snapcompact frame from already-normalized text. */
245
+ export declare function renderSnapcompactFrame(text: string, shape: SnapcompactShape, size?: number): RenderedFrame;
246
+ /** Validate and extract a persisted frame archive from `preserveData`. */
247
+ export declare function getPreservedSnapcompactArchive(preserveData: Record<string, unknown> | undefined): SnapcompactArchive | undefined;
248
+ /** Convert archive frames into LLM image blocks (oldest first). */
249
+ export declare function snapcompactImages(archive: SnapcompactArchive): ImageContent[];
250
+ /**
251
+ * Run a snapcompact compaction over prepared messages. Fully local: serializes
252
+ * the discarded history, prints it onto PNG frames in the provider-optimal
253
+ * shape, merges previously archived frames (oldest dropped beyond the
254
+ * budget), and produces a deterministic summary explaining how to read the
255
+ * frames.
256
+ *
257
+ * Frames archived under a different shape (provider switches, legacy 5x8
258
+ * sessions) are kept as-is — each frame carries its own geometry, and the
259
+ * summary describes the newest shape while noting that older frames may
260
+ * differ.
261
+ *
262
+ * If the previous compaction was text-based, its summary is printed at the
263
+ * head of the frame archive as `[Summary of earlier history]` so no continuity is lost.
264
+ */
265
+ export declare function snapcompactCompact<TMessage = Message>(preparation: SnapcompactCompactionPreparation<TMessage>, options?: SnapcompactOptions<TMessage>): Promise<SnapcompactCompactionResult>;
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "type": "module",
3
+ "name": "@oh-my-pi/snapcompact",
4
+ "version": "15.11.0",
5
+ "description": "Bitmap-frame context compression for vision-capable LLMs",
6
+ "homepage": "https://omp.sh",
7
+ "author": "Can Boluk",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/can1357/oh-my-pi.git",
12
+ "directory": "packages/snapcompact"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/can1357/oh-my-pi/issues"
16
+ },
17
+ "keywords": [
18
+ "context-compression",
19
+ "vision",
20
+ "compaction",
21
+ "llm"
22
+ ],
23
+ "main": "./src/index.ts",
24
+ "types": "./dist/types/index.d.ts",
25
+ "scripts": {
26
+ "check": "biome check . && bun run check:types",
27
+ "check:types": "tsgo -p tsconfig.json --noEmit",
28
+ "lint": "biome lint .",
29
+ "test": "bun test --parallel",
30
+ "fix": "biome check --write --unsafe .",
31
+ "fmt": "biome format --write ."
32
+ },
33
+ "dependencies": {
34
+ "@oh-my-pi/pi-ai": "15.11.0",
35
+ "@oh-my-pi/pi-natives": "15.11.0",
36
+ "@oh-my-pi/pi-utils": "15.11.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/bun": "^1.3.14"
40
+ },
41
+ "engines": {
42
+ "bun": ">=1.3.14"
43
+ },
44
+ "files": [
45
+ "src",
46
+ "CHANGELOG.md",
47
+ "dist/types"
48
+ ],
49
+ "exports": {
50
+ ".": {
51
+ "types": "./dist/types/index.d.ts",
52
+ "import": "./src/index.ts"
53
+ },
54
+ "./snapcompact": {
55
+ "types": "./dist/types/snapcompact.d.ts",
56
+ "import": "./src/snapcompact.ts"
57
+ },
58
+ "./*": {
59
+ "types": "./dist/types/*.d.ts",
60
+ "import": "./src/*.ts"
61
+ }
62
+ }
63
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./snapcompact";
@@ -0,0 +1,5 @@
1
+ {{#if files}}
2
+ {{#xml "files"}}
3
+ {{files}}
4
+ {{/xml}}
5
+ {{/if}}
@@ -0,0 +1,17 @@
1
+ Prior conversation history has been archived verbatim onto {{frameCount}} snapcompact frame{{#if multipleFrames}}s{{/if}} — the bitmap image{{#if multipleFrames}}s{{/if}} attached below{{#if multipleFrames}}, ordered oldest to newest{{/if}}.
2
+
3
+ Reading a frame: monospace {{fontCell}} pixel font on a white background, {{cols}} characters per row, {{rows}} text rows per frame; read left to right, top to bottom. Text flows continuously with no word wrap, so words may break across row ends. Whitespace runs (including newlines) were collapsed to single spaces. {{#if sentenceInk}}Ink color cycles through six colors, advancing at sentence boundaries — a color change marks a new sentence.{{else}}Glyphs are plain black ink.{{/if}}{{#if dimmedToolResults}} Tool output is printed in dim gray ink — gray text is archived tool output, not conversation.{{/if}}{{#if lineRepeated}} Every text line is printed twice in a row — first on the white background, then repeated on a pale yellow band. The copies are identical: read each line once and use the duplicate only to double-check hard glyphs.{{/if}} Roles are tagged inline as [User]:, [Assistant]:, [Assistant thinking]:, [Assistant tool calls]:, and [Tool result]:.
4
+ {{#if mixedShapes}}
5
+
6
+ Older frames may use a different font, grid, or ink coloring than described above; the reading order is always the same (left to right, top to bottom, oldest frame first).
7
+ {{/if}}
8
+ {{#if includedPreviousSummary}}
9
+
10
+ The earliest frame begins with "[Summary of earlier history]" — a condensed digest of context that predates the archived conversation.
11
+ {{/if}}
12
+ {{#if truncatedChars}}
13
+
14
+ {{truncatedChars}} characters of older history were dropped to respect the frame budget. The first frame (session start) is always kept, so the missing span sits between the first frame and the next.
15
+ {{/if}}
16
+
17
+ Total archived: {{totalChars}} characters. Consult the frames whenever you need exact earlier details (user wording, decisions, file paths, tool output). If a region is hard to read, re-derive the fact from the workspace (re-read files, re-run commands) rather than guessing.
@@ -0,0 +1,746 @@
1
+ /**
2
+ * Snapcompact compaction: archive conversation history as dense bitmap images.
3
+ *
4
+ * Instead of asking an LLM to summarize discarded history, the serialized
5
+ * conversation is rendered into square PNG frames of pixel-font text that
6
+ * vision models read back directly, like an archivist at a snapcompact frame
7
+ * reader.
8
+ *
9
+ * The frame shape is provider-aware, following the snapcompact SQuAD evals
10
+ * (`packages/snapcompact`, 200k-token monolithic runs):
11
+ *
12
+ * - **Anthropic** (`8x8r-bw`): unscii-8 square cells, black ink, every line
13
+ * printed twice with the copy on a pale highlight band. Read at F1 parity
14
+ * with raw text at ~2x lower cost; the colored variants drew refusals at
15
+ * scale, the repeated plain shape did not.
16
+ * - **Google** (`8x8r-sent`): same repeated grid with six-hue sentence
17
+ * coloring (0.90 F1 at ~2.9x lower cost on gemini-3.5-flash).
18
+ * - **OpenAI** (`6x6u-sent`): OpenAI bills a flat ~2.9k tokens per image, so
19
+ * image count is the only cost lever — unscii-8 Lanczos-stretched to 6x6
20
+ * cells packs the most readable chars per frame. Frames request
21
+ * `detail: "original"`; the default `auto` downscale destroys 6px glyphs.
22
+ * - **Unknown providers** default to the Anthropic shape (most
23
+ * refusal-robust). Gateways that resize images (e.g. OpenRouter normalizes
24
+ * visual payloads to a fixed token budget) defeat any shape — optical
25
+ * context fails silently there.
26
+ *
27
+ * The whole pass is local and deterministic — no LLM call, no API key, no
28
+ * latency beyond rendering. Rasterization and PNG encoding happen in native
29
+ * code (`renderSnapcompactPng` in `crates/pi-natives/src/snapcompact.rs`).
30
+ * Frames persist in the compaction entry's `preserveData` and are
31
+ * re-attached to the compaction summary message on every context rebuild.
32
+ */
33
+
34
+ import type { Api, ImageContent, Message, Model } from "@oh-my-pi/pi-ai";
35
+ import { renderSnapcompactPng } from "@oh-my-pi/pi-natives";
36
+ import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils";
37
+ import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
38
+ import snapcompactSummaryPrompt from "./prompts/snapcompact-summary.md" with { type: "text" };
39
+
40
+ // ============================================================================
41
+ // Shapes
42
+ // ============================================================================
43
+
44
+ /** One eval-validated frame shape: font, cell, ink, repetition, and size. */
45
+ export interface SnapcompactShape {
46
+ /** Bundled font in the native renderer. */
47
+ font: "5x8" | "8x8";
48
+ /** Target cell advance in pixels; differing from the font's natural cell
49
+ * renders via Lanczos stretch (anti-aliased RGB frame). */
50
+ cellWidth: number;
51
+ /** Target cell pitch in pixels. */
52
+ cellHeight: number;
53
+ /** Ink: `sent` cycles six hues at sentence boundaries; `bw` is black. */
54
+ variant: "sent" | "bw";
55
+ /** Each text line is printed this many times; copies after the first sit
56
+ * on a pale highlight band (redundancy coding). */
57
+ lineRepeat: number;
58
+ /** Frame edge in pixels. */
59
+ frameSize: number;
60
+ /** Per-frame billed-token estimate for the shape's target provider. */
61
+ frameTokenEstimate: number;
62
+ /** Resolution hint attached to frame images (OpenAI-only). */
63
+ imageDetail?: ImageContent["detail"];
64
+ }
65
+
66
+ /** Eval-validated shapes, keyed by the provider family they won on. */
67
+ export const SNAPCOMPACT_SHAPES = {
68
+ /** `8x8r-bw`: unscii square, black ink, lines doubled on highlight bands. */
69
+ anthropic: {
70
+ font: "8x8",
71
+ cellWidth: 8,
72
+ cellHeight: 8,
73
+ variant: "bw",
74
+ lineRepeat: 2,
75
+ frameSize: 1568,
76
+ frameTokenEstimate: 3300,
77
+ },
78
+ /** `8x8r-sent`: the repeated grid with sentence-hue ink. */
79
+ google: {
80
+ font: "8x8",
81
+ cellWidth: 8,
82
+ cellHeight: 8,
83
+ variant: "sent",
84
+ lineRepeat: 2,
85
+ frameSize: 1568,
86
+ frameTokenEstimate: 1100,
87
+ },
88
+ /** `6x6u-sent`: unscii stretched to 6x6 — densest readable cell, fewest
89
+ * frames (OpenAI bills per image, ~2.9k tokens flat). */
90
+ openaiDense: {
91
+ font: "8x8",
92
+ cellWidth: 6,
93
+ cellHeight: 6,
94
+ variant: "sent",
95
+ lineRepeat: 1,
96
+ frameSize: 1568,
97
+ frameTokenEstimate: 2900,
98
+ imageDetail: "original",
99
+ },
100
+ /** Original 5x8 X.org shape (pre-shape-table sessions rendered this). */
101
+ legacy: {
102
+ font: "5x8",
103
+ cellWidth: 5,
104
+ cellHeight: 8,
105
+ variant: "sent",
106
+ lineRepeat: 1,
107
+ frameSize: 2576,
108
+ frameTokenEstimate: 3300,
109
+ },
110
+ } as const satisfies Record<string, SnapcompactShape>;
111
+
112
+ /** Runtime guard for shape overrides loaded from config or preserve data. */
113
+ export function isSnapcompactShape(value: unknown): value is SnapcompactShape {
114
+ if (!value || typeof value !== "object") return false;
115
+ const shape = value as Record<string, unknown>;
116
+ const font = shape.font;
117
+ const variant = shape.variant;
118
+ const detail = shape.imageDetail;
119
+ return (
120
+ (font === "5x8" || font === "8x8") &&
121
+ typeof shape.cellWidth === "number" &&
122
+ shape.cellWidth > 0 &&
123
+ typeof shape.cellHeight === "number" &&
124
+ shape.cellHeight > 0 &&
125
+ (variant === "sent" || variant === "bw") &&
126
+ typeof shape.lineRepeat === "number" &&
127
+ shape.lineRepeat > 0 &&
128
+ typeof shape.frameSize === "number" &&
129
+ shape.frameSize > 0 &&
130
+ typeof shape.frameTokenEstimate === "number" &&
131
+ shape.frameTokenEstimate > 0 &&
132
+ (detail === undefined || detail === "auto" || detail === "low" || detail === "high" || detail === "original")
133
+ );
134
+ }
135
+
136
+ /** Pick the eval-optimal frame shape for a provider API. */
137
+ export function resolveSnapcompactShape(api?: Api): SnapcompactShape {
138
+ switch (api) {
139
+ case "openai-completions":
140
+ case "openai-responses":
141
+ case "openai-codex-responses":
142
+ case "azure-openai-responses":
143
+ return SNAPCOMPACT_SHAPES.openaiDense;
144
+ case "google-generative-ai":
145
+ case "google-gemini-cli":
146
+ case "google-vertex":
147
+ return SNAPCOMPACT_SHAPES.google;
148
+ default:
149
+ // anthropic-messages, bedrock-converse-stream, and anything unknown:
150
+ // the plain repeated grid is the most refusal-robust reader shape.
151
+ return SNAPCOMPACT_SHAPES.anthropic;
152
+ }
153
+ }
154
+
155
+ // ============================================================================
156
+ // Constants
157
+ // ============================================================================
158
+
159
+ /** Legacy frame edge in pixels (the 5x8 shape's eval-validated size). New
160
+ * shapes carry their own `frameSize`. */
161
+ export const SNAPCOMPACT_FRAME_SIZE = 2576;
162
+
163
+ /** Maximum frames carried on a compaction entry. Oldest frames are dropped
164
+ * first once the budget is exceeded (mirrors how iterative text summaries
165
+ * fade the oldest detail). */
166
+ export const SNAPCOMPACT_MAX_FRAMES = 8;
167
+
168
+ /** Conservative per-frame token estimate used for context budgeting
169
+ * (upper bound across shapes: Anthropic bills 1568*1568/750 ≈ 3,278). */
170
+ export const SNAPCOMPACT_FRAME_TOKEN_ESTIMATE = 3300;
171
+
172
+ /** Key under `CompactionEntry.preserveData` holding the frame archive. */
173
+ export const SNAPCOMPACT_PRESERVE_KEY = "snapcompact";
174
+
175
+ // ============================================================================
176
+ // Types
177
+ // ============================================================================
178
+
179
+ /** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
180
+ export interface SnapcompactFrame {
181
+ /** Base64-encoded PNG. */
182
+ data: string;
183
+ mimeType: string;
184
+ /** Characters per row in the frame grid. */
185
+ cols: number;
186
+ /** Text rows in the frame grid (unique lines, not repeated copies). */
187
+ rows: number;
188
+ /** Characters actually printed onto this frame. */
189
+ chars: number;
190
+ /** Shape metadata (absent on legacy frames, which are 5x8 `sent`). */
191
+ font?: SnapcompactShape["font"];
192
+ variant?: SnapcompactShape["variant"];
193
+ lineRepeat?: number;
194
+ /** Resolution hint forwarded to the provider when re-attaching. */
195
+ detail?: ImageContent["detail"];
196
+ }
197
+
198
+ /** Frame archive persisted under `preserveData[SNAPCOMPACT_PRESERVE_KEY]`. */
199
+ export interface SnapcompactArchive {
200
+ /** Frames ordered oldest to newest. */
201
+ frames: SnapcompactFrame[];
202
+ /** Characters currently readable across all frames. */
203
+ totalChars: number;
204
+ /** Characters dropped so far to respect the frame budget. */
205
+ truncatedChars: number;
206
+ }
207
+
208
+ export interface SnapcompactGeometry {
209
+ cols: number;
210
+ rows: number;
211
+ /** Characters that fit one frame (cols * rows). */
212
+ capacity: number;
213
+ }
214
+
215
+ export interface SnapcompactOptions<TMessage = Message> extends SnapcompactSerializeOptions {
216
+ /** App-level message transformer (same contract as agent-core's `SummaryOptions.convertToLlm`). */
217
+ convertToLlm?: SnapcompactConvertToLlm<TMessage>;
218
+ /** Model whose provider API selects the frame shape. */
219
+ model?: Pick<Model, "api">;
220
+ /** Explicit shape override; wins over `model`. */
221
+ shape?: SnapcompactShape;
222
+ /** Frame edge in pixels. Defaults to the shape's `frameSize`. */
223
+ frameSize?: number;
224
+ /** Frame budget. Defaults to {@link SNAPCOMPACT_MAX_FRAMES}. */
225
+ maxFrames?: number;
226
+ }
227
+
228
+ /** Result of rendering one frame. */
229
+ export interface RenderedFrame {
230
+ /** Base64-encoded PNG, as returned by the native renderer. */
231
+ data: string;
232
+ cols: number;
233
+ rows: number;
234
+ /** Characters printed (ink toggles excluded; input may be shorter than capacity). */
235
+ chars: number;
236
+ }
237
+
238
+ // ============================================================================
239
+ // Compaction data contracts
240
+ // ============================================================================
241
+
242
+ export interface SnapcompactFileOperations {
243
+ read: Set<string>;
244
+ written: Set<string>;
245
+ edited: Set<string>;
246
+ }
247
+
248
+ export interface SnapcompactCompactionDetails {
249
+ readFiles: string[];
250
+ modifiedFiles: string[];
251
+ }
252
+
253
+ export interface SnapcompactCompactionPreparation<TMessage = Message> {
254
+ /** UUID of first entry to keep. */
255
+ firstKeptEntryId: string;
256
+ /** Messages that will be archived and discarded. */
257
+ messagesToSummarize: TMessage[];
258
+ /** Messages that will be archived as the split-turn prefix, if any. */
259
+ turnPrefixMessages: TMessage[];
260
+ tokensBefore: number;
261
+ /** Summary from previous compaction, for continuity when no prior snapcompact archive exists. */
262
+ previousSummary?: string;
263
+ /** Preserved opaque compaction payload from the previous compaction, if any. */
264
+ previousPreserveData?: Record<string, unknown>;
265
+ /** File operations extracted by the host agent. */
266
+ fileOps: SnapcompactFileOperations;
267
+ }
268
+
269
+ export interface SnapcompactCompactionResult<T = SnapcompactCompactionDetails> {
270
+ summary: string;
271
+ shortSummary?: string;
272
+ firstKeptEntryId: string;
273
+ tokensBefore: number;
274
+ details?: T;
275
+ preserveData?: Record<string, unknown>;
276
+ }
277
+
278
+ export type SnapcompactConvertToLlm<TMessage = Message> = (messages: TMessage[]) => Message[];
279
+
280
+ function defaultConvertToLlm<TMessage>(messages: TMessage[]): Message[] {
281
+ return messages as unknown as Message[];
282
+ }
283
+
284
+ // ============================================================================
285
+ // File operation helpers
286
+ // ============================================================================
287
+
288
+ export function createSnapcompactFileOps(): SnapcompactFileOperations {
289
+ return {
290
+ read: new Set(),
291
+ written: new Set(),
292
+ edited: new Set(),
293
+ };
294
+ }
295
+
296
+ export function computeSnapcompactFileLists(fileOps: SnapcompactFileOperations): SnapcompactCompactionDetails {
297
+ const modified = new Set([...fileOps.edited, ...fileOps.written]);
298
+ const readFiles = [...fileOps.read].filter(file => !modified.has(file)).sort();
299
+ const modifiedFiles = [...modified].sort();
300
+ return { readFiles, modifiedFiles };
301
+ }
302
+
303
+ /**
304
+ * Format file operations as one `<files>` tag: a grouped, prefix-folded
305
+ * directory tree (find-tool shape) with a ` (Read)` / ` (Write)` / ` (RW)`
306
+ * marker per file. `readSet` is the cumulative read set (`fileOps.read`),
307
+ * used to tell modified files that were also read (RW) from blind writes.
308
+ */
309
+ const FILE_OPERATION_SUMMARY_LIMIT = 20;
310
+
311
+ function stripFileOperationTags(summary: string): string {
312
+ // Legacy <read-files>/<modified-files> tags are still stripped so summaries
313
+ // written before the combined <files> tag self-heal on the next compaction.
314
+ return summary
315
+ .replace(/<files>[\s\S]*?<\/files>\s*/g, "")
316
+ .replace(/<read-files>[\s\S]*?<\/read-files>\s*/g, "")
317
+ .replace(/<modified-files>[\s\S]*?<\/modified-files>\s*/g, "")
318
+ .trimEnd();
319
+ }
320
+
321
+ function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string {
322
+ if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
323
+ const mode = new Map<string, "Read" | "Write" | "RW">();
324
+ for (const file of readFiles) mode.set(file, "Read");
325
+ for (const file of modifiedFiles) mode.set(file, readSet?.has(file) ? "RW" : "Write");
326
+ const all = [...mode.keys()].sort();
327
+ let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
328
+ if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
329
+ files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`;
330
+ }
331
+ return prompt.render(fileOperationsTemplate, { files });
332
+ }
333
+
334
+ export function upsertSnapcompactFileOperations(
335
+ summary: string,
336
+ readFiles: string[],
337
+ modifiedFiles: string[],
338
+ readSet?: ReadonlySet<string>,
339
+ ): string {
340
+ const baseSummary = stripFileOperationTags(summary);
341
+ const fileOperations = formatFileOperations(readFiles, modifiedFiles, readSet);
342
+ if (!fileOperations) return baseSummary;
343
+ if (!baseSummary) return fileOperations;
344
+ return `${baseSummary}\n\n${fileOperations}`;
345
+ }
346
+
347
+ // ============================================================================
348
+ // Message serialization
349
+ // ============================================================================
350
+
351
+ /** Default per-tool-result character cap in serialized history. */
352
+ export const SNAPCOMPACT_TOOL_RESULT_MAX_CHARS = 2000;
353
+
354
+ /** Default per-argument-value character cap inside serialized tool calls
355
+ * (write/edit bodies otherwise dump whole files into the archive). */
356
+ export const SNAPCOMPACT_TOOL_ARG_MAX_CHARS = 500;
357
+
358
+ /** Default character cap across one tool call's full serialized argument list. */
359
+ export const SNAPCOMPACT_TOOL_CALL_MAX_CHARS = 2000;
360
+
361
+ /** Default fraction of a truncation budget spent on the head; the remainder
362
+ * keeps the tail, where command errors and test failures usually land. */
363
+ export const SNAPCOMPACT_TRUNCATE_HEAD_RATIO = 0.6;
364
+
365
+ /** Zero-width ink toggles understood by the native renderer (shift-out/in):
366
+ * text between them prints in dim gray ink without occupying a cell. */
367
+ export const SNAPCOMPACT_DIM_ON = "\u000e";
368
+ export const SNAPCOMPACT_DIM_OFF = "\u000f";
369
+
370
+ /** Character budgets applied while serializing discarded history for frame
371
+ * rendering. Pass `Infinity` to disable an individual cap. */
372
+ export interface SnapcompactSerializeOptions {
373
+ /** Per-tool-result cap. Defaults to {@link SNAPCOMPACT_TOOL_RESULT_MAX_CHARS}. */
374
+ toolResultMaxChars?: number;
375
+ /** Per-argument-value cap. Defaults to {@link SNAPCOMPACT_TOOL_ARG_MAX_CHARS}. */
376
+ toolArgMaxChars?: number;
377
+ /** Whole-argument-list cap per call. Defaults to {@link SNAPCOMPACT_TOOL_CALL_MAX_CHARS}. */
378
+ toolCallMaxChars?: number;
379
+ /** Head share of each budget, clamped to [0, 1]. Defaults to {@link SNAPCOMPACT_TRUNCATE_HEAD_RATIO}. */
380
+ truncateHeadRatio?: number;
381
+ /** Print tool-result text in dim gray ink so archived conversation reads
382
+ * louder than archived tool noise. Defaults to `true`. */
383
+ dimToolResults?: boolean;
384
+ }
385
+
386
+ /** Keep the head and tail of `text`, eliding the middle beyond `maxChars`. */
387
+ function truncateForSummary(text: string, maxChars: number, headRatio: number): string {
388
+ if (text.length <= maxChars) return text;
389
+ const ratio = Math.min(Math.max(headRatio, 0), 1);
390
+ const headChars = Math.round(maxChars * ratio);
391
+ const tailChars = maxChars - headChars;
392
+ const elided = text.length - maxChars;
393
+ const tail = tailChars > 0 ? text.slice(-tailChars) : "";
394
+ return `${text.slice(0, headChars)} [... ${elided} chars elided ...] ${tail}`;
395
+ }
396
+
397
+ const DIM_MARKERS = /[\u000e\u000f]/g;
398
+
399
+ /** Strip stray ink toggles from raw content so it cannot forge dim spans. */
400
+ function stripDimMarkers(text: string): string {
401
+ return text.replace(DIM_MARKERS, "");
402
+ }
403
+
404
+ export function serializeSnapcompactConversation(messages: Message[], options?: SnapcompactSerializeOptions): string {
405
+ const toolResultMaxChars = options?.toolResultMaxChars ?? SNAPCOMPACT_TOOL_RESULT_MAX_CHARS;
406
+ const toolArgMaxChars = options?.toolArgMaxChars ?? SNAPCOMPACT_TOOL_ARG_MAX_CHARS;
407
+ const toolCallMaxChars = options?.toolCallMaxChars ?? SNAPCOMPACT_TOOL_CALL_MAX_CHARS;
408
+ const headRatio = options?.truncateHeadRatio ?? SNAPCOMPACT_TRUNCATE_HEAD_RATIO;
409
+ const dimToolResults = options?.dimToolResults !== false;
410
+ const parts: string[] = [];
411
+
412
+ for (const msg of messages) {
413
+ if (msg.role === "user") {
414
+ const content =
415
+ typeof msg.content === "string"
416
+ ? msg.content
417
+ : msg.content
418
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
419
+ .map(content => content.text)
420
+ .join("");
421
+ if (content) parts.push(`[User]: ${stripDimMarkers(content)}`);
422
+ } else if (msg.role === "assistant") {
423
+ const textParts: string[] = [];
424
+ const thinkingParts: string[] = [];
425
+ const toolCalls: string[] = [];
426
+
427
+ for (const block of msg.content) {
428
+ if (block.type === "text") {
429
+ textParts.push(stripDimMarkers(block.text));
430
+ } else if (block.type === "thinking") {
431
+ thinkingParts.push(stripDimMarkers(block.thinking));
432
+ } else if (block.type === "toolCall") {
433
+ const args = block.arguments as Record<string, unknown>;
434
+ const argsStr = truncateForSummary(
435
+ Object.entries(args)
436
+ .map(
437
+ ([key, value]) =>
438
+ `${key}=${truncateForSummary(JSON.stringify(value) ?? "undefined", toolArgMaxChars, headRatio)}`,
439
+ )
440
+ .join(", "),
441
+ toolCallMaxChars,
442
+ headRatio,
443
+ );
444
+ toolCalls.push(`${block.name}(${argsStr})`);
445
+ }
446
+ }
447
+
448
+ if (thinkingParts.length > 0) {
449
+ parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
450
+ }
451
+ if (textParts.length > 0) {
452
+ parts.push(`[Assistant]: ${textParts.join("\n")}`);
453
+ }
454
+ if (toolCalls.length > 0) {
455
+ parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
456
+ }
457
+ } else if (msg.role === "toolResult") {
458
+ const content = msg.content
459
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
460
+ .map(block => block.text)
461
+ .join("");
462
+ if (content) {
463
+ // Args above are JSON-escaped, so only raw result text can carry toggles.
464
+ const body = truncateForSummary(stripDimMarkers(content), toolResultMaxChars, headRatio);
465
+ parts.push(
466
+ dimToolResults
467
+ ? `[Tool result]: ${SNAPCOMPACT_DIM_ON}${body}${SNAPCOMPACT_DIM_OFF}`
468
+ : `[Tool result]: ${body}`,
469
+ );
470
+ }
471
+ }
472
+ }
473
+
474
+ return parts.join("\n\n");
475
+ }
476
+
477
+ // ============================================================================
478
+ // Preserve-data helpers
479
+ // ============================================================================
480
+
481
+ const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
482
+
483
+ function stripOpenAiRemoteCompactionPreserveData(
484
+ preserveData: Record<string, unknown> | undefined,
485
+ ): Record<string, unknown> | undefined {
486
+ if (!preserveData || !(OPENAI_REMOTE_COMPACTION_PRESERVE_KEY in preserveData)) {
487
+ return preserveData;
488
+ }
489
+ const { [OPENAI_REMOTE_COMPACTION_PRESERVE_KEY]: _removed, ...rest } = preserveData;
490
+ return Object.keys(rest).length > 0 ? rest : undefined;
491
+ }
492
+
493
+ // ============================================================================
494
+ // Text normalization
495
+ // ============================================================================
496
+
497
+ /** Folds for common non-Latin-1 characters the bundled fonts cannot draw. */
498
+ const CHAR_FOLD: Record<string, string> = {
499
+ "\u2018": "'",
500
+ "\u2019": "'",
501
+ "\u201a": "'",
502
+ "\u201b": "'",
503
+ "\u201c": '"',
504
+ "\u201d": '"',
505
+ "\u201e": '"',
506
+ "\u2013": "-",
507
+ "\u2014": "-",
508
+ "\u2015": "-",
509
+ "\u2212": "-",
510
+ "\u2026": "...",
511
+ "\u2022": "*",
512
+ "\u25cf": "*",
513
+ "\u25a0": "*",
514
+ "\u25aa": "*",
515
+ "\u2190": "<-",
516
+ "\u2192": "->",
517
+ "\u21d2": "=>",
518
+ "\u2713": "v",
519
+ "\u2714": "v",
520
+ "\u2717": "x",
521
+ "\u2718": "x",
522
+ };
523
+
524
+ /**
525
+ * Prepare text for printing: collapse whitespace runs (incl. newlines) to
526
+ * single spaces — the eval's "paragraph breaks collapsed to spaces" format —
527
+ * then fold everything outside the fonts' ASCII + Latin-1 coverage to ASCII
528
+ * approximations (`?` as the last resort).
529
+ */
530
+ export function normalizeForSnapcompact(text: string): string {
531
+ const collapsed = text.replace(/\s+/g, " ").trim();
532
+ let out = "";
533
+ for (const ch of collapsed) {
534
+ const cp = ch.codePointAt(0) as number;
535
+ if (cp < 0x7f || (cp >= 0xa0 && cp <= 0xff)) {
536
+ out += ch;
537
+ continue;
538
+ }
539
+ const fold = CHAR_FOLD[ch];
540
+ if (fold !== undefined) {
541
+ out += fold;
542
+ } else if (cp >= 0x2500 && cp <= 0x257f) {
543
+ // Box drawing: keep table skeletons legible.
544
+ out += cp === 0x2502 || cp === 0x2503 ? "|" : cp === 0x2500 || cp === 0x2501 ? "-" : "+";
545
+ } else {
546
+ out += "?";
547
+ }
548
+ }
549
+ return out;
550
+ }
551
+
552
+ // ============================================================================
553
+ // Rendering
554
+ // ============================================================================
555
+
556
+ export function snapcompactGeometry(shape: SnapcompactShape, size: number = shape.frameSize): SnapcompactGeometry {
557
+ const cols = Math.floor(size / shape.cellWidth);
558
+ const rows = Math.floor(size / shape.cellHeight / shape.lineRepeat);
559
+ return { cols, rows, capacity: cols * rows };
560
+ }
561
+
562
+ /** Render one snapcompact frame from already-normalized text. */
563
+ export function renderSnapcompactFrame(
564
+ text: string,
565
+ shape: SnapcompactShape,
566
+ size: number = shape.frameSize,
567
+ ): RenderedFrame {
568
+ const { cols, rows, capacity } = snapcompactGeometry(shape, size);
569
+ const visible = text.length - (text.match(DIM_MARKERS)?.length ?? 0);
570
+ const chars = Math.min(visible, capacity);
571
+ const data = renderSnapcompactPng(text, {
572
+ size,
573
+ font: shape.font,
574
+ cellWidth: shape.cellWidth,
575
+ cellHeight: shape.cellHeight,
576
+ variant: shape.variant,
577
+ lineRepeat: shape.lineRepeat,
578
+ });
579
+ return { data, cols, rows, chars };
580
+ }
581
+
582
+ // ============================================================================
583
+ // Archive helpers
584
+ // ============================================================================
585
+
586
+ /** Validate and extract a persisted frame archive from `preserveData`. */
587
+ export function getPreservedSnapcompactArchive(
588
+ preserveData: Record<string, unknown> | undefined,
589
+ ): SnapcompactArchive | undefined {
590
+ const candidate = preserveData?.[SNAPCOMPACT_PRESERVE_KEY];
591
+ if (!candidate || typeof candidate !== "object") return undefined;
592
+ const archive = candidate as SnapcompactArchive;
593
+ if (!Array.isArray(archive.frames)) return undefined;
594
+ const frames = archive.frames.filter(
595
+ frame =>
596
+ !!frame &&
597
+ typeof frame.data === "string" &&
598
+ frame.data.length > 0 &&
599
+ typeof frame.mimeType === "string" &&
600
+ typeof frame.cols === "number" &&
601
+ typeof frame.rows === "number" &&
602
+ typeof frame.chars === "number",
603
+ );
604
+ if (frames.length === 0) return undefined;
605
+ return {
606
+ frames,
607
+ totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0,
608
+ truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0,
609
+ };
610
+ }
611
+
612
+ /** Convert archive frames into LLM image blocks (oldest first). */
613
+ export function snapcompactImages(archive: SnapcompactArchive): ImageContent[] {
614
+ return archive.frames.map(frame => ({
615
+ type: "image",
616
+ data: frame.data,
617
+ mimeType: frame.mimeType,
618
+ ...(frame.detail ? { detail: frame.detail } : {}),
619
+ }));
620
+ }
621
+
622
+ // ============================================================================
623
+ // Compaction entry point
624
+ // ============================================================================
625
+
626
+ /**
627
+ * Run a snapcompact compaction over prepared messages. Fully local: serializes
628
+ * the discarded history, prints it onto PNG frames in the provider-optimal
629
+ * shape, merges previously archived frames (oldest dropped beyond the
630
+ * budget), and produces a deterministic summary explaining how to read the
631
+ * frames.
632
+ *
633
+ * Frames archived under a different shape (provider switches, legacy 5x8
634
+ * sessions) are kept as-is — each frame carries its own geometry, and the
635
+ * summary describes the newest shape while noting that older frames may
636
+ * differ.
637
+ *
638
+ * If the previous compaction was text-based, its summary is printed at the
639
+ * head of the frame archive as `[Summary of earlier history]` so no continuity is lost.
640
+ */
641
+ export async function snapcompactCompact<TMessage = Message>(
642
+ preparation: SnapcompactCompactionPreparation<TMessage>,
643
+ options?: SnapcompactOptions<TMessage>,
644
+ ): Promise<SnapcompactCompactionResult> {
645
+ const { firstKeptEntryId, tokensBefore, previousSummary, previousPreserveData, fileOps } = preparation;
646
+ if (!firstKeptEntryId) {
647
+ throw new Error("First kept entry has no ID - session may need migration");
648
+ }
649
+ const shape = options?.shape ?? resolveSnapcompactShape(options?.model?.api);
650
+ const frameSize = options?.frameSize ?? shape.frameSize;
651
+ const maxFrames = Math.max(1, options?.maxFrames ?? SNAPCOMPACT_MAX_FRAMES);
652
+ const geometry = snapcompactGeometry(shape, frameSize);
653
+
654
+ const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
655
+ const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
656
+ let archiveText = normalizeForSnapcompact(serializeSnapcompactConversation(llmMessages, options));
657
+
658
+ const previousArchive = getPreservedSnapcompactArchive(previousPreserveData);
659
+ const includedPreviousSummary = !previousArchive && !!previousSummary;
660
+ if (includedPreviousSummary && previousSummary) {
661
+ const head = `[Summary of earlier history] ${normalizeForSnapcompact(previousSummary)}`;
662
+ archiveText = archiveText.length > 0 ? `${head} [Recent conversation] ${archiveText}` : head;
663
+ }
664
+
665
+ let truncatedChars = previousArchive?.truncatedChars ?? 0;
666
+
667
+ const newFrames: SnapcompactFrame[] = [];
668
+ let dimOpen = false;
669
+ for (let offset = 0; offset < archiveText.length; offset += geometry.capacity) {
670
+ let chunk = archiveText.slice(offset, offset + geometry.capacity);
671
+ // Re-open a dim span that the previous frame boundary cut through.
672
+ if (dimOpen) chunk = SNAPCOMPACT_DIM_ON + chunk;
673
+ dimOpen = chunk.lastIndexOf(SNAPCOMPACT_DIM_ON) > chunk.lastIndexOf(SNAPCOMPACT_DIM_OFF);
674
+ const rendered = renderSnapcompactFrame(chunk, shape, frameSize);
675
+ newFrames.push({
676
+ data: rendered.data,
677
+ mimeType: "image/png",
678
+ cols: rendered.cols,
679
+ rows: rendered.rows,
680
+ chars: rendered.chars,
681
+ font: shape.font,
682
+ variant: shape.variant,
683
+ lineRepeat: shape.lineRepeat,
684
+ ...(shape.imageDetail ? { detail: shape.imageDetail } : {}),
685
+ });
686
+ // Keep the event loop responsive between native render passes.
687
+ await Bun.sleep(0);
688
+ }
689
+
690
+ const frames = [...(previousArchive?.frames ?? []), ...newFrames];
691
+ if (frames.length > maxFrames) {
692
+ // Pin the earliest frame: it anchors the session head (the original
693
+ // request, or the filmed summary of even older history) the way the
694
+ // LLM-summary strategies keep the original goal alive across rounds.
695
+ // Eviction removes the oldest *unpinned* frames, so the archive fades
696
+ // from the middle out — head and tail survive. With a budget of one
697
+ // frame the pin is moot; keep the newest frame instead.
698
+ const evictStart = maxFrames >= 2 ? 1 : 0;
699
+ const dropped = frames.splice(evictStart, frames.length - maxFrames);
700
+ for (const frame of dropped) truncatedChars += frame.chars;
701
+ }
702
+ const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0);
703
+ const mixedShapes = frames.some(
704
+ frame =>
705
+ frame.cols !== geometry.cols ||
706
+ frame.rows !== geometry.rows ||
707
+ (frame.variant ?? "sent") !== shape.variant ||
708
+ (frame.lineRepeat ?? 1) !== shape.lineRepeat,
709
+ );
710
+
711
+ let summary: string;
712
+ if (frames.length === 0) {
713
+ summary = "No prior history.";
714
+ } else {
715
+ summary = prompt.render(snapcompactSummaryPrompt, {
716
+ frameCount: frames.length,
717
+ multipleFrames: frames.length > 1,
718
+ fontCell: `${shape.cellWidth}x${shape.cellHeight}`,
719
+ cols: geometry.cols,
720
+ rows: geometry.rows,
721
+ sentenceInk: shape.variant === "sent",
722
+ lineRepeated: shape.lineRepeat > 1,
723
+ dimmedToolResults: options?.dimToolResults !== false,
724
+ mixedShapes,
725
+ totalChars,
726
+ truncatedChars,
727
+ includedPreviousSummary,
728
+ });
729
+ }
730
+ const { readFiles, modifiedFiles } = computeSnapcompactFileLists(fileOps);
731
+ summary = upsertSnapcompactFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
732
+
733
+ // A snapcompact pass replaces any provider-side replacement history; strip the
734
+ // OpenAI remote-compaction payload like the default summarizer path does.
735
+ const basePreserve = stripOpenAiRemoteCompactionPreserveData(previousPreserveData) ?? {};
736
+ const archive: SnapcompactArchive = { frames, totalChars, truncatedChars };
737
+
738
+ return {
739
+ summary,
740
+ shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}`,
741
+ firstKeptEntryId,
742
+ tokensBefore,
743
+ details: { readFiles, modifiedFiles },
744
+ preserveData: { ...basePreserve, [SNAPCOMPACT_PRESERVE_KEY]: archive },
745
+ };
746
+ }