@linxiraos/pi-snapcompact 1.2.1 → 1.3.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 +17 -0
- package/README.md +13 -11
- package/THIRD-PARTY-NOTICES.txt +55 -5
- package/dist/types/snapcompact.d.ts +90 -34
- package/package.json +7 -7
- package/src/snapcompact.d.ts +90 -34
- package/src/snapcompact.ts +290 -82
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.3.0] - 2026-10-18
|
|
4
|
+
|
|
5
|
+
### Breaking Changes
|
|
6
|
+
|
|
7
|
+
- `providerImageBudget(provider)` and `providerFrameBudget(provider)` became `providerImageBudget(model)` and `providerFrameBudget(model)`, and `maxFramesForDataBudget(shape)` became `maxFramesForDataBudget(shape, budget)` with a required byte budget ([#15434](https://github.com/can1357/oh-my-pi/pull/15434) by [@will-bogusz](https://github.com/will-bogusz))
|
|
8
|
+
- Removed `PROVIDER_IMAGE_BUDGETS` and `FRAME_DATA_BYTES_BUDGET`: image counts come from the catalog's `image-count-budget` rules, and frame byte budgets from `frameDataBytesBudget(model)` (`DEFAULT_FRAME_DATA_BYTES_BUDGET` where the host has none) ([#15434](https://github.com/can1357/oh-my-pi/pull/15434) by [@will-bogusz](https://github.com/will-bogusz))
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Added `plannedFrameTokens` and `Shape.frameTokensVerified`: archive sizing charges a frame its own price when the reader's `image-tokenization` rule is `verified`, and at least `FRAME_TOKEN_ESTIMATE` otherwise ([#15442](https://github.com/can1357/oh-my-pi/pull/15442) by [@will-bogusz](https://github.com/will-bogusz))
|
|
13
|
+
- Added `viewWithoutThinking()`, `textWithoutThinking()`, `readerIncludesThinking()` and an optional `Archive.withoutThinking` variant, so an archive rendered with reasoning can be read by a model that must not see it; without a stored variant the view keeps only reasoning-free text and no frames ([#15436](https://github.com/can1357/oh-my-pi/pull/15436) by [@will-bogusz](https://github.com/will-bogusz))
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed snapcompact frames for Claude and GPT on Cursor, including its `default` (Auto) route, being drawn at 1568–1932px, which Cursor shrank to 1024px before the model read them; frames now render at the host's catalog `image-max-edge` ([#15448](https://github.com/can1357/oh-my-pi/pull/15448) by [@will-bogusz](https://github.com/will-bogusz))
|
|
18
|
+
- Fixed re-rendering an archive without reasoning keeping every paragraph after the first of a multi-paragraph `¶think:` section ([#15436](https://github.com/can1357/oh-my-pi/pull/15436) by [@will-bogusz](https://github.com/will-bogusz))
|
|
19
|
+
|
|
3
20
|
## [1.2.0] - 2026-10-12
|
|
4
21
|
|
|
5
22
|
- 版本线推进至 1.1.28;本版无独立用户可见变化。
|
package/README.md
CHANGED
|
@@ -15,17 +15,19 @@ Built for [omp](https://github.com/can1357/oh-my-pi)'s compaction pipeline, but
|
|
|
15
15
|
|
|
16
16
|
Frame shapes are provider-aware, chosen by SQuAD recall evals (see `research/`) against real provider billing:
|
|
17
17
|
|
|
18
|
-
| Reader
|
|
19
|
-
|
|
|
20
|
-
| Anthropic | `11on16-bw`
|
|
21
|
-
| Google
|
|
22
|
-
| OpenAI
|
|
23
|
-
| Unknown
|
|
18
|
+
| Reader | Default shape | Notes |
|
|
19
|
+
| --------- | ---------------- | --------------------------------------------------------------------------------------------------------- |
|
|
20
|
+
| Anthropic | `11on16-bw` | X.org 8x13 glyphs on an 11px advance; high-res Claude lines get 1932px frames |
|
|
21
|
+
| Google | `8on22-bw` @2048 | X.org 8x13 glyphs on a 22px pitch; Gemini bills a fixed per-image budget, so larger frames are free chars |
|
|
22
|
+
| OpenAI | `8on22-bw` | X.org 8x13 glyphs on a 22px pitch, sent at `detail: "original"` |
|
|
23
|
+
| Unknown | Anthropic shape | Per-provider image-count budgets guard against gateways that silently drop frames |
|
|
24
24
|
|
|
25
|
-
`resolveShape({ api, id })` matches the model id, not just the wire API — a Claude routed through Vertex or OpenRouter keeps its Claude shape. Frames are priced by the reading model's lineage: its catalog `image-tokenization` rule (so that Claude is billed as Claude, and Opus 4.6 under the standard 1,568-token tier), falling back to the catalog rule for the wire API carrying the request when the lineage has none. A reader with neither costs `FRAME_TOKEN_ESTIMATE` per frame.
|
|
25
|
+
`resolveShape({ api, id })` matches the model id, not just the wire API — a Claude routed through Vertex or OpenRouter keeps its Claude shape. Frames are priced by the reading model's lineage: its catalog `image-tokenization` rule (so that Claude is billed as Claude, and Opus 4.6 under the standard 1,568-token tier), falling back to the catalog rule for the wire API carrying the request when the lineage has none. A reader with neither costs `FRAME_TOKEN_ESTIMATE` per frame. Archive sizing (`plannedFrameTokens`) charges each frame its own price when the rule is `verified` against provider billing, and at least `FRAME_TOKEN_ESTIMATE` otherwise.
|
|
26
26
|
|
|
27
27
|
Bitmap shapes keep their provider-tuned geometry and draw missing glyphs through the embedded Silver TrueType fallback one character at a time; East Asian (CJK/Kana/Hangul) glyphs render full-width across two cells so they stay legible in the narrow ASCII grid. Selecting `silver16-bw` uses Silver for the whole frame.
|
|
28
28
|
|
|
29
|
+
A host that downscales images before the model reads them (catalog `image-max-edge`; Cursor fits Claude and GPT images into 1024×1024) gets frames rendered at that edge, forced variants included, so the glyphs reach the model at the size they were drawn.
|
|
30
|
+
|
|
29
31
|
## Install
|
|
30
32
|
|
|
31
33
|
```sh
|
|
@@ -42,8 +44,8 @@ Render arbitrary text into LLM image blocks:
|
|
|
42
44
|
import { renderMany, frames, resolveShape } from "@linxiraos/pi-snapcompact";
|
|
43
45
|
|
|
44
46
|
const images = renderMany(longText, { model }); // ImageContent[], first page first
|
|
45
|
-
const count = frames(longText, { model });
|
|
46
|
-
const shape = resolveShape(model);
|
|
47
|
+
const count = frames(longText, { model }); // frame count without rendering
|
|
48
|
+
const shape = resolveShape(model); // eval-optimal Shape for the reader
|
|
47
49
|
```
|
|
48
50
|
|
|
49
51
|
Run a full compaction pass over prepared messages:
|
|
@@ -58,11 +60,11 @@ const result = await compact(preparation, { model });
|
|
|
58
60
|
|
|
59
61
|
## API surface
|
|
60
62
|
|
|
61
|
-
- **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images`, `historyBlocks`
|
|
63
|
+
- **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images`, `historyBlocks`, `capFrameDataBytes`
|
|
62
64
|
- **Rendering**: `render`, `renderMany`, `frames`, `geometry`
|
|
63
65
|
- **Shapes**: `SHAPES`, `SHAPE_VARIANTS`, `resolveShape`, `resolveShapeForText`, `idealShapeVariant`, `isShape`, `isShapeVariantName`
|
|
64
66
|
- **Text**: `serializeConversation`, `normalize`, `scanRenderability`, `renderabilityProbeText`, `dimStopwords`, `wrap`
|
|
65
|
-
- **Budgets**: `providerImageBudget`, `MAX_FRAMES_DEFAULT`, `FRAME_TOKEN_ESTIMATE`, `frameBilling`, `frameTokens`, `frameBillingKey`, `HQ_EDGE_FRAMES`
|
|
67
|
+
- **Budgets**: `providerImageBudget`, `frameDataBytesBudget`, `MAX_FRAMES_DEFAULT`, `FRAME_TOKEN_ESTIMATE`, `frameBilling`, `frameTokens`, `frameBillingKey`, `plannedFrameTokens`, `HQ_EDGE_FRAMES`
|
|
66
68
|
- **File ops**: `createFileOps`, `computeFileLists`, `upsertFileOperations`
|
|
67
69
|
|
|
68
70
|
## References
|
package/THIRD-PARTY-NOTICES.txt
CHANGED
|
@@ -8,8 +8,8 @@ no rights in third-party code, fonts, data, or marks identified below.
|
|
|
8
8
|
PYTHON DEPENDENCY DISTRIBUTION ROLE
|
|
9
9
|
===================================
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
Uvicorn, HTTPX, Pydantic, pydantic-settings, python-dotenv, Click, and
|
|
11
|
+
zeta-rpc has no third-party runtime dependencies. robomp declares FastAPI,
|
|
12
|
+
Uvicorn, HTTPX, Pydantic, pydantic-settings, python-dotenv, Click, and zeta-rpc
|
|
13
13
|
as ordinary installer-resolved dependencies. The Python wheels do not vendor
|
|
14
14
|
those projects; each dependency remains a separately installed distribution
|
|
15
15
|
carrying its own license metadata and files. Container/system assemblers must
|
|
@@ -79,9 +79,9 @@ uutils coreutils (https://github.com/uutils/coreutils), MIT
|
|
|
79
79
|
--------------------------------------------------------------------------------
|
|
80
80
|
Covers: base32, base64, basename, cat, cksum (shared checksum machinery),
|
|
81
81
|
b2sum, md5sum, sha1sum, sha224sum, sha256sum, sha384sum, sha512sum, comm, cp,
|
|
82
|
-
cut, date, dirname, head, hostname, ln, ls, mkdir, mktemp, mv,
|
|
83
|
-
printenv, readlink, realpath, rm, seq, sort, stat, tac, tail, tee,
|
|
84
|
-
truncate, uname, uniq, wc, whoami, yes.
|
|
82
|
+
cut, date, dirname, head, hostname, ln, ls, mkdir, mktemp, mv, nl, nproc,
|
|
83
|
+
paste, printenv, readlink, realpath, rm, seq, sort, stat, tac, tail, tee,
|
|
84
|
+
touch, tr, truncate, uname, uniq, wc, whoami, yes.
|
|
85
85
|
|
|
86
86
|
Copyright (c) uutils developers
|
|
87
87
|
|
|
@@ -824,6 +824,56 @@ public licenses.
|
|
|
824
824
|
|
|
825
825
|
Creative Commons may be contacted at creativecommons.org.
|
|
826
826
|
|
|
827
|
+
-------------------------------------------------------------------------------
|
|
828
|
+
scripts/brand/fonts/ANSI Shadow.flf, scripts/brand/fonts/mini.flf
|
|
829
|
+
|
|
830
|
+
Two FIGlet font files from the standard FIGlet font distribution — the
|
|
831
|
+
figlet.org font set shipped by figlet.js
|
|
832
|
+
(https://github.com/patorjk/figlet.js/tree/master/fonts), fetched from
|
|
833
|
+
figlet.js 1.8.0 — vendored so the welcome/splash brand art
|
|
834
|
+
(`packages/tui/src/prompt/welcome.ts`) is generated reproducibly and offline
|
|
835
|
+
by `bun scripts/brand/generate-logo-art.ts`. Both files are unmodified and
|
|
836
|
+
retain their original FIGlet headers. They are build-time inputs only: no font
|
|
837
|
+
file or font data is compiled into, or shipped with, a Zeta release artifact.
|
|
838
|
+
The fonts' own headers carry their terms:
|
|
839
|
+
|
|
840
|
+
- `ANSI Shadow.flf` — "ANSI Shadow". Author unknown ("Font Author: ?" in the
|
|
841
|
+
header); a TheDraw-archive font converted with the FIGlet font editor
|
|
842
|
+
(http://patorjk.com/figfont-editor). Distributed under the FIGlet font
|
|
843
|
+
license as carried by the standard FIGlet font set: free to use and
|
|
844
|
+
redistribute with the header intact.
|
|
845
|
+
- `mini.flf` — "mini" by Glenn Chappell (4/93), from figlet release 2.1
|
|
846
|
+
(12 Aug 1994), "Includes ISO Latin-1"; modified by Paul Burton
|
|
847
|
+
<solution@earthlink.net> 12/96. Header grant: "Permission is hereby given to
|
|
848
|
+
modify this font, as long as the modifier's name is placed on a comment
|
|
849
|
+
line."
|
|
850
|
+
|
|
851
|
+
FIGlet renderer — `figlet` 1.8.0 (https://github.com/patorjk/figlet.js), a
|
|
852
|
+
root devDependency used only to generate the art at build time; it is not
|
|
853
|
+
imported by any runtime bundle or shipped artifact.
|
|
854
|
+
|
|
855
|
+
MIT License
|
|
856
|
+
|
|
857
|
+
Copyright (C) 2014-present Patrick Gillespie and other contributors
|
|
858
|
+
|
|
859
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
860
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
861
|
+
in the Software without restriction, including without limitation the rights
|
|
862
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
863
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
864
|
+
furnished to do so, subject to the following conditions:
|
|
865
|
+
|
|
866
|
+
The above copyright notice and this permission notice shall be included in
|
|
867
|
+
all copies or substantial portions of the Software.
|
|
868
|
+
|
|
869
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
870
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
871
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
872
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
873
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
874
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
875
|
+
SOFTWARE.
|
|
876
|
+
|
|
827
877
|
BUN / NPM DEPENDENCY INVENTORY
|
|
828
878
|
==============================
|
|
829
879
|
This inventory is traversed from bun.lock, not from the host node_modules tree.
|
|
@@ -32,11 +32,9 @@
|
|
|
32
32
|
* billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
|
|
33
33
|
* area-proportional, so resolution cannot improve chars/$ — 1568 stays.
|
|
34
34
|
* `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
|
|
35
|
-
* - **Unknown providers** default to `8on22-bw`. `providerImageBudget`
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* gone; routers now use the same permissive budget as direct
|
|
39
|
-
* Anthropic/Claude lines unless configured otherwise upstream.
|
|
35
|
+
* - **Unknown providers** default to `8on22-bw`. `providerImageBudget` caps
|
|
36
|
+
* per-request images at the deployment's catalog `image-count-budget`, so
|
|
37
|
+
* inline imaging cannot flood a request with attachments.
|
|
40
38
|
*
|
|
41
39
|
* The per-frame token estimate follows the reading model's catalog
|
|
42
40
|
* `image-tokenization` rule (its lineage, not the gateway), so Claude behind
|
|
@@ -44,13 +42,17 @@
|
|
|
44
42
|
* Models without a lineage rule fall back to the catalog rule for their wire
|
|
45
43
|
* API; a reader with neither costs {@link FRAME_TOKEN_ESTIMATE} per frame.
|
|
46
44
|
*
|
|
45
|
+
* A host that downscales every image before the model reads it (catalog
|
|
46
|
+
* `image-max-edge`, e.g. Cursor) gets frames rendered at that edge instead,
|
|
47
|
+
* so glyphs reach the model at the size they were drawn.
|
|
48
|
+
*
|
|
47
49
|
* The whole pass is local and deterministic — no LLM call, no API key, no
|
|
48
50
|
* latency beyond rendering. Rasterization and PNG encoding happen in native
|
|
49
51
|
* code (`renderSnapcompactPng` in `crates/pi-natives/src/snapcompact.rs`).
|
|
50
52
|
* Frames persist in the compaction entry's `preserveData` and are
|
|
51
53
|
* re-attached to the compaction summary message on every context rebuild.
|
|
52
54
|
*/
|
|
53
|
-
import type { Api, ImageContent, Message, TextContent } from "@linxiraos/pi-ai";
|
|
55
|
+
import type { Api, ImageContent, Message, Model, TextContent } from "@linxiraos/pi-ai";
|
|
54
56
|
import { type ImageSize, type ImageTokenization, type ImageTokenizationTarget } from "@linxiraos/pi-catalog/compat/image-tokenization";
|
|
55
57
|
/** One eval-validated frame shape: font, cell, ink, repetition, and size. */
|
|
56
58
|
export interface Shape {
|
|
@@ -78,11 +80,13 @@ export interface Shape {
|
|
|
78
80
|
frameSize: number;
|
|
79
81
|
/** Per-frame billed-token estimate for the shape's target provider. */
|
|
80
82
|
frameTokenEstimate: number;
|
|
83
|
+
/** `frameTokenEstimate` comes from a catalog rule measured against provider billing. */
|
|
84
|
+
frameTokensVerified?: boolean;
|
|
81
85
|
/** Resolution hint attached to frame images (OpenAI-only). */
|
|
82
86
|
imageDetail?: ImageContent["detail"];
|
|
83
87
|
}
|
|
84
88
|
/** Geometry half of a {@link Shape}: everything except provider billing. */
|
|
85
|
-
export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "imageDetail">;
|
|
89
|
+
export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "frameTokensVerified" | "imageDetail">;
|
|
86
90
|
/**
|
|
87
91
|
* Frame variants exercised by the SQuAD evals in `research/` that the native
|
|
88
92
|
* renderer reproduces faithfully, keyed by their research names. Font codes:
|
|
@@ -269,6 +273,11 @@ export declare function frameBilling(target: ShapeTarget | undefined): FrameBill
|
|
|
269
273
|
export declare function frameBillingKey(billing: FrameBilling): string;
|
|
270
274
|
/** Billed-token estimate for one rendered frame of `size` under `billing`. */
|
|
271
275
|
export declare function frameTokens(billing: FrameBilling, size: ImageSize): number;
|
|
276
|
+
/**
|
|
277
|
+
* Per-frame tokens archive sizing charges `shape`: its own price when provider
|
|
278
|
+
* billing verified it, else at least {@link FRAME_TOKEN_ESTIMATE}.
|
|
279
|
+
*/
|
|
280
|
+
export declare function plannedFrameTokens(shape: Shape): number;
|
|
272
281
|
/** Eval-validated shapes, keyed by the provider family they won on. */
|
|
273
282
|
export declare const SHAPES: {
|
|
274
283
|
/** `11on16-bw`: 8x13 glyphs on an 11px advance (extra tracking), black ink.
|
|
@@ -312,7 +321,8 @@ export interface ShapeTarget {
|
|
|
312
321
|
* Pick the frame shape for a reader. An explicit `variant` (anything but
|
|
313
322
|
* `"auto"`) forces that geometry; otherwise the model id selects the
|
|
314
323
|
* eval-winning shape — and frame size — for its model line, falling back to
|
|
315
|
-
* the API family's winner when the model is unmeasured.
|
|
324
|
+
* the API family's winner when the model is unmeasured. Either way the frame
|
|
325
|
+
* is no wider than the host's catalog `image-max-edge`. Billing (token
|
|
316
326
|
* estimate, detail hint) is computed for the resolved frame size from the
|
|
317
327
|
* model's catalog image rule. Accepts a full pi-ai `Model` or any
|
|
318
328
|
* `{ api, id }` subset.
|
|
@@ -340,44 +350,46 @@ export declare const MAX_FRAMES_DEFAULT = 80;
|
|
|
340
350
|
* foveated archive — the session head (oldest) and the slice just before the
|
|
341
351
|
* text region (newest) — with the denser low-quality tier filling the middle. */
|
|
342
352
|
export declare const HQ_EDGE_FRAMES = 3;
|
|
343
|
-
/** Conservative per-frame token estimate for
|
|
344
|
-
*
|
|
345
|
-
* 4,784 visual-token cap, billed
|
|
346
|
-
*
|
|
347
|
-
* {@link MAX_FRAMES_DEFAULT}. */
|
|
353
|
+
/** Conservative per-frame token estimate for sizing archives whose frame price
|
|
354
|
+
* is not billing-verified, and for frames of unknown size — the upper bound
|
|
355
|
+
* across shapes: high-res Claude frames hit the 4,784 visual-token cap, billed
|
|
356
|
+
* at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
|
|
357
|
+
* undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
|
|
348
358
|
export declare const FRAME_TOKEN_ESTIMATE = 5024;
|
|
349
359
|
/** Conservative upper bound for one persisted frame's base64 payload. The
|
|
350
360
|
* measured high-res Anthropic `8x13`/`11on16` PNG frames sit around 159 KB;
|
|
351
361
|
* 170 KB leaves margin for denser glyph pages without permitting multi-MB
|
|
352
362
|
* standing request bodies at large context windows. */
|
|
353
363
|
export declare const FRAME_DATA_BYTES_ESTIMATE = 170000;
|
|
354
|
-
/**
|
|
355
|
-
*
|
|
364
|
+
/** Frame payload cap for a deployment without a catalog `inline-image-byte-budget`:
|
|
365
|
+
* an unmeasured backend can accept the HTTP body and still fail mid-stream
|
|
356
366
|
* with opaque 5xx errors. Keep this independent from visual-token budgeting:
|
|
357
367
|
* a 1M-token model can afford 70 images on paper, but not the resulting
|
|
358
368
|
* ~11 MB JSON payload on every turn. */
|
|
359
|
-
export declare const
|
|
360
|
-
/**
|
|
361
|
-
*
|
|
369
|
+
export declare const DEFAULT_FRAME_DATA_BYTES_BUDGET = 3000000;
|
|
370
|
+
/** Base64 image bytes the archive frames riding every request to `model` may
|
|
371
|
+
* carry: half the deployment's inline-image byte budget, else
|
|
372
|
+
* {@link DEFAULT_FRAME_DATA_BYTES_BUDGET}. Frames are the oldest images in the
|
|
373
|
+
* request, so the send-time byte clamp evicts them first; the other half stays
|
|
374
|
+
* free for live screenshots. */
|
|
375
|
+
export declare function frameDataBytesBudget(model: Model | undefined): number;
|
|
376
|
+
/** Frame-count cap a `budget` of base64 bytes implies for frames rendered by
|
|
377
|
+
* `shape`. {@link AREA_PRICED_VARIANTS} below 1932px are charged
|
|
362
378
|
* {@link FRAME_DATA_BYTES_ESTIMATE} scaled by pixel area; every other shape
|
|
363
379
|
* pays the full estimate. */
|
|
364
|
-
export declare function maxFramesForDataBudget(shape: ShapeGeometry): number;
|
|
380
|
+
export declare function maxFramesForDataBudget(shape: ShapeGeometry, budget: number): number;
|
|
365
381
|
/** Base64 byte length for persisted snapcompact frames. */
|
|
366
382
|
export declare function frameDataBytes(frames: readonly Pick<Frame, "data">[]): number;
|
|
367
|
-
/**
|
|
368
|
-
*
|
|
369
|
-
* entire request may carry (archive/system-prompt/tool-result imaging combined).
|
|
370
|
-
* The values are conservative policy caps under the vendor hard limits
|
|
371
|
-
* (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe
|
|
372
|
-
* floor rather than sending unbounded attachments.
|
|
373
|
-
*/
|
|
374
|
-
export declare const PROVIDER_IMAGE_BUDGETS: Record<string, number>;
|
|
375
|
-
/** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
|
|
383
|
+
/** Safe floor for a deployment whose catalog rules set no `image-count-budget`
|
|
384
|
+
* (strictest mainstream measured: Groq ~5). */
|
|
376
385
|
export declare const DEFAULT_PROVIDER_IMAGE_BUDGET = 5;
|
|
377
|
-
/**
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
386
|
+
/** Most images one request to `model`'s deployment may carry, archive,
|
|
387
|
+
* system-prompt and tool-result imaging combined. */
|
|
388
|
+
export declare function providerImageBudget(model: Model | undefined): number;
|
|
389
|
+
/** Archive frame cap for `model`: its image budget, never above {@link MAX_FRAMES_DEFAULT}. */
|
|
390
|
+
export declare function providerFrameBudget(model: Model | undefined): number;
|
|
391
|
+
/** Whether `model` may get a fresh `read` result as frames (catalog `snapcompact-image-reads`, opt-in). */
|
|
392
|
+
export declare function readerImagesReadResults(model: Model): boolean;
|
|
381
393
|
/** Key under `CompactionEntry.preserveData` holding the frame archive. */
|
|
382
394
|
export declare const PRESERVE_KEY = "snapcompact";
|
|
383
395
|
/** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
|
|
@@ -419,6 +431,15 @@ export interface Archive {
|
|
|
419
431
|
textHead?: string;
|
|
420
432
|
/** Newest text region kept verbatim around the imaged middle. */
|
|
421
433
|
textTail?: string;
|
|
434
|
+
/** The same history re-rendered for a reader that excludes assistant
|
|
435
|
+
* reasoning ({@link SerializeOptions.includeThinking} `false`), when this
|
|
436
|
+
* archive carries it. Read through {@link viewWithoutThinking}. */
|
|
437
|
+
withoutThinking?: ArchiveView;
|
|
438
|
+
}
|
|
439
|
+
/** What a reader gets for one compaction: the summary text and the archive it introduces. */
|
|
440
|
+
export interface ArchiveView {
|
|
441
|
+
summary: string;
|
|
442
|
+
archive: Archive;
|
|
422
443
|
}
|
|
423
444
|
export interface Geometry {
|
|
424
445
|
/** Characters per row (per-column line width when `columns === 2`). */
|
|
@@ -515,7 +536,8 @@ export interface SerializeOptions {
|
|
|
515
536
|
* louder than archived tool noise. Defaults to `true`. */
|
|
516
537
|
dimToolResults?: boolean;
|
|
517
538
|
/** Serialize assistant reasoning as `¶think:` sections. Defaults to `true`.
|
|
518
|
-
* Callers archiving for a
|
|
539
|
+
* Callers archiving for a reader whose classifier refuses quoted reasoning
|
|
540
|
+
* ({@link readerIncludesThinking} `false`) set this `false`:
|
|
519
541
|
* the archive frames are replayed as text into every later request, and
|
|
520
542
|
* reasoning rendered back to Claude trips its `reasoning_extraction`
|
|
521
543
|
* classifier (issue #6093). */
|
|
@@ -586,7 +608,8 @@ export declare function renderMany(text: string, options?: RenderManyOptions): P
|
|
|
586
608
|
* For doc shapes this wraps the text once and counts pages of `2 * rows`
|
|
587
609
|
* lines; for grid shapes it divides by the frame capacity. */
|
|
588
610
|
export declare function frames(text: string, options?: Pick<RenderManyOptions, "shape" | "model" | "frameSize">): number;
|
|
589
|
-
/** Validate and extract a persisted frame archive from `preserveData
|
|
611
|
+
/** Validate and extract a persisted frame archive from `preserveData`, with its
|
|
612
|
+
* {@link Archive.withoutThinking} variant when that is valid too. */
|
|
590
613
|
export declare function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined;
|
|
591
614
|
/** Drop the persisted frame archive ({@link PRESERVE_KEY}) from `preserveData`,
|
|
592
615
|
* returning the remaining state — or `undefined` when nothing else remains, so
|
|
@@ -620,6 +643,39 @@ export declare function images(archive: Archive): ImageContent[];
|
|
|
620
643
|
* Runtime-only; reconstructed from {@link Archive} on each context rebuild
|
|
621
644
|
* instead of persisted on the session entry. */
|
|
622
645
|
export declare function historyBlocks(archive: Archive, options?: HistoryBlockOptions): (TextContent | ImageContent)[];
|
|
646
|
+
/**
|
|
647
|
+
* Re-applies a smaller frame byte budget to blocks {@link historyBlocks} already built: keeps the newest frames that
|
|
648
|
+
* fit, priced as {@link historyBlocks} prices them, and replaces each run of dropped frames with one omission notice.
|
|
649
|
+
* Returns `blocks` itself when every frame fits.
|
|
650
|
+
*/
|
|
651
|
+
export declare function capFrameDataBytes(blocks: (TextContent | ImageContent)[], maxFrameDataBytes: number): (TextContent | ImageContent)[];
|
|
652
|
+
/**
|
|
653
|
+
* Whether an archive read by `model` may carry assistant reasoning. The archive
|
|
654
|
+
* replays as text into every later request, and a reader whose classifier
|
|
655
|
+
* refuses reasoning quoted back into its input (the `classifier-cot-extraction`
|
|
656
|
+
* catalog axis, set for Claude: `reasoning_extraction`, issue #6093) gets
|
|
657
|
+
* `¶think:` sections and their legend left out.
|
|
658
|
+
*/
|
|
659
|
+
export declare function readerIncludesThinking(model: Model): boolean;
|
|
660
|
+
/** Whether a snapcompact summary declares `¶think:` reasoning scopes: true for
|
|
661
|
+
* archives rendered with {@link SerializeOptions.includeThinking}, and for
|
|
662
|
+
* summaries written before that legend was gated on it. */
|
|
663
|
+
export declare function summaryIncludesThinking(summary: string): boolean;
|
|
664
|
+
/** `text` (a summary or archive source, raw or normalized) without its
|
|
665
|
+
* `¶think:` sections, and with the reasoning scope dropped from any legend. */
|
|
666
|
+
export declare function textWithoutThinking(text: string): string;
|
|
667
|
+
/**
|
|
668
|
+
* What a reader that excludes assistant reasoning reads for a compaction
|
|
669
|
+
* whose summary is `summary` and archive `archive`: the archive's
|
|
670
|
+
* {@link Archive.withoutThinking} variant when it carries one, the original
|
|
671
|
+
* when its summary declares no reasoning, else a fail-closed text-only form.
|
|
672
|
+
* That form keeps no frames (pixels cannot be scrubbed), takes its text edges
|
|
673
|
+
* from the source after every `¶think:` section is removed (so a section
|
|
674
|
+
* crossing an edge cannot leak), counts the dropped middle as truncated, and
|
|
675
|
+
* re-renders the summary without the reasoning legend; `details` restores its
|
|
676
|
+
* file list.
|
|
677
|
+
*/
|
|
678
|
+
export declare function viewWithoutThinking(summary: string, archive: Archive, details?: CompactionDetails): ArchiveView;
|
|
623
679
|
/**
|
|
624
680
|
* Run a snapcompact compaction over prepared messages. Fully local: serializes
|
|
625
681
|
* the discarded history, appends it to the accumulated archive source text, and
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@linxiraos/pi-snapcompact",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.3.0",
|
|
5
5
|
"description": "Bitmap-frame context compression for vision-capable LLMs",
|
|
6
6
|
"homepage": "https://linxira-os.github.io/zeta/",
|
|
7
7
|
"author": "Linxira-OS",
|
|
@@ -24,18 +24,18 @@
|
|
|
24
24
|
"types": "./dist/types/index.d.ts",
|
|
25
25
|
"scripts": {
|
|
26
26
|
"check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
|
|
27
|
-
"check:types": "
|
|
27
|
+
"check:types": "tsc -p tsconfig.json --noEmit",
|
|
28
28
|
"lint": "oxlint .",
|
|
29
29
|
"test": "bun test --parallel",
|
|
30
30
|
"fix": "oxlint --fix --fix-suggestions . && bun run fmt",
|
|
31
31
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@linxiraos/pi-ai": "1.
|
|
35
|
-
"@linxiraos/pi-catalog": "1.
|
|
36
|
-
"@linxiraos/pi-natives": "1.
|
|
37
|
-
"@linxiraos/pi-utils": "1.
|
|
38
|
-
"@linxiraos/pi-wire": "1.
|
|
34
|
+
"@linxiraos/pi-ai": "1.3.0",
|
|
35
|
+
"@linxiraos/pi-catalog": "1.3.0",
|
|
36
|
+
"@linxiraos/pi-natives": "1.3.0",
|
|
37
|
+
"@linxiraos/pi-utils": "1.3.0",
|
|
38
|
+
"@linxiraos/pi-wire": "1.3.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/bun": "1.4.3"
|
package/src/snapcompact.d.ts
CHANGED
|
@@ -32,11 +32,9 @@
|
|
|
32
32
|
* billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
|
|
33
33
|
* area-proportional, so resolution cannot improve chars/$ — 1568 stays.
|
|
34
34
|
* `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
|
|
35
|
-
* - **Unknown providers** default to `8on22-bw`. `providerImageBudget`
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* gone; routers now use the same permissive budget as direct
|
|
39
|
-
* Anthropic/Claude lines unless configured otherwise upstream.
|
|
35
|
+
* - **Unknown providers** default to `8on22-bw`. `providerImageBudget` caps
|
|
36
|
+
* per-request images at the deployment's catalog `image-count-budget`, so
|
|
37
|
+
* inline imaging cannot flood a request with attachments.
|
|
40
38
|
*
|
|
41
39
|
* The per-frame token estimate follows the reading model's catalog
|
|
42
40
|
* `image-tokenization` rule (its lineage, not the gateway), so Claude behind
|
|
@@ -44,13 +42,17 @@
|
|
|
44
42
|
* Models without a lineage rule fall back to the catalog rule for their wire
|
|
45
43
|
* API; a reader with neither costs {@link FRAME_TOKEN_ESTIMATE} per frame.
|
|
46
44
|
*
|
|
45
|
+
* A host that downscales every image before the model reads it (catalog
|
|
46
|
+
* `image-max-edge`, e.g. Cursor) gets frames rendered at that edge instead,
|
|
47
|
+
* so glyphs reach the model at the size they were drawn.
|
|
48
|
+
*
|
|
47
49
|
* The whole pass is local and deterministic — no LLM call, no API key, no
|
|
48
50
|
* latency beyond rendering. Rasterization and PNG encoding happen in native
|
|
49
51
|
* code (`renderSnapcompactPng` in `crates/pi-natives/src/snapcompact.rs`).
|
|
50
52
|
* Frames persist in the compaction entry's `preserveData` and are
|
|
51
53
|
* re-attached to the compaction summary message on every context rebuild.
|
|
52
54
|
*/
|
|
53
|
-
import type { Api, ImageContent, Message, TextContent } from "@linxiraos/pi-ai";
|
|
55
|
+
import type { Api, ImageContent, Message, Model, TextContent } from "@linxiraos/pi-ai";
|
|
54
56
|
import { type ImageSize, type ImageTokenization, type ImageTokenizationTarget } from "@linxiraos/pi-catalog/compat/image-tokenization";
|
|
55
57
|
/** One eval-validated frame shape: font, cell, ink, repetition, and size. */
|
|
56
58
|
export interface Shape {
|
|
@@ -78,11 +80,13 @@ export interface Shape {
|
|
|
78
80
|
frameSize: number;
|
|
79
81
|
/** Per-frame billed-token estimate for the shape's target provider. */
|
|
80
82
|
frameTokenEstimate: number;
|
|
83
|
+
/** `frameTokenEstimate` comes from a catalog rule measured against provider billing. */
|
|
84
|
+
frameTokensVerified?: boolean;
|
|
81
85
|
/** Resolution hint attached to frame images (OpenAI-only). */
|
|
82
86
|
imageDetail?: ImageContent["detail"];
|
|
83
87
|
}
|
|
84
88
|
/** Geometry half of a {@link Shape}: everything except provider billing. */
|
|
85
|
-
export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "imageDetail">;
|
|
89
|
+
export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "frameTokensVerified" | "imageDetail">;
|
|
86
90
|
/**
|
|
87
91
|
* Frame variants exercised by the SQuAD evals in `research/` that the native
|
|
88
92
|
* renderer reproduces faithfully, keyed by their research names. Font codes:
|
|
@@ -269,6 +273,11 @@ export declare function frameBilling(target: ShapeTarget | undefined): FrameBill
|
|
|
269
273
|
export declare function frameBillingKey(billing: FrameBilling): string;
|
|
270
274
|
/** Billed-token estimate for one rendered frame of `size` under `billing`. */
|
|
271
275
|
export declare function frameTokens(billing: FrameBilling, size: ImageSize): number;
|
|
276
|
+
/**
|
|
277
|
+
* Per-frame tokens archive sizing charges `shape`: its own price when provider
|
|
278
|
+
* billing verified it, else at least {@link FRAME_TOKEN_ESTIMATE}.
|
|
279
|
+
*/
|
|
280
|
+
export declare function plannedFrameTokens(shape: Shape): number;
|
|
272
281
|
/** Eval-validated shapes, keyed by the provider family they won on. */
|
|
273
282
|
export declare const SHAPES: {
|
|
274
283
|
/** `11on16-bw`: 8x13 glyphs on an 11px advance (extra tracking), black ink.
|
|
@@ -312,7 +321,8 @@ export interface ShapeTarget {
|
|
|
312
321
|
* Pick the frame shape for a reader. An explicit `variant` (anything but
|
|
313
322
|
* `"auto"`) forces that geometry; otherwise the model id selects the
|
|
314
323
|
* eval-winning shape — and frame size — for its model line, falling back to
|
|
315
|
-
* the API family's winner when the model is unmeasured.
|
|
324
|
+
* the API family's winner when the model is unmeasured. Either way the frame
|
|
325
|
+
* is no wider than the host's catalog `image-max-edge`. Billing (token
|
|
316
326
|
* estimate, detail hint) is computed for the resolved frame size from the
|
|
317
327
|
* model's catalog image rule. Accepts a full pi-ai `Model` or any
|
|
318
328
|
* `{ api, id }` subset.
|
|
@@ -340,44 +350,46 @@ export declare const MAX_FRAMES_DEFAULT = 80;
|
|
|
340
350
|
* foveated archive — the session head (oldest) and the slice just before the
|
|
341
351
|
* text region (newest) — with the denser low-quality tier filling the middle. */
|
|
342
352
|
export declare const HQ_EDGE_FRAMES = 3;
|
|
343
|
-
/** Conservative per-frame token estimate for
|
|
344
|
-
*
|
|
345
|
-
* 4,784 visual-token cap, billed
|
|
346
|
-
*
|
|
347
|
-
* {@link MAX_FRAMES_DEFAULT}. */
|
|
353
|
+
/** Conservative per-frame token estimate for sizing archives whose frame price
|
|
354
|
+
* is not billing-verified, and for frames of unknown size — the upper bound
|
|
355
|
+
* across shapes: high-res Claude frames hit the 4,784 visual-token cap, billed
|
|
356
|
+
* at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
|
|
357
|
+
* undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
|
|
348
358
|
export declare const FRAME_TOKEN_ESTIMATE = 5024;
|
|
349
359
|
/** Conservative upper bound for one persisted frame's base64 payload. The
|
|
350
360
|
* measured high-res Anthropic `8x13`/`11on16` PNG frames sit around 159 KB;
|
|
351
361
|
* 170 KB leaves margin for denser glyph pages without permitting multi-MB
|
|
352
362
|
* standing request bodies at large context windows. */
|
|
353
363
|
export declare const FRAME_DATA_BYTES_ESTIMATE = 170000;
|
|
354
|
-
/**
|
|
355
|
-
*
|
|
364
|
+
/** Frame payload cap for a deployment without a catalog `inline-image-byte-budget`:
|
|
365
|
+
* an unmeasured backend can accept the HTTP body and still fail mid-stream
|
|
356
366
|
* with opaque 5xx errors. Keep this independent from visual-token budgeting:
|
|
357
367
|
* a 1M-token model can afford 70 images on paper, but not the resulting
|
|
358
368
|
* ~11 MB JSON payload on every turn. */
|
|
359
|
-
export declare const
|
|
360
|
-
/**
|
|
361
|
-
*
|
|
369
|
+
export declare const DEFAULT_FRAME_DATA_BYTES_BUDGET = 3000000;
|
|
370
|
+
/** Base64 image bytes the archive frames riding every request to `model` may
|
|
371
|
+
* carry: half the deployment's inline-image byte budget, else
|
|
372
|
+
* {@link DEFAULT_FRAME_DATA_BYTES_BUDGET}. Frames are the oldest images in the
|
|
373
|
+
* request, so the send-time byte clamp evicts them first; the other half stays
|
|
374
|
+
* free for live screenshots. */
|
|
375
|
+
export declare function frameDataBytesBudget(model: Model | undefined): number;
|
|
376
|
+
/** Frame-count cap a `budget` of base64 bytes implies for frames rendered by
|
|
377
|
+
* `shape`. {@link AREA_PRICED_VARIANTS} below 1932px are charged
|
|
362
378
|
* {@link FRAME_DATA_BYTES_ESTIMATE} scaled by pixel area; every other shape
|
|
363
379
|
* pays the full estimate. */
|
|
364
|
-
export declare function maxFramesForDataBudget(shape: ShapeGeometry): number;
|
|
380
|
+
export declare function maxFramesForDataBudget(shape: ShapeGeometry, budget: number): number;
|
|
365
381
|
/** Base64 byte length for persisted snapcompact frames. */
|
|
366
382
|
export declare function frameDataBytes(frames: readonly Pick<Frame, "data">[]): number;
|
|
367
|
-
/**
|
|
368
|
-
*
|
|
369
|
-
* entire request may carry (archive/system-prompt/tool-result imaging combined).
|
|
370
|
-
* The values are conservative policy caps under the vendor hard limits
|
|
371
|
-
* (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe
|
|
372
|
-
* floor rather than sending unbounded attachments.
|
|
373
|
-
*/
|
|
374
|
-
export declare const PROVIDER_IMAGE_BUDGETS: Record<string, number>;
|
|
375
|
-
/** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
|
|
383
|
+
/** Safe floor for a deployment whose catalog rules set no `image-count-budget`
|
|
384
|
+
* (strictest mainstream measured: Groq ~5). */
|
|
376
385
|
export declare const DEFAULT_PROVIDER_IMAGE_BUDGET = 5;
|
|
377
|
-
/**
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
386
|
+
/** Most images one request to `model`'s deployment may carry, archive,
|
|
387
|
+
* system-prompt and tool-result imaging combined. */
|
|
388
|
+
export declare function providerImageBudget(model: Model | undefined): number;
|
|
389
|
+
/** Archive frame cap for `model`: its image budget, never above {@link MAX_FRAMES_DEFAULT}. */
|
|
390
|
+
export declare function providerFrameBudget(model: Model | undefined): number;
|
|
391
|
+
/** Whether `model` may get a fresh `read` result as frames (catalog `snapcompact-image-reads`, opt-in). */
|
|
392
|
+
export declare function readerImagesReadResults(model: Model): boolean;
|
|
381
393
|
/** Key under `CompactionEntry.preserveData` holding the frame archive. */
|
|
382
394
|
export declare const PRESERVE_KEY = "snapcompact";
|
|
383
395
|
/** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
|
|
@@ -419,6 +431,15 @@ export interface Archive {
|
|
|
419
431
|
textHead?: string;
|
|
420
432
|
/** Newest text region kept verbatim around the imaged middle. */
|
|
421
433
|
textTail?: string;
|
|
434
|
+
/** The same history re-rendered for a reader that excludes assistant
|
|
435
|
+
* reasoning ({@link SerializeOptions.includeThinking} `false`), when this
|
|
436
|
+
* archive carries it. Read through {@link viewWithoutThinking}. */
|
|
437
|
+
withoutThinking?: ArchiveView;
|
|
438
|
+
}
|
|
439
|
+
/** What a reader gets for one compaction: the summary text and the archive it introduces. */
|
|
440
|
+
export interface ArchiveView {
|
|
441
|
+
summary: string;
|
|
442
|
+
archive: Archive;
|
|
422
443
|
}
|
|
423
444
|
export interface Geometry {
|
|
424
445
|
/** Characters per row (per-column line width when `columns === 2`). */
|
|
@@ -515,7 +536,8 @@ export interface SerializeOptions {
|
|
|
515
536
|
* louder than archived tool noise. Defaults to `true`. */
|
|
516
537
|
dimToolResults?: boolean;
|
|
517
538
|
/** Serialize assistant reasoning as `¶think:` sections. Defaults to `true`.
|
|
518
|
-
* Callers archiving for a
|
|
539
|
+
* Callers archiving for a reader whose classifier refuses quoted reasoning
|
|
540
|
+
* ({@link readerIncludesThinking} `false`) set this `false`:
|
|
519
541
|
* the archive frames are replayed as text into every later request, and
|
|
520
542
|
* reasoning rendered back to Claude trips its `reasoning_extraction`
|
|
521
543
|
* classifier (issue #6093). */
|
|
@@ -586,7 +608,8 @@ export declare function renderMany(text: string, options?: RenderManyOptions): P
|
|
|
586
608
|
* For doc shapes this wraps the text once and counts pages of `2 * rows`
|
|
587
609
|
* lines; for grid shapes it divides by the frame capacity. */
|
|
588
610
|
export declare function frames(text: string, options?: Pick<RenderManyOptions, "shape" | "model" | "frameSize">): number;
|
|
589
|
-
/** Validate and extract a persisted frame archive from `preserveData
|
|
611
|
+
/** Validate and extract a persisted frame archive from `preserveData`, with its
|
|
612
|
+
* {@link Archive.withoutThinking} variant when that is valid too. */
|
|
590
613
|
export declare function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined;
|
|
591
614
|
/** Drop the persisted frame archive ({@link PRESERVE_KEY}) from `preserveData`,
|
|
592
615
|
* returning the remaining state — or `undefined` when nothing else remains, so
|
|
@@ -620,6 +643,39 @@ export declare function images(archive: Archive): ImageContent[];
|
|
|
620
643
|
* Runtime-only; reconstructed from {@link Archive} on each context rebuild
|
|
621
644
|
* instead of persisted on the session entry. */
|
|
622
645
|
export declare function historyBlocks(archive: Archive, options?: HistoryBlockOptions): (TextContent | ImageContent)[];
|
|
646
|
+
/**
|
|
647
|
+
* Re-applies a smaller frame byte budget to blocks {@link historyBlocks} already built: keeps the newest frames that
|
|
648
|
+
* fit, priced as {@link historyBlocks} prices them, and replaces each run of dropped frames with one omission notice.
|
|
649
|
+
* Returns `blocks` itself when every frame fits.
|
|
650
|
+
*/
|
|
651
|
+
export declare function capFrameDataBytes(blocks: (TextContent | ImageContent)[], maxFrameDataBytes: number): (TextContent | ImageContent)[];
|
|
652
|
+
/**
|
|
653
|
+
* Whether an archive read by `model` may carry assistant reasoning. The archive
|
|
654
|
+
* replays as text into every later request, and a reader whose classifier
|
|
655
|
+
* refuses reasoning quoted back into its input (the `classifier-cot-extraction`
|
|
656
|
+
* catalog axis, set for Claude: `reasoning_extraction`, issue #6093) gets
|
|
657
|
+
* `¶think:` sections and their legend left out.
|
|
658
|
+
*/
|
|
659
|
+
export declare function readerIncludesThinking(model: Model): boolean;
|
|
660
|
+
/** Whether a snapcompact summary declares `¶think:` reasoning scopes: true for
|
|
661
|
+
* archives rendered with {@link SerializeOptions.includeThinking}, and for
|
|
662
|
+
* summaries written before that legend was gated on it. */
|
|
663
|
+
export declare function summaryIncludesThinking(summary: string): boolean;
|
|
664
|
+
/** `text` (a summary or archive source, raw or normalized) without its
|
|
665
|
+
* `¶think:` sections, and with the reasoning scope dropped from any legend. */
|
|
666
|
+
export declare function textWithoutThinking(text: string): string;
|
|
667
|
+
/**
|
|
668
|
+
* What a reader that excludes assistant reasoning reads for a compaction
|
|
669
|
+
* whose summary is `summary` and archive `archive`: the archive's
|
|
670
|
+
* {@link Archive.withoutThinking} variant when it carries one, the original
|
|
671
|
+
* when its summary declares no reasoning, else a fail-closed text-only form.
|
|
672
|
+
* That form keeps no frames (pixels cannot be scrubbed), takes its text edges
|
|
673
|
+
* from the source after every `¶think:` section is removed (so a section
|
|
674
|
+
* crossing an edge cannot leak), counts the dropped middle as truncated, and
|
|
675
|
+
* re-renders the summary without the reasoning legend; `details` restores its
|
|
676
|
+
* file list.
|
|
677
|
+
*/
|
|
678
|
+
export declare function viewWithoutThinking(summary: string, archive: Archive, details?: CompactionDetails): ArchiveView;
|
|
623
679
|
/**
|
|
624
680
|
* Run a snapcompact compaction over prepared messages. Fully local: serializes
|
|
625
681
|
* the discarded history, appends it to the accumulated archive source text, and
|
package/src/snapcompact.ts
CHANGED
|
@@ -32,11 +32,9 @@
|
|
|
32
32
|
* billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
|
|
33
33
|
* area-proportional, so resolution cannot improve chars/$ — 1568 stays.
|
|
34
34
|
* `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
|
|
35
|
-
* - **Unknown providers** default to `8on22-bw`. `providerImageBudget`
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* gone; routers now use the same permissive budget as direct
|
|
39
|
-
* Anthropic/Claude lines unless configured otherwise upstream.
|
|
35
|
+
* - **Unknown providers** default to `8on22-bw`. `providerImageBudget` caps
|
|
36
|
+
* per-request images at the deployment's catalog `image-count-budget`, so
|
|
37
|
+
* inline imaging cannot flood a request with attachments.
|
|
40
38
|
*
|
|
41
39
|
* The per-frame token estimate follows the reading model's catalog
|
|
42
40
|
* `image-tokenization` rule (its lineage, not the gateway), so Claude behind
|
|
@@ -44,6 +42,10 @@
|
|
|
44
42
|
* Models without a lineage rule fall back to the catalog rule for their wire
|
|
45
43
|
* API; a reader with neither costs {@link FRAME_TOKEN_ESTIMATE} per frame.
|
|
46
44
|
*
|
|
45
|
+
* A host that downscales every image before the model reads it (catalog
|
|
46
|
+
* `image-max-edge`, e.g. Cursor) gets frames rendered at that edge instead,
|
|
47
|
+
* so glyphs reach the model at the size they were drawn.
|
|
48
|
+
*
|
|
47
49
|
* The whole pass is local and deterministic — no LLM call, no API key, no
|
|
48
50
|
* latency beyond rendering. Rasterization and PNG encoding happen in native
|
|
49
51
|
* code (`renderSnapcompactPng` in `crates/pi-natives/src/snapcompact.rs`).
|
|
@@ -51,17 +53,21 @@
|
|
|
51
53
|
* re-attached to the compaction summary message on every context rebuild.
|
|
52
54
|
*/
|
|
53
55
|
|
|
54
|
-
import type { Api, ImageContent, Message, TextContent } from "@linxiraos/pi-ai";
|
|
56
|
+
import type { Api, ImageContent, Message, Model, TextContent } from "@linxiraos/pi-ai";
|
|
57
|
+
import { reachesOfficialEndpoint } from "@linxiraos/pi-ai/providers/anthropic-state";
|
|
58
|
+
import { resolveCatalogPolicy } from "@linxiraos/pi-catalog/compat/catalog-policy";
|
|
55
59
|
import {
|
|
56
60
|
type ImageSize,
|
|
57
61
|
type ImageTokenization,
|
|
58
62
|
type ImageTokenizationTarget,
|
|
59
63
|
imageTokens,
|
|
64
|
+
resolveImageMaxEdge,
|
|
60
65
|
resolveImageTokenization,
|
|
61
66
|
} from "@linxiraos/pi-catalog/compat/image-tokenization";
|
|
67
|
+
import { resolveImageCountBudget, resolveInlineImageByteBudget } from "@linxiraos/pi-catalog/compat/request-size";
|
|
62
68
|
import { classifyModel, compareRevision, parseRevision } from "@linxiraos/pi-catalog/identity";
|
|
63
69
|
import { renderSnapcompactPng, snapcompactSupportedChars } from "@linxiraos/pi-natives";
|
|
64
|
-
import { formatGroupedPaths, prompt } from "@linxiraos/pi-utils";
|
|
70
|
+
import { formatGroupedPaths, isRecord, prompt } from "@linxiraos/pi-utils";
|
|
65
71
|
import { INTENT_FIELD } from "@linxiraos/pi-wire";
|
|
66
72
|
import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
|
|
67
73
|
import snapcompactSummaryPrompt from "./prompts/snapcompact-summary.md" with { type: "text" };
|
|
@@ -96,12 +102,14 @@ export interface Shape {
|
|
|
96
102
|
frameSize: number;
|
|
97
103
|
/** Per-frame billed-token estimate for the shape's target provider. */
|
|
98
104
|
frameTokenEstimate: number;
|
|
105
|
+
/** `frameTokenEstimate` comes from a catalog rule measured against provider billing. */
|
|
106
|
+
frameTokensVerified?: boolean;
|
|
99
107
|
/** Resolution hint attached to frame images (OpenAI-only). */
|
|
100
108
|
imageDetail?: ImageContent["detail"];
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
/** Geometry half of a {@link Shape}: everything except provider billing. */
|
|
104
|
-
export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "imageDetail">;
|
|
112
|
+
export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "frameTokensVerified" | "imageDetail">;
|
|
105
113
|
|
|
106
114
|
/**
|
|
107
115
|
* Frame variants exercised by the SQuAD evals in `research/` that the native
|
|
@@ -273,7 +281,22 @@ export function frameTokens(billing: FrameBilling, size: ImageSize): number {
|
|
|
273
281
|
function priceShape(base: ShapeGeometry, target: ShapeTarget | undefined): Shape {
|
|
274
282
|
const billing = frameBilling(target);
|
|
275
283
|
const frameTokenEstimate = frameTokens(billing, { width: base.frameSize, height: base.frameSize });
|
|
276
|
-
return {
|
|
284
|
+
return {
|
|
285
|
+
...base,
|
|
286
|
+
frameTokenEstimate,
|
|
287
|
+
...(billing.rule?.verified && { frameTokensVerified: true }),
|
|
288
|
+
...(billing.imageDetail && { imageDetail: billing.imageDetail }),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Per-frame tokens archive sizing charges `shape`: its own price when provider
|
|
294
|
+
* billing verified it, else at least {@link FRAME_TOKEN_ESTIMATE}.
|
|
295
|
+
*/
|
|
296
|
+
export function plannedFrameTokens(shape: Shape): number {
|
|
297
|
+
return shape.frameTokensVerified
|
|
298
|
+
? shape.frameTokenEstimate
|
|
299
|
+
: Math.max(FRAME_TOKEN_ESTIMATE, shape.frameTokenEstimate);
|
|
277
300
|
}
|
|
278
301
|
|
|
279
302
|
/** Eval-validated shapes, keyed by the provider family they won on. */
|
|
@@ -320,6 +343,7 @@ export function isShape(value: unknown): value is Shape {
|
|
|
320
343
|
shape.frameSize > 0 &&
|
|
321
344
|
typeof shape.frameTokenEstimate === "number" &&
|
|
322
345
|
shape.frameTokenEstimate > 0 &&
|
|
346
|
+
(shape.frameTokensVerified === undefined || typeof shape.frameTokensVerified === "boolean") &&
|
|
323
347
|
(detail === undefined || detail === "auto" || detail === "low" || detail === "high" || detail === "original")
|
|
324
348
|
);
|
|
325
349
|
}
|
|
@@ -414,16 +438,23 @@ export interface ShapeTarget {
|
|
|
414
438
|
* Pick the frame shape for a reader. An explicit `variant` (anything but
|
|
415
439
|
* `"auto"`) forces that geometry; otherwise the model id selects the
|
|
416
440
|
* eval-winning shape — and frame size — for its model line, falling back to
|
|
417
|
-
* the API family's winner when the model is unmeasured.
|
|
441
|
+
* the API family's winner when the model is unmeasured. Either way the frame
|
|
442
|
+
* is no wider than the host's catalog `image-max-edge`. Billing (token
|
|
418
443
|
* estimate, detail hint) is computed for the resolved frame size from the
|
|
419
444
|
* model's catalog image rule. Accepts a full pi-ai `Model` or any
|
|
420
445
|
* `{ api, id }` subset.
|
|
421
446
|
*/
|
|
422
447
|
export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape {
|
|
423
|
-
if (variant && variant !== "auto") return priceShape(SHAPE_VARIANTS[variant], model);
|
|
448
|
+
if (variant && variant !== "auto") return priceShape(fitHostImageEdge(SHAPE_VARIANTS[variant], model), model);
|
|
424
449
|
const ideal = model?.id ? idealShapeVariant(model.id) : undefined;
|
|
425
450
|
const base = SHAPE_VARIANTS[ideal?.variant ?? FAMILY_VARIANT[apiFamily(model?.api)]];
|
|
426
|
-
return priceShape(ideal?.frameSize ? { ...base, frameSize: ideal.frameSize } : base, model);
|
|
451
|
+
return priceShape(fitHostImageEdge(ideal?.frameSize ? { ...base, frameSize: ideal.frameSize } : base, model), model);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Shrink `base` to the longest image edge `target`'s host passes to the model. */
|
|
455
|
+
function fitHostImageEdge(base: ShapeGeometry, target: ShapeTarget | undefined): ShapeGeometry {
|
|
456
|
+
const maxEdge = target ? resolveImageMaxEdge(target) : undefined;
|
|
457
|
+
return maxEdge !== undefined && base.frameSize > maxEdge ? { ...base, frameSize: maxEdge } : base;
|
|
427
458
|
}
|
|
428
459
|
|
|
429
460
|
const CJK_HEAVY_MIN_WIDE_CHARS = 8;
|
|
@@ -483,11 +514,11 @@ export const MAX_FRAMES_DEFAULT = 80;
|
|
|
483
514
|
* text region (newest) — with the denser low-quality tier filling the middle. */
|
|
484
515
|
export const HQ_EDGE_FRAMES = 3;
|
|
485
516
|
|
|
486
|
-
/** Conservative per-frame token estimate for
|
|
487
|
-
*
|
|
488
|
-
* 4,784 visual-token cap, billed
|
|
489
|
-
*
|
|
490
|
-
* {@link MAX_FRAMES_DEFAULT}. */
|
|
517
|
+
/** Conservative per-frame token estimate for sizing archives whose frame price
|
|
518
|
+
* is not billing-verified, and for frames of unknown size — the upper bound
|
|
519
|
+
* across shapes: high-res Claude frames hit the 4,784 visual-token cap, billed
|
|
520
|
+
* at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
|
|
521
|
+
* undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
|
|
491
522
|
export const FRAME_TOKEN_ESTIMATE = 5024;
|
|
492
523
|
|
|
493
524
|
/** Conservative upper bound for one persisted frame's base64 payload. The
|
|
@@ -496,22 +527,32 @@ export const FRAME_TOKEN_ESTIMATE = 5024;
|
|
|
496
527
|
* standing request bodies at large context windows. */
|
|
497
528
|
export const FRAME_DATA_BYTES_ESTIMATE = 170_000;
|
|
498
529
|
|
|
499
|
-
/**
|
|
500
|
-
*
|
|
530
|
+
/** Frame payload cap for a deployment without a catalog `inline-image-byte-budget`:
|
|
531
|
+
* an unmeasured backend can accept the HTTP body and still fail mid-stream
|
|
501
532
|
* with opaque 5xx errors. Keep this independent from visual-token budgeting:
|
|
502
533
|
* a 1M-token model can afford 70 images on paper, but not the resulting
|
|
503
534
|
* ~11 MB JSON payload on every turn. */
|
|
504
|
-
export const
|
|
535
|
+
export const DEFAULT_FRAME_DATA_BYTES_BUDGET = 3_000_000;
|
|
536
|
+
|
|
537
|
+
/** Base64 image bytes the archive frames riding every request to `model` may
|
|
538
|
+
* carry: half the deployment's inline-image byte budget, else
|
|
539
|
+
* {@link DEFAULT_FRAME_DATA_BYTES_BUDGET}. Frames are the oldest images in the
|
|
540
|
+
* request, so the send-time byte clamp evicts them first; the other half stays
|
|
541
|
+
* free for live screenshots. */
|
|
542
|
+
export function frameDataBytesBudget(model: Model | undefined): number {
|
|
543
|
+
const inlineBudget = model ? resolveInlineImageByteBudget(model, reachesOfficialEndpoint(model)) : undefined;
|
|
544
|
+
return inlineBudget === undefined ? DEFAULT_FRAME_DATA_BYTES_BUDGET : Math.floor(inlineBudget / 2);
|
|
545
|
+
}
|
|
505
546
|
|
|
506
547
|
/** Default edge variants whose frames shrink with pixel area. Inkier variants
|
|
507
548
|
* keep the full {@link FRAME_DATA_BYTES_ESTIMATE} at any frame size. */
|
|
508
549
|
const AREA_PRICED_VARIANTS: readonly ShapeGeometry[] = [SHAPE_VARIANTS["8on22-bw"], SHAPE_VARIANTS["11on16-bw"]];
|
|
509
550
|
|
|
510
|
-
/** Frame-count cap
|
|
511
|
-
*
|
|
551
|
+
/** Frame-count cap a `budget` of base64 bytes implies for frames rendered by
|
|
552
|
+
* `shape`. {@link AREA_PRICED_VARIANTS} below 1932px are charged
|
|
512
553
|
* {@link FRAME_DATA_BYTES_ESTIMATE} scaled by pixel area; every other shape
|
|
513
554
|
* pays the full estimate. */
|
|
514
|
-
export function maxFramesForDataBudget(shape: ShapeGeometry): number {
|
|
555
|
+
export function maxFramesForDataBudget(shape: ShapeGeometry, budget: number): number {
|
|
515
556
|
const areaPriced = AREA_PRICED_VARIANTS.some(
|
|
516
557
|
variant =>
|
|
517
558
|
variant.font === shape.font &&
|
|
@@ -525,7 +566,7 @@ export function maxFramesForDataBudget(shape: ShapeGeometry): number {
|
|
|
525
566
|
);
|
|
526
567
|
const areaRatio = areaPriced ? (shape.frameSize / HIGH_RES_ANTHROPIC_VARIANT.frameSize) ** 2 : 1;
|
|
527
568
|
const frameBytes = Math.min(FRAME_DATA_BYTES_ESTIMATE, Math.ceil(FRAME_DATA_BYTES_ESTIMATE * areaRatio));
|
|
528
|
-
return Math.max(1, Math.floor(
|
|
569
|
+
return Math.max(1, Math.floor(budget / frameBytes));
|
|
529
570
|
}
|
|
530
571
|
|
|
531
572
|
/** Base64 byte length for persisted snapcompact frames. */
|
|
@@ -533,36 +574,27 @@ export function frameDataBytes(frames: readonly Pick<Frame, "data">[]): number {
|
|
|
533
574
|
return frames.reduce((sum, frame) => sum + frame.data.length, 0);
|
|
534
575
|
}
|
|
535
576
|
|
|
536
|
-
/**
|
|
537
|
-
*
|
|
538
|
-
* entire request may carry (archive/system-prompt/tool-result imaging combined).
|
|
539
|
-
* The values are conservative policy caps under the vendor hard limits
|
|
540
|
-
* (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe
|
|
541
|
-
* floor rather than sending unbounded attachments.
|
|
542
|
-
*/
|
|
543
|
-
export const PROVIDER_IMAGE_BUDGETS: Record<string, number> = {
|
|
544
|
-
anthropic: 90,
|
|
545
|
-
"amazon-bedrock": 90,
|
|
546
|
-
openai: 200,
|
|
547
|
-
"openai-codex": 200,
|
|
548
|
-
google: 200,
|
|
549
|
-
"google-vertex": 200,
|
|
550
|
-
"google-gemini-cli": 200,
|
|
551
|
-
openrouter: 90,
|
|
552
|
-
umans: 10,
|
|
553
|
-
};
|
|
554
|
-
|
|
555
|
-
/** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
|
|
577
|
+
/** Safe floor for a deployment whose catalog rules set no `image-count-budget`
|
|
578
|
+
* (strictest mainstream measured: Groq ~5). */
|
|
556
579
|
export const DEFAULT_PROVIDER_IMAGE_BUDGET = 5;
|
|
557
580
|
|
|
558
|
-
/**
|
|
559
|
-
|
|
560
|
-
|
|
581
|
+
/** Most images one request to `model`'s deployment may carry, archive,
|
|
582
|
+
* system-prompt and tool-result imaging combined. */
|
|
583
|
+
export function providerImageBudget(model: Model | undefined): number {
|
|
584
|
+
return (
|
|
585
|
+
(model ? resolveImageCountBudget(model, reachesOfficialEndpoint(model)) : undefined) ??
|
|
586
|
+
DEFAULT_PROVIDER_IMAGE_BUDGET
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Archive frame cap for `model`: its image budget, never above {@link MAX_FRAMES_DEFAULT}. */
|
|
591
|
+
export function providerFrameBudget(model: Model | undefined): number {
|
|
592
|
+
return Math.min(providerImageBudget(model), MAX_FRAMES_DEFAULT);
|
|
561
593
|
}
|
|
562
594
|
|
|
563
|
-
/**
|
|
564
|
-
export function
|
|
565
|
-
return
|
|
595
|
+
/** Whether `model` may get a fresh `read` result as frames (catalog `snapcompact-image-reads`, opt-in). */
|
|
596
|
+
export function readerImagesReadResults(model: Model): boolean {
|
|
597
|
+
return resolveCatalogPolicy(model).snapcompactImageReads === true;
|
|
566
598
|
}
|
|
567
599
|
|
|
568
600
|
/** Key under `CompactionEntry.preserveData` holding the frame archive. */
|
|
@@ -612,6 +644,16 @@ export interface Archive {
|
|
|
612
644
|
textHead?: string;
|
|
613
645
|
/** Newest text region kept verbatim around the imaged middle. */
|
|
614
646
|
textTail?: string;
|
|
647
|
+
/** The same history re-rendered for a reader that excludes assistant
|
|
648
|
+
* reasoning ({@link SerializeOptions.includeThinking} `false`), when this
|
|
649
|
+
* archive carries it. Read through {@link viewWithoutThinking}. */
|
|
650
|
+
withoutThinking?: ArchiveView;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** What a reader gets for one compaction: the summary text and the archive it introduces. */
|
|
654
|
+
export interface ArchiveView {
|
|
655
|
+
summary: string;
|
|
656
|
+
archive: Archive;
|
|
615
657
|
}
|
|
616
658
|
|
|
617
659
|
export interface Geometry {
|
|
@@ -803,7 +845,8 @@ export interface SerializeOptions {
|
|
|
803
845
|
* louder than archived tool noise. Defaults to `true`. */
|
|
804
846
|
dimToolResults?: boolean;
|
|
805
847
|
/** Serialize assistant reasoning as `¶think:` sections. Defaults to `true`.
|
|
806
|
-
* Callers archiving for a
|
|
848
|
+
* Callers archiving for a reader whose classifier refuses quoted reasoning
|
|
849
|
+
* ({@link readerIncludesThinking} `false`) set this `false`:
|
|
807
850
|
* the archive frames are replayed as text into every later request, and
|
|
808
851
|
* reasoning rendered back to Claude trips its `reasoning_extraction`
|
|
809
852
|
* classifier (issue #6093). */
|
|
@@ -1736,9 +1779,8 @@ export function frames(text: string, options?: Pick<RenderManyOptions, "shape" |
|
|
|
1736
1779
|
// Archive helpers
|
|
1737
1780
|
// ============================================================================
|
|
1738
1781
|
|
|
1739
|
-
/** Validate
|
|
1740
|
-
|
|
1741
|
-
const candidate = preserveData?.[PRESERVE_KEY];
|
|
1782
|
+
/** Validate one persisted archive object (frames, counters, text regions). */
|
|
1783
|
+
function validateArchive(candidate: unknown): Archive | undefined {
|
|
1742
1784
|
if (!candidate || typeof candidate !== "object") return undefined;
|
|
1743
1785
|
const archive = candidate as Archive;
|
|
1744
1786
|
const frames = Array.isArray(archive.frames)
|
|
@@ -1769,6 +1811,18 @@ export function getPreservedArchive(preserveData: Record<string, unknown> | unde
|
|
|
1769
1811
|
};
|
|
1770
1812
|
}
|
|
1771
1813
|
|
|
1814
|
+
/** Validate and extract a persisted frame archive from `preserveData`, with its
|
|
1815
|
+
* {@link Archive.withoutThinking} variant when that is valid too. */
|
|
1816
|
+
export function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined {
|
|
1817
|
+
const slot = preserveData?.[PRESERVE_KEY];
|
|
1818
|
+
const archive = validateArchive(slot);
|
|
1819
|
+
if (!archive || !isRecord(slot) || !isRecord(slot.withoutThinking)) return archive;
|
|
1820
|
+
const { summary, archive: variant } = slot.withoutThinking;
|
|
1821
|
+
if (typeof summary !== "string" || !summary) return archive;
|
|
1822
|
+
const variantArchive = validateArchive(variant);
|
|
1823
|
+
return variantArchive ? { ...archive, withoutThinking: { summary, archive: variantArchive } } : archive;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1772
1826
|
/** Drop the persisted frame archive ({@link PRESERVE_KEY}) from `preserveData`,
|
|
1773
1827
|
* returning the remaining state — or `undefined` when nothing else remains, so
|
|
1774
1828
|
* an empty `{}` is never persisted. Callers strip the archive once its frames
|
|
@@ -1971,6 +2025,53 @@ export function historyBlocks(archive: Archive, options: HistoryBlockOptions = {
|
|
|
1971
2025
|
return blocks;
|
|
1972
2026
|
}
|
|
1973
2027
|
|
|
2028
|
+
/**
|
|
2029
|
+
* Re-applies a smaller frame byte budget to blocks {@link historyBlocks} already built: keeps the newest frames that
|
|
2030
|
+
* fit, priced as {@link historyBlocks} prices them, and replaces each run of dropped frames with one omission notice.
|
|
2031
|
+
* Returns `blocks` itself when every frame fits.
|
|
2032
|
+
*/
|
|
2033
|
+
export function capFrameDataBytes(
|
|
2034
|
+
blocks: (TextContent | ImageContent)[],
|
|
2035
|
+
maxFrameDataBytes: number,
|
|
2036
|
+
): (TextContent | ImageContent)[] {
|
|
2037
|
+
const kept = new Set<number>();
|
|
2038
|
+
let usedBytes = 0;
|
|
2039
|
+
let dropped = false;
|
|
2040
|
+
for (let index = blocks.length - 1; index >= 0; index--) {
|
|
2041
|
+
const block = blocks[index];
|
|
2042
|
+
if (block.type !== "image") continue;
|
|
2043
|
+
if (usedBytes + block.data.length > maxFrameDataBytes) {
|
|
2044
|
+
dropped = true;
|
|
2045
|
+
continue;
|
|
2046
|
+
}
|
|
2047
|
+
usedBytes += block.data.length;
|
|
2048
|
+
kept.add(index);
|
|
2049
|
+
}
|
|
2050
|
+
if (!dropped) return blocks;
|
|
2051
|
+
|
|
2052
|
+
const capped: (TextContent | ImageContent)[] = [];
|
|
2053
|
+
let omittedFrames = 0;
|
|
2054
|
+
let omittedBytes = 0;
|
|
2055
|
+
const flushOmitted = (): void => {
|
|
2056
|
+
if (omittedFrames === 0) return;
|
|
2057
|
+
capped.push({ type: "text", text: omittedFrameNotice(omittedFrames, omittedBytes) });
|
|
2058
|
+
omittedFrames = 0;
|
|
2059
|
+
omittedBytes = 0;
|
|
2060
|
+
};
|
|
2061
|
+
for (let index = 0; index < blocks.length; index++) {
|
|
2062
|
+
const block = blocks[index];
|
|
2063
|
+
if (block.type === "image" && !kept.has(index)) {
|
|
2064
|
+
omittedFrames++;
|
|
2065
|
+
omittedBytes += block.data.length;
|
|
2066
|
+
continue;
|
|
2067
|
+
}
|
|
2068
|
+
flushOmitted();
|
|
2069
|
+
capped.push(block);
|
|
2070
|
+
}
|
|
2071
|
+
flushOmitted();
|
|
2072
|
+
return capped;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
1974
2075
|
// ============================================================================
|
|
1975
2076
|
// Compaction entry point
|
|
1976
2077
|
// ============================================================================
|
|
@@ -2105,29 +2206,133 @@ function planArchive(text: string, high: Shape, low: Shape, maxFrames: number):
|
|
|
2105
2206
|
};
|
|
2106
2207
|
}
|
|
2107
2208
|
|
|
2209
|
+
const THINK_LABEL = "¶think:";
|
|
2210
|
+
|
|
2211
|
+
/** A scope label opening a serialized section, at the start of a line in raw
|
|
2212
|
+
* (`\n`) or normalized ({@link NEWLINE_GLYPH}) archive text. */
|
|
2213
|
+
const SECTION_START = new RegExp(`(?<=^|[\\n${NEWLINE_GLYPH}])(?=¶(?:user|think|ai|call):)`);
|
|
2214
|
+
|
|
2215
|
+
const TRAILING_LINE_BREAKS = new RegExp(`[\\n${NEWLINE_GLYPH}]+$`);
|
|
2216
|
+
|
|
2108
2217
|
/**
|
|
2109
|
-
* Drop `¶think:`
|
|
2218
|
+
* Drop every `¶think:` section from archive source text: its label line and
|
|
2219
|
+
* every following line up to the next scope label.
|
|
2110
2220
|
*
|
|
2111
|
-
* Archives
|
|
2112
|
-
* reasoning into their kept source; replaying it to Claude trips
|
|
2113
|
-
* `reasoning_extraction` classifier (issue #6093). Re-
|
|
2114
|
-
*
|
|
2115
|
-
*
|
|
2116
|
-
*
|
|
2221
|
+
* Archives rendered with {@link SerializeOptions.includeThinking} (or before it
|
|
2222
|
+
* existed) bake reasoning into their kept source; replaying it to Claude trips
|
|
2223
|
+
* the `reasoning_extraction` classifier (issue #6093). Re-rendering for a reader
|
|
2224
|
+
* that excludes thinking scrubs the prior text with this. Paragraph breaks
|
|
2225
|
+
* inside a thought are line breaks like any other, so a section ends only at
|
|
2226
|
+
* the next label; a label inside a dimmed tool-output body is output text.
|
|
2117
2227
|
*/
|
|
2118
2228
|
function stripThinkingSections(text: string): string {
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
)
|
|
2127
|
-
|
|
2229
|
+
const sections: string[] = [];
|
|
2230
|
+
let dimOpen = false;
|
|
2231
|
+
for (const piece of text.split(SECTION_START)) {
|
|
2232
|
+
if (dimOpen) sections[sections.length - 1] += piece;
|
|
2233
|
+
else sections.push(piece);
|
|
2234
|
+
const on = piece.lastIndexOf(DIM_ON);
|
|
2235
|
+
const off = piece.lastIndexOf(DIM_OFF);
|
|
2236
|
+
if (on !== off) dimOpen = on > off;
|
|
2237
|
+
}
|
|
2238
|
+
const kept = sections.filter(section => !section.startsWith(THINK_LABEL));
|
|
2239
|
+
if (kept.length === sections.length) return text;
|
|
2240
|
+
const stripped = kept.join("");
|
|
2241
|
+
// A dropped final section leaves its predecessor's line break dangling.
|
|
2242
|
+
return sections.at(-1)?.startsWith(THINK_LABEL) ? stripped.replace(TRAILING_LINE_BREAKS, "") : stripped;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/**
|
|
2246
|
+
* Whether an archive read by `model` may carry assistant reasoning. The archive
|
|
2247
|
+
* replays as text into every later request, and a reader whose classifier
|
|
2248
|
+
* refuses reasoning quoted back into its input (the `classifier-cot-extraction`
|
|
2249
|
+
* catalog axis, set for Claude: `reasoning_extraction`, issue #6093) gets
|
|
2250
|
+
* `¶think:` sections and their legend left out.
|
|
2251
|
+
*/
|
|
2252
|
+
export function readerIncludesThinking(model: Model): boolean {
|
|
2253
|
+
return resolveCatalogPolicy(model).classifierCotExtraction !== true;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/** Whether a snapcompact summary declares `¶think:` reasoning scopes: true for
|
|
2257
|
+
* archives rendered with {@link SerializeOptions.includeThinking}, and for
|
|
2258
|
+
* summaries written before that legend was gated on it. */
|
|
2259
|
+
export function summaryIncludesThinking(summary: string): boolean {
|
|
2260
|
+
return summary.includes(THINK_LABEL);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/** The reasoning scope in a scope legend, current and earlier summary wordings
|
|
2264
|
+
* alike, wherever it sits: a summary, or one archived inside another archive. */
|
|
2265
|
+
const LEGEND_REASONING_SCOPE =
|
|
2266
|
+
/`¶user:`, `¶think:`, (`¶ai:`, (?:and )?`¶call:`:? (?:open )?user, )assistant reasoning, /g;
|
|
2267
|
+
|
|
2268
|
+
/** `text` (a summary or archive source, raw or normalized) without its
|
|
2269
|
+
* `¶think:` sections, and with the reasoning scope dropped from any legend. */
|
|
2270
|
+
export function textWithoutThinking(text: string): string {
|
|
2271
|
+
return stripThinkingSections(text).replace(LEGEND_REASONING_SCOPE, "`¶user:`, $1");
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
const PREVIOUS_SUMMARY_HEAD = "[Summary of earlier history]";
|
|
2275
|
+
|
|
2276
|
+
/**
|
|
2277
|
+
* `archive`'s kept source as a reader that excludes reasoning may unfold it.
|
|
2278
|
+
* An archive persisted without {@link Archive.text} keeps only its edges, and
|
|
2279
|
+
* the tail's lines before its first scope label continue a section cut off in
|
|
2280
|
+
* the dropped middle, whose scope is unknown; they are left out.
|
|
2281
|
+
*/
|
|
2282
|
+
function sourceForReaderWithoutThinking(archive: Archive): string {
|
|
2283
|
+
if (archive.text) return archive.text;
|
|
2284
|
+
const tail = archive.textTail ?? "";
|
|
2285
|
+
const labelled = tail.search(SECTION_START);
|
|
2286
|
+
return [archive.textHead, labelled < 0 ? "" : tail.slice(labelled)]
|
|
2287
|
+
.filter((part): part is string => typeof part === "string" && part.length > 0)
|
|
2128
2288
|
.join(NEWLINE_GLYPH);
|
|
2129
2289
|
}
|
|
2130
2290
|
|
|
2291
|
+
/**
|
|
2292
|
+
* What a reader that excludes assistant reasoning reads for a compaction
|
|
2293
|
+
* whose summary is `summary` and archive `archive`: the archive's
|
|
2294
|
+
* {@link Archive.withoutThinking} variant when it carries one, the original
|
|
2295
|
+
* when its summary declares no reasoning, else a fail-closed text-only form.
|
|
2296
|
+
* That form keeps no frames (pixels cannot be scrubbed), takes its text edges
|
|
2297
|
+
* from the source after every `¶think:` section is removed (so a section
|
|
2298
|
+
* crossing an edge cannot leak), counts the dropped middle as truncated, and
|
|
2299
|
+
* re-renders the summary without the reasoning legend; `details` restores its
|
|
2300
|
+
* file list.
|
|
2301
|
+
*/
|
|
2302
|
+
export function viewWithoutThinking(summary: string, archive: Archive, details?: CompactionDetails): ArchiveView {
|
|
2303
|
+
if (archive.withoutThinking) return archive.withoutThinking;
|
|
2304
|
+
if (!summaryIncludesThinking(summary)) return { summary, archive };
|
|
2305
|
+
const source = sourceForReaderWithoutThinking(archive);
|
|
2306
|
+
const text = textWithoutThinking(source);
|
|
2307
|
+
const headChars = archive.textHead?.length ?? 0;
|
|
2308
|
+
const tailChars = archive.textTail?.length ?? 0;
|
|
2309
|
+
const fits = headChars + tailChars >= text.length;
|
|
2310
|
+
const textHead = fits ? text : text.slice(0, headChars);
|
|
2311
|
+
const textTail = fits || tailChars === 0 ? "" : text.slice(text.length - tailChars);
|
|
2312
|
+
const truncatedChars = archive.truncatedChars + (fits ? 0 : text.length - headChars - tailChars);
|
|
2313
|
+
const files = details ? formatFileList(details.readFiles, details.modifiedFiles) : "";
|
|
2314
|
+
return {
|
|
2315
|
+
summary:
|
|
2316
|
+
textHead.length === 0 && files.length === 0
|
|
2317
|
+
? "No prior history."
|
|
2318
|
+
: prompt.render(snapcompactSummaryPrompt, {
|
|
2319
|
+
frameCount: 0,
|
|
2320
|
+
truncatedChars,
|
|
2321
|
+
includedPreviousSummary: text.startsWith(PREVIOUS_SUMMARY_HEAD),
|
|
2322
|
+
files: files.length > 0 ? files : undefined,
|
|
2323
|
+
includeThinking: false,
|
|
2324
|
+
}),
|
|
2325
|
+
archive: {
|
|
2326
|
+
frames: [],
|
|
2327
|
+
totalChars: textHead.length + textTail.length,
|
|
2328
|
+
truncatedChars,
|
|
2329
|
+
...(text ? { text } : {}),
|
|
2330
|
+
...(textHead ? { textHead } : {}),
|
|
2331
|
+
...(textTail ? { textTail } : {}),
|
|
2332
|
+
},
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2131
2336
|
/**
|
|
2132
2337
|
* Run a snapcompact compaction over prepared messages. Fully local: serializes
|
|
2133
2338
|
* the discarded history, appends it to the accumulated archive source text, and
|
|
@@ -2153,21 +2358,23 @@ export async function compact<TMessage = Message>(
|
|
|
2153
2358
|
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
|
|
2154
2359
|
const serialized = serializeConversation(llmMessages, options);
|
|
2155
2360
|
const previousArchive = getPreservedArchive(previousPreserveData);
|
|
2361
|
+
const excludeThinking = options?.includeThinking === false;
|
|
2156
2362
|
const previousTextRaw =
|
|
2157
|
-
previousArchive
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2363
|
+
excludeThinking && previousArchive
|
|
2364
|
+
? sourceForReaderWithoutThinking(previousArchive)
|
|
2365
|
+
: (previousArchive?.text ??
|
|
2366
|
+
[previousArchive?.textHead, previousArchive?.textTail]
|
|
2367
|
+
.filter((part): part is string => typeof part === "string" && part.length > 0)
|
|
2368
|
+
.join(NEWLINE_GLYPH));
|
|
2161
2369
|
// Legacy archives may carry `¶think:` sections from before includeThinking
|
|
2162
|
-
// existed
|
|
2163
|
-
//
|
|
2164
|
-
//
|
|
2165
|
-
//
|
|
2370
|
+
// existed, and any archive may carry an earlier summary's reasoning legend;
|
|
2371
|
+
// scrub both when this compaction excludes thinking so the re-rendered
|
|
2372
|
+
// archive stops replaying reasoning (issue #6093). They may also carry data
|
|
2373
|
+
// URLs a pre-guard slice cut at any offset; heal those in archive context
|
|
2374
|
+
// before the text is folded into the new source.
|
|
2166
2375
|
const previousTextHealed = elideDataUrls(previousTextRaw, "archive");
|
|
2167
2376
|
const previousText =
|
|
2168
|
-
|
|
2169
|
-
? stripThinkingSections(previousTextHealed)
|
|
2170
|
-
: previousTextHealed;
|
|
2377
|
+
excludeThinking && previousTextHealed.length > 0 ? textWithoutThinking(previousTextHealed) : previousTextHealed;
|
|
2171
2378
|
const hasPreviousText = previousText.length > 0;
|
|
2172
2379
|
const includedPreviousSummary = !hasPreviousText && !!previousSummary;
|
|
2173
2380
|
const shapeProbeText = renderabilityProbeText(serialized, previousPreserveData, previousSummary);
|
|
@@ -2183,7 +2390,8 @@ export async function compact<TMessage = Message>(
|
|
|
2183
2390
|
let archiveText = normalize(serialized, { shape: high });
|
|
2184
2391
|
|
|
2185
2392
|
if (includedPreviousSummary && previousSummary) {
|
|
2186
|
-
const
|
|
2393
|
+
const lead = excludeThinking ? textWithoutThinking(previousSummary) : previousSummary;
|
|
2394
|
+
const head = `${PREVIOUS_SUMMARY_HEAD} ${normalize(lead, { shape: high })}`;
|
|
2187
2395
|
archiveText = archiveText.length > 0 ? `${head} [Recent conversation] ${archiveText}` : head;
|
|
2188
2396
|
}
|
|
2189
2397
|
|