@linxiraos/pi-snapcompact 1.0.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,206 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [1.0.9] - 2026-08-19
6
-
7
- ## [17.2.15] - 2026-08-12
8
-
9
- ### Fixed
10
-
11
- - Fixed Anthropic model ID parsing to be case-insensitive and extended the high-resolution 1932px frame tier to Claude Opus 5 and later, preventing sessions from falling back to lower-resolution 1568px frames and preserving full history per compaction.
12
-
13
- ## [17.1.5] - 2026-07-27
14
-
15
- ### Fixed
16
-
17
- - Fixed snapcompact resume guides reporting only the HQ grid width for mixed-width foveated archives ([#6712](https://github.com/can1357/oh-my-pi/issues/6712)).
18
-
19
- ## [17.1.0] - 2026-07-24
20
-
21
- ### Added
22
-
23
- - Added an `includeThinking` serialization option (defaulting to `true`) to allow excluding assistant reasoning (`¶think:` sections) from archived transcripts.
24
-
25
- ## [16.5.0] - 2026-07-13
26
-
27
- ### Changed
28
-
29
- - Updated archived transcript rendering to use a more compact format with `¶user:`, `¶think:`, `¶ai:`, and `¶call:` scopes, omitting repeated adjacent scope headers and appending tool-call intents as comments.
30
-
31
- ## [16.3.7] - 2026-07-05
32
-
33
- ### Fixed
34
-
35
- - Fixed `resolveShapeForText(..., "auto")` to correctly select the `silver16-bw` shape for CJK-heavy transcript text while preserving explicit shape overrides.
36
-
37
- ## [16.2.8] - 2026-06-30
38
-
39
- ### Fixed
40
-
41
- - Fixed large snapcompact archives being reconstructed into unbounded per-request image payloads by adding a frame base64 byte budget and omitting over-budget archive frames from prompt blocks. ([#3792](https://github.com/can1357/oh-my-pi/issues/3792))
42
-
43
- ## [16.2.7] - 2026-06-30
44
-
45
- ### Added
46
-
47
- - Added the `silver16-bw` shape backed by an embedded Silver TrueType font to support CJK and other non-Latin text.
48
- - Added `resolveShapeForText` to support font-aware shape resolution.
49
-
50
- ### Changed
51
-
52
- - Improved non-ASCII text normalization by folding semantic emojis to ASCII labels (e.g., `[OK]`, `[WARN]`), dropping decorative emojis, and folding box-drawing symbols to ASCII skeletons.
53
- - Enhanced missing glyph rendering to use the embedded Silver TrueType fallback per-character, including support for East Asian wide characters across two grid cells.
54
- - Updated text wrapping, pagination, and provider shape geometries to support wide character footprints and updated X.org 8x13 font metrics.
55
-
56
- ## [16.1.23] - 2026-06-26
57
-
58
- ### Added
59
-
60
- - Added `archiveSourceText(archive)` to extract a persisted frame archive's source text as plain text for LLM summarization. ([#3561](https://github.com/can1357/oh-my-pi/pull/3561) by [@serverinspector](https://github.com/serverinspector))
61
- - Added `stripPreservedArchive(preserveData)` to drop the persisted frame-archive slot (`PRESERVE_KEY`) and collapse to `undefined` when no other state remains — shared by the agent and coding-agent compaction paths instead of duplicating the strip rule.
62
-
63
- ## [16.1.13] - 2026-06-22
64
-
65
- ### Fixed
66
-
67
- - Fixed the Umans provider image budget to match its 10-image request cap.
68
-
69
- ## [16.1.8] - 2026-06-20
70
-
71
- ### Breaking Changes
72
-
73
- - Changed core rendering functions `render` and `renderMany` to be asynchronous
74
-
75
- ## [16.1.0] - 2026-06-19
76
-
77
- ### Added
78
-
79
- - Added `historyBlocks(archive)` to reconstruct ordered history blocks from archive data
80
-
81
- ### Changed
82
-
83
- - Refactored compaction to be text-sourced, re-rendering from unified `Archive.text` source
84
- - Implemented foveated archive layout (HQ edges, dense LQ middle) for optimized context usage
85
- - Raised `MAX_FRAMES_DEFAULT` to 80 and consolidated `PROVIDER_IMAGE_BUDGETS`
86
- - Updated OpenRouter to use standard 90-image budget
87
- - Updated prompt instructions to clearly distinguish between plain-text and image history regions
88
- - `Options.maxFrames` is now an upper limit clamped to `MAX_FRAMES_DEFAULT`, not a per-call default
89
- - Rewrote the resume summary prompt into a structured reading guide (turn headings, grid/two-column layout, ink notes) and render file operations inline as a `FILES` section instead of a spliced `<files>` tag
90
-
91
- ### Fixed
92
-
93
- - Fixed context budget undercounting by raising `FRAME_TOKEN_ESTIMATE` to 5024
94
- - Improved file list formatting in compaction summaries
95
-
96
- ## [16.0.11] - 2026-06-19
5
+ ## [1.1.0] - 2026-08-25
97
6
 
98
7
  ### Changed
99
8
 
100
- - Refined elision markers for file operations and truncated text for better display consistency
101
- - Updated summary text for consistent descriptions of archived tool output
102
- - Folded a much wider range of Unicode to ASCII in `normalize()` before native rendering: added a per-character Unicode NFKD decomposition fallback (fullwidth forms, super/subscripts, ligatures, circled and math-styled alphanumerics, Roman numerals, vulgar fractions) and expanded the `CHAR_FOLD` punctuation table (more quotes/primes, hyphens, the fraction slash, dot leaders, bullets, and arrows) so undrawable glyphs land on close ASCII equivalents instead of `?`
103
-
104
- ## [16.0.8] - 2026-06-18
105
-
106
- ### Added
107
-
108
- - Added `<out>` block wrapping for tool results to improve document structure
109
- - Rendered thinking process as italicized blocks above assistant text
110
- - Displayed tool call intents as `//` comments in tool call headers
111
- - Changed conversation role markers to standard Markdown headings
112
-
113
- ### Changed
114
-
115
- - Merged tool results into their corresponding tool call blocks
116
- - Preserved prose formatting around tool calls to maintain conversation flow
117
- - Hidden `_i` argument from tool call output when an intent is provided
118
- - Optimized assistant turn output to group thinking and text blocks efficiently
119
-
120
- ### Fixed
121
-
122
- - Fixed improper splitting of assistant messages around useless tool calls
123
-
124
- ## [16.0.1] - 2026-06-15
125
-
126
- ### Added
127
-
128
- - Added `openai-codex` to first-party provider image budgets so ChatGPT Plus/Pro Codex sessions use the same 200-image request cap as OpenAI API sessions instead of the unknown-provider floor.
129
-
130
- ## [15.13.1] - 2026-06-15
131
-
132
- ### Added
133
-
134
- - Added two spacing-tuned frame variants to `SHAPE_VARIANTS`: `8on22-bw` (8x13 glyphs on a 22px pitch — extra line spacing) and `11on16-bw` (8x13 glyphs on an 11px advance — extra letter spacing). Both pin the indexed `stretch: false` path, so the native renderer draws natural-size glyphs on the padded cell box (the Rust path already advances by `cellWidth` and the new variants validate horizontal padding)
135
-
136
- ### Changed
137
-
138
- - **Changed the per-provider default shapes to the spacing-tuned cells.** The previous shapes were tuned on the SQuAD *prose* eval, where dense cells won; a new tool-result legibility benchmark (`research/toolbench.py` — real `search`/`read`/`find` output with structure-sensitive QA) showed the prose-era density erases the line numbers and indentation that code/search output depends on. Anthropic moves from `6x12-dim` to `11on16-bw` (opus-4.8 f1 .806 vs .755 for plain `8on16-bw` and .351 for `6x12-dim`, which fell below the OCR ~16px/char floor and abstained); OpenAI and Google move to `8on22-bw` (gemini-3.5-flash f1 .934 vs .807 for `8on16-bw` and .287 for `doc-8on16-sent-dim`; same leading win on gpt-5.5/gpt-5.4-mini). Kimi and GLM keep their measured `8on16-bw`. The bigger cells pack fewer chars per frame, so inline frame-swapping now breaks even at a larger tool-result size
139
-
140
- ## [15.12.1] - 2026-06-12
141
-
142
- ### Changed
143
-
144
- - `serializeConversation` now skips tool call/result pairs whose result is flagged contextually useless (`useless: true`, non-error), so archived frames stop carrying zero-match searches and timed-out waits
145
-
146
- ## [15.11.7] - 2026-06-12
147
-
148
- ### Added
149
-
150
- - Added `SHAPE_VARIANTS`, the catalog of research-eval frame variants the native renderer reproduces faithfully (`8x8r`/`8x8u`/`6x6u`/`5x8` × `sent`/`bw`), with `ShapeVariantName`, `SHAPE_VARIANT_NAMES`, and the `isShapeVariantName` guard
151
- - `resolveShape(api, variant?)` now accepts an explicit variant name (or `"auto"`); forced variants keep their geometry but are re-priced for the target provider's image billing (token estimate and OpenAI `original` detail hint)
152
- - Added the six research-eval winning frame variants to `SHAPE_VARIANTS`: `6x12-dim` (Claude fable), `8x13-bw` (Opus), `8on16-bw` (GPT grid runner-up), `doc-8on16-bw` (GPT), `doc-8on16-sent` (GLM), and `doc-8on16-sent-dim` (Gemini/Kimi), backed by new `Shape` fields `stretch` (disable Lanczos stretch: natural glyphs on a larger cell pitch), `columns` (two word-wrapped newspaper columns), `stopwordDim`, and the X.org `6x12`/`8x13` fonts
153
- - Added `dimStopwords()`, which prints high-frequency function words in dim ink via zero-width markers (skipping spans that are already dim), and `wrap()`, the greedy word-wrap used to typeset doc-layout pages; `geometry`/`render`/`renderMany`/`frames`/`compact` understand doc shapes (wrap once, paginate into `2 * rows`-line pages), and compaction frames persist `columns`/`stopwordDim` for mixed-shape detection
154
- - `resolveShape` now takes a `ShapeTarget` (`{ api, id }` — a pi-ai `Model` works as-is) and detects the ideal shape from the **model id**, not just the wire API: a Claude routed through Vertex or an OpenAI-compatible gateway keeps its Claude shape, with billing still priced by the API family actually carrying the request. `idealShapeVariant(modelId)` exposes the model-line table; unmeasured models fall back to the API family's winner
155
- - `resolveShape` now also resolves an ideal **frame size** per model line, and billing estimates come from verified per-family formulas instead of flat 1568px constants: Anthropic bills 28px patches capped at 4,784 visual tokens (+5% margin), Gemini 3.x bills a fixed 1,120-token `media_resolution` budget per image at any pixel size, and OpenAI bills 32px patches × 1.2 under the 10,000-patch `detail: "original"` budget. High-res Claude lines (Opus 4.7+, Fable, Mythos — native 2576px-edge ingestion) get 1932px frames (same recall and cost, a third fewer frames); Gemini gets 2048px frames (+70% chars per frame at the same bill); GPT and Kimi stay at 1568px (area-proportional billing and a model-side 1792px processor cap, respectively). `idealShapeVariant` now returns an `IdealShape` (`{ variant, frameSize? }`)
156
- - Added per-provider image-count budgets: `PROVIDER_IMAGE_BUDGETS`, `DEFAULT_PROVIDER_IMAGE_BUDGET`, `providerImageBudget()`, and `providerFrameBudget()` (the image budget clamped to `MAX_FRAMES`). OpenRouter is capped at its measured hard limit of 8 images per request (excess images are silently dropped with no error); unknown providers get a safe floor of 5
157
- - Added `Archive.textTail`: archive content past the frame budget is no longer dropped — `compact()` stops rendering at the budget and keeps the newest unframed slice as verbatim text on the summary (capped at two frame capacities with middle elision, counted into `truncatedChars` when elided). The tail persists in `preserveData` and is folded back into frames by the next compaction
158
-
159
- ### Changed
160
-
161
- - Frames are no longer padded to a square: the native renderer clips each PNG's height to the text rows actually printed, so a partially filled frame (typically the newest) bills only the pixel rows it uses
162
- - **Changed the OpenAI default shape from `6x6u-sent` to `8on16-bw`.** A production-regime mono eval (gpt-5.5, the full 800k-char SQuAD flow in one request, n=50) scored the old dense default f1 .602 vs .851 for `8on16-bw` rendered by the production pipeline, at near-equal total cost (the dense cells burned the frame savings on reasoning tokens); chunked exp14 had already scored `8on16-bw` .906. `SHAPES.openaiDense` is renamed to `SHAPES.openai`
163
- - **Changed the Google default shape from `8x8r-sent` to `doc-8on16-sent-dim`.** Production-rendered mono eval on gemini-3.5-flash (400k chars, one request, n=25): f1 .900 vs .853 for the repeated grid at lower cost, agreeing with the chunked round-2 winner
164
- - **Changed the Anthropic default shape from `8x8r-bw` to `6x12-dim`.** Production mono eval on claude-fable (400k chars, one request, n=25): f1 .840 vs .877 for the repeated grid — within noise — at 37% lower cost (12 frames instead of 21 per 400k chars), with clean completions in every probe; opus reads the same trade (.800 vs .833 at 42% lower cost)
165
- - `normalize()` now keeps line structure: whitespace runs containing a line break collapse to `NEWLINE_GLYPH` (U+2588 FULL BLOCK, drawn by the native renderer as a pitch-black cell one character wide) instead of a plain space; leading/trailing breaks are trimmed, and the frame-reading prompt explains the marker
166
- - `normalize()` now skips characters the fonts cannot render instead of printing `?` blanks: whole ANSI escape sequences are stripped, and bare control characters, zero-width format characters (ZWSP, BOM, directional marks), combining marks, and lone surrogates are dropped without occupying a cell; `?` remains the fallback for unsupported graphic characters only
167
-
168
- ## [15.11.4] - 2026-06-12
169
-
170
- ### Breaking Changes
171
-
172
- - Renamed every export to drop the `snapcompact`/`Snapcompact`/`SNAPCOMPACT_` qualifier — the package is meant to be consumed via `import * as snapcompact from "@linxiraos/pi-snapcompact"`. Functions: `snapcompactCompact` → `compact`, `renderSnapcompactFrame` → `render`, `snapcompactGeometry` → `geometry`, `normalizeForSnapcompact` → `normalize`, `serializeSnapcompactConversation` → `serializeConversation`, `snapcompactImages` → `images`, `getPreservedSnapcompactArchive` → `getPreservedArchive`, `isSnapcompactShape` → `isShape`, `resolveSnapcompactShape` → `resolveShape`, `createSnapcompactFileOps` → `createFileOps`, `computeSnapcompactFileLists` → `computeFileLists`, `upsertSnapcompactFileOperations` → `upsertFileOperations`. Types: `SnapcompactShape` → `Shape`, `SnapcompactFrame` → `Frame`, `SnapcompactArchive` → `Archive`, `SnapcompactGeometry` → `Geometry`, `SnapcompactOptions` → `Options`, `SnapcompactSerializeOptions` → `SerializeOptions`, `SnapcompactFileOperations` → `FileOperations`, `SnapcompactCompactionDetails`/`Preparation`/`Result` → `CompactionDetails`/`CompactionPreparation`/`CompactionResult`, `SnapcompactConvertToLlm` → `ConvertToLlm`. Constants: `SNAPCOMPACT_X` → `X` (`SHAPES`, `FRAME_SIZE`, `MAX_FRAMES`, `FRAME_TOKEN_ESTIMATE`, `PRESERVE_KEY`, `TOOL_RESULT_MAX_CHARS`, `TOOL_ARG_MAX_CHARS`, `TOOL_CALL_MAX_CHARS`, `TRUNCATE_HEAD_RATIO`, `DIM_ON`, `DIM_OFF`).
173
-
174
- ### Added
175
-
176
- - Added `renderMany()` for paging arbitrary text into snapcompact PNG frames as LLM image blocks, and `frames()` for predicting the frame count without rendering
177
-
178
- ## [15.11.0] - 2026-06-10
179
-
180
- ### Breaking Changes
181
-
182
- - Changed `renderSnapcompactFrame` output from `png: Uint8Array` to `data: string` base64, requiring consumers to read frame payloads from `frame.data`
183
-
184
- ### Added
185
-
186
- - Added new serialization options `toolResultMaxChars`, `toolArgMaxChars`, `toolCallMaxChars`, `truncateHeadRatio`, and `dimToolResults` to `snapcompactCompact`/`serializeSnapcompactConversation` so callers can tune how tool results and arguments are archived
187
- - 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
188
- - 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
189
- - Added `file-operations.md` and `snapcompact-summary.md` prompts to preserve file-read/write context and frame metadata in the compaction prompt flow
190
- - Added a full `packages/snapcompact/research` experiment and visualization suite for running snapcompact SQuAD studies, provider probes, and activation-style analyses
191
- - Added package-level TypeScript exports and publication config so consumers can import `@linxiraos/pi-snapcompact` with typed access to snapcompact APIs
192
- - Published `@linxiraos/pi-snapcompact` as the reusable snapcompact compaction package, including bitmap-frame rendering helpers, archive helpers, and the local `snapcompactCompact()` strategy.
193
-
194
- ### Changed
195
-
196
- - 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
197
- - 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
198
- - Changed `RenderedFrame` visible-character accounting so `chars` no longer includes invisible dim-control markers
199
- - 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
200
-
201
- ### Fixed
202
-
203
- - Fixed frame rendering at archive chunk boundaries to reopen dim spans when a chunk ends inside a dimmed tool-result segment
204
- - Fixed message serialization to strip user- and assistant-provided dim markers so only renderer-generated dim spans can be applied
9
+ - 同步上游 OMP v18.0.3 / v18.0.4(内部运行时与构建改进,无独立用户可见变更)。
205
10
 
206
11
  ## [1.0.0] - 2026-08-13
207
12
 
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Can Bölük
4
+ Copyright (c) 2026 Stencil Labs, Inc.
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/package.json CHANGED
@@ -1,66 +1,65 @@
1
1
  {
2
- "type": "module",
3
- "name": "@linxiraos/pi-snapcompact",
4
- "version": "1.0.9",
5
- "description": "Bitmap-frame context compression for vision-capable LLMs",
6
- "homepage": "https://linxira-os.github.io",
7
- "author": "Can Boluk",
8
- "license": "MIT",
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/Linxira-OS/linxira-zeta.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
- "@linxiraos/pi-ai": "1.0.9",
35
- "@linxiraos/pi-catalog": "1.0.9",
36
- "@linxiraos/pi-natives": "1.0.9",
37
- "@linxiraos/pi-utils": "1.0.9",
38
- "@linxiraos/pi-wire": "1.0.9"
39
- },
40
- "devDependencies": {
41
- "@types/bun": "^1.3.14"
42
- },
43
- "engines": {
44
- "bun": ">=1.3.14"
45
- },
46
- "files": [
47
- "src",
48
- "README.md",
49
- "CHANGELOG.md",
50
- "dist/types"
51
- ],
52
- "exports": {
53
- ".": {
54
- "types": "./dist/types/index.d.ts",
55
- "import": "./src/index.ts"
56
- },
57
- "./snapcompact": {
58
- "types": "./dist/types/snapcompact.d.ts",
59
- "import": "./src/snapcompact.ts"
60
- },
61
- "./*": {
62
- "types": "./dist/types/*.d.ts",
63
- "import": "./src/*.ts"
64
- }
65
- }
66
- }
2
+ "type": "module",
3
+ "name": "@linxiraos/pi-snapcompact",
4
+ "version": "1.1.0",
5
+ "description": "Bitmap-frame context compression for vision-capable LLMs",
6
+ "homepage": "https://linxira-os.github.io/zeta/",
7
+ "author": "Can Boluk",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Linxira-OS/linxira-zeta.git",
12
+ "directory": "packages/snapcompact"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/Linxira-OS/linxira-zeta/issues"
16
+ },
17
+ "keywords": [
18
+ "context-compression",
19
+ "vision",
20
+ "compaction",
21
+ "llm"
22
+ ],
23
+ "main": "./src/index.ts",
24
+ "types": "./src/index.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
+ "@linxiraos/pi-ai": "catalog:",
35
+ "@linxiraos/pi-catalog": "catalog:",
36
+ "@linxiraos/pi-natives": "catalog:",
37
+ "@linxiraos/pi-utils": "catalog:",
38
+ "@linxiraos/pi-wire": "catalog:"
39
+ },
40
+ "devDependencies": {
41
+ "@types/bun": "catalog:"
42
+ },
43
+ "engines": {
44
+ "bun": ">=1.3.14"
45
+ },
46
+ "files": [
47
+ "src",
48
+ "README.md",
49
+ "CHANGELOG.md"
50
+ ],
51
+ "exports": {
52
+ ".": {
53
+ "types": "./src/index.ts",
54
+ "import": "./src/index.ts"
55
+ },
56
+ "./snapcompact": {
57
+ "types": "./src/snapcompact.ts",
58
+ "import": "./src/snapcompact.ts"
59
+ },
60
+ "./*": {
61
+ "types": "./src/*.ts",
62
+ "import": "./src/*.ts"
63
+ }
64
+ }
65
+ }
@@ -519,6 +519,11 @@ export function providerImageBudget(provider: string | undefined): number {
519
519
  return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET;
520
520
  }
