@linxiraos/pi-snapcompact 1.2.0 → 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 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 | 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 |
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 }); // frame count without rendering
46
- const shape = resolveShape(model); // eval-optimal Shape for the reader
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
@@ -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
- omp-rpc has no third-party runtime dependencies. robomp declares FastAPI,
12
- Uvicorn, HTTPX, Pydantic, pydantic-settings, python-dotenv, Click, and omp-rpc
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, nproc, paste,
83
- printenv, readlink, realpath, rm, seq, sort, stat, tac, tail, tee, touch, tr,
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` still
36
- * caps per-request images per provider so inline imaging cannot flood a
37
- * request with attachments, but the old OpenRouter-specific 8-image cap is
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. Billing (token
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 archive sizing and for frames of
344
- * unknown size — the upper bound across shapes: high-res Claude frames hit the
345
- * 4,784 visual-token cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the
346
- * overflow guard from undercounting a high-res archive at the raised
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
- /** Maximum snapcompact image base64 carried in every rebuilt provider request.
355
- * Above this, provider backends can accept the HTTP body but fail mid-stream
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 FRAME_DATA_BYTES_BUDGET = 3000000;
360
- /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET} for frames
361
- * rendered by `shape`. {@link AREA_PRICED_VARIANTS} below 1932px are charged
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
- * Per-request image-count budgets by provider id. These cap how many images an
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
- /** Per-request image budget for `provider`; unknown providers get the floor. */
378
- export declare function providerImageBudget(provider: string | undefined): number;
379
- /** Archive frame cap for `provider`: image budget, never above {@link MAX_FRAMES_DEFAULT}. */
380
- export declare function providerFrameBudget(provider: string | undefined): number;
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 Claude/Anthropic-dialect model set this `false`:
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.2.0",
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,21 +24,21 @@
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": "tsgo -p tsconfig.json --noEmit",
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.2.0",
35
- "@linxiraos/pi-catalog": "1.2.0",
36
- "@linxiraos/pi-natives": "1.2.0",
37
- "@linxiraos/pi-utils": "1.2.0",
38
- "@linxiraos/pi-wire": "1.2.0"
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
- "@types/bun": "^1.3.14"
41
+ "@types/bun": "1.4.3"
42
42
  },
43
43
  "engines": {
44
44
  "bun": ">=1.3.14"
@@ -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` still
36
- * caps per-request images per provider so inline imaging cannot flood a
37
- * request with attachments, but the old OpenRouter-specific 8-image cap is
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. Billing (token
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 archive sizing and for frames of
344
- * unknown size — the upper bound across shapes: high-res Claude frames hit the
345
- * 4,784 visual-token cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the
346
- * overflow guard from undercounting a high-res archive at the raised
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
- /** Maximum snapcompact image base64 carried in every rebuilt provider request.
355
- * Above this, provider backends can accept the HTTP body but fail mid-stream
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 FRAME_DATA_BYTES_BUDGET = 3000000;
360
- /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET} for frames
361
- * rendered by `shape`. {@link AREA_PRICED_VARIANTS} below 1932px are charged
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
- * Per-request image-count budgets by provider id. These cap how many images an
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
- /** Per-request image budget for `provider`; unknown providers get the floor. */
378
- export declare function providerImageBudget(provider: string | undefined): number;
379
- /** Archive frame cap for `provider`: image budget, never above {@link MAX_FRAMES_DEFAULT}. */
380
- export declare function providerFrameBudget(provider: string | undefined): number;
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 Claude/Anthropic-dialect model set this `false`:
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
@@ -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` still
36
- * caps per-request images per provider so inline imaging cannot flood a
37
- * request with attachments, but the old OpenRouter-specific 8-image cap is
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 { ...base, frameTokenEstimate, ...(billing.imageDetail && { imageDetail: billing.imageDetail }) };
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. Billing (token
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 archive sizing and for frames of
487
- * unknown size — the upper bound across shapes: high-res Claude frames hit the
488
- * 4,784 visual-token cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the
489
- * overflow guard from undercounting a high-res archive at the raised
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
- /** Maximum snapcompact image base64 carried in every rebuilt provider request.
500
- * Above this, provider backends can accept the HTTP body but fail mid-stream
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 FRAME_DATA_BYTES_BUDGET = 3_000_000;
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 implied by {@link FRAME_DATA_BYTES_BUDGET} for frames
511
- * rendered by `shape`. {@link AREA_PRICED_VARIANTS} below 1932px are charged
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(FRAME_DATA_BYTES_BUDGET / frameBytes));
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
- * Per-request image-count budgets by provider id. These cap how many images an
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
- /** Per-request image budget for `provider`; unknown providers get the floor. */
559
- export function providerImageBudget(provider: string | undefined): number {
560
- return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET;
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
- /** Archive frame cap for `provider`: image budget, never above {@link MAX_FRAMES_DEFAULT}. */
564
- export function providerFrameBudget(provider: string | undefined): number {
565
- return Math.min(providerImageBudget(provider), MAX_FRAMES_DEFAULT);
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 Claude/Anthropic-dialect model set this `false`:
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 and extract a persisted frame archive from `preserveData`. */
1740
- export function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined {
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:` sections from serialized archive source text.
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 written before {@link SerializeOptions.includeThinking} existed bake
2112
- * reasoning into their kept source; replaying it to Claude trips the
2113
- * `reasoning_extraction` classifier (issue #6093). Re-compaction re-renders the
2114
- * whole unfolded source, so scrubbing the prior text heals a poisoned session
2115
- * at its next compaction. Conservative by construction: only sections that
2116
- * start with `¶think:` at a section boundary are dropped.
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
- return text
2120
- .split(NEWLINE_GLYPH)
2121
- .map(segment =>
2122
- segment
2123
- .split(/\n\n(?=¶(?:user|think|ai|call):)/)
2124
- .filter(section => !section.startsWith("¶think:"))
2125
- .join("\n\n"),
2126
- )
2127
- .filter(segment => segment.length > 0)
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?.text ??
2158
- [previousArchive?.textHead, previousArchive?.textTail]
2159
- .filter((part): part is string => typeof part === "string" && part.length > 0)
2160
- .join(NEWLINE_GLYPH);
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; scrub them when this compaction excludes thinking so the
2163
- // re-rendered archive stops replaying reasoning (issue #6093). They may
2164
- // also carry data URLs a pre-guard slice cut at any offset; heal those in
2165
- // archive context before the text is folded into the new source.
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
- options?.includeThinking === false && previousTextHealed.length > 0
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 head = `[Summary of earlier history] ${normalize(previousSummary, { shape: high })}`;
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