@oh-my-pi/snapcompact 16.0.11 → 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 +21 -0
- package/README.md +5 -5
- package/dist/types/snapcompact.d.ts +53 -45
- package/package.json +5 -5
- package/src/prompts/snapcompact-summary.md +20 -28
- package/src/snapcompact.ts +309 -151
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
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
|
+
|
|
5
26
|
## [16.0.11] - 2026-06-19
|
|
6
27
|
|
|
7
28
|
### Changed
|
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. */
|
|
@@ -504,22 +512,22 @@ export declare function frames(text: string, options?: Pick<RenderManyOptions, "
|
|
|
504
512
|
export declare function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined;
|
|
505
513
|
/** Convert archive frames into LLM image blocks (oldest first). */
|
|
506
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)[];
|
|
507
520
|
/**
|
|
508
521
|
* Run a snapcompact compaction over prepared messages. Fully local: serializes
|
|
509
|
-
* the discarded history,
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
* hard image caps silently drop excess frames on the wire) — the newest
|
|
514
|
-
* unrendered slice survives verbatim as a text tail on the summary and is
|
|
515
|
-
* 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.
|
|
516
526
|
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
* summary describes the newest shape while noting that older frames may
|
|
520
|
-
* 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.
|
|
521
529
|
*
|
|
522
|
-
* If the previous compaction was text-based, its summary is printed at the
|
|
523
|
-
*
|
|
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.
|
|
524
532
|
*/
|
|
525
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,10 +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
|
|
37
|
-
"@oh-my-pi/pi-wire": "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"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/bun": "^1.3.14"
|
|
@@ -1,32 +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 docColumns}}- Two side-by-side text columns, each {{cols}} characters wide and up to {{rows}} rows tall: read the left column top to bottom, then the right.
|
|
5
|
-
{{else}}- A single grid {{cols}} characters wide and up to {{rows}} rows tall: read left to right, top to bottom — no word wrap, so words may break across rows.
|
|
6
|
-
{{/if}}
|
|
7
|
-
{{#if sentenceInk}}- Ink cycles six colors, one per sentence.
|
|
8
|
-
{{/if}}{{#if stopwordDimmed}}- Function words are dim gray; content words keep full ink.
|
|
9
|
-
{{/if}}{{#if dimmedToolResults}}- Text inside <out> is dim gray; that gray is archived tool output, not conversation.
|
|
10
|
-
{{/if}}{{#if lineRepeated}}- Each line is printed twice (white, then a pale-yellow band); the copies are identical.
|
|
11
|
-
{{/if}}
|
|
12
|
-
{{#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>`.
|
|
13
4
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
{{#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.
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
{{
|
|
19
|
+
{{#if files}}FILES
|
|
20
|
+
===================
|
|
21
|
+
{{files}}
|
|
21
22
|
|
|
22
|
-
{{
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
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.
|
|
26
|
-
{{#if textTail}}
|
|
27
|
-
|
|
28
|
-
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.
|
|
29
|
-
|
|
30
|
-
[Archived history, continued as text]
|
|
31
|
-
{{textTail}}
|
|
32
|
-
{{/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,7 +44,7 @@
|
|
|
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";
|
|
53
50
|
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
@@ -301,6 +298,16 @@ const FAMILY_VARIANT: Record<BillingFamily, ShapeVariantName> = {
|
|
|
301
298
|
openai: "8on22-bw",
|
|
302
299
|
};
|
|
303
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
|
+
|
|
304
311
|
const FAMILY_SHAPE: Record<BillingFamily, Shape> = {
|
|
305
312
|
anthropic: SHAPES.anthropic,
|
|
306
313
|
google: SHAPES.google,
|
|
@@ -377,22 +384,32 @@ export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "
|
|
|
377
384
|
* shapes carry their own `frameSize`. */
|
|
378
385
|
export const FRAME_SIZE = 2576;
|
|
379
386
|
|
|
380
|
-
/**
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
*
|
|
387
|
-
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;
|
|
388
406
|
|
|
389
407
|
/**
|
|
390
|
-
* Per-request image-count budgets by provider id.
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
* (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.
|
|
396
413
|
*/
|
|
397
414
|
export const PROVIDER_IMAGE_BUDGETS: Record<string, number> = {
|
|
398
415
|
anthropic: 90,
|
|
@@ -402,7 +419,7 @@ export const PROVIDER_IMAGE_BUDGETS: Record<string, number> = {
|
|
|
402
419
|
google: 200,
|
|
403
420
|
"google-vertex": 200,
|
|
404
421
|
"google-gemini-cli": 200,
|
|
405
|
-
openrouter:
|
|
422
|
+
openrouter: 90,
|
|
406
423
|
};
|
|
407
424
|
|
|
408
425
|
/** Safe floor for unknown providers (strictest mainstream measured: Groq ~5). */
|
|
@@ -413,11 +430,6 @@ export function providerImageBudget(provider: string | undefined): number {
|
|
|
413
430
|
return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET;
|
|
414
431
|
}
|
|
415
432
|
|
|
416
|
-
/** Archive frame budget for `provider`: its image budget clamped to {@link MAX_FRAMES}. */
|
|
417
|
-
export function providerFrameBudget(provider: string | undefined): number {
|
|
418
|
-
return Math.min(MAX_FRAMES, providerImageBudget(provider));
|
|
419
|
-
}
|
|
420
|
-
|
|
421
433
|
/** Key under `CompactionEntry.preserveData` holding the frame archive. */
|
|
422
434
|
export const PRESERVE_KEY = "snapcompact";
|
|
423
435
|
|
|
@@ -450,16 +462,20 @@ export interface Frame {
|
|
|
450
462
|
|
|
451
463
|
/** Frame archive persisted under `preserveData[PRESERVE_KEY]`. */
|
|
452
464
|
export interface Archive {
|
|
453
|
-
/**
|
|
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. */
|
|
454
468
|
frames: Frame[];
|
|
455
|
-
/** Characters currently readable across all frames. */
|
|
469
|
+
/** Characters currently readable across all frames plus the text regions. */
|
|
456
470
|
totalChars: number;
|
|
457
|
-
/** Characters dropped so far to respect the
|
|
471
|
+
/** Characters dropped so far to respect the archive budget. */
|
|
458
472
|
truncatedChars: number;
|
|
459
|
-
/**
|
|
460
|
-
*
|
|
461
|
-
|
|
462
|
-
|
|
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. */
|
|
463
479
|
textTail?: string;
|
|
464
480
|
}
|
|
465
481
|
|
|
@@ -481,7 +497,7 @@ export interface Options<TMessage = Message> extends SerializeOptions {
|
|
|
481
497
|
shape?: Shape;
|
|
482
498
|
/** Frame edge in pixels. Defaults to the shape's `frameSize`. */
|
|
483
499
|
frameSize?: number;
|
|
484
|
-
/**
|
|
500
|
+
/** Upper limit on archive frames; clamped to (and defaulting to) {@link MAX_FRAMES_DEFAULT}. */
|
|
485
501
|
maxFrames?: number;
|
|
486
502
|
}
|
|
487
503
|
|
|
@@ -585,7 +601,7 @@ function stripFileOperationTags(summary: string): string {
|
|
|
585
601
|
.trimEnd();
|
|
586
602
|
}
|
|
587
603
|
|
|
588
|
-
function
|
|
604
|
+
function formatFileList(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string {
|
|
589
605
|
if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
|
|
590
606
|
const mode = new Map<string, "Read" | "Write" | "RW">();
|
|
591
607
|
for (const file of readFiles) mode.set(file, "Read");
|
|
@@ -595,7 +611,12 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[], read
|
|
|
595
611
|
if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
|
|
596
612
|
files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`;
|
|
597
613
|
}
|
|
598
|
-
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 }) : "";
|
|
599
620
|
}
|
|
600
621
|
|
|
601
622
|
export function upsertFileOperations(
|
|
@@ -663,10 +684,11 @@ function truncateForSummary(text: string, maxChars: number, headRatio: number):
|
|
|
663
684
|
|
|
664
685
|
const DIM_MARKERS = /[\u000e\u000f]/g;
|
|
665
686
|
|
|
666
|
-
/**
|
|
667
|
-
*
|
|
668
|
-
* context
|
|
669
|
-
|
|
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;
|
|
670
692
|
|
|
671
693
|
/** Normalized archive text → plain text: drop zero-width dim toggles and
|
|
672
694
|
* print newline glyphs as real newlines. */
|
|
@@ -1189,23 +1211,31 @@ export function getPreservedArchive(preserveData: Record<string, unknown> | unde
|
|
|
1189
1211
|
const candidate = preserveData?.[PRESERVE_KEY];
|
|
1190
1212
|
if (!candidate || typeof candidate !== "object") return undefined;
|
|
1191
1213
|
const archive = candidate as Archive;
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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;
|
|
1204
1232
|
return {
|
|
1205
1233
|
frames,
|
|
1206
1234
|
totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0,
|
|
1207
1235
|
truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0,
|
|
1208
|
-
...(
|
|
1236
|
+
...(text !== undefined ? { text } : {}),
|
|
1237
|
+
...(textHead !== undefined ? { textHead } : {}),
|
|
1238
|
+
...(textTail !== undefined ? { textTail } : {}),
|
|
1209
1239
|
};
|
|
1210
1240
|
}
|
|
1211
1241
|
|
|
@@ -1218,28 +1248,176 @@ export function images(archive: Archive): ImageContent[] {
|
|
|
1218
1248
|
...(frame.detail ? { detail: frame.detail } : {}),
|
|
1219
1249
|
}));
|
|
1220
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
|
+
}
|
|
1221
1278
|
|
|
1222
1279
|
// ============================================================================
|
|
1223
1280
|
// Compaction entry point
|
|
1224
1281
|
// ============================================================================
|
|
1225
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
|
+
|
|
1226
1409
|
/**
|
|
1227
1410
|
* Run a snapcompact compaction over prepared messages. Fully local: serializes
|
|
1228
|
-
* the discarded history,
|
|
1229
|
-
*
|
|
1230
|
-
*
|
|
1231
|
-
*
|
|
1232
|
-
* hard image caps silently drop excess frames on the wire) — the newest
|
|
1233
|
-
* unrendered slice survives verbatim as a text tail on the summary and is
|
|
1234
|
-
* 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.
|
|
1235
1415
|
*
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1238
|
-
* summary describes the newest shape while noting that older frames may
|
|
1239
|
-
* 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.
|
|
1240
1418
|
*
|
|
1241
|
-
* If the previous compaction was text-based, its summary is printed at the
|
|
1242
|
-
*
|
|
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.
|
|
1243
1421
|
*/
|
|
1244
1422
|
export async function compact<TMessage = Message>(
|
|
1245
1423
|
preparation: CompactionPreparation<TMessage>,
|
|
@@ -1249,10 +1427,14 @@ export async function compact<TMessage = Message>(
|
|
|
1249
1427
|
if (!firstKeptEntryId) {
|
|
1250
1428
|
throw new Error("First kept entry has no ID - session may need migration");
|
|
1251
1429
|
}
|
|
1252
|
-
const
|
|
1253
|
-
const frameSize = options?.frameSize ??
|
|
1254
|
-
const
|
|
1255
|
-
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));
|
|
1256
1438
|
|
|
1257
1439
|
const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
|
1258
1440
|
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
|
|
@@ -1267,127 +1449,103 @@ export async function compact<TMessage = Message>(
|
|
|
1267
1449
|
|
|
1268
1450
|
let truncatedChars = previousArchive?.truncatedChars ?? 0;
|
|
1269
1451
|
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
: previousArchive.textTail;
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
const pages: string[] = [];
|
|
1280
|
-
if (shape.columns === 2) {
|
|
1281
|
-
pages.push(...docPages(archiveText, geo));
|
|
1282
|
-
} else {
|
|
1283
|
-
for (let offset = 0; offset < archiveText.length; offset += geo.capacity) {
|
|
1284
|
-
pages.push(archiveText.slice(offset, offset + geo.capacity));
|
|
1285
|
-
}
|
|
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;
|
|
1286
1458
|
}
|
|
1287
1459
|
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
// (the archive fades oldest-first, as before); new pages that still do
|
|
1291
|
-
// not fit stay behind as a verbatim text tail instead of being dropped.
|
|
1292
|
-
const prevFrames = previousArchive?.frames ?? [];
|
|
1293
|
-
let keptPrev = prevFrames;
|
|
1294
|
-
if (prevFrames.length + pages.length > maxFrames) {
|
|
1295
|
-
// Pin the earliest frame: it anchors the session head (the original
|
|
1296
|
-
// request, or the filmed summary of even older history) the way the
|
|
1297
|
-
// LLM-summary strategies keep the original goal alive across rounds.
|
|
1298
|
-
// With a budget of one frame the pin is moot.
|
|
1299
|
-
const pinCount = maxFrames >= 2 && prevFrames.length > 0 ? 1 : 0;
|
|
1300
|
-
const evictable = prevFrames.slice(pinCount);
|
|
1301
|
-
const surviving = Math.min(evictable.length, Math.max(0, maxFrames - pages.length - pinCount));
|
|
1302
|
-
const dropped = evictable.slice(0, evictable.length - surviving);
|
|
1303
|
-
for (const frame of dropped) truncatedChars += frame.chars;
|
|
1304
|
-
keptPrev = [...prevFrames.slice(0, pinCount), ...evictable.slice(evictable.length - surviving)];
|
|
1305
|
-
}
|
|
1306
|
-
const renderPages = pages.slice(0, maxFrames - keptPrev.length);
|
|
1307
|
-
const tailPages = pages.slice(renderPages.length);
|
|
1460
|
+
const layout = planArchive(archiveText, high, low, maxFrames);
|
|
1461
|
+
truncatedChars += layout.truncatedChars;
|
|
1308
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);
|
|
1309
1466
|
const newFrames: Frame[] = [];
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
|
|
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);
|
|
1313
1472
|
newFrames.push({
|
|
1314
1473
|
data: rendered.data,
|
|
1315
1474
|
mimeType: "image/png",
|
|
1316
1475
|
cols: rendered.cols,
|
|
1317
1476
|
rows: rendered.rows,
|
|
1318
1477
|
chars: rendered.chars,
|
|
1319
|
-
font: shape.font,
|
|
1320
|
-
variant: shape.variant,
|
|
1321
|
-
lineRepeat: shape.lineRepeat,
|
|
1322
|
-
...(shape.columns === 2 ? { columns: 2 } : {}),
|
|
1323
|
-
...(shape.stopwordDim ? { stopwordDim: true } : {}),
|
|
1324
|
-
...(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 } : {}),
|
|
1325
1484
|
});
|
|
1326
1485
|
// Keep the event loop responsive between native render passes.
|
|
1327
1486
|
await Bun.sleep(0);
|
|
1328
1487
|
}
|
|
1329
1488
|
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
if (tailPages.length > 0) {
|
|
1334
|
-
const raw =
|
|
1335
|
-
shape.columns === 2 ? tailPages.map(page => page.replaceAll("\n", " ")).join(" ") : tailPages.join("");
|
|
1336
|
-
const tailCap = TEXT_TAIL_MAX_PAGES * geo.capacity;
|
|
1337
|
-
if (raw.length > tailCap) truncatedChars += raw.length - tailCap;
|
|
1338
|
-
// Re-open a dim span the render boundary cut through, so the carried
|
|
1339
|
-
// tail keeps tool output dim when it lands on frames next compaction.
|
|
1340
|
-
const renderedText = shape.columns === 2 ? renderPages.join("\n") : renderPages.join("");
|
|
1341
|
-
const dimOpen = renderedText.lastIndexOf(DIM_ON) > renderedText.lastIndexOf(DIM_OFF);
|
|
1342
|
-
textTail = (dimOpen ? DIM_ON : "") + truncateForSummary(raw, tailCap, TRUNCATE_HEAD_RATIO);
|
|
1343
|
-
}
|
|
1489
|
+
const textHead = layout.textHead;
|
|
1490
|
+
const textTail = layout.textTail.length > 0 ? (dimOpen ? DIM_ON : "") + layout.textTail : "";
|
|
1491
|
+
const textChars = textHead.length + textTail.length;
|
|
1344
1492
|
|
|
1345
|
-
const frames =
|
|
1346
|
-
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;
|
|
1347
1495
|
const mixedShapes = frames.some(
|
|
1348
1496
|
frame =>
|
|
1349
1497
|
frame.cols !== geo.cols ||
|
|
1350
1498
|
frame.rows !== geo.rows ||
|
|
1351
|
-
(frame.variant ?? "sent") !==
|
|
1352
|
-
(frame.lineRepeat ?? 1) !==
|
|
1353
|
-
(frame.columns ?? 1) !== (
|
|
1354
|
-
(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),
|
|
1355
1503
|
);
|
|
1356
1504
|
|
|
1505
|
+
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
1506
|
+
const files = formatFileList(readFiles, modifiedFiles, fileOps.read);
|
|
1507
|
+
|
|
1357
1508
|
let summary: string;
|
|
1358
|
-
if (frames.length === 0) {
|
|
1509
|
+
if (frames.length === 0 && textHead.length === 0 && textTail.length === 0 && files.length === 0) {
|
|
1359
1510
|
summary = "No prior history.";
|
|
1360
1511
|
} else {
|
|
1361
1512
|
summary = prompt.render(snapcompactSummaryPrompt, {
|
|
1362
1513
|
frameCount: frames.length,
|
|
1363
1514
|
multipleFrames: frames.length > 1,
|
|
1364
|
-
|
|
1515
|
+
docColumns: high.columns === 2,
|
|
1365
1516
|
cols: geo.cols,
|
|
1366
1517
|
rows: geo.rows,
|
|
1367
|
-
sentenceInk:
|
|
1368
|
-
|
|
1369
|
-
docColumns: shape.columns === 2,
|
|
1370
|
-
stopwordDimmed: shape.stopwordDim === true,
|
|
1518
|
+
sentenceInk: high.variant === "sent",
|
|
1519
|
+
stopwordDimmed: high.stopwordDim === true,
|
|
1371
1520
|
dimmedToolResults: options?.dimToolResults !== false,
|
|
1521
|
+
lineRepeated: high.lineRepeat > 1,
|
|
1372
1522
|
mixedShapes,
|
|
1373
|
-
totalChars,
|
|
1374
1523
|
truncatedChars,
|
|
1375
1524
|
includedPreviousSummary,
|
|
1376
|
-
|
|
1525
|
+
files: files.length > 0 ? files : undefined,
|
|
1377
1526
|
});
|
|
1378
1527
|
}
|
|
1379
|
-
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
1380
|
-
summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
|
|
1381
1528
|
|
|
1382
1529
|
// A snapcompact pass replaces any provider-side replacement history; strip the
|
|
1383
1530
|
// OpenAI remote-compaction payload like the default summarizer path does.
|
|
1384
1531
|
const basePreserve = stripOpenAiRemoteCompactionPreserveData(previousPreserveData) ?? {};
|
|
1385
|
-
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
|
+
};
|
|
1386
1544
|
|
|
1387
|
-
const
|
|
1545
|
+
const textNote = textChars > 0 ? ` (+${textChars.toLocaleString()} chars as text)` : "";
|
|
1388
1546
|
return {
|
|
1389
1547
|
summary,
|
|
1390
|
-
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}`,
|
|
1391
1549
|
firstKeptEntryId,
|
|
1392
1550
|
tokensBefore,
|
|
1393
1551
|
details: { readFiles, modifiedFiles },
|