521
521
 
522
+ /** Archive frame cap for `provider`: image budget, never above {@link MAX_FRAMES_DEFAULT}. */
523
+ export function providerFrameBudget(provider: string | undefined): number {
524
+ return Math.min(providerImageBudget(provider), MAX_FRAMES_DEFAULT);
525
+ }
526
+
522
527
  /** Key under `CompactionEntry.preserveData` holding the frame archive. */
523
528
  export const PRESERVE_KEY = "snapcompact";
524
529
 
@@ -775,6 +780,132 @@ function truncateForSummary(text: string, maxChars: number, headRatio: number):
775
780
  return `${text.slice(0, headChars)} […${elided}ch elided…] ${tail}`;
776
781
  }
777
782
 
783
+ /** One elision marker as emitted by {@link truncateForSummary} (Unicode
784
+ * ellipses) or as persisted after `normalize()` (ASCII dots). */
785
+ const ELIDED_MARKER = String.raw`\[(?:…|\.{3})\d+ch elided(?:…|\.{3})\]`;
786
+
787
+ /** Unquoted RFC 2045 token used as a media-type parameter name or value.
788
+ * Quoted-string values (RFC 822) are out of scope. */
789
+ const MEDIA_TYPE_TOKEN = String.raw`[\w!#$%&'*+.^|~-]+`;
790
+
791
+ /** An inline base64 data URL. The payload may be empty or carry one embedded
792
+ * elision marker so fragments left by pre-guard slices — including a cut
793
+ * landing exactly on `;base64,` — still match. RFC 2397 allows `*( ";" parameter )`
794
+ * between type/subtype and the terminal `;base64`; unquoted tokens are matched,
795
+ * quoted-string values are out of scope. `data:` and `base64` match
796
+ * case-insensitively (`gi`). Matching starts at `data:`; Markdown wrappers are
797
+ * recovered by {@link adjacentMarkdownOpenerStart} after each hit. */
798
+ const DATA_URL_ATOM = new RegExp(
799
+ String.raw`data:([A-Za-z][\w.+-]*\/[\w.+-]+(?:;${MEDIA_TYPE_TOKEN}=${MEDIA_TYPE_TOKEN})*);base64,` +
800
+ String.raw`([A-Za-z0-9+/=]*(?:\s*${ELIDED_MARKER}\s*[A-Za-z0-9+/=]*)?)` +
801
+ String.raw`(\s*\))?`,
802
+ "gi",
803
+ );
804
+
805
+ const ELIDED_MARKER_RE = new RegExp(String.raw`\s*${ELIDED_MARKER}\s*`);
806
+ const MARKDOWN_WHITESPACE_CHAR = /\s/;
807
+
808
+ /** Canonical base64: 4-char groups with valid terminal padding. */
809
+ const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/;
810
+
811
+ /** A non-canonical payload at least this long is a damaged fragment of a real
812
+ * data URL (e.g. an archive head cut mid-payload by a structure-blind slice),
813
+ * not a prose mention like `data:image/png;base64,abc`. */
814
+ const DAMAGED_PAYLOAD_MIN_CHARS = 40;
815
+
816
+ /** Context for {@link elideDataUrls}. `source` text is intact (never sliced),
817
+ * so a short non-canonical payload is a prose mention and stays untouched.
818
+ * `archive` text may have been cut by pre-guard structure-blind slices at
819
+ * any offset — even 0–39 chars past `;base64,` — so every recognized prefix
820
+ * is suspect and is always elided. */
821
+ type DataUrlContext = "source" | "archive";
822
+
823
+ /** Start of `!?[label](\s*` immediately before `dataIndex`, or `undefined`.
824
+ * The opener must lie in `[cursor, dataIndex)`. Nested `[` in the label is
825
+ * kept (the old `[^\]\n]*` class allowed it) by taking the earliest `[` after
826
+ * a prior `]`, newline, or `cursor`; the scan never walks already-emitted
827
+ * text, so repeated `](data:...)` stays linear. */
828
+ function adjacentMarkdownOpenerStart(text: string, dataIndex: number, cursor: number): number | undefined {
829
+ let i = dataIndex;
830
+ while (i > cursor && MARKDOWN_WHITESPACE_CHAR.test(text.charAt(i - 1))) i--;
831
+ // `](` and any following whitespace must sit in [cursor, dataIndex).
832
+ if (i - 2 < cursor || text.charAt(i - 1) !== "(" || text.charAt(i - 2) !== "]") return undefined;
833
+ let opener = -1;
834
+ for (let j = i - 3; j >= cursor; j--) {
835
+ const c = text.charAt(j);
836
+ if (c === "]" || c === "\n") break;
837
+ if (c === "[") opener = j;
838
+ }
839
+ if (opener < 0) return undefined;
840
+ return opener > cursor && text.charAt(opener - 1) === "!" ? opener - 1 : opener;
841
+ }
842
+
843
+ /** Replace every inline base64 data URL atomically with a deterministic
844
+ * placeholder. A character cap that slices inside a base64 payload leaves a
845
+ * recognizable image reference that can never decode; OpenAI-dialect
846
+ * providers reject such requests as invalid image input, and because the
847
+ * corrupted text persists in the archive the session re-fails on every later
848
+ * request. The payload is worthless to a model as text, so the whole atom —
849
+ * Markdown wrapper included — collapses to its metadata. Payloads already
850
+ * carrying an elision marker, and non-canonical fragments left by pre-guard
851
+ * slices, are healed the same way.
852
+ *
853
+ * The placeholder's `<mime>` is the media type as written: type/subtype plus
854
+ * any unquoted RFC 2397 `;parameter=value` segments, original case preserved.
855
+ * Parameters are kept rather than stripped to a bare type/subtype so charset
856
+ * (and similar) remain visible after elision and the label stays a pure
857
+ * function of the captured text. */
858
+ function elideDataUrls(text: string, context: DataUrlContext = "source"): string {
859
+ if (!/;base64,/i.test(text)) return text;
860
+ DATA_URL_ATOM.lastIndex = 0;
861
+ let match = DATA_URL_ATOM.exec(text);
862
+ if (match === null) return text;
863
+ const out: string[] = [];
864
+ let cursor = 0;
865
+ while (match !== null) {
866
+ const urlStart = match.index;
867
+ const urlEnd = urlStart + match[0].length;
868
+ const mime = match[1] ?? "";
869
+ const payload = match[2] ?? "";
870
+ const closer = match[3];
871
+ const marker = ELIDED_MARKER_RE.exec(payload);
872
+ const isAtom =
873
+ context === "archive" ||
874
+ marker !== null ||
875
+ CANONICAL_BASE64.test(payload) ||
876
+ payload.length >= DAMAGED_PAYLOAD_MIN_CHARS;
877
+ if (!isAtom) {
878
+ // Advance through short prose too, so a later wrapper cannot swallow
879
+ // a data URL already copied out of its Markdown label.
880
+ out.push(text.slice(cursor, urlEnd));
881
+ cursor = urlEnd;
882
+ } else {
883
+ const b64Chars = marker
884
+ ? payload.length - marker[0].length + Number(/\d+/.exec(marker[0])?.[0] ?? 0)
885
+ : payload.length;
886
+ const placeholder = `[data URL omitted: ${mime}, ${b64Chars} base64 chars]`;
887
+ const foundOpener = adjacentMarkdownOpenerStart(text, urlStart, cursor);
888
+ const openerStart = foundOpener !== undefined && foundOpener >= cursor ? foundOpener : undefined;
889
+ const emitStart = openerStart ?? urlStart;
890
+ out.push(text.slice(cursor, emitStart));
891
+ // Swallow the Markdown wrapper only when both delimiters matched;
892
+ // otherwise re-emit whichever half was captured untouched. An opener
893
+ // that starts before the already-emitted cursor would overlap a prior
894
+ // replacement, so that URL is treated as bare.
895
+ if (openerStart !== undefined && closer !== undefined) {
896
+ out.push(placeholder);
897
+ } else {
898
+ const opener = openerStart !== undefined ? text.slice(openerStart, urlStart) : "";
899
+ out.push(opener, placeholder, closer ?? "");
900
+ }
901
+ cursor = urlEnd;
902
+ }
903
+ match = DATA_URL_ATOM.exec(text);
904
+ }
905
+ out.push(text.slice(cursor));
906
+ return out.join("");
907
+ }
908
+
778
909
  const DIM_MARKERS = /[\u000e\u000f]/g;
