@oh-my-pi/snapcompact 16.0.10 → 16.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +5 -5
- package/dist/types/snapcompact.d.ts +61 -50
- package/package.json +5 -4
- package/src/prompts/snapcompact-summary.md +20 -20
- package/src/snapcompact.ts +394 -165
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.1.0] - 2026-06-19
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `historyBlocks(archive)` to reconstruct ordered history blocks from archive data
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Refactored compaction to be text-sourced, re-rendering from unified `Archive.text` source
|
|
14
|
+
- Implemented foveated archive layout (HQ edges, dense LQ middle) for optimized context usage
|
|
15
|
+
- Raised `MAX_FRAMES_DEFAULT` to 80 and consolidated `PROVIDER_IMAGE_BUDGETS`
|
|
16
|
+
- Updated OpenRouter to use standard 90-image budget
|
|
17
|
+
- Updated prompt instructions to clearly distinguish between plain-text and image history regions
|
|
18
|
+
- `Options.maxFrames` is now an upper limit clamped to `MAX_FRAMES_DEFAULT`, not a per-call default
|
|
19
|
+
- Rewrote the resume summary prompt into a structured reading guide (turn headings, grid/two-column layout, ink notes) and render file operations inline as a `FILES` section instead of a spliced `<files>` tag
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Fixed context budget undercounting by raising `FRAME_TOKEN_ESTIMATE` to 5024
|
|
24
|
+
- Improved file list formatting in compaction summaries
|
|
25
|
+
|
|
26
|
+
## [16.0.11] - 2026-06-19
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Refined elision markers for file operations and truncated text for better display consistency
|
|
31
|
+
- Updated summary text for consistent descriptions of archived tool output
|
|
32
|
+
- Folded a much wider range of Unicode to ASCII in `normalize()` before native rendering: added a per-character Unicode NFKD decomposition fallback (fullwidth forms, super/subscripts, ligatures, circled and math-styled alphanumerics, Roman numerals, vulgar fractions) and expanded the `CHAR_FOLD` punctuation table (more quotes/primes, hyphens, the fraction slash, dot leaders, bullets, and arrows) so undrawable glyphs land on close ASCII equivalents instead of `?`
|
|
33
|
+
|
|
5
34
|
## [16.0.8] - 2026-06-18
|
|
6
35
|
|
|
7
36
|
### Added
|
package/README.md
CHANGED
|
@@ -49,18 +49,18 @@ Run a full compaction pass over prepared messages:
|
|
|
49
49
|
```ts
|
|
50
50
|
import { compact } from "@oh-my-pi/snapcompact";
|
|
51
51
|
|
|
52
|
-
const result = await compact(preparation, { model
|
|
53
|
-
// result.summary —
|
|
54
|
-
// result.preserveData —
|
|
52
|
+
const result = await compact(preparation, { model });
|
|
53
|
+
// result.summary — short "resume prior conversation" lead-in, reading guide, and FILES section
|
|
54
|
+
// result.preserveData — bounded archive source + rendered image middle
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
## API surface
|
|
58
58
|
|
|
59
|
-
- **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images`
|
|
59
|
+
- **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images`, `historyBlocks`
|
|
60
60
|
- **Rendering**: `render`, `renderMany`, `frames`, `geometry`
|
|
61
61
|
- **Shapes**: `SHAPES`, `SHAPE_VARIANTS`, `resolveShape`, `idealShapeVariant`, `isShape`, `isShapeVariantName`
|
|
62
62
|
- **Text**: `serializeConversation`, `normalize`, `dimStopwords`, `wrap`
|
|
63
|
-
- **Budgets**: `providerImageBudget`, `
|
|
63
|
+
- **Budgets**: `providerImageBudget`, `MAX_FRAMES_DEFAULT`, `FRAME_TOKEN_ESTIMATE`, `HQ_EDGE_FRAMES`
|
|
64
64
|
- **File ops**: `createFileOps`, `computeFileLists`, `upsertFileOperations`
|
|
65
65
|
|
|
66
66
|
## References
|
|
@@ -31,14 +31,11 @@
|
|
|
31
31
|
* billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
|
|
32
32
|
* area-proportional, so resolution cannot improve chars/$ — 1568 stays.
|
|
33
33
|
* `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
|
|
34
|
-
* - **Unknown providers** default to the Anthropic shape.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* `providerImageBudget` caps per-request images per provider (OpenRouter
|
|
40
|
-
* 8, unknown 5) and `compact()` keeps any archive overflow as a text tail
|
|
41
|
-
* on the summary instead of rendering frames that would be dropped.
|
|
34
|
+
* - **Unknown providers** default to the Anthropic shape. `providerImageBudget`
|
|
35
|
+
* still caps per-request images per provider so inline imaging cannot flood a
|
|
36
|
+
* request with attachments, but the old OpenRouter-specific 8-image cap is
|
|
37
|
+
* gone; routers now use the same permissive budget as direct Anthropic/Claude
|
|
38
|
+
* lines unless configured otherwise upstream.
|
|
42
39
|
*
|
|
43
40
|
* The whole pass is local and deterministic — no LLM call, no API key, no
|
|
44
41
|
* latency beyond rendering. Rasterization and PNG encoding happen in native
|
|
@@ -46,7 +43,7 @@
|
|
|
46
43
|
* Frames persist in the compaction entry's `preserveData` and are
|
|
47
44
|
* re-attached to the compaction summary message on every context rebuild.
|
|
48
45
|
*/
|
|
49
|
-
import type { Api, ImageContent, Message, Model } from "@oh-my-pi/pi-ai";
|
|
46
|
+
import type { Api, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
|
|
50
47
|
/** One eval-validated frame shape: font, cell, ink, repetition, and size. */
|
|
51
48
|
export interface Shape {
|
|
52
49
|
/** Bundled font in the native renderer. */
|
|
@@ -286,28 +283,35 @@ export declare function resolveShape(model?: ShapeTarget, variant?: ShapeVariant
|
|
|
286
283
|
/** Legacy frame edge in pixels (the 5x8 shape's eval-validated size). New
|
|
287
284
|
* shapes carry their own `frameSize`. */
|
|
288
285
|
export declare const FRAME_SIZE = 2576;
|
|
289
|
-
/**
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
*
|
|
295
|
-
|
|
286
|
+
/** Default upper bound on archive frames carried per compaction. Sized to hold
|
|
287
|
+
* ~400k tokens of the high-res Anthropic frame Opus reads (1932px ≈ 5,000
|
|
288
|
+
* billed tokens each → 80 frames) while staying under the ~100-image
|
|
289
|
+
* per-request wire cap. Oldest frames are dropped first once the budget is
|
|
290
|
+
* exceeded (mirrors how iterative text summaries fade the oldest detail); a
|
|
291
|
+
* caller may pass a lower `maxFrames` upper limit, and per-model context
|
|
292
|
+
* fitting is handled by the caller's overflow guard. */
|
|
293
|
+
export declare const MAX_FRAMES_DEFAULT = 80;
|
|
294
|
+
/** High-quality (legible) frames rendered at each chronological edge of a
|
|
295
|
+
* foveated archive — the session head (oldest) and the slice just before the
|
|
296
|
+
* text region (newest) — with the denser low-quality tier filling the middle. */
|
|
297
|
+
export declare const HQ_EDGE_FRAMES = 3;
|
|
298
|
+
/** Conservative per-frame token estimate used for context budgeting — the
|
|
299
|
+
* upper bound across shapes: high-res Claude frames hit the 4,784 visual-token
|
|
300
|
+
* cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
|
|
301
|
+
* undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
|
|
302
|
+
export declare const FRAME_TOKEN_ESTIMATE = 5024;
|
|
296
303
|
/**
|
|
297
|
-
* Per-request image-count budgets by provider id.
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
* (Anthropic 100, OpenAI 500, Gemini ~2500).
|
|
304
|
+
* Per-request image-count budgets by provider id. These cap how many images an
|
|
305
|
+
* entire request may carry (archive/system-prompt/tool-result imaging combined).
|
|
306
|
+
* The values are conservative policy caps under the vendor hard limits
|
|
307
|
+
* (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe
|
|
308
|
+
* floor rather than sending unbounded attachments.
|
|
303
309
|
*/
|
|
304
310
|
export declare const PROVIDER_IMAGE_BUDGETS: Record<string, number>;
|
|
305
311
|
/** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
|
|
306
312
|
export declare const DEFAULT_PROVIDER_IMAGE_BUDGET = 5;
|
|
307
313
|
/** Per-request image budget for `provider`; unknown providers get the floor. */
|
|
308
314
|
export declare function providerImageBudget(provider: string | undefined): number;
|
|
309
|
-
/** Archive frame budget for `provider`: its image budget clamped to {@link MAX_FRAMES}. */
|
|
310
|
-
export declare function providerFrameBudget(provider: string | undefined): number;
|
|
311
315
|
/** Key under `CompactionEntry.preserveData` holding the frame archive. */
|
|
312
316
|
export declare const PRESERVE_KEY = "snapcompact";
|
|
313
317
|
/** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
|
|
@@ -334,16 +338,20 @@ export interface Frame {
|
|
|
334
338
|
}
|
|
335
339
|
/** Frame archive persisted under `preserveData[PRESERVE_KEY]`. */
|
|
336
340
|
export interface Archive {
|
|
337
|
-
/**
|
|
341
|
+
/** Rendered frames ordered oldest to newest, re-derived from {@link text}
|
|
342
|
+
* each compaction with foveated quality tiers (HQ/LQ/HQ inside the imaged
|
|
343
|
+
* middle). May be empty when the whole archive fits in text. */
|
|
338
344
|
frames: Frame[];
|
|
339
|
-
/** Characters currently readable across all frames. */
|
|
345
|
+
/** Characters currently readable across all frames plus the text regions. */
|
|
340
346
|
totalChars: number;
|
|
341
|
-
/** Characters dropped so far to respect the
|
|
347
|
+
/** Characters dropped so far to respect the archive budget. */
|
|
342
348
|
truncatedChars: number;
|
|
343
|
-
/**
|
|
344
|
-
*
|
|
345
|
-
|
|
346
|
-
|
|
349
|
+
/** Full kept archive source (oldest to newest, normalized, bounded to the
|
|
350
|
+
* rendered budget) — the single source re-rendered each compaction. */
|
|
351
|
+
text?: string;
|
|
352
|
+
/** Oldest text region kept verbatim around the imaged middle. */
|
|
353
|
+
textHead?: string;
|
|
354
|
+
/** Newest text region kept verbatim around the imaged middle. */
|
|
347
355
|
textTail?: string;
|
|
348
356
|
}
|
|
349
357
|
export interface Geometry {
|
|
@@ -363,7 +371,7 @@ export interface Options<TMessage = Message> extends SerializeOptions {
|
|
|
363
371
|
shape?: Shape;
|
|
364
372
|
/** Frame edge in pixels. Defaults to the shape's `frameSize`. */
|
|
365
373
|
frameSize?: number;
|
|
366
|
-
/**
|
|
374
|
+
/** Upper limit on archive frames; clamped to (and defaulting to) {@link MAX_FRAMES_DEFAULT}. */
|
|
367
375
|
maxFrames?: number;
|
|
368
376
|
}
|
|
369
377
|
/** Result of rendering one frame. */
|
|
@@ -450,11 +458,14 @@ export declare const NEWLINE_GLYPH = "\u2588";
|
|
|
450
458
|
* Prepare text for printing: strip ANSI escape sequences, collapse horizontal
|
|
451
459
|
* whitespace runs to single spaces and newline-bearing runs to one
|
|
452
460
|
* {@link NEWLINE_GLYPH} (drawn as a pitch-black cell), then fold everything
|
|
453
|
-
* outside the fonts' ASCII + Latin-1 coverage to ASCII approximations
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
461
|
+
* outside the fonts' ASCII + Latin-1 coverage to ASCII approximations — first
|
|
462
|
+
* through the {@link CHAR_FOLD} punctuation table, then via an NFKD
|
|
463
|
+
* decomposition that recovers the ASCII skeleton of compatibility characters
|
|
464
|
+
* (fullwidth, super/subscripts, ligatures, circled/math-styled alphanumerics,
|
|
465
|
+
* Roman numerals, vulgar fractions). Unrenderable control/format/combining
|
|
466
|
+
* characters are dropped without occupying a cell; `?` remains the fallback
|
|
467
|
+
* for unsupported graphic characters. The zero-width ink toggles
|
|
468
|
+
* {@link DIM_ON}/{@link DIM_OFF} pass through untouched.
|
|
458
469
|
*/
|
|
459
470
|
export declare function normalize(text: string): string;
|
|
460
471
|
/**
|
|
@@ -501,22 +512,22 @@ export declare function frames(text: string, options?: Pick<RenderManyOptions, "
|
|
|
501
512
|
export declare function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined;
|
|
502
513
|
/** Convert archive frames into LLM image blocks (oldest first). */
|
|
503
514
|
export declare function images(archive: Archive): ImageContent[];
|
|
515
|
+
/** Ordered archive blocks for a compaction summary message, oldest to newest:
|
|
516
|
+
* the oldest text region, the imaged middle, then the newest text region.
|
|
517
|
+
* Runtime-only; reconstructed from {@link Archive} on each context rebuild
|
|
518
|
+
* instead of persisted on the session entry. */
|
|
519
|
+
export declare function historyBlocks(archive: Archive): (TextContent | ImageContent)[];
|
|
504
520
|
/**
|
|
505
521
|
* Run a snapcompact compaction over prepared messages. Fully local: serializes
|
|
506
|
-
* the discarded history,
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* hard image caps silently drop excess frames on the wire) — the newest
|
|
511
|
-
* unrendered slice survives verbatim as a text tail on the summary and is
|
|
512
|
-
* folded back into frames by the next compaction.
|
|
522
|
+
* the discarded history, appends it to the accumulated archive source text, and
|
|
523
|
+
* re-renders that source into an ordered history layout: plain text at the
|
|
524
|
+
* oldest edge, imaged middle, then plain text at the newest edge. The imaged
|
|
525
|
+
* middle itself foveates (HQ/LQ/HQ) when it grows large.
|
|
513
526
|
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
* summary describes the newest shape while noting that older frames may
|
|
517
|
-
* differ.
|
|
527
|
+
* The full kept source persists on the archive (`text`) so each later compaction
|
|
528
|
+
* unfolds and re-renders it coherently alongside the newly archived history.
|
|
518
529
|
*
|
|
519
|
-
* If the previous compaction was text-based, its summary is printed at the
|
|
520
|
-
*
|
|
530
|
+
* If the previous compaction was text-based, its summary is printed at the head
|
|
531
|
+
* of the archive as `[Summary of earlier history]` so no continuity is lost.
|
|
521
532
|
*/
|
|
522
533
|
export declare function compact<TMessage = Message>(preparation: CompactionPreparation<TMessage>, options?: Options<TMessage>): Promise<CompactionResult>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/snapcompact",
|
|
4
|
-
"version": "16.0
|
|
4
|
+
"version": "16.1.0",
|
|
5
5
|
"description": "Bitmap-frame context compression for vision-capable LLMs",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -31,9 +31,10 @@
|
|
|
31
31
|
"fmt": "biome format --write ."
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@oh-my-pi/pi-ai": "16.0
|
|
35
|
-
"@oh-my-pi/pi-natives": "16.0
|
|
36
|
-
"@oh-my-pi/pi-utils": "16.0
|
|
34
|
+
"@oh-my-pi/pi-ai": "16.1.0",
|
|
35
|
+
"@oh-my-pi/pi-natives": "16.1.0",
|
|
36
|
+
"@oh-my-pi/pi-utils": "16.1.0",
|
|
37
|
+
"@oh-my-pi/pi-wire": "16.1.0"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"@types/bun": "^1.3.14"
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
|
|
1
|
+
You are resuming a prior conversation. Its earlier turns were archived to reclaim context and are reproduced under HISTORY below, oldest to newest. Read HISTORY in full, then continue from the live conversation that follows it.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
{{#if mixedShapes}}
|
|
3
|
+
The archived transcript is compact: each turn opens with a heading — `# User ¶`, `# Assistant ¶`, or `# Tool call ¶` — assistant reasoning is wrapped in _italics_, and tool output sits inside `<out>…</out>`.
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
{{#if
|
|
5
|
+
Reading HISTORY:
|
|
6
|
+
- Plain-text sections are the verbatim transcript — rely on them exactly.
|
|
7
|
+
{{#if frameCount}}- Some middle sections are attached as images instead of text. Each image is a page of that same transcript and belongs at its place in the reading order, between marked delimiters. Within an image, a solid black cell marks a newline and runs of spaces collapse to one.
|
|
8
|
+
{{#if docColumns}} - A frame holds two side-by-side columns, each {{cols}} characters wide and up to {{rows}} rows tall: read the left column top to bottom, then the right.
|
|
9
|
+
{{else}} - A frame is one grid {{cols}} characters wide and up to {{rows}} rows tall: read left to right, top to bottom — there is no word wrap, so a word may break across rows.
|
|
10
|
+
{{/if}}{{#if sentenceInk}} - Ink cycles through six colors, one per sentence.
|
|
11
|
+
{{/if}}{{#if stopwordDimmed}} - Function words are dim gray; content words keep full ink.
|
|
12
|
+
{{/if}}{{#if dimmedToolResults}} - Text inside `<out>` is dim gray — that gray is archived tool output, not conversation.
|
|
13
|
+
{{/if}}{{#if lineRepeated}} - Each line is printed twice (white, then a pale-yellow band); the two copies are identical.
|
|
14
|
+
{{/if}}{{#if mixedShapes}} - The compressed middle frames use a smaller, denser font than the edge frames; the reading order is unchanged.
|
|
15
|
+
{{/if}}{{/if}}{{#if includedPreviousSummary}}- HISTORY opens with a condensed digest of still-older context that predates the archived turns.
|
|
16
|
+
{{/if}}{{#if truncatedChars}}- About {{truncatedChars}} characters of older middle history were dropped to fit the archive budget.
|
|
17
|
+
{{/if}}- When an exact earlier detail matters and a section reads unclearly, re-derive it from the workspace (re-read files, re-run commands) rather than guessing.
|
|
9
18
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
{{
|
|
19
|
+
{{#if files}}FILES
|
|
20
|
+
===================
|
|
21
|
+
{{files}}
|
|
13
22
|
|
|
14
|
-
{{
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Total archived: {{totalChars}} characters. Consult the frames whenever you need exact earlier details (user wording, decisions, file paths, tool output). If a region is hard to read, re-derive the fact from the workspace (re-read files, re-run commands) rather than guessing.
|
|
18
|
-
{{#if textTail}}
|
|
19
|
-
|
|
20
|
-
The frame budget ran out before the newest part of the archive. That remainder continues below as plain text — it is newer than every frame and ends where the live conversation resumes.
|
|
21
|
-
|
|
22
|
-
[Archived history, continued as text]
|
|
23
|
-
{{textTail}}
|
|
24
|
-
{{/if}}
|
|
23
|
+
{{/if}}HISTORY
|
|
24
|
+
===================
|
package/src/snapcompact.ts
CHANGED
|
@@ -31,14 +31,11 @@
|
|
|
31
31
|
* billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
|
|
32
32
|
* area-proportional, so resolution cannot improve chars/$ — 1568 stays.
|
|
33
33
|
* `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
|
|
34
|
-
* - **Unknown providers** default to the Anthropic shape.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* `providerImageBudget` caps per-request images per provider (OpenRouter
|
|
40
|
-
* 8, unknown 5) and `compact()` keeps any archive overflow as a text tail
|
|
41
|
-
* on the summary instead of rendering frames that would be dropped.
|
|
34
|
+
* - **Unknown providers** default to the Anthropic shape. `providerImageBudget`
|
|
35
|
+
* still caps per-request images per provider so inline imaging cannot flood a
|
|
36
|
+
* request with attachments, but the old OpenRouter-specific 8-image cap is
|
|
37
|
+
* gone; routers now use the same permissive budget as direct Anthropic/Claude
|
|
38
|
+
* lines unless configured otherwise upstream.
|
|
42
39
|
*
|
|
43
40
|
* The whole pass is local and deterministic — no LLM call, no API key, no
|
|
44
41
|
* latency beyond rendering. Rasterization and PNG encoding happen in native
|
|
@@ -47,9 +44,10 @@
|
|
|
47
44
|
* re-attached to the compaction summary message on every context rebuild.
|
|
48
45
|
*/
|
|
49
46
|
|
|
50
|
-
import type { Api, ImageContent, Message, Model } from "@oh-my-pi/pi-ai";
|
|
47
|
+
import type { Api, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
|
|
51
48
|
import { renderSnapcompactPng } from "@oh-my-pi/pi-natives";
|
|
52
49
|
import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils";
|
|
50
|
+
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
53
51
|
import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
|
|
54
52
|
import snapcompactSummaryPrompt from "./prompts/snapcompact-summary.md" with { type: "text" };
|
|
55
53
|
|
|
@@ -300,6 +298,16 @@ const FAMILY_VARIANT: Record<BillingFamily, ShapeVariantName> = {
|
|
|
300
298
|
openai: "8on22-bw",
|
|
301
299
|
};
|
|
302
300
|
|
|
301
|
+
/** Denser companion variant per family for the foveated archive middle: same
|
|
302
|
+
* pixels (identical per-frame bill) but a tighter 8px cell, trading some
|
|
303
|
+
* legibility for ~40% more chars per frame so the least-important middle of a
|
|
304
|
+
* long archive compresses into fewer frames. */
|
|
305
|
+
const FAMILY_VARIANT_LOW: Record<BillingFamily, ShapeVariantName> = {
|
|
306
|
+
anthropic: "8on16-bw",
|
|
307
|
+
google: "8on16-bw",
|
|
308
|
+
openai: "8on16-bw",
|
|
309
|
+
};
|
|
310
|
+
|
|
303
311
|
const FAMILY_SHAPE: Record<BillingFamily, Shape> = {
|
|
304
312
|
anthropic: SHAPES.anthropic,
|
|
305
313
|
google: SHAPES.google,
|
|
@@ -376,22 +384,32 @@ export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "
|
|
|
376
384
|
* shapes carry their own `frameSize`. */
|
|
377
385
|
export const FRAME_SIZE = 2576;
|
|
378
386
|
|
|
379
|
-
/**
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
*
|
|
386
|
-
export const
|
|
387
|
+
/** Default upper bound on archive frames carried per compaction. Sized to hold
|
|
388
|
+
* ~400k tokens of the high-res Anthropic frame Opus reads (1932px ≈ 5,000
|
|
389
|
+
* billed tokens each → 80 frames) while staying under the ~100-image
|
|
390
|
+
* per-request wire cap. Oldest frames are dropped first once the budget is
|
|
391
|
+
* exceeded (mirrors how iterative text summaries fade the oldest detail); a
|
|
392
|
+
* caller may pass a lower `maxFrames` upper limit, and per-model context
|
|
393
|
+
* fitting is handled by the caller's overflow guard. */
|
|
394
|
+
export const MAX_FRAMES_DEFAULT = 80;
|
|
395
|
+
|
|
396
|
+
/** High-quality (legible) frames rendered at each chronological edge of a
|
|
397
|
+
* foveated archive — the session head (oldest) and the slice just before the
|
|
398
|
+
* text region (newest) — with the denser low-quality tier filling the middle. */
|
|
399
|
+
export const HQ_EDGE_FRAMES = 3;
|
|
400
|
+
|
|
401
|
+
/** Conservative per-frame token estimate used for context budgeting — the
|
|
402
|
+
* upper bound across shapes: high-res Claude frames hit the 4,784 visual-token
|
|
403
|
+
* cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
|
|
404
|
+
* undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
|
|
405
|
+
export const FRAME_TOKEN_ESTIMATE = 5024;
|
|
387
406
|
|
|
388
407
|
/**
|
|
389
|
-
* Per-request image-count budgets by provider id.
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* (Anthropic 100, OpenAI 500, Gemini ~2500).
|
|
408
|
+
* Per-request image-count budgets by provider id. These cap how many images an
|
|
409
|
+
* entire request may carry (archive/system-prompt/tool-result imaging combined).
|
|
410
|
+
* The values are conservative policy caps under the vendor hard limits
|
|
411
|
+
* (Anthropic 100, OpenAI 500, Gemini ~2500); unknown providers fall to a safe
|
|
412
|
+
* floor rather than sending unbounded attachments.
|
|
395
413
|
*/
|
|
396
414
|
export const PROVIDER_IMAGE_BUDGETS: Record<string, number> = {
|
|
397
415
|
anthropic: 90,
|
|
@@ -401,7 +419,7 @@ export const PROVIDER_IMAGE_BUDGETS: Record<string, number> = {
|
|
|
401
419
|
google: 200,
|
|
402
420
|
"google-vertex": 200,
|
|
403
421
|
"google-gemini-cli": 200,
|
|
404
|
-
openrouter:
|
|
422
|
+
openrouter: 90,
|
|
405
423
|
};
|
|
406
424
|
|
|
407
425
|
/** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
|
|
@@ -412,11 +430,6 @@ export function providerImageBudget(provider: string | undefined): number {
|
|
|
412
430
|
return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET;
|
|
413
431
|
}
|
|
414
432
|
|
|
415
|
-
/** Archive frame budget for `provider`: its image budget clamped to {@link MAX_FRAMES}. */
|
|
416
|
-
export function providerFrameBudget(provider: string | undefined): number {
|
|
417
|
-
return Math.min(MAX_FRAMES, providerImageBudget(provider));
|
|
418
|
-
}
|
|
419
|
-
|
|
420
433
|
/** Key under `CompactionEntry.preserveData` holding the frame archive. */
|
|
421
434
|
export const PRESERVE_KEY = "snapcompact";
|
|
422
435
|
|
|
@@ -449,16 +462,20 @@ export interface Frame {
|
|
|
449
462
|
|
|
450
463
|
/** Frame archive persisted under `preserveData[PRESERVE_KEY]`. */
|
|
451
464
|
export interface Archive {
|
|
452
|
-
/**
|
|
465
|
+
/** Rendered frames ordered oldest to newest, re-derived from {@link text}
|
|
466
|
+
* each compaction with foveated quality tiers (HQ/LQ/HQ inside the imaged
|
|
467
|
+
* middle). May be empty when the whole archive fits in text. */
|
|
453
468
|
frames: Frame[];
|
|
454
|
-
/** Characters currently readable across all frames. */
|
|
469
|
+
/** Characters currently readable across all frames plus the text regions. */
|
|
455
470
|
totalChars: number;
|
|
456
|
-
/** Characters dropped so far to respect the
|
|
471
|
+
/** Characters dropped so far to respect the archive budget. */
|
|
457
472
|
truncatedChars: number;
|
|
458
|
-
/**
|
|
459
|
-
*
|
|
460
|
-
|
|
461
|
-
|
|
473
|
+
/** Full kept archive source (oldest to newest, normalized, bounded to the
|
|
474
|
+
* rendered budget) — the single source re-rendered each compaction. */
|
|
475
|
+
text?: string;
|
|
476
|
+
/** Oldest text region kept verbatim around the imaged middle. */
|
|
477
|
+
textHead?: string;
|
|
478
|
+
/** Newest text region kept verbatim around the imaged middle. */
|
|
462
479
|
textTail?: string;
|
|
463
480
|
}
|
|
464
481
|
|
|
@@ -480,7 +497,7 @@ export interface Options<TMessage = Message> extends SerializeOptions {
|
|
|
480
497
|
shape?: Shape;
|
|
481
498
|
/** Frame edge in pixels. Defaults to the shape's `frameSize`. */
|
|
482
499
|
frameSize?: number;
|
|
483
|
-
/**
|
|
500
|
+
/** Upper limit on archive frames; clamped to (and defaulting to) {@link MAX_FRAMES_DEFAULT}. */
|
|
484
501
|
maxFrames?: number;
|
|
485
502
|
}
|
|
486
503
|
|
|
@@ -584,7 +601,7 @@ function stripFileOperationTags(summary: string): string {
|
|
|
584
601
|
.trimEnd();
|
|
585
602
|
}
|
|
586
603
|
|
|
587
|
-
function
|
|
604
|
+
function formatFileList(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string {
|
|
588
605
|
if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
|
|
589
606
|
const mode = new Map<string, "Read" | "Write" | "RW">();
|
|
590
607
|
for (const file of readFiles) mode.set(file, "Read");
|
|
@@ -592,9 +609,14 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[], read
|
|
|
592
609
|
const all = [...mode.keys()].sort();
|
|
593
610
|
let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
|
|
594
611
|
if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
|
|
595
|
-
files += `\n
|
|
612
|
+
files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`;
|
|
596
613
|
}
|
|
597
|
-
return
|
|
614
|
+
return files;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string {
|
|
618
|
+
const files = formatFileList(readFiles, modifiedFiles, readSet);
|
|
619
|
+
return files.length > 0 ? prompt.render(fileOperationsTemplate, { files }) : "";
|
|
598
620
|
}
|
|
599
621
|
|
|
600
622
|
export function upsertFileOperations(
|
|
@@ -657,15 +679,16 @@ function truncateForSummary(text: string, maxChars: number, headRatio: number):
|
|
|
657
679
|
const tailChars = maxChars - headChars;
|
|
658
680
|
const elided = text.length - maxChars;
|
|
659
681
|
const tail = tailChars > 0 ? text.slice(-tailChars) : "";
|
|
660
|
-
return `${text.slice(0, headChars)} [
|
|
682
|
+
return `${text.slice(0, headChars)} […${elided}ch elided…] ${tail}`;
|
|
661
683
|
}
|
|
662
684
|
|
|
663
685
|
const DIM_MARKERS = /[\u000e\u000f]/g;
|
|
664
686
|
|
|
665
|
-
/**
|
|
666
|
-
*
|
|
667
|
-
* context
|
|
668
|
-
|
|
687
|
+
/** Plain-text history kept verbatim at each chronological edge, in HQ-frame-
|
|
688
|
+
* capacity units per edge. One page at the start and one at the end preserves
|
|
689
|
+
* high-fidelity context around the imaged middle while keeping the total text
|
|
690
|
+
* budget equal to the prior 2-page tail-only scheme. */
|
|
691
|
+
const TEXT_EDGE_PAGES = 1;
|
|
669
692
|
|
|
670
693
|
/** Normalized archive text → plain text: drop zero-width dim toggles and
|
|
671
694
|
* print newline glyphs as real newlines. */
|
|
@@ -740,9 +763,11 @@ export function serializeConversation(messages: Message[], options?: SerializeOp
|
|
|
740
763
|
|
|
741
764
|
for (const block of msg.content) {
|
|
742
765
|
if (block.type === "text") {
|
|
743
|
-
|
|
766
|
+
const text = stripDimMarkers(block.text);
|
|
767
|
+
if (text.trim()) pendingText.push(text);
|
|
744
768
|
} else if (block.type === "thinking") {
|
|
745
|
-
|
|
769
|
+
const thinking = stripDimMarkers(block.thinking);
|
|
770
|
+
if (thinking.trim()) pendingThinking.push(thinking);
|
|
746
771
|
} else if (block.type === "toolCall") {
|
|
747
772
|
if (uselessCallIds.has(block.id)) continue;
|
|
748
773
|
flushAssistant();
|
|
@@ -750,11 +775,15 @@ export function serializeConversation(messages: Message[], options?: SerializeOp
|
|
|
750
775
|
// Prefer the harness-derived intent, else the raw `_i` arg; render it as
|
|
751
776
|
// a one-line `//comment` and drop `_i` from the args below.
|
|
752
777
|
const rawIntent =
|
|
753
|
-
typeof block.intent === "string"
|
|
778
|
+
typeof block.intent === "string"
|
|
779
|
+
? block.intent
|
|
780
|
+
: typeof args[INTENT_FIELD] === "string"
|
|
781
|
+
? (args[INTENT_FIELD] as string)
|
|
782
|
+
: "";
|
|
754
783
|
const intent = stripDimMarkers(rawIntent).replace(/\s+/g, " ").trim();
|
|
755
784
|
const argsStr = truncateForSummary(
|
|
756
785
|
Object.entries(args)
|
|
757
|
-
.filter(([key]) => key !==
|
|
786
|
+
.filter(([key]) => key !== INTENT_FIELD)
|
|
758
787
|
.map(
|
|
759
788
|
([key, value]) =>
|
|
760
789
|
`${key}=${truncateForSummary(JSON.stringify(value) ?? "undefined", toolArgMaxChars, headRatio)}`,
|
|
@@ -807,8 +836,11 @@ function stripOpenAiRemoteCompactionPreserveData(
|
|
|
807
836
|
// Text normalization
|
|
808
837
|
// ============================================================================
|
|
809
838
|
|
|
810
|
-
/**
|
|
839
|
+
/** Punctuation and symbol folds applied before the NFKD fallback in
|
|
840
|
+
* {@link normalize}: quotes, dashes, bullets, arrows, and dot leaders that
|
|
841
|
+
* have no compatibility decomposition (or one that is itself non-ASCII). */
|
|
811
842
|
const CHAR_FOLD: Record<string, string> = {
|
|
843
|
+
// Quotation marks and primes.
|
|
812
844
|
"\u2018": "'",
|
|
813
845
|
"\u2019": "'",
|
|
814
846
|
"\u201a": "'",
|
|
@@ -816,18 +848,44 @@ const CHAR_FOLD: Record<string, string> = {
|
|
|
816
848
|
"\u201c": '"',
|
|
817
849
|
"\u201d": '"',
|
|
818
850
|
"\u201e": '"',
|
|
851
|
+
"\u2032": "'",
|
|
852
|
+
"\u2033": '"',
|
|
853
|
+
"\u2035": "'",
|
|
854
|
+
"\u2036": '"',
|
|
855
|
+
"\u2039": "<",
|
|
856
|
+
"\u203a": ">",
|
|
857
|
+
// Dashes, hyphens, and the fraction slash NFKD leaves in vulgar fractions.
|
|
858
|
+
"\u2010": "-",
|
|
859
|
+
"\u2011": "-",
|
|
860
|
+
"\u2012": "-",
|
|
819
861
|
"\u2013": "-",
|
|
820
862
|
"\u2014": "-",
|
|
821
863
|
"\u2015": "-",
|
|
822
864
|
"\u2212": "-",
|
|
865
|
+
"\u2044": "/",
|
|
866
|
+
// Dot leaders and ellipses.
|
|
867
|
+
"\u2024": ".",
|
|
868
|
+
"\u2025": "..",
|
|
823
869
|
"\u2026": "...",
|
|
870
|
+
"\u22ef": "...",
|
|
871
|
+
// Bullets.
|
|
824
872
|
"\u2022": "*",
|
|
873
|
+
"\u2023": "*",
|
|
874
|
+
"\u2043": "-",
|
|
875
|
+
"\u2219": "*",
|
|
825
876
|
"\u25cf": "*",
|
|
826
877
|
"\u25a0": "*",
|
|
827
878
|
"\u25aa": "*",
|
|
879
|
+
// Arrows.
|
|
828
880
|
"\u2190": "<-",
|
|
881
|
+
"\u2191": "^",
|
|
829
882
|
"\u2192": "->",
|
|
883
|
+
"\u2193": "v",
|
|
884
|
+
"\u2194": "<->",
|
|
885
|
+
"\u21d0": "<=",
|
|
830
886
|
"\u21d2": "=>",
|
|
887
|
+
"\u21d4": "<=>",
|
|
888
|
+
// Check marks and crosses.
|
|
831
889
|
"\u2713": "v",
|
|
832
890
|
"\u2714": "v",
|
|
833
891
|
"\u2717": "x",
|
|
@@ -855,15 +913,48 @@ const EDGE_RUNS = /^[ \u2588]+|[ \u2588]+$/g;
|
|
|
855
913
|
* combining marks the fonts cannot compose, and lone surrogates. */
|
|
856
914
|
const UNRENDERABLE = /[\p{Cc}\p{Mn}\p{Me}\p{Cs}]/u;
|
|
857
915
|
|
|
916
|
+
/** Combining marks NFKD splits off accented letters; dropped so the base
|
|
917
|
+
* letter prints without the diacritic the bundled fonts cannot compose. */
|
|
918
|
+
const COMBINING_MARKS = /\p{M}+/gu;
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Aggressive single-code-point ASCII fold via Unicode NFKD: decompose the
|
|
922
|
+
* compatibility form (fullwidth, super/subscripts, ligatures, circled and
|
|
923
|
+
* math-styled alphanumerics, Roman numerals, vulgar fractions, …), strip the
|
|
924
|
+
* combining marks, and keep the ASCII/Latin-1 skeleton — routing any residual
|
|
925
|
+
* punctuation back through {@link CHAR_FOLD}. Returns `undefined` when the code
|
|
926
|
+
* point has no decomposition or still leaves an undrawable glyph, so the
|
|
927
|
+
* caller falls back to `?`.
|
|
928
|
+
*/
|
|
929
|
+
function foldToAscii(ch: string): string | undefined {
|
|
930
|
+
const decomposed = ch.normalize("NFKD").replace(COMBINING_MARKS, "");
|
|
931
|
+
if (decomposed === ch) return undefined;
|
|
932
|
+
let out = "";
|
|
933
|
+
for (const part of decomposed) {
|
|
934
|
+
const cp = part.codePointAt(0) as number;
|
|
935
|
+
if ((cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff)) {
|
|
936
|
+
out += part;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
const fold = CHAR_FOLD[part];
|
|
940
|
+
if (fold === undefined) return undefined;
|
|
941
|
+
out += fold;
|
|
942
|
+
}
|
|
943
|
+
return out;
|
|
944
|
+
}
|
|
945
|
+
|
|
858
946
|
/**
|
|
859
947
|
* Prepare text for printing: strip ANSI escape sequences, collapse horizontal
|
|
860
948
|
* whitespace runs to single spaces and newline-bearing runs to one
|
|
861
949
|
* {@link NEWLINE_GLYPH} (drawn as a pitch-black cell), then fold everything
|
|
862
|
-
* outside the fonts' ASCII + Latin-1 coverage to ASCII approximations
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
950
|
+
* outside the fonts' ASCII + Latin-1 coverage to ASCII approximations — first
|
|
951
|
+
* through the {@link CHAR_FOLD} punctuation table, then via an NFKD
|
|
952
|
+
* decomposition that recovers the ASCII skeleton of compatibility characters
|
|
953
|
+
* (fullwidth, super/subscripts, ligatures, circled/math-styled alphanumerics,
|
|
954
|
+
* Roman numerals, vulgar fractions). Unrenderable control/format/combining
|
|
955
|
+
* characters are dropped without occupying a cell; `?` remains the fallback
|
|
956
|
+
* for unsupported graphic characters. The zero-width ink toggles
|
|
957
|
+
* {@link DIM_ON}/{@link DIM_OFF} pass through untouched.
|
|
867
958
|
*/
|
|
868
959
|
export function normalize(text: string): string {
|
|
869
960
|
const stripped = text.includes("\u001b") ? Bun.stripANSI(text) : text;
|
|
@@ -889,8 +980,10 @@ export function normalize(text: string): string {
|
|
|
889
980
|
} else if (cp >= 0x2500 && cp <= 0x257f) {
|
|
890
981
|
// Box drawing: keep table skeletons legible.
|
|
891
982
|
out += cp === 0x2502 || cp === 0x2503 ? "|" : cp === 0x2500 || cp === 0x2501 ? "-" : "+";
|
|
892
|
-
} else
|
|
893
|
-
|
|
983
|
+
} else {
|
|
984
|
+
const folded = foldToAscii(ch);
|
|
985
|
+
if (folded !== undefined) out += folded;
|
|
986
|
+
else if (!UNRENDERABLE.test(ch)) out += "?";
|
|
894
987
|
}
|
|
895
988
|
}
|
|
896
989
|
return out;
|
|
@@ -1118,23 +1211,31 @@ export function getPreservedArchive(preserveData: Record<string, unknown> | unde
|
|
|
1118
1211
|
const candidate = preserveData?.[PRESERVE_KEY];
|
|
1119
1212
|
if (!candidate || typeof candidate !== "object") return undefined;
|
|
1120
1213
|
const archive = candidate as Archive;
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1214
|
+
const frames = Array.isArray(archive.frames)
|
|
1215
|
+
? archive.frames.filter(
|
|
1216
|
+
frame =>
|
|
1217
|
+
!!frame &&
|
|
1218
|
+
typeof frame.data === "string" &&
|
|
1219
|
+
frame.data.length > 0 &&
|
|
1220
|
+
typeof frame.mimeType === "string" &&
|
|
1221
|
+
typeof frame.cols === "number" &&
|
|
1222
|
+
typeof frame.rows === "number" &&
|
|
1223
|
+
typeof frame.chars === "number",
|
|
1224
|
+
)
|
|
1225
|
+
: [];
|
|
1226
|
+
const text = typeof archive.text === "string" && archive.text.length > 0 ? archive.text : undefined;
|
|
1227
|
+
const textHead = typeof archive.textHead === "string" && archive.textHead.length > 0 ? archive.textHead : undefined;
|
|
1228
|
+
const textTail = typeof archive.textTail === "string" && archive.textTail.length > 0 ? archive.textTail : undefined;
|
|
1229
|
+
// A text-only archive (everything fit in the plain-text regions) is valid;
|
|
1230
|
+
// only an archive carrying neither frames nor text is empty.
|
|
1231
|
+
if (frames.length === 0 && text === undefined && textHead === undefined && textTail === undefined) return undefined;
|
|
1133
1232
|
return {
|
|
1134
1233
|
frames,
|
|
1135
1234
|
totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0,
|
|
1136
1235
|
truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0,
|
|
1137
|
-
...(
|
|
1236
|
+
...(text !== undefined ? { text } : {}),
|
|
1237
|
+
...(textHead !== undefined ? { textHead } : {}),
|
|
1238
|
+
...(textTail !== undefined ? { textTail } : {}),
|
|
1138
1239
|
};
|
|
1139
1240
|
}
|
|
1140
1241
|
|
|
@@ -1147,28 +1248,176 @@ export function images(archive: Archive): ImageContent[] {
|
|
|
1147
1248
|
...(frame.detail ? { detail: frame.detail } : {}),
|
|
1148
1249
|
}));
|
|
1149
1250
|
}
|
|
1251
|
+
/** Ordered archive blocks for a compaction summary message, oldest to newest:
|
|
1252
|
+
* the oldest text region, the imaged middle, then the newest text region.
|
|
1253
|
+
* Runtime-only; reconstructed from {@link Archive} on each context rebuild
|
|
1254
|
+
* instead of persisted on the session entry. */
|
|
1255
|
+
export function historyBlocks(archive: Archive): (TextContent | ImageContent)[] {
|
|
1256
|
+
const blocks: (TextContent | ImageContent)[] = [];
|
|
1257
|
+
const hasImages = archive.frames.length > 0;
|
|
1258
|
+
if (archive.textHead) {
|
|
1259
|
+
const suffix = hasImages ? "\n-------------- imaged middle below\n" : "";
|
|
1260
|
+
blocks.push({ type: "text", text: toPlainText(archive.textHead) + suffix });
|
|
1261
|
+
}
|
|
1262
|
+
blocks.push(...images(archive));
|
|
1263
|
+
if (archive.textTail) {
|
|
1264
|
+
const prefix = hasImages
|
|
1265
|
+
? "-------------- imaged middle above\n"
|
|
1266
|
+
: archive.truncatedChars > 0
|
|
1267
|
+
? "\n-------------- middle history omitted above\n"
|
|
1268
|
+
: "";
|
|
1269
|
+
const tail = prefix + toPlainText(archive.textTail);
|
|
1270
|
+
if (blocks.length > 0 && blocks[blocks.length - 1]?.type === "text") {
|
|
1271
|
+
(blocks[blocks.length - 1] as TextContent).text += tail;
|
|
1272
|
+
} else {
|
|
1273
|
+
blocks.push({ type: "text", text: tail });
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return blocks;
|
|
1277
|
+
}
|
|
1150
1278
|
|
|
1151
1279
|
// ============================================================================
|
|
1152
1280
|
// Compaction entry point
|
|
1153
1281
|
// ============================================================================
|
|
1154
1282
|
|
|
1283
|
+
/** Denser companion of `high` for the foveated archive middle: same family and
|
|
1284
|
+
* frame size (identical per-frame bill) but a tighter cell. Returns `high`
|
|
1285
|
+
* unchanged for doc layouts or when no denser variant exists (foveation off). */
|
|
1286
|
+
function denseCompanion(high: Shape, api: Api | undefined): Shape {
|
|
1287
|
+
if (high.columns === 2) return high;
|
|
1288
|
+
const family = billingFamily(api);
|
|
1289
|
+
const low = priceShape({ ...SHAPE_VARIANTS[FAMILY_VARIANT_LOW[family]], frameSize: high.frameSize }, family);
|
|
1290
|
+
return geometry(low).capacity > geometry(high).capacity ? low : high;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** One planned frame: the source slice and the shape (quality tier) to render. */
|
|
1294
|
+
interface PlanFrame {
|
|
1295
|
+
text: string;
|
|
1296
|
+
shape: Shape;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** A foveated archive layout: frames oldest→newest for the imaged middle, the
|
|
1300
|
+
* verbatim text kept at both chronological edges, the flat kept source to
|
|
1301
|
+
* persist, and the chars dropped this round to fit the budget. */
|
|
1302
|
+
interface ArchiveLayout {
|
|
1303
|
+
frames: PlanFrame[];
|
|
1304
|
+
textHead: string;
|
|
1305
|
+
textTail: string;
|
|
1306
|
+
keptText: string;
|
|
1307
|
+
truncatedChars: number;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** Slice `text` into `capacity`-char frames at one shape (tier). */
|
|
1311
|
+
function sliceFrames(text: string, capacity: number, shape: Shape): PlanFrame[] {
|
|
1312
|
+
const out: PlanFrame[] = [];
|
|
1313
|
+
for (let offset = 0; offset < text.length; offset += capacity) {
|
|
1314
|
+
out.push({ text: text.slice(offset, offset + capacity), shape });
|
|
1315
|
+
}
|
|
1316
|
+
return out;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Lay out the accumulated archive `text` (oldest→newest) with text at both
|
|
1321
|
+
* chronological edges and images in the middle. One HQ-capacity stays verbatim
|
|
1322
|
+
* at the oldest edge, one at the newest edge, and the middle between them is
|
|
1323
|
+
* imaged. If the imaged middle itself overflows `maxFrames`, foveate it
|
|
1324
|
+
* internally (HQ/LQ/HQ) and drop the oldest slice of its dense center.
|
|
1325
|
+
*/
|
|
1326
|
+
function planArchive(text: string, high: Shape, low: Shape, maxFrames: number): ArchiveLayout {
|
|
1327
|
+
const capHi = geometry(high).capacity;
|
|
1328
|
+
const edgeCap = TEXT_EDGE_PAGES * capHi;
|
|
1329
|
+
if (text.length <= 2 * edgeCap) {
|
|
1330
|
+
return { frames: [], textHead: text, textTail: "", keptText: text, truncatedChars: 0 };
|
|
1331
|
+
}
|
|
1332
|
+
if (maxFrames < 1) {
|
|
1333
|
+
const textHead = text.slice(0, edgeCap);
|
|
1334
|
+
const textTail = text.slice(text.length - edgeCap);
|
|
1335
|
+
return {
|
|
1336
|
+
frames: [],
|
|
1337
|
+
textHead,
|
|
1338
|
+
textTail,
|
|
1339
|
+
keptText: textHead + textTail,
|
|
1340
|
+
truncatedChars: text.length - textHead.length - textTail.length,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
const textHead = text.slice(0, edgeCap);
|
|
1345
|
+
const textTail = text.slice(text.length - edgeCap);
|
|
1346
|
+
const imageText = text.slice(edgeCap, text.length - edgeCap);
|
|
1347
|
+
if (imageText.length === 0) {
|
|
1348
|
+
return { frames: [], textHead: text, textTail: "", keptText: text, truncatedChars: 0 };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Doc layouts wrap (no char-slicing) and don't foveate: one tier, keep the
|
|
1352
|
+
// newest pages with the session head pinned, drop the oldest middle.
|
|
1353
|
+
if (high.columns === 2) {
|
|
1354
|
+
const pages = docPages(imageText, geometry(high));
|
|
1355
|
+
let kept = pages;
|
|
1356
|
+
let truncatedChars = 0;
|
|
1357
|
+
if (pages.length > maxFrames) {
|
|
1358
|
+
const dropped = pages.slice(1, pages.length - (maxFrames - 1));
|
|
1359
|
+
truncatedChars = dropped.reduce((sum, page) => sum + page.length, 0);
|
|
1360
|
+
kept = [...pages.slice(0, 1), ...pages.slice(pages.length - (maxFrames - 1))];
|
|
1361
|
+
}
|
|
1362
|
+
const flat = kept.map(page => page.replaceAll("\n", " ")).join(" ");
|
|
1363
|
+
return {
|
|
1364
|
+
frames: kept.map(page => ({ text: page, shape: high })),
|
|
1365
|
+
textHead,
|
|
1366
|
+
textTail,
|
|
1367
|
+
keptText: textHead + flat + textTail,
|
|
1368
|
+
truncatedChars,
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// Grid: render all-HQ when the image region fits the budget outright.
|
|
1373
|
+
if (Math.ceil(imageText.length / capHi) <= maxFrames) {
|
|
1374
|
+
return {
|
|
1375
|
+
frames: sliceFrames(imageText, capHi, high),
|
|
1376
|
+
textHead,
|
|
1377
|
+
textTail,
|
|
1378
|
+
keptText: textHead + imageText + textTail,
|
|
1379
|
+
truncatedChars: 0,
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Foveate the imaged middle: HQ edges, dense center, drop the oldest dense slice.
|
|
1384
|
+
const capLo = geometry(low).capacity;
|
|
1385
|
+
const imageEdgeFrames = Math.min(HQ_EDGE_FRAMES, Math.floor((maxFrames - 1) / 2));
|
|
1386
|
+
const imageEdgeCap = imageEdgeFrames * capHi;
|
|
1387
|
+
const imageHead = imageText.slice(0, imageEdgeCap);
|
|
1388
|
+
const imageTail = imageEdgeCap > 0 ? imageText.slice(imageText.length - imageEdgeCap) : "";
|
|
1389
|
+
let middleText = imageText.slice(imageEdgeCap, imageText.length - imageEdgeCap);
|
|
1390
|
+
let truncatedChars = 0;
|
|
1391
|
+
const middleCap = (maxFrames - 2 * imageEdgeFrames) * capLo;
|
|
1392
|
+
if (middleText.length > middleCap) {
|
|
1393
|
+
truncatedChars = middleText.length - middleCap;
|
|
1394
|
+
middleText = middleText.slice(truncatedChars);
|
|
1395
|
+
}
|
|
1396
|
+
return {
|
|
1397
|
+
frames: [
|
|
1398
|
+
...sliceFrames(imageHead, capHi, high),
|
|
1399
|
+
...sliceFrames(middleText, capLo, low),
|
|
1400
|
+
...sliceFrames(imageTail, capHi, high),
|
|
1401
|
+
],
|
|
1402
|
+
textHead,
|
|
1403
|
+
textTail,
|
|
1404
|
+
keptText: textHead + imageHead + middleText + imageTail + textTail,
|
|
1405
|
+
truncatedChars,
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1155
1409
|
/**
|
|
1156
1410
|
* Run a snapcompact compaction over prepared messages. Fully local: serializes
|
|
1157
|
-
* the discarded history,
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1161
|
-
* hard image caps silently drop excess frames on the wire) — the newest
|
|
1162
|
-
* unrendered slice survives verbatim as a text tail on the summary and is
|
|
1163
|
-
* folded back into frames by the next compaction.
|
|
1411
|
+
* the discarded history, appends it to the accumulated archive source text, and
|
|
1412
|
+
* re-renders that source into an ordered history layout: plain text at the
|
|
1413
|
+
* oldest edge, imaged middle, then plain text at the newest edge. The imaged
|
|
1414
|
+
* middle itself foveates (HQ/LQ/HQ) when it grows large.
|
|
1164
1415
|
*
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
* summary describes the newest shape while noting that older frames may
|
|
1168
|
-
* differ.
|
|
1416
|
+
* The full kept source persists on the archive (`text`) so each later compaction
|
|
1417
|
+
* unfolds and re-renders it coherently alongside the newly archived history.
|
|
1169
1418
|
*
|
|
1170
|
-
* If the previous compaction was text-based, its summary is printed at the
|
|
1171
|
-
*
|
|
1419
|
+
* If the previous compaction was text-based, its summary is printed at the head
|
|
1420
|
+
* of the archive as `[Summary of earlier history]` so no continuity is lost.
|
|
1172
1421
|
*/
|
|
1173
1422
|
export async function compact<TMessage = Message>(
|
|
1174
1423
|
preparation: CompactionPreparation<TMessage>,
|
|
@@ -1178,10 +1427,14 @@ export async function compact<TMessage = Message>(
|
|
|
1178
1427
|
if (!firstKeptEntryId) {
|
|
1179
1428
|
throw new Error("First kept entry has no ID - session may need migration");
|
|
1180
1429
|
}
|
|
1181
|
-
const
|
|
1182
|
-
const frameSize = options?.frameSize ??
|
|
1183
|
-
const
|
|
1184
|
-
const
|
|
1430
|
+
const baseShape = options?.shape ?? resolveShape(options?.model);
|
|
1431
|
+
const frameSize = options?.frameSize ?? baseShape.frameSize;
|
|
1432
|
+
const high = frameSize === baseShape.frameSize ? baseShape : { ...baseShape, frameSize };
|
|
1433
|
+
const low = denseCompanion(high, options?.model?.api);
|
|
1434
|
+
const geo = geometry(high);
|
|
1435
|
+
// The engine default caps archive growth; a caller-supplied maxFrames only
|
|
1436
|
+
// lowers it further (an upper limit), never raising it past the default.
|
|
1437
|
+
const maxFrames = Math.max(1, Math.min(options?.maxFrames ?? MAX_FRAMES_DEFAULT, MAX_FRAMES_DEFAULT));
|
|
1185
1438
|
|
|
1186
1439
|
const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
|
1187
1440
|
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
|
|
@@ -1196,127 +1449,103 @@ export async function compact<TMessage = Message>(
|
|
|
1196
1449
|
|
|
1197
1450
|
let truncatedChars = previousArchive?.truncatedChars ?? 0;
|
|
1198
1451
|
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
: previousArchive.textTail;
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
const pages: string[] = [];
|
|
1209
|
-
if (shape.columns === 2) {
|
|
1210
|
-
pages.push(...docPages(archiveText, geo));
|
|
1211
|
-
} else {
|
|
1212
|
-
for (let offset = 0; offset < archiveText.length; offset += geo.capacity) {
|
|
1213
|
-
pages.push(archiveText.slice(offset, offset + geo.capacity));
|
|
1214
|
-
}
|
|
1452
|
+
// Re-compacting a snapcompacted history unfolds the prior archive's source
|
|
1453
|
+
// text and treats it as one coherent transcript: the previous kept source
|
|
1454
|
+
// ages in ahead of the new history, then the whole thing is re-rendered.
|
|
1455
|
+
const previousText = previousArchive?.text;
|
|
1456
|
+
if (previousText) {
|
|
1457
|
+
archiveText = archiveText.length > 0 ? `${previousText}${NEWLINE_GLYPH}${archiveText}` : previousText;
|
|
1215
1458
|
}
|
|
1216
1459
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
// (the archive fades oldest-first, as before); new pages that still do
|
|
1220
|
-
// not fit stay behind as a verbatim text tail instead of being dropped.
|
|
1221
|
-
const prevFrames = previousArchive?.frames ?? [];
|
|
1222
|
-
let keptPrev = prevFrames;
|
|
1223
|
-
if (prevFrames.length + pages.length > maxFrames) {
|
|
1224
|
-
// Pin the earliest frame: it anchors the session head (the original
|
|
1225
|
-
// request, or the filmed summary of even older history) the way the
|
|
1226
|
-
// LLM-summary strategies keep the original goal alive across rounds.
|
|
1227
|
-
// With a budget of one frame the pin is moot.
|
|
1228
|
-
const pinCount = maxFrames >= 2 && prevFrames.length > 0 ? 1 : 0;
|
|
1229
|
-
const evictable = prevFrames.slice(pinCount);
|
|
1230
|
-
const surviving = Math.min(evictable.length, Math.max(0, maxFrames - pages.length - pinCount));
|
|
1231
|
-
const dropped = evictable.slice(0, evictable.length - surviving);
|
|
1232
|
-
for (const frame of dropped) truncatedChars += frame.chars;
|
|
1233
|
-
keptPrev = [...prevFrames.slice(0, pinCount), ...evictable.slice(evictable.length - surviving)];
|
|
1234
|
-
}
|
|
1235
|
-
const renderPages = pages.slice(0, maxFrames - keptPrev.length);
|
|
1236
|
-
const tailPages = pages.slice(renderPages.length);
|
|
1460
|
+
const layout = planArchive(archiveText, high, low, maxFrames);
|
|
1461
|
+
truncatedChars += layout.truncatedChars;
|
|
1237
1462
|
|
|
1463
|
+
// Re-render the planned frames, carrying any open dim span across every
|
|
1464
|
+
// boundary: textHead → frames → textTail.
|
|
1465
|
+
let dimOpen = layout.textHead.lastIndexOf(DIM_ON) > layout.textHead.lastIndexOf(DIM_OFF);
|
|
1238
1466
|
const newFrames: Frame[] = [];
|
|
1239
|
-
const
|
|
1240
|
-
|
|
1241
|
-
|
|
1467
|
+
for (const planned of layout.frames) {
|
|
1468
|
+
let pageText: string = dimOpen ? DIM_ON + planned.text : planned.text;
|
|
1469
|
+
dimOpen = pageText.lastIndexOf(DIM_ON) > pageText.lastIndexOf(DIM_OFF);
|
|
1470
|
+
if (planned.shape.stopwordDim) pageText = dimStopwords(pageText);
|
|
1471
|
+
const rendered = render(pageText, planned.shape);
|
|
1242
1472
|
newFrames.push({
|
|
1243
1473
|
data: rendered.data,
|
|
1244
1474
|
mimeType: "image/png",
|
|
1245
1475
|
cols: rendered.cols,
|
|
1246
1476
|
rows: rendered.rows,
|
|
1247
1477
|
chars: rendered.chars,
|
|
1248
|
-
font: shape.font,
|
|
1249
|
-
variant: shape.variant,
|
|
1250
|
-
lineRepeat: shape.lineRepeat,
|
|
1251
|
-
...(shape.columns === 2 ? { columns: 2 } : {}),
|
|
1252
|
-
...(shape.stopwordDim ? { stopwordDim: true } : {}),
|
|
1253
|
-
...(shape.imageDetail ? { detail: shape.imageDetail } : {}),
|
|
1478
|
+
font: planned.shape.font,
|
|
1479
|
+
variant: planned.shape.variant,
|
|
1480
|
+
lineRepeat: planned.shape.lineRepeat,
|
|
1481
|
+
...(planned.shape.columns === 2 ? { columns: 2 } : {}),
|
|
1482
|
+
...(planned.shape.stopwordDim ? { stopwordDim: true } : {}),
|
|
1483
|
+
...(planned.shape.imageDetail ? { detail: planned.shape.imageDetail } : {}),
|
|
1254
1484
|
});
|
|
1255
1485
|
// Keep the event loop responsive between native render passes.
|
|
1256
1486
|
await Bun.sleep(0);
|
|
1257
1487
|
}
|
|
1258
1488
|
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
if (tailPages.length > 0) {
|
|
1263
|
-
const raw =
|
|
1264
|
-
shape.columns === 2 ? tailPages.map(page => page.replaceAll("\n", " ")).join(" ") : tailPages.join("");
|
|
1265
|
-
const tailCap = TEXT_TAIL_MAX_PAGES * geo.capacity;
|
|
1266
|
-
if (raw.length > tailCap) truncatedChars += raw.length - tailCap;
|
|
1267
|
-
// Re-open a dim span the render boundary cut through, so the carried
|
|
1268
|
-
// tail keeps tool output dim when it lands on frames next compaction.
|
|
1269
|
-
const renderedText = shape.columns === 2 ? renderPages.join("\n") : renderPages.join("");
|
|
1270
|
-
const dimOpen = renderedText.lastIndexOf(DIM_ON) > renderedText.lastIndexOf(DIM_OFF);
|
|
1271
|
-
textTail = (dimOpen ? DIM_ON : "") + truncateForSummary(raw, tailCap, TRUNCATE_HEAD_RATIO);
|
|
1272
|
-
}
|
|
1489
|
+
const textHead = layout.textHead;
|
|
1490
|
+
const textTail = layout.textTail.length > 0 ? (dimOpen ? DIM_ON : "") + layout.textTail : "";
|
|
1491
|
+
const textChars = textHead.length + textTail.length;
|
|
1273
1492
|
|
|
1274
|
-
const frames =
|
|
1275
|
-
const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0);
|
|
1493
|
+
const frames = newFrames;
|
|
1494
|
+
const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0) + textChars;
|
|
1276
1495
|
const mixedShapes = frames.some(
|
|
1277
1496
|
frame =>
|
|
1278
1497
|
frame.cols !== geo.cols ||
|
|
1279
1498
|
frame.rows !== geo.rows ||
|
|
1280
|
-
(frame.variant ?? "sent") !==
|
|
1281
|
-
(frame.lineRepeat ?? 1) !==
|
|
1282
|
-
(frame.columns ?? 1) !== (
|
|
1283
|
-
(frame.stopwordDim ?? false) !== (
|
|
1499
|
+
(frame.variant ?? "sent") !== high.variant ||
|
|
1500
|
+
(frame.lineRepeat ?? 1) !== high.lineRepeat ||
|
|
1501
|
+
(frame.columns ?? 1) !== (high.columns ?? 1) ||
|
|
1502
|
+
(frame.stopwordDim ?? false) !== (high.stopwordDim ?? false),
|
|
1284
1503
|
);
|
|
1285
1504
|
|
|
1505
|
+
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
1506
|
+
const files = formatFileList(readFiles, modifiedFiles, fileOps.read);
|
|
1507
|
+
|
|
1286
1508
|
let summary: string;
|
|
1287
|
-
if (frames.length === 0) {
|
|
1509
|
+
if (frames.length === 0 && textHead.length === 0 && textTail.length === 0 && files.length === 0) {
|
|
1288
1510
|
summary = "No prior history.";
|
|
1289
1511
|
} else {
|
|
1290
1512
|
summary = prompt.render(snapcompactSummaryPrompt, {
|
|
1291
1513
|
frameCount: frames.length,
|
|
1292
1514
|
multipleFrames: frames.length > 1,
|
|
1293
|
-
|
|
1515
|
+
docColumns: high.columns === 2,
|
|
1294
1516
|
cols: geo.cols,
|
|
1295
1517
|
rows: geo.rows,
|
|
1296
|
-
sentenceInk:
|
|
1297
|
-
|
|
1298
|
-
docColumns: shape.columns === 2,
|
|
1299
|
-
stopwordDimmed: shape.stopwordDim === true,
|
|
1518
|
+
sentenceInk: high.variant === "sent",
|
|
1519
|
+
stopwordDimmed: high.stopwordDim === true,
|
|
1300
1520
|
dimmedToolResults: options?.dimToolResults !== false,
|
|
1521
|
+
lineRepeated: high.lineRepeat > 1,
|
|
1301
1522
|
mixedShapes,
|
|
1302
|
-
totalChars,
|
|
1303
1523
|
truncatedChars,
|
|
1304
1524
|
includedPreviousSummary,
|
|
1305
|
-
|
|
1525
|
+
files: files.length > 0 ? files : undefined,
|
|
1306
1526
|
});
|
|
1307
1527
|
}
|
|
1308
|
-
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
1309
|
-
summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
|
|
1310
1528
|
|
|
1311
1529
|
// A snapcompact pass replaces any provider-side replacement history; strip the
|
|
1312
1530
|
// OpenAI remote-compaction payload like the default summarizer path does.
|
|
1313
1531
|
const basePreserve = stripOpenAiRemoteCompactionPreserveData(previousPreserveData) ?? {};
|
|
1314
|
-
const
|
|
1532
|
+
const persistedText =
|
|
1533
|
+
layout.keptText.length > 0 && layout.textTail.length > 0
|
|
1534
|
+
? `${layout.keptText.slice(0, layout.keptText.length - layout.textTail.length)}${textTail}`
|
|
1535
|
+
: layout.keptText;
|
|
1536
|
+
const archive: Archive = {
|
|
1537
|
+
frames,
|
|
1538
|
+
totalChars,
|
|
1539
|
+
truncatedChars,
|
|
1540
|
+
...(persistedText.length > 0 ? { text: persistedText } : {}),
|
|
1541
|
+
...(textHead ? { textHead } : {}),
|
|
1542
|
+
...(textTail ? { textTail } : {}),
|
|
1543
|
+
};
|
|
1315
1544
|
|
|
1316
|
-
const
|
|
1545
|
+
const textNote = textChars > 0 ? ` (+${textChars.toLocaleString()} chars as text)` : "";
|
|
1317
1546
|
return {
|
|
1318
1547
|
summary,
|
|
1319
|
-
shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}${
|
|
1548
|
+
shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}${textNote}`,
|
|
1320
1549
|
firstKeptEntryId,
|
|
1321
1550
|
tokensBefore,
|
|
1322
1551
|
details: { readFiles, modifiedFiles },
|