779
910
 
780
911
  /** Plain-text history kept verbatim at each chronological edge, in HQ-frame-
@@ -836,7 +967,7 @@ export function serializeConversation(messages: Message[], options?: SerializeOp
836
967
  // Wrap a raw tool-result body in an `<out>` block, dimming only the body so
837
968
  // the frame coloring keeps scope markers and calls loud.
838
969
  const renderResultBlock = (rawText: string): string => {
839
- const body = truncateForSummary(stripDimMarkers(rawText), toolResultMaxChars, headRatio);
970
+ const body = truncateForSummary(elideDataUrls(stripDimMarkers(rawText)), toolResultMaxChars, headRatio);
840
971
  return `<out>\n${dimToolResults ? `${DIM_ON}${body}${DIM_OFF}` : body}\n</out>`;
841
972
  };
842
973
 
@@ -894,7 +1025,7 @@ export function serializeConversation(messages: Message[], options?: SerializeOp
894
1025
  .filter(([key]) => key !== INTENT_FIELD)
895
1026
  .map(
896
1027
  ([key, value]) =>
897
- `${key}=${truncateForSummary(JSON.stringify(value) ?? "undefined", toolArgMaxChars, headRatio)}`,
1028
+ `${key}=${truncateForSummary(elideDataUrls(JSON.stringify(value) ?? "undefined"), toolArgMaxChars, headRatio)}`,
898
1029
  )
899
1030
  .join(", "),
900
1031
  toolCallMaxChars,
@@ -1616,7 +1747,7 @@ export function archiveSourceText(archive: Archive): string | undefined {
1616
1747
  [archive.textHead, archive.textTail]
1617
1748
  .filter((part): part is string => typeof part === "string" && part.length > 0)
1618
1749
  .join(NEWLINE_GLYPH);
1619
- return text.length > 0 ? toPlainText(text) : undefined;
1750
+ return text.length > 0 ? elideDataUrls(toPlainText(text), "archive") : undefined;
1620
1751
  }
1621
1752
 
1622
1753
  /** Build the text used to choose and preflight a font-aware snapcompact shape. */
@@ -1704,7 +1835,7 @@ export function historyBlocks(archive: Archive, options: HistoryBlockOptions = {
1704
1835
  : hasOmittedImages
1705
1836
  ? `\n${omittedFrameNotice(budgeted.omittedFrames, budgeted.omittedBytes)}\n`
1706
1837
  : "";
1707
- blocks.push({ type: "text", text: toPlainText(archive.textHead) + suffix });
1838
+ blocks.push({ type: "text", text: elideDataUrls(toPlainText(archive.textHead), "archive") + suffix });
1708
1839
  } else if (hasOmittedImages && !hasImages) {
1709
1840
  blocks.push({ type: "text", text: omittedFrameNotice(budgeted.omittedFrames, budgeted.omittedBytes) });
1710
1841
  }
@@ -1721,7 +1852,7 @@ export function historyBlocks(archive: Archive, options: HistoryBlockOptions = {
1721
1852
  : archive.truncatedChars > 0 || hasOmittedImages
1722
1853
  ? "\n-------------- middle history omitted above\n"
1723
1854
  : "";
1724
- const tail = prefix + toPlainText(archive.textTail);
1855
+ const tail = prefix + elideDataUrls(toPlainText(archive.textTail), "archive");
1725
1856
  const lastBlock = blocks[blocks.length - 1];
1726
1857
  if (lastBlock?.type === "text") {
1727
1858
  lastBlock.text += tail;
@@ -1917,11 +2048,14 @@ export async function compact<TMessage = Message>(
1917
2048
  .join(NEWLINE_GLYPH);
1918
2049
  // Legacy archives may carry `¶think:` sections from before includeThinking
1919
2050
  // existed; scrub them when this compaction excludes thinking so the
1920
- // re-rendered archive stops replaying reasoning (issue #6093).
2051
+ // re-rendered archive stops replaying reasoning (issue #6093). They may
2052
+ // also carry data URLs a pre-guard slice cut at any offset; heal those in
2053
+ // archive context before the text is folded into the new source.
2054
+ const previousTextHealed = elideDataUrls(previousTextRaw, "archive");
1921
2055
  const previousText =
1922
- options?.includeThinking === false && previousTextRaw.length > 0
1923
- ? stripThinkingSections(previousTextRaw)
1924
- : previousTextRaw;
2056
+ options?.includeThinking === false && previousTextHealed.length > 0
2057
+ ? stripThinkingSections(previousTextHealed)
2058
+ : previousTextHealed;
1925
2059
  const hasPreviousText = previousText.length > 0;
1926
2060
  const includedPreviousSummary = !hasPreviousText && !!previousSummary;
1927
2061
  const shapeProbeText = renderabilityProbeText(serialized, previousPreserveData, previousSummary);
@@ -1949,6 +2083,12 @@ export async function compact<TMessage = Message>(
1949
2083
  if (hasPreviousText) {
1950
2084
  archiveText = archiveText.length > 0 ? `${previousText}${NEWLINE_GLYPH}${archiveText}` : previousText;
1951
2085
  }
2086
+ // Data URLs must never reach planArchive: its edge slices are structure-
2087
+ // blind, and a split payload replays as broken image input on every later
2088
+ // request. previousText is already strictly healed above; this source-mode
2089
+ // pass covers intact URLs in fresh user/assistant text, which the
2090
+ // serializer never truncates.
2091
+ archiveText = elideDataUrls(archiveText);
1952
2092
 
1953
2093
  const layout = planArchive(archiveText, high, low, maxFrames);
1954
2094
  truncatedChars += layout.truncatedChars;
@@ -1 +0,0 @@
1
- export * from "./snapcompact.js";
@@ -1,592 +0,0 @@
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 PNG frames of pixel-font text that vision
6
- * models read back directly, like an archivist at a snapcompact frame
7
- * reader. Frames are `frameSize` wide; their height hugs the text rows
8
- * actually printed, so a partially filled frame never bills blank rows.
9
- *
10
- * The frame shape is provider-aware. Original choices came from the SQuAD
11
- * prose evals (`packages/snapcompact`, 200k-token monolithic runs); the
12
- * spacing choices below come from the tool-result legibility bench
13
- * (`research/toolbench.py`, real search/read/find output with structure QA),
14
- * which exposed that the prose-tuned dense cells erase the line numbers and
15
- * indentation that code/search output depends on:
16
- *
17
- * - **Anthropic** (`11on16-bw`): 8x13 glyphs on an 11px advance (extra
18
- * letter-spacing), black ink. On the tool-result bench, tracking the
19
- * readable cell beat plain `8on16-bw` (opus-4.8 f1 .806 vs .755) and far
20
- * beat the prior dense `6x12-dim` (.351, which fell below the OCR ~16px/char
21
- * floor and abstained). Opus 4.7+/Fable/Mythos ingest high-res natively
22
- * (2576px edge, 4,784 visual-token cap), so those lines get 1932px frames:
23
- * same bill, fewer frames. Older Claude lines downscale past 1568px.
24
- * - **Google** (`8on22-bw` @2048): 8x13 glyphs on a 22px pitch (extra line
25
- * spacing), black ink. Leading lifted gemini-3.5-flash to f1 .934 vs .807
26
- * for `8on16-bw` and .287 for the prior `doc-8on16-sent-dim`. Gemini 3.x
27
- * bills a fixed `media_resolution` budget per image (default 1,120 tokens)
28
- * regardless of pixels, so the 2048px frame carries more chars at the same
29
- * bill.
30
- * - **OpenAI** (`8on22-bw`): same leading win (gpt-5.5/gpt-5.4-mini). Patch
31
- * billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
32
- * area-proportional, so resolution cannot improve chars/$ — 1568 stays.
33
- * `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
34
- * - **Unknown providers** default to `8on22-bw` with Anthropic-style
35
- * visual-token area billing. `providerImageBudget` still caps per-request
36
- * images per provider so inline imaging cannot flood a request with
37
- * attachments, but the old OpenRouter-specific 8-image cap is gone; routers
38
- * now use the same permissive budget as direct Anthropic/Claude lines unless
39
- * configured otherwise upstream.
40
- *
41
- * The whole pass is local and deterministic — no LLM call, no API key, no
42
- * latency beyond rendering. Rasterization and PNG encoding happen in native
43
- * code (`renderSnapcompactPng` in `crates/pi-natives/src/snapcompact.rs`).
44
- * Frames persist in the compaction entry's `preserveData` and are
45
- * re-attached to the compaction summary message on every context rebuild.
46
- */
47
- import type { Api, ImageContent, Message, TextContent } from "@linxiraos/pi-ai";
48
- /** One eval-validated frame shape: font, cell, ink, repetition, and size. */
49
- export interface Shape {
50
- /** Bundled font in the native renderer. */
51
- font: "5x8" | "8x8" | "6x12" | "8x13" | "silver";
52
- /** Target cell advance in pixels; differing from the font's natural cell
53
- * renders via Lanczos stretch (anti-aliased RGB frame). */
54
- cellWidth: number;
55
- /** Target cell pitch in pixels. */
56
- cellHeight: number;
57
- /** `false` → glyphs drawn at natural size on the cell pitch (8on16);
58
- * `true`/`undefined` → legacy auto Lanczos stretch when cell ≠ natural. */
59
- stretch?: boolean;
60
- /** Ink: `sent` cycles six hues at sentence boundaries; `bw` is black. */
61
- variant: "sent" | "bw";
62
- /** Print stopwords in dim ink (research `dim`/`sent-dim` variants). */
63
- stopwordDim?: boolean;
64
- /** 1/undefined = row-major grid; 2 = two word-wrapped newspaper columns
65
- * (research `doc`). */
66
- columns?: number;
67
- /** Each text line is printed this many times; copies after the first sit
68
- * on a pale highlight band (redundancy coding). */
69
- lineRepeat: number;
70
- /** Frame edge in pixels. */
71
- frameSize: number;
72
- /** Per-frame billed-token estimate for the shape's target provider. */
73
- frameTokenEstimate: number;
74
- /** Resolution hint attached to frame images (OpenAI-only). */
75
- imageDetail?: ImageContent["detail"];
76
- }
77
- /** Geometry half of a {@link Shape}: everything except provider billing. */
78
- export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "imageDetail">;
79
- /**
80
- * Frame variants exercised by the SQuAD evals in `research/` that the native
81
- * renderer reproduces faithfully, keyed by their research names. Font codes:
82
- * `8x8u` unscii square cell, `8x8r` unscii with every line printed twice
83
- * (redundancy coding), `6x6u` unscii Lanczos-squeezed to 6x6 (densest
84
- * readable cell), `5x8` the X.org legacy font on its 2576px frame, `6x12`
85
- * and `8x13` the X.org misc fonts, `8on16` 8x13 glyphs on an 8x16 cell pitch
86
- * (no stretch, extra leading), `8on22` the same glyphs on a 22px pitch (more
87
- * leading), `11on16` the same glyphs on an 11px advance (more tracking),
88
- * `silver16` the embedded Silver TrueType font on a 16px grid for CJK and
89
- * other non-Latin text, and `doc-` prefixed shapes a two-column word-wrapped
90
- * newspaper layout. Ink: `sent` cycles six hues at sentence boundaries, `bw`
91
- * is plain black, `-dim` suffix prints stopwords in gray.
92
- */
93
- export declare const SHAPE_VARIANTS: {
94
- readonly "8x8r-bw": {
95
- readonly font: "8x8";
96
- readonly cellWidth: 8;
97
- readonly cellHeight: 8;
98
- readonly variant: "bw";
99
- readonly lineRepeat: 2;
100
- readonly frameSize: 1568;
101
- };
102
- readonly "8x8r-sent": {
103
- readonly font: "8x8";
104
- readonly cellWidth: 8;
105
- readonly cellHeight: 8;
106
- readonly variant: "sent";
107
- readonly lineRepeat: 2;
108
- readonly frameSize: 1568;
109
- };
110
- readonly "8x8u-bw": {
111
- readonly font: "8x8";
112
- readonly cellWidth: 8;
113
- readonly cellHeight: 8;
114
- readonly variant: "bw";
115
- readonly lineRepeat: 1;
116
- readonly frameSize: 1568;
117
- };
118
- readonly "8x8u-sent": {
119
- readonly font: "8x8";
120
- readonly cellWidth: 8;
121
- readonly cellHeight: 8;
122
- readonly variant: "sent";
123
- readonly lineRepeat: 1;
124
- readonly frameSize: 1568;
125
- };
126
- readonly "6x6u-bw": {
127
- readonly font: "8x8";
128
- readonly cellWidth: 6;
129
- readonly cellHeight: 6;
130
- readonly variant: "bw";
131
- readonly lineRepeat: 1;
132
- readonly frameSize: 1568;
133
- };
134
- readonly "6x6u-sent": {
135
- readonly font: "8x8";
136
- readonly cellWidth: 6;
137
- readonly cellHeight: 6;
138
- readonly variant: "sent";
139
- readonly lineRepeat: 1;
140
- readonly frameSize: 1568;
141
- };
142
- readonly "5x8-bw": {
143
- readonly font: "5x8";
144
- readonly cellWidth: 5;
145
- readonly cellHeight: 8;
146
- readonly variant: "bw";
147
- readonly lineRepeat: 1;
148
- readonly frameSize: 2576;
149
- };
150
- readonly "5x8-sent": {
151
- readonly font: "5x8";
152
- readonly cellWidth: 5;
153
- readonly cellHeight: 8;
154
- readonly variant: "sent";
155
- readonly lineRepeat: 1;
156
- readonly frameSize: 2576;
157
- };
158
- readonly "6x12-dim": {
159
- readonly font: "6x12";
160
- readonly cellWidth: 6;
161
- readonly cellHeight: 12;
162
- readonly variant: "bw";
163
- readonly stopwordDim: true;
164
- readonly lineRepeat: 1;
165
- readonly frameSize: 1568;
166
- };
167
- readonly "8x13-bw": {
168
- readonly font: "8x13";
169
- readonly cellWidth: 8;
170
- readonly cellHeight: 13;
171
- readonly variant: "bw";
172
- readonly lineRepeat: 1;
173
- readonly frameSize: 1568;
174
- };
175
- readonly "8on16-bw": {
176
- readonly font: "8x13";
177
- readonly cellWidth: 8;
178
- readonly cellHeight: 16;
179
- readonly stretch: false;
180
- readonly variant: "bw";
181
- readonly lineRepeat: 1;
182
- readonly frameSize: 1568;
183
- };
184
- readonly "8on22-bw": {
185
- readonly font: "8x13";
186
- readonly cellWidth: 8;
187
- readonly cellHeight: 22;
188
- readonly stretch: false;
189
- readonly variant: "bw";
190
- readonly lineRepeat: 1;
191
- readonly frameSize: 1568;
192
- };
193
- readonly "11on16-bw": {
194
- readonly font: "8x13";
195
- readonly cellWidth: 11;
196
- readonly cellHeight: 16;
197
- readonly stretch: false;
198
- readonly variant: "bw";
199
- readonly lineRepeat: 1;
200
- readonly frameSize: 1568;
201
- };
202
- readonly "silver16-bw": {
203
- readonly font: "silver";
204
- readonly cellWidth: 16;
205
- readonly cellHeight: 16;
206
- readonly variant: "bw";
207
- readonly lineRepeat: 1;
208
- readonly frameSize: 1568;
209
- };
210
- readonly "doc-8on16-bw": {
211
- readonly font: "8x13";
212
- readonly cellWidth: 8;
213
- readonly cellHeight: 16;
214
- readonly stretch: false;
215
- readonly variant: "bw";
216
- readonly columns: 2;
217
- readonly lineRepeat: 1;
218
- readonly frameSize: 1568;
219
- };
220
- readonly "doc-8on16-sent": {
221
- readonly font: "8x13";
222
- readonly cellWidth: 8;
223
- readonly cellHeight: 16;
224
- readonly stretch: false;
225
- readonly variant: "sent";
226
- readonly columns: 2;
227
- readonly lineRepeat: 1;
228
- readonly frameSize: 1568;
229
- };
230
- readonly "doc-8on16-sent-dim": {
231
- readonly font: "8x13";
232
- readonly cellWidth: 8;
233
- readonly cellHeight: 16;
234
- readonly stretch: false;
235
- readonly variant: "sent";
236
- readonly stopwordDim: true;
237
- readonly columns: 2;
238
- readonly lineRepeat: 1;
239
- readonly frameSize: 1568;
240
- };
241
- };
242
- /** Research name of one renderable frame variant. */
243
- export type ShapeVariantName = keyof typeof SHAPE_VARIANTS;
244
- /** All variant names, in declaration order (for settings enums). */
245
- export declare const SHAPE_VARIANT_NAMES: readonly ShapeVariantName[];
246
- /** Runtime guard for variant names loaded from config. */
247
- export declare function isShapeVariantName(value: unknown): value is ShapeVariantName;
248
- /** Eval-validated shapes, keyed by the provider family they won on. */
249
- export declare const SHAPES: {
250
- /** `11on16-bw`: 8x13 glyphs on an 11px advance (extra tracking), black ink.
251
- * Tool-result legibility bench (real search/read/find output, structure QA)
252
- * on opus-4.8: f1 .806 vs .755 for plain `8on16-bw` and .351 for the prior
253
- * `6x12-dim` default — letter-spacing the readable cell wins; the dense
254
- * 6x12 was below the OCR ~16px/char floor and abstained. */
255
- anthropic: Shape;
256
- /** `8on22-bw`: 8x13 glyphs on a 22px pitch (extra leading), black ink.
257
- * Tool-result legibility bench on gemini-3.5-flash: f1 .934 vs .807 for
258
- * plain `8on16-bw` and .287 for the prior `doc-8on16-sent-dim`; the
259
- * line-spacing reduces row crowding so line numbers stay legible. */
260
- google: Shape;
261
- /** `8on22-bw`: 8x13 glyphs on a 22px pitch (extra leading), black ink.
262
- * Same line-spacing win for OpenAI; bench on gpt-5.5/gpt-5.4-mini showed
263
- * leading lifts recall on the readable cell over plain `8on16-bw`. */
264
- openai: Shape;
265
- /** Original 5x8 X.org shape (pre-shape-table sessions rendered this). */
266
- legacy: Shape;
267
- };
268
- /** Runtime guard for shape overrides loaded from config or preserve data. */
269
- export declare function isShape(value: unknown): value is Shape;
270
- /** One model line's ideal format: variant plus an optional frame-size
271
- * override when the line reads larger frames at no extra cost. */
272
- export interface IdealShape {
273
- variant: ShapeVariantName;
274
- frameSize?: number;
275
- }
276
- /** Eval-ideal format for a model id, or undefined when unmeasured. */
277
- export declare function idealShapeVariant(modelId: string): IdealShape | undefined;
278
- /** What will read the frames: the wire API (billing) and model id (shape). */
279
- export interface ShapeTarget {
280
- api?: Api;
281
- id?: string;
282
- }
283
- /**
284
- * Pick the frame shape for a reader. An explicit `variant` (anything but
285
- * `"auto"`) forces that geometry; otherwise the model id selects the
286
- * eval-winning shape — and frame size — for its model line, falling back to
287
- * the API family's winner when the model is unmeasured. Billing (token
288
- * estimate, detail hint) always follows the API family actually carrying
289
- * the request, computed for the resolved frame size. Accepts a full pi-ai
290
- * `Model` or any `{ api, id }` subset.
291
- */
292
- export declare function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape;
293
- /**
294
- * Pick the frame shape for `text`. Explicit variants remain forced. Auto first
295
- * resolves the model/provider default, then selects the Silver CJK grid when
296
- * the default font cannot safely render the text or wide CJK glyphs dominate
297
- * the transcript and Silver can render it safely.
298
- */
299
- export declare function resolveShapeForText(text: string, model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape;
300
- /** Legacy frame edge in pixels (the 5x8 shape's eval-validated size). New
301
- * shapes carry their own `frameSize`. */
302
- export declare const FRAME_SIZE = 2576;
303
- /** Default upper bound on archive frames carried per compaction. Sized to hold
304
- * ~400k tokens of the high-res Anthropic frame Opus reads (1932px ≈ 5,000
305
- * billed tokens each → 80 frames) while staying under the ~100-image
306
- * per-request wire cap. Oldest frames are dropped first once the budget is
307
- * exceeded (mirrors how iterative text summaries fade the oldest detail); a
308
- * caller may pass a lower `maxFrames` upper limit, and per-model context
309
- * fitting is handled by the caller's overflow guard. */
310
- export declare const MAX_FRAMES_DEFAULT = 80;
311
- /** High-quality (legible) frames rendered at each chronological edge of a
312
- * foveated archive — the session head (oldest) and the slice just before the
313
- * text region (newest) — with the denser low-quality tier filling the middle. */
314
- export declare const HQ_EDGE_FRAMES = 3;
315
- /** Conservative per-frame token estimate used for context budgeting — the
316
- * upper bound across shapes: high-res Claude frames hit the 4,784 visual-token
317
- * cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
318
- * undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
319
- export declare const FRAME_TOKEN_ESTIMATE = 5024;
320
- /** Conservative upper bound for one persisted frame's base64 payload. The
321
- * measured high-res Anthropic `8x13`/`11on16` PNG frames sit around 159 KB;
322
- * 170 KB leaves margin for denser glyph pages without permitting multi-MB
323
- * standing request bodies at large context windows. */
324
- export declare const FRAME_DATA_BYTES_ESTIMATE = 170000;
325
- /** Maximum snapcompact image base64 carried in every rebuilt provider request.
326
- * Above this, provider backends can accept the HTTP body but fail mid-stream
327
- * with opaque 5xx errors. Keep this independent from visual-token budgeting:
328
- * a 1M-token model can afford 70 images on paper, but not the resulting
329
- * ~11 MB JSON payload on every turn. */
330
- export declare const FRAME_DATA_BYTES_BUDGET = 3000000;
331
- /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET}. */
332
- export declare function maxFramesForDataBudget(maxFrameDataBytes?: number): number;
333
- /** Base64 byte length for persisted snapcompact frames. */
334
- export declare function frameDataBytes(frames: readonly Pick<Frame, "data">[]): number;
335
- /**
336
- * Per-request image-count budgets by provider id. These cap how many images an
337
- * entire request may carry (archive/system-prompt/tool-result imaging combined).
338
- * The values are conservative policy caps under the vendor hard limits
339
- * (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe
340
- * floor rather than sending unbounded attachments.
341
- */
342
- export declare const PROVIDER_IMAGE_BUDGETS: Record<string, number>;
343
- /** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
344
- export declare const DEFAULT_PROVIDER_IMAGE_BUDGET = 5;
345
- /** Per-request image budget for `provider`; unknown providers get the floor. */
346
- export declare function providerImageBudget(provider: string | undefined): number;
347
- /** Key under `CompactionEntry.preserveData` holding the frame archive. */
348
- export declare const PRESERVE_KEY = "snapcompact";
349
- /** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
350
- export interface Frame {
351
- /** Base64-encoded PNG. */
352
- data: string;
353
- mimeType: string;
354
- /** Characters per row in the frame grid (per-column width on doc frames). */
355
- cols: number;
356
- /** Text rows in the frame grid (unique lines, not repeated copies). */
357
- rows: number;
358
- /** Characters actually printed onto this frame. */
359
- chars: number;
360
- /** Shape metadata (absent on legacy frames, which are 5x8 `sent`). */
361
- font?: Shape["font"];
362
- variant?: Shape["variant"];
363
- lineRepeat?: number;
364
- /** 2 on two-column doc frames; absent on row-major grid frames. */
365
- columns?: number;
366
- /** True when stopwords were printed in dim ink. */
367
- stopwordDim?: boolean;
368
- /** Resolution hint forwarded to the provider when re-attaching. */
369
- detail?: ImageContent["detail"];
370
- }
371
- /** Frame archive persisted under `preserveData[PRESERVE_KEY]`. */
372
- export interface Archive {
373
- /** Rendered frames ordered oldest to newest, re-derived from {@link text}
374
- * each compaction with foveated quality tiers (HQ/LQ/HQ inside the imaged
375
- * middle). May be empty when the whole archive fits in text. */
376
- frames: Frame[];
377
- /** Characters currently readable across all frames plus the text regions. */
378
- totalChars: number;
379
- /** Characters dropped so far to respect the archive budget. */
380
- truncatedChars: number;
381
- /** Full kept archive source (oldest to newest, normalized, bounded to the
382
- * rendered budget) — the single source re-rendered each compaction. */
383
- text?: string;
384
- /** Oldest text region kept verbatim around the imaged middle. */
385
- textHead?: string;
386
- /** Newest text region kept verbatim around the imaged middle. */
387
- textTail?: string;
388
- }
389
- export interface Geometry {
390
- /** Characters per row (per-column line width when `columns === 2`). */
391
- cols: number;
392
- rows: number;
393
- /** Characters that fit one frame (nominal upper bound on doc shapes,
394
- * where real consumption is wrap-dependent). */
395
- capacity: number;
396
- }
397
- export interface Options<TMessage = Message> extends SerializeOptions {
398
- /** App-level message transformer (same contract as agent-core's `SummaryOptions.convertToLlm`). */
399
- convertToLlm?: ConvertToLlm<TMessage>;
400
- /** Model whose provider API and id select the frame shape. */
401
- model?: ShapeTarget;
402
- /** Explicit shape override; wins over `model`. */
403
- shape?: Shape;
404
- /** Frame edge in pixels. Defaults to the shape's `frameSize`. */
405
- frameSize?: number;
406
- /** Upper limit on archive frames; clamped to (and defaulting to) {@link MAX_FRAMES_DEFAULT}. */
407
- maxFrames?: number;
408
- }
409
- /** Result of rendering one frame. */
410
- export interface RenderedFrame {
411
- /** Base64-encoded PNG, as returned by the native renderer. */
412
- data: string;
413
- cols: number;
414
- rows: number;
415
- /** Characters printed (ink toggles excluded; input may be shorter than capacity). */
416
- chars: number;
417
- }
418
- export interface FileOperations {
419
- read: Set<string>;
420
- written: Set<string>;
421
- edited: Set<string>;
422
- }
423
- export interface CompactionDetails {
424
- readFiles: string[];
425
- modifiedFiles: string[];
426
- }
427
- export interface CompactionPreparation<TMessage = Message> {
428
- /** UUID of first entry to keep. */
429
- firstKeptEntryId: string;
430
- /** Messages that will be archived and discarded. */
431
- messagesToSummarize: TMessage[];
432
- /** Messages that will be archived as the split-turn prefix, if any. */
433
- turnPrefixMessages: TMessage[];
434
- tokensBefore: number;
435
- /** Summary from previous compaction, for continuity when no prior snapcompact archive exists. */
436
- previousSummary?: string;
437
- /** Preserved opaque compaction payload from the previous compaction, if any. */
438
- previousPreserveData?: Record<string, unknown>;
439
- /** File operations extracted by the host agent. */
440
- fileOps: FileOperations;
441
- }
442
- export interface CompactionResult<T = CompactionDetails> {
443
- summary: string;
444
- shortSummary?: string;
445
- firstKeptEntryId: string;
446
- tokensBefore: number;
447
- details?: T;
448
- preserveData?: Record<string, unknown>;
449
- }
450
- export type ConvertToLlm<TMessage = Message> = (messages: TMessage[]) => Message[];
451
- export declare function createFileOps(): FileOperations;
452
- export declare function isUrlSchemePath(path: string): boolean;
453
- export declare function computeFileLists(fileOps: FileOperations): CompactionDetails;
454
- export declare function upsertFileOperations(summary: string, readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string;
455
- /** Default per-tool-result character cap in serialized history. */
456
- export declare const TOOL_RESULT_MAX_CHARS = 2000;
457
- /** Default per-argument-value character cap inside serialized tool calls
458
- * (write/edit bodies otherwise dump whole files into the archive). */
459
- export declare const TOOL_ARG_MAX_CHARS = 500;
460
- /** Default character cap across one tool call's full serialized argument list. */
461
- export declare const TOOL_CALL_MAX_CHARS = 2000;
462
- /** Default fraction of a truncation budget spent on the head; the remainder
463
- * keeps the tail, where command errors and test failures usually land. */
464
- export declare const TRUNCATE_HEAD_RATIO = 0.6;
465
- /** Zero-width ink toggles understood by the native renderer (shift-out/in):
466
- * text between them prints in dim gray ink without occupying a cell. */
467
- export declare const DIM_ON = "\u000E";
468
- export declare const DIM_OFF = "\u000F";
469
- /** Character budgets applied while serializing discarded history for frame
470
- * rendering. Pass `Infinity` to disable an individual cap. */
471
- export interface SerializeOptions {
472
- /** Per-tool-result cap. Defaults to {@link TOOL_RESULT_MAX_CHARS}. */
473
- toolResultMaxChars?: number;
474
- /** Per-argument-value cap. Defaults to {@link TOOL_ARG_MAX_CHARS}. */
475
- toolArgMaxChars?: number;
476
- /** Whole-argument-list cap per call. Defaults to {@link TOOL_CALL_MAX_CHARS}. */
477
- toolCallMaxChars?: number;
478
- /** Head share of each budget, clamped to [0, 1]. Defaults to {@link TRUNCATE_HEAD_RATIO}. */
479
- truncateHeadRatio?: number;
480
- /** Print tool-result text in dim gray ink so archived conversation reads
481
- * louder than archived tool noise. Defaults to `true`. */
482
- dimToolResults?: boolean;
483
- /** Serialize assistant reasoning as `¶think:` sections. Defaults to `true`.
484
- * Callers archiving for a Claude/Anthropic-dialect model set this `false`:
485
- * the archive frames are replayed as text into every later request, and
486
- * reasoning rendered back to Claude trips its `reasoning_extraction`
487
- * classifier (issue #6093). */
488
- includeThinking?: boolean;
489
- }
490
- export declare function serializeConversation(messages: Message[], options?: SerializeOptions): string;
491
- /** Printed in place of newline runs: the native renderer fills this cell
492
- * entirely with pitch-black ink, so line structure survives whitespace
493
- * collapsing at a one-cell cost. */
494
- export declare const NEWLINE_GLYPH = "\u2588";
495
- export interface NormalizeOptions {
496
- /** Shape whose font is tried before the embedded Silver fallback. */
497
- shape?: Pick<Shape, "font">;
498
- /** Native font name when a full shape is not available. */
499
- font?: Shape["font"];
500
- }
501
- /**
502
- * Prepare text for printing: strip ANSI escape sequences, collapse horizontal
503
- * whitespace runs, fold unsupported symbols (including box drawing to ASCII),
504
- * preserve Unicode glyphs that either the selected font or embedded Silver
505
- * fallback can render, and drop decorative emoji instead of printing `?`.
506
- */
507
- export declare function normalize(text: string, options?: NormalizeOptions): string;
508
- /**
509
- * Scan text with the same font-aware path as {@link normalize}; unsafe means
510
- * more than 5% of graphic characters would hit the `?` fallback.
511
- */
512
- export declare function scanRenderability(text: string, options?: NormalizeOptions): {
513
- isSafe: boolean;
514
- unrenderableRatio: number;
515
- };
516
- /**
517
- * Wrap each maximal alphabetic run that is a stopword in {@link DIM_ON} /
518
- * {@link DIM_OFF} so it prints in dim gray ink. Spans that are already dim
519
- * (e.g. archived tool output) pass through untouched — wrapping there would
520
- * terminate the enclosing dim span early. Markers are zero-width, so the
521
- * visible glyph count is unchanged.
522
- */
523
- export declare function dimStopwords(text: string): string;
524
- /**
525
- * Greedy word-wrap, no mid-word breaks (hard split only for width+ words) —
526
- * ported verbatim from `research/exp14_bestgpt.py` `wrap()`. Zero-width dim
527
- * markers count toward word length here; serialized history places them at
528
- * word boundaries, so the drift is at most one cell per affected line.
529
- */
530
- export declare function wrap(text: string, width: number, wideCells?: boolean): string[];
531
- export declare function geometry(shape: Shape, size?: number): Geometry;
532
- /** Render one snapcompact frame from already-normalized text. Doc shapes
533
- * (`columns === 2`) expect one page of `\n`-joined pre-wrapped lines. */
534
- export declare function render(text: string, shape: Shape, size?: number): Promise<RenderedFrame>;
535
- /** Options for {@link renderMany} and {@link frames}. */
536
- export interface RenderManyOptions {
537
- /** Explicit shape; wins over `model`. */
538
- shape?: Shape;
539
- /** Model whose provider API and id select the frame shape. */
540
- model?: ShapeTarget;
541
- /** Frame edge in px; defaults to the shape's `frameSize`. */
542
- frameSize?: number;
543
- /** Hard cap on frames produced; omit for unbounded (caller decides usage). */
544
- maxFrames?: number;
545
- }
546
- /**
547
- * Render arbitrary text into snapcompact PNG frames as LLM image blocks
548
- * (first page first). Empty/whitespace-only input yields no frames.
549
- */
550
- export declare function renderMany(text: string, options?: RenderManyOptions): Promise<ImageContent[]>;
551
- /** Frames needed to hold `text` at the given shape/size, without rendering.
552
- * For doc shapes this wraps the text once and counts pages of `2 * rows`
553
- * lines; for grid shapes it divides by the frame capacity. */
554
- export declare function frames(text: string, options?: Pick<RenderManyOptions, "shape" | "model" | "frameSize">): number;
555
- /** Validate and extract a persisted frame archive from `preserveData`. */
556
- export declare function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined;
557
- /** Drop the persisted frame archive ({@link PRESERVE_KEY}) from `preserveData`,
558
- * returning the remaining state — or `undefined` when nothing else remains, so
559
- * an empty `{}` is never persisted. Callers strip the archive once its frames
560
- * have been migrated into a new compaction's text, preventing the stale frames
561
- * from leaking back into the rebuilt context. */
562
- export declare function stripPreservedArchive(preserveData: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
563
- /** Extract persisted archive source text as plain text for LLM summarization. */
564
- export declare function archiveSourceText(archive: Archive): string | undefined;
565
- /** Build the text used to choose and preflight a font-aware snapcompact shape. */
566
- export declare function renderabilityProbeText(serialized: string, previousPreserveData?: Record<string, unknown>, previousSummary?: string): string;
567
- /** Options for reconstructing a persisted snapcompact archive into prompt blocks. */
568
- export interface HistoryBlockOptions {
569
- /** Hard cap on image base64 bytes attached to one rebuilt provider request. */
570
- maxFrameDataBytes?: number;
571
- }
572
- /** Convert archive frames into LLM image blocks (oldest first). */
573
- export declare function images(archive: Archive): ImageContent[];
574
- /** Ordered archive blocks for a compaction summary message, oldest to newest:
575
- * the oldest text region, the imaged middle, then the newest text region.
576
- * Runtime-only; reconstructed from {@link Archive} on each context rebuild
577
- * instead of persisted on the session entry. */
578
- export declare function historyBlocks(archive: Archive, options?: HistoryBlockOptions): (TextContent | ImageContent)[];
579
- /**
580
- * Run a snapcompact compaction over prepared messages. Fully local: serializes
581
- * the discarded history, appends it to the accumulated archive source text, and
582
- * re-renders that source into an ordered history layout: plain text at the
583
- * oldest edge, imaged middle, then plain text at the newest edge. The imaged
584
- * middle itself foveates (HQ/LQ/HQ) when it grows large.
585
- *
586
- * The full kept source persists on the archive (`text`) so each later compaction
587
- * unfolds and re-renders it coherently alongside the newly archived history.
588
- *
589
- * If the previous compaction was text-based, its summary is printed at the head
590
- * of the archive as `[Summary of earlier history]` so no continuity is lost.
591
- */
592
- export declare function compact<TMessage = Message>(preparation: CompactionPreparation<TMessage>, options?: Options<TMessage>): Promise<CompactionResult>;