@oh-my-pi/snapcompact 15.11.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 +31 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/snapcompact.d.ts +265 -0
- package/package.json +63 -0
- package/src/index.ts +1 -0
- package/src/prompts/file-operations.md +5 -0
- package/src/prompts/snapcompact-summary.md +17 -0
- package/src/snapcompact.ts +746 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [15.11.0] - 2026-06-10
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Changed `renderSnapcompactFrame` output from `png: Uint8Array` to `data: string` base64, requiring consumers to read frame payloads from `frame.data`
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added new serialization options `toolResultMaxChars`, `toolArgMaxChars`, `toolCallMaxChars`, `truncateHeadRatio`, and `dimToolResults` to `snapcompactCompact`/`serializeSnapcompactConversation` so callers can tune how tool results and arguments are archived
|
|
14
|
+
- Added exported default constants `SNAPCOMPACT_TOOL_RESULT_MAX_CHARS`, `SNAPCOMPACT_TOOL_ARG_MAX_CHARS`, `SNAPCOMPACT_TOOL_CALL_MAX_CHARS`, and `SNAPCOMPACT_TRUNCATE_HEAD_RATIO` for reuse when configuring truncation limits
|
|
15
|
+
- Added provider-specific snapcompact frame-shape presets and shape helpers (`SNAPCOMPACT_SHAPES`, `resolveSnapcompactShape`, `isSnapcompactShape`) so callers can consistently select validated image-frame geometry for archive renders
|
|
16
|
+
- Added `file-operations.md` and `snapcompact-summary.md` prompts to preserve file-read/write context and frame metadata in the compaction prompt flow
|
|
17
|
+
- Added a full `packages/snapcompact/research` experiment and visualization suite for running snapcompact SQuAD studies, provider probes, and activation-style analyses
|
|
18
|
+
- Added package-level TypeScript exports and publication config so consumers can import `@oh-my-pi/snapcompact` with typed access to snapcompact APIs
|
|
19
|
+
- Published `@oh-my-pi/snapcompact` as the reusable snapcompact compaction package, including bitmap-frame rendering helpers, archive helpers, and the local `snapcompactCompact()` strategy.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Changed truncation in archived tool output to keep both the beginning and end of long text using a configurable head/tail ratio instead of a single hard cut
|
|
24
|
+
- Changed tool-result text rendering so archived tool results are shown in dim gray ink by default and the summary prompt notes that dim text is archived tool output
|
|
25
|
+
- Changed `RenderedFrame` visible-character accounting so `chars` no longer includes invisible dim-control markers
|
|
26
|
+
- Changed the file-operations summary block to a single `<files>` tag: one grouped, prefix-folded directory tree with per-file `(Read)`/`(Write)`/`(RW)` markers, replacing the separate `<read-files>`/`<modified-files>` lists; `upsertSnapcompactFileOperations` takes the cumulative read set to distinguish `(RW)` from blind writes
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Fixed frame rendering at archive chunk boundaries to reopen dim spans when a chunk ends inside a dimmed tool-result segment
|
|
31
|
+
- Fixed message serialization to strip user- and assistant-provided dim markers so only renderer-generated dim spans can be applied
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./snapcompact";
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapcompact compaction: archive conversation history as dense bitmap images.
|
|
3
|
+
*
|
|
4
|
+
* Instead of asking an LLM to summarize discarded history, the serialized
|
|
5
|
+
* conversation is rendered into square PNG frames of pixel-font text that
|
|
6
|
+
* vision models read back directly, like an archivist at a snapcompact frame
|
|
7
|
+
* reader.
|
|
8
|
+
*
|
|
9
|
+
* The frame shape is provider-aware, following the snapcompact SQuAD evals
|
|
10
|
+
* (`packages/snapcompact`, 200k-token monolithic runs):
|
|
11
|
+
*
|
|
12
|
+
* - **Anthropic** (`8x8r-bw`): unscii-8 square cells, black ink, every line
|
|
13
|
+
* printed twice with the copy on a pale highlight band. Read at F1 parity
|
|
14
|
+
* with raw text at ~2x lower cost; the colored variants drew refusals at
|
|
15
|
+
* scale, the repeated plain shape did not.
|
|
16
|
+
* - **Google** (`8x8r-sent`): same repeated grid with six-hue sentence
|
|
17
|
+
* coloring (0.90 F1 at ~2.9x lower cost on gemini-3.5-flash).
|
|
18
|
+
* - **OpenAI** (`6x6u-sent`): OpenAI bills a flat ~2.9k tokens per image, so
|
|
19
|
+
* image count is the only cost lever — unscii-8 Lanczos-stretched to 6x6
|
|
20
|
+
* cells packs the most readable chars per frame. Frames request
|
|
21
|
+
* `detail: "original"`; the default `auto` downscale destroys 6px glyphs.
|
|
22
|
+
* - **Unknown providers** default to the Anthropic shape (most
|
|
23
|
+
* refusal-robust). Gateways that resize images (e.g. OpenRouter normalizes
|
|
24
|
+
* visual payloads to a fixed token budget) defeat any shape — optical
|
|
25
|
+
* context fails silently there.
|
|
26
|
+
*
|
|
27
|
+
* The whole pass is local and deterministic — no LLM call, no API key, no
|
|
28
|
+
* latency beyond rendering. Rasterization and PNG encoding happen in native
|
|
29
|
+
* code (`renderSnapcompactPng` in `crates/pi-natives/src/snapcompact.rs`).
|
|
30
|
+
* Frames persist in the compaction entry's `preserveData` and are
|
|
31
|
+
* re-attached to the compaction summary message on every context rebuild.
|
|
32
|
+
*/
|
|
33
|
+
import type { Api, ImageContent, Message, Model } from "@oh-my-pi/pi-ai";
|
|
34
|
+
/** One eval-validated frame shape: font, cell, ink, repetition, and size. */
|
|
35
|
+
export interface SnapcompactShape {
|
|
36
|
+
/** Bundled font in the native renderer. */
|
|
37
|
+
font: "5x8" | "8x8";
|
|
38
|
+
/** Target cell advance in pixels; differing from the font's natural cell
|
|
39
|
+
* renders via Lanczos stretch (anti-aliased RGB frame). */
|
|
40
|
+
cellWidth: number;
|
|
41
|
+
/** Target cell pitch in pixels. */
|
|
42
|
+
cellHeight: number;
|
|
43
|
+
/** Ink: `sent` cycles six hues at sentence boundaries; `bw` is black. */
|
|
44
|
+
variant: "sent" | "bw";
|
|
45
|
+
/** Each text line is printed this many times; copies after the first sit
|
|
46
|
+
* on a pale highlight band (redundancy coding). */
|
|
47
|
+
lineRepeat: number;
|
|
48
|
+
/** Frame edge in pixels. */
|
|
49
|
+
frameSize: number;
|
|
50
|
+
/** Per-frame billed-token estimate for the shape's target provider. */
|
|
51
|
+
frameTokenEstimate: number;
|
|
52
|
+
/** Resolution hint attached to frame images (OpenAI-only). */
|
|
53
|
+
imageDetail?: ImageContent["detail"];
|
|
54
|
+
}
|
|
55
|
+
/** Eval-validated shapes, keyed by the provider family they won on. */
|
|
56
|
+
export declare const SNAPCOMPACT_SHAPES: {
|
|
57
|
+
/** `8x8r-bw`: unscii square, black ink, lines doubled on highlight bands. */
|
|
58
|
+
readonly anthropic: {
|
|
59
|
+
readonly font: "8x8";
|
|
60
|
+
readonly cellWidth: 8;
|
|
61
|
+
readonly cellHeight: 8;
|
|
62
|
+
readonly variant: "bw";
|
|
63
|
+
readonly lineRepeat: 2;
|
|
64
|
+
readonly frameSize: 1568;
|
|
65
|
+
readonly frameTokenEstimate: 3300;
|
|
66
|
+
};
|
|
67
|
+
/** `8x8r-sent`: the repeated grid with sentence-hue ink. */
|
|
68
|
+
readonly google: {
|
|
69
|
+
readonly font: "8x8";
|
|
70
|
+
readonly cellWidth: 8;
|
|
71
|
+
readonly cellHeight: 8;
|
|
72
|
+
readonly variant: "sent";
|
|
73
|
+
readonly lineRepeat: 2;
|
|
74
|
+
readonly frameSize: 1568;
|
|
75
|
+
readonly frameTokenEstimate: 1100;
|
|
76
|
+
};
|
|
77
|
+
/** `6x6u-sent`: unscii stretched to 6x6 — densest readable cell, fewest
|
|
78
|
+
* frames (OpenAI bills per image, ~2.9k tokens flat). */
|
|
79
|
+
readonly openaiDense: {
|
|
80
|
+
readonly font: "8x8";
|
|
81
|
+
readonly cellWidth: 6;
|
|
82
|
+
readonly cellHeight: 6;
|
|
83
|
+
readonly variant: "sent";
|
|
84
|
+
readonly lineRepeat: 1;
|
|
85
|
+
readonly frameSize: 1568;
|
|
86
|
+
readonly frameTokenEstimate: 2900;
|
|
87
|
+
readonly imageDetail: "original";
|
|
88
|
+
};
|
|
89
|
+
/** Original 5x8 X.org shape (pre-shape-table sessions rendered this). */
|
|
90
|
+
readonly legacy: {
|
|
91
|
+
readonly font: "5x8";
|
|
92
|
+
readonly cellWidth: 5;
|
|
93
|
+
readonly cellHeight: 8;
|
|
94
|
+
readonly variant: "sent";
|
|
95
|
+
readonly lineRepeat: 1;
|
|
96
|
+
readonly frameSize: 2576;
|
|
97
|
+
readonly frameTokenEstimate: 3300;
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
/** Runtime guard for shape overrides loaded from config or preserve data. */
|
|
101
|
+
export declare function isSnapcompactShape(value: unknown): value is SnapcompactShape;
|
|
102
|
+
/** Pick the eval-optimal frame shape for a provider API. */
|
|
103
|
+
export declare function resolveSnapcompactShape(api?: Api): SnapcompactShape;
|
|
104
|
+
/** Legacy frame edge in pixels (the 5x8 shape's eval-validated size). New
|
|
105
|
+
* shapes carry their own `frameSize`. */
|
|
106
|
+
export declare const SNAPCOMPACT_FRAME_SIZE = 2576;
|
|
107
|
+
/** Maximum frames carried on a compaction entry. Oldest frames are dropped
|
|
108
|
+
* first once the budget is exceeded (mirrors how iterative text summaries
|
|
109
|
+
* fade the oldest detail). */
|
|
110
|
+
export declare const SNAPCOMPACT_MAX_FRAMES = 8;
|
|
111
|
+
/** Conservative per-frame token estimate used for context budgeting
|
|
112
|
+
* (upper bound across shapes: Anthropic bills 1568*1568/750 ≈ 3,278). */
|
|
113
|
+
export declare const SNAPCOMPACT_FRAME_TOKEN_ESTIMATE = 3300;
|
|
114
|
+
/** Key under `CompactionEntry.preserveData` holding the frame archive. */
|
|
115
|
+
export declare const SNAPCOMPACT_PRESERVE_KEY = "snapcompact";
|
|
116
|
+
/** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
|
|
117
|
+
export interface SnapcompactFrame {
|
|
118
|
+
/** Base64-encoded PNG. */
|
|
119
|
+
data: string;
|
|
120
|
+
mimeType: string;
|
|
121
|
+
/** Characters per row in the frame grid. */
|
|
122
|
+
cols: number;
|
|
123
|
+
/** Text rows in the frame grid (unique lines, not repeated copies). */
|
|
124
|
+
rows: number;
|
|
125
|
+
/** Characters actually printed onto this frame. */
|
|
126
|
+
chars: number;
|
|
127
|
+
/** Shape metadata (absent on legacy frames, which are 5x8 `sent`). */
|
|
128
|
+
font?: SnapcompactShape["font"];
|
|
129
|
+
variant?: SnapcompactShape["variant"];
|
|
130
|
+
lineRepeat?: number;
|
|
131
|
+
/** Resolution hint forwarded to the provider when re-attaching. */
|
|
132
|
+
detail?: ImageContent["detail"];
|
|
133
|
+
}
|
|
134
|
+
/** Frame archive persisted under `preserveData[SNAPCOMPACT_PRESERVE_KEY]`. */
|
|
135
|
+
export interface SnapcompactArchive {
|
|
136
|
+
/** Frames ordered oldest to newest. */
|
|
137
|
+
frames: SnapcompactFrame[];
|
|
138
|
+
/** Characters currently readable across all frames. */
|
|
139
|
+
totalChars: number;
|
|
140
|
+
/** Characters dropped so far to respect the frame budget. */
|
|
141
|
+
truncatedChars: number;
|
|
142
|
+
}
|
|
143
|
+
export interface SnapcompactGeometry {
|
|
144
|
+
cols: number;
|
|
145
|
+
rows: number;
|
|
146
|
+
/** Characters that fit one frame (cols * rows). */
|
|
147
|
+
capacity: number;
|
|
148
|
+
}
|
|
149
|
+
export interface SnapcompactOptions<TMessage = Message> extends SnapcompactSerializeOptions {
|
|
150
|
+
/** App-level message transformer (same contract as agent-core's `SummaryOptions.convertToLlm`). */
|
|
151
|
+
convertToLlm?: SnapcompactConvertToLlm<TMessage>;
|
|
152
|
+
/** Model whose provider API selects the frame shape. */
|
|
153
|
+
model?: Pick<Model, "api">;
|
|
154
|
+
/** Explicit shape override; wins over `model`. */
|
|
155
|
+
shape?: SnapcompactShape;
|
|
156
|
+
/** Frame edge in pixels. Defaults to the shape's `frameSize`. */
|
|
157
|
+
frameSize?: number;
|
|
158
|
+
/** Frame budget. Defaults to {@link SNAPCOMPACT_MAX_FRAMES}. */
|
|
159
|
+
maxFrames?: number;
|
|
160
|
+
}
|
|
161
|
+
/** Result of rendering one frame. */
|
|
162
|
+
export interface RenderedFrame {
|
|
163
|
+
/** Base64-encoded PNG, as returned by the native renderer. */
|
|
164
|
+
data: string;
|
|
165
|
+
cols: number;
|
|
166
|
+
rows: number;
|
|
167
|
+
/** Characters printed (ink toggles excluded; input may be shorter than capacity). */
|
|
168
|
+
chars: number;
|
|
169
|
+
}
|
|
170
|
+
export interface SnapcompactFileOperations {
|
|
171
|
+
read: Set<string>;
|
|
172
|
+
written: Set<string>;
|
|
173
|
+
edited: Set<string>;
|
|
174
|
+
}
|
|
175
|
+
export interface SnapcompactCompactionDetails {
|
|
176
|
+
readFiles: string[];
|
|
177
|
+
modifiedFiles: string[];
|
|
178
|
+
}
|
|
179
|
+
export interface SnapcompactCompactionPreparation<TMessage = Message> {
|
|
180
|
+
/** UUID of first entry to keep. */
|
|
181
|
+
firstKeptEntryId: string;
|
|
182
|
+
/** Messages that will be archived and discarded. */
|
|
183
|
+
messagesToSummarize: TMessage[];
|
|
184
|
+
/** Messages that will be archived as the split-turn prefix, if any. */
|
|
185
|
+
turnPrefixMessages: TMessage[];
|
|
186
|
+
tokensBefore: number;
|
|
187
|
+
/** Summary from previous compaction, for continuity when no prior snapcompact archive exists. */
|
|
188
|
+
previousSummary?: string;
|
|
189
|
+
/** Preserved opaque compaction payload from the previous compaction, if any. */
|
|
190
|
+
previousPreserveData?: Record<string, unknown>;
|
|
191
|
+
/** File operations extracted by the host agent. */
|
|
192
|
+
fileOps: SnapcompactFileOperations;
|
|
193
|
+
}
|
|
194
|
+
export interface SnapcompactCompactionResult<T = SnapcompactCompactionDetails> {
|
|
195
|
+
summary: string;
|
|
196
|
+
shortSummary?: string;
|
|
197
|
+
firstKeptEntryId: string;
|
|
198
|
+
tokensBefore: number;
|
|
199
|
+
details?: T;
|
|
200
|
+
preserveData?: Record<string, unknown>;
|
|
201
|
+
}
|
|
202
|
+
export type SnapcompactConvertToLlm<TMessage = Message> = (messages: TMessage[]) => Message[];
|
|
203
|
+
export declare function createSnapcompactFileOps(): SnapcompactFileOperations;
|
|
204
|
+
export declare function computeSnapcompactFileLists(fileOps: SnapcompactFileOperations): SnapcompactCompactionDetails;
|
|
205
|
+
export declare function upsertSnapcompactFileOperations(summary: string, readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string;
|
|
206
|
+
/** Default per-tool-result character cap in serialized history. */
|
|
207
|
+
export declare const SNAPCOMPACT_TOOL_RESULT_MAX_CHARS = 2000;
|
|
208
|
+
/** Default per-argument-value character cap inside serialized tool calls
|
|
209
|
+
* (write/edit bodies otherwise dump whole files into the archive). */
|
|
210
|
+
export declare const SNAPCOMPACT_TOOL_ARG_MAX_CHARS = 500;
|
|
211
|
+
/** Default character cap across one tool call's full serialized argument list. */
|
|
212
|
+
export declare const SNAPCOMPACT_TOOL_CALL_MAX_CHARS = 2000;
|
|
213
|
+
/** Default fraction of a truncation budget spent on the head; the remainder
|
|
214
|
+
* keeps the tail, where command errors and test failures usually land. */
|
|
215
|
+
export declare const SNAPCOMPACT_TRUNCATE_HEAD_RATIO = 0.6;
|
|
216
|
+
/** Zero-width ink toggles understood by the native renderer (shift-out/in):
|
|
217
|
+
* text between them prints in dim gray ink without occupying a cell. */
|
|
218
|
+
export declare const SNAPCOMPACT_DIM_ON = "\u000E";
|
|
219
|
+
export declare const SNAPCOMPACT_DIM_OFF = "\u000F";
|
|
220
|
+
/** Character budgets applied while serializing discarded history for frame
|
|
221
|
+
* rendering. Pass `Infinity` to disable an individual cap. */
|
|
222
|
+
export interface SnapcompactSerializeOptions {
|
|
223
|
+
/** Per-tool-result cap. Defaults to {@link SNAPCOMPACT_TOOL_RESULT_MAX_CHARS}. */
|
|
224
|
+
toolResultMaxChars?: number;
|
|
225
|
+
/** Per-argument-value cap. Defaults to {@link SNAPCOMPACT_TOOL_ARG_MAX_CHARS}. */
|
|
226
|
+
toolArgMaxChars?: number;
|
|
227
|
+
/** Whole-argument-list cap per call. Defaults to {@link SNAPCOMPACT_TOOL_CALL_MAX_CHARS}. */
|
|
228
|
+
toolCallMaxChars?: number;
|
|
229
|
+
/** Head share of each budget, clamped to [0, 1]. Defaults to {@link SNAPCOMPACT_TRUNCATE_HEAD_RATIO}. */
|
|
230
|
+
truncateHeadRatio?: number;
|
|
231
|
+
/** Print tool-result text in dim gray ink so archived conversation reads
|
|
232
|
+
* louder than archived tool noise. Defaults to `true`. */
|
|
233
|
+
dimToolResults?: boolean;
|
|
234
|
+
}
|
|
235
|
+
export declare function serializeSnapcompactConversation(messages: Message[], options?: SnapcompactSerializeOptions): string;
|
|
236
|
+
/**
|
|
237
|
+
* Prepare text for printing: collapse whitespace runs (incl. newlines) to
|
|
238
|
+
* single spaces — the eval's "paragraph breaks collapsed to spaces" format —
|
|
239
|
+
* then fold everything outside the fonts' ASCII + Latin-1 coverage to ASCII
|
|
240
|
+
* approximations (`?` as the last resort).
|
|
241
|
+
*/
|
|
242
|
+
export declare function normalizeForSnapcompact(text: string): string;
|
|
243
|
+
export declare function snapcompactGeometry(shape: SnapcompactShape, size?: number): SnapcompactGeometry;
|
|
244
|
+
/** Render one snapcompact frame from already-normalized text. */
|
|
245
|
+
export declare function renderSnapcompactFrame(text: string, shape: SnapcompactShape, size?: number): RenderedFrame;
|
|
246
|
+
/** Validate and extract a persisted frame archive from `preserveData`. */
|
|
247
|
+
export declare function getPreservedSnapcompactArchive(preserveData: Record<string, unknown> | undefined): SnapcompactArchive | undefined;
|
|
248
|
+
/** Convert archive frames into LLM image blocks (oldest first). */
|
|
249
|
+
export declare function snapcompactImages(archive: SnapcompactArchive): ImageContent[];
|
|
250
|
+
/**
|
|
251
|
+
* Run a snapcompact compaction over prepared messages. Fully local: serializes
|
|
252
|
+
* the discarded history, prints it onto PNG frames in the provider-optimal
|
|
253
|
+
* shape, merges previously archived frames (oldest dropped beyond the
|
|
254
|
+
* budget), and produces a deterministic summary explaining how to read the
|
|
255
|
+
* frames.
|
|
256
|
+
*
|
|
257
|
+
* Frames archived under a different shape (provider switches, legacy 5x8
|
|
258
|
+
* sessions) are kept as-is — each frame carries its own geometry, and the
|
|
259
|
+
* summary describes the newest shape while noting that older frames may
|
|
260
|
+
* differ.
|
|
261
|
+
*
|
|
262
|
+
* If the previous compaction was text-based, its summary is printed at the
|
|
263
|
+
* head of the frame archive as `[Summary of earlier history]` so no continuity is lost.
|
|
264
|
+
*/
|
|
265
|
+
export declare function snapcompactCompact<TMessage = Message>(preparation: SnapcompactCompactionPreparation<TMessage>, options?: SnapcompactOptions<TMessage>): Promise<SnapcompactCompactionResult>;
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "@oh-my-pi/snapcompact",
|
|
4
|
+
"version": "15.11.0",
|
|
5
|
+
"description": "Bitmap-frame context compression for vision-capable LLMs",
|
|
6
|
+
"homepage": "https://omp.sh",
|
|
7
|
+
"author": "Can Boluk",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/can1357/oh-my-pi.git",
|
|
12
|
+
"directory": "packages/snapcompact"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/can1357/oh-my-pi/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"context-compression",
|
|
19
|
+
"vision",
|
|
20
|
+
"compaction",
|
|
21
|
+
"llm"
|
|
22
|
+
],
|
|
23
|
+
"main": "./src/index.ts",
|
|
24
|
+
"types": "./dist/types/index.d.ts",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"check": "biome check . && bun run check:types",
|
|
27
|
+
"check:types": "tsgo -p tsconfig.json --noEmit",
|
|
28
|
+
"lint": "biome lint .",
|
|
29
|
+
"test": "bun test --parallel",
|
|
30
|
+
"fix": "biome check --write --unsafe .",
|
|
31
|
+
"fmt": "biome format --write ."
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@oh-my-pi/pi-ai": "15.11.0",
|
|
35
|
+
"@oh-my-pi/pi-natives": "15.11.0",
|
|
36
|
+
"@oh-my-pi/pi-utils": "15.11.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/bun": "^1.3.14"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"bun": ">=1.3.14"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"src",
|
|
46
|
+
"CHANGELOG.md",
|
|
47
|
+
"dist/types"
|
|
48
|
+
],
|
|
49
|
+
"exports": {
|
|
50
|
+
".": {
|
|
51
|
+
"types": "./dist/types/index.d.ts",
|
|
52
|
+
"import": "./src/index.ts"
|
|
53
|
+
},
|
|
54
|
+
"./snapcompact": {
|
|
55
|
+
"types": "./dist/types/snapcompact.d.ts",
|
|
56
|
+
"import": "./src/snapcompact.ts"
|
|
57
|
+
},
|
|
58
|
+
"./*": {
|
|
59
|
+
"types": "./dist/types/*.d.ts",
|
|
60
|
+
"import": "./src/*.ts"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./snapcompact";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Prior conversation history has been archived verbatim onto {{frameCount}} snapcompact frame{{#if multipleFrames}}s{{/if}} — the bitmap image{{#if multipleFrames}}s{{/if}} attached below{{#if multipleFrames}}, ordered oldest to newest{{/if}}.
|
|
2
|
+
|
|
3
|
+
Reading a frame: monospace {{fontCell}} pixel font on a white background, {{cols}} characters per row, {{rows}} text rows per frame; read left to right, top to bottom. Text flows continuously with no word wrap, so words may break across row ends. Whitespace runs (including newlines) were collapsed to single spaces. {{#if sentenceInk}}Ink color cycles through six colors, advancing at sentence boundaries — a color change marks a new sentence.{{else}}Glyphs are plain black ink.{{/if}}{{#if dimmedToolResults}} Tool output is printed in dim gray ink — gray text is archived tool output, not conversation.{{/if}}{{#if lineRepeated}} Every text line is printed twice in a row — first on the white background, then repeated on a pale yellow band. The copies are identical: read each line once and use the duplicate only to double-check hard glyphs.{{/if}} Roles are tagged inline as [User]:, [Assistant]:, [Assistant thinking]:, [Assistant tool calls]:, and [Tool result]:.
|
|
4
|
+
{{#if mixedShapes}}
|
|
5
|
+
|
|
6
|
+
Older frames may use a different font, grid, or ink coloring than described above; the reading order is always the same (left to right, top to bottom, oldest frame first).
|
|
7
|
+
{{/if}}
|
|
8
|
+
{{#if includedPreviousSummary}}
|
|
9
|
+
|
|
10
|
+
The earliest frame begins with "[Summary of earlier history]" — a condensed digest of context that predates the archived conversation.
|
|
11
|
+
{{/if}}
|
|
12
|
+
{{#if truncatedChars}}
|
|
13
|
+
|
|
14
|
+
{{truncatedChars}} characters of older history were dropped to respect the frame budget. The first frame (session start) is always kept, so the missing span sits between the first frame and the next.
|
|
15
|
+
{{/if}}
|
|
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.
|
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapcompact compaction: archive conversation history as dense bitmap images.
|
|
3
|
+
*
|
|
4
|
+
* Instead of asking an LLM to summarize discarded history, the serialized
|
|
5
|
+
* conversation is rendered into square PNG frames of pixel-font text that
|
|
6
|
+
* vision models read back directly, like an archivist at a snapcompact frame
|
|
7
|
+
* reader.
|
|
8
|
+
*
|
|
9
|
+
* The frame shape is provider-aware, following the snapcompact SQuAD evals
|
|
10
|
+
* (`packages/snapcompact`, 200k-token monolithic runs):
|
|
11
|
+
*
|
|
12
|
+
* - **Anthropic** (`8x8r-bw`): unscii-8 square cells, black ink, every line
|
|
13
|
+
* printed twice with the copy on a pale highlight band. Read at F1 parity
|
|
14
|
+
* with raw text at ~2x lower cost; the colored variants drew refusals at
|
|
15
|
+
* scale, the repeated plain shape did not.
|
|
16
|
+
* - **Google** (`8x8r-sent`): same repeated grid with six-hue sentence
|
|
17
|
+
* coloring (0.90 F1 at ~2.9x lower cost on gemini-3.5-flash).
|
|
18
|
+
* - **OpenAI** (`6x6u-sent`): OpenAI bills a flat ~2.9k tokens per image, so
|
|
19
|
+
* image count is the only cost lever — unscii-8 Lanczos-stretched to 6x6
|
|
20
|
+
* cells packs the most readable chars per frame. Frames request
|
|
21
|
+
* `detail: "original"`; the default `auto` downscale destroys 6px glyphs.
|
|
22
|
+
* - **Unknown providers** default to the Anthropic shape (most
|
|
23
|
+
* refusal-robust). Gateways that resize images (e.g. OpenRouter normalizes
|
|
24
|
+
* visual payloads to a fixed token budget) defeat any shape — optical
|
|
25
|
+
* context fails silently there.
|
|
26
|
+
*
|
|
27
|
+
* The whole pass is local and deterministic — no LLM call, no API key, no
|
|
28
|
+
* latency beyond rendering. Rasterization and PNG encoding happen in native
|
|
29
|
+
* code (`renderSnapcompactPng` in `crates/pi-natives/src/snapcompact.rs`).
|
|
30
|
+
* Frames persist in the compaction entry's `preserveData` and are
|
|
31
|
+
* re-attached to the compaction summary message on every context rebuild.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { Api, ImageContent, Message, Model } from "@oh-my-pi/pi-ai";
|
|
35
|
+
import { renderSnapcompactPng } from "@oh-my-pi/pi-natives";
|
|
36
|
+
import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils";
|
|
37
|
+
import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
|
|
38
|
+
import snapcompactSummaryPrompt from "./prompts/snapcompact-summary.md" with { type: "text" };
|
|
39
|
+
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// Shapes
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
/** One eval-validated frame shape: font, cell, ink, repetition, and size. */
|
|
45
|
+
export interface SnapcompactShape {
|
|
46
|
+
/** Bundled font in the native renderer. */
|
|
47
|
+
font: "5x8" | "8x8";
|
|
48
|
+
/** Target cell advance in pixels; differing from the font's natural cell
|
|
49
|
+
* renders via Lanczos stretch (anti-aliased RGB frame). */
|
|
50
|
+
cellWidth: number;
|
|
51
|
+
/** Target cell pitch in pixels. */
|
|
52
|
+
cellHeight: number;
|
|
53
|
+
/** Ink: `sent` cycles six hues at sentence boundaries; `bw` is black. */
|
|
54
|
+
variant: "sent" | "bw";
|
|
55
|
+
/** Each text line is printed this many times; copies after the first sit
|
|
56
|
+
* on a pale highlight band (redundancy coding). */
|
|
57
|
+
lineRepeat: number;
|
|
58
|
+
/** Frame edge in pixels. */
|
|
59
|
+
frameSize: number;
|
|
60
|
+
/** Per-frame billed-token estimate for the shape's target provider. */
|
|
61
|
+
frameTokenEstimate: number;
|
|
62
|
+
/** Resolution hint attached to frame images (OpenAI-only). */
|
|
63
|
+
imageDetail?: ImageContent["detail"];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Eval-validated shapes, keyed by the provider family they won on. */
|
|
67
|
+
export const SNAPCOMPACT_SHAPES = {
|
|
68
|
+
/** `8x8r-bw`: unscii square, black ink, lines doubled on highlight bands. */
|
|
69
|
+
anthropic: {
|
|
70
|
+
font: "8x8",
|
|
71
|
+
cellWidth: 8,
|
|
72
|
+
cellHeight: 8,
|
|
73
|
+
variant: "bw",
|
|
74
|
+
lineRepeat: 2,
|
|
75
|
+
frameSize: 1568,
|
|
76
|
+
frameTokenEstimate: 3300,
|
|
77
|
+
},
|
|
78
|
+
/** `8x8r-sent`: the repeated grid with sentence-hue ink. */
|
|
79
|
+
google: {
|
|
80
|
+
font: "8x8",
|
|
81
|
+
cellWidth: 8,
|
|
82
|
+
cellHeight: 8,
|
|
83
|
+
variant: "sent",
|
|
84
|
+
lineRepeat: 2,
|
|
85
|
+
frameSize: 1568,
|
|
86
|
+
frameTokenEstimate: 1100,
|
|
87
|
+
},
|
|
88
|
+
/** `6x6u-sent`: unscii stretched to 6x6 — densest readable cell, fewest
|
|
89
|
+
* frames (OpenAI bills per image, ~2.9k tokens flat). */
|
|
90
|
+
openaiDense: {
|
|
91
|
+
font: "8x8",
|
|
92
|
+
cellWidth: 6,
|
|
93
|
+
cellHeight: 6,
|
|
94
|
+
variant: "sent",
|
|
95
|
+
lineRepeat: 1,
|
|
96
|
+
frameSize: 1568,
|
|
97
|
+
frameTokenEstimate: 2900,
|
|
98
|
+
imageDetail: "original",
|
|
99
|
+
},
|
|
100
|
+
/** Original 5x8 X.org shape (pre-shape-table sessions rendered this). */
|
|
101
|
+
legacy: {
|
|
102
|
+
font: "5x8",
|
|
103
|
+
cellWidth: 5,
|
|
104
|
+
cellHeight: 8,
|
|
105
|
+
variant: "sent",
|
|
106
|
+
lineRepeat: 1,
|
|
107
|
+
frameSize: 2576,
|
|
108
|
+
frameTokenEstimate: 3300,
|
|
109
|
+
},
|
|
110
|
+
} as const satisfies Record<string, SnapcompactShape>;
|
|
111
|
+
|
|
112
|
+
/** Runtime guard for shape overrides loaded from config or preserve data. */
|
|
113
|
+
export function isSnapcompactShape(value: unknown): value is SnapcompactShape {
|
|
114
|
+
if (!value || typeof value !== "object") return false;
|
|
115
|
+
const shape = value as Record<string, unknown>;
|
|
116
|
+
const font = shape.font;
|
|
117
|
+
const variant = shape.variant;
|
|
118
|
+
const detail = shape.imageDetail;
|
|
119
|
+
return (
|
|
120
|
+
(font === "5x8" || font === "8x8") &&
|
|
121
|
+
typeof shape.cellWidth === "number" &&
|
|
122
|
+
shape.cellWidth > 0 &&
|
|
123
|
+
typeof shape.cellHeight === "number" &&
|
|
124
|
+
shape.cellHeight > 0 &&
|
|
125
|
+
(variant === "sent" || variant === "bw") &&
|
|
126
|
+
typeof shape.lineRepeat === "number" &&
|
|
127
|
+
shape.lineRepeat > 0 &&
|
|
128
|
+
typeof shape.frameSize === "number" &&
|
|
129
|
+
shape.frameSize > 0 &&
|
|
130
|
+
typeof shape.frameTokenEstimate === "number" &&
|
|
131
|
+
shape.frameTokenEstimate > 0 &&
|
|
132
|
+
(detail === undefined || detail === "auto" || detail === "low" || detail === "high" || detail === "original")
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Pick the eval-optimal frame shape for a provider API. */
|
|
137
|
+
export function resolveSnapcompactShape(api?: Api): SnapcompactShape {
|
|
138
|
+
switch (api) {
|
|
139
|
+
case "openai-completions":
|
|
140
|
+
case "openai-responses":
|
|
141
|
+
case "openai-codex-responses":
|
|
142
|
+
case "azure-openai-responses":
|
|
143
|
+
return SNAPCOMPACT_SHAPES.openaiDense;
|
|
144
|
+
case "google-generative-ai":
|
|
145
|
+
case "google-gemini-cli":
|
|
146
|
+
case "google-vertex":
|
|
147
|
+
return SNAPCOMPACT_SHAPES.google;
|
|
148
|
+
default:
|
|
149
|
+
// anthropic-messages, bedrock-converse-stream, and anything unknown:
|
|
150
|
+
// the plain repeated grid is the most refusal-robust reader shape.
|
|
151
|
+
return SNAPCOMPACT_SHAPES.anthropic;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ============================================================================
|
|
156
|
+
// Constants
|
|
157
|
+
// ============================================================================
|
|
158
|
+
|
|
159
|
+
/** Legacy frame edge in pixels (the 5x8 shape's eval-validated size). New
|
|
160
|
+
* shapes carry their own `frameSize`. */
|
|
161
|
+
export const SNAPCOMPACT_FRAME_SIZE = 2576;
|
|
162
|
+
|
|
163
|
+
/** Maximum frames carried on a compaction entry. Oldest frames are dropped
|
|
164
|
+
* first once the budget is exceeded (mirrors how iterative text summaries
|
|
165
|
+
* fade the oldest detail). */
|
|
166
|
+
export const SNAPCOMPACT_MAX_FRAMES = 8;
|
|
167
|
+
|
|
168
|
+
/** Conservative per-frame token estimate used for context budgeting
|
|
169
|
+
* (upper bound across shapes: Anthropic bills 1568*1568/750 ≈ 3,278). */
|
|
170
|
+
export const SNAPCOMPACT_FRAME_TOKEN_ESTIMATE = 3300;
|
|
171
|
+
|
|
172
|
+
/** Key under `CompactionEntry.preserveData` holding the frame archive. */
|
|
173
|
+
export const SNAPCOMPACT_PRESERVE_KEY = "snapcompact";
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// Types
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
/** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
|
|
180
|
+
export interface SnapcompactFrame {
|
|
181
|
+
/** Base64-encoded PNG. */
|
|
182
|
+
data: string;
|
|
183
|
+
mimeType: string;
|
|
184
|
+
/** Characters per row in the frame grid. */
|
|
185
|
+
cols: number;
|
|
186
|
+
/** Text rows in the frame grid (unique lines, not repeated copies). */
|
|
187
|
+
rows: number;
|
|
188
|
+
/** Characters actually printed onto this frame. */
|
|
189
|
+
chars: number;
|
|
190
|
+
/** Shape metadata (absent on legacy frames, which are 5x8 `sent`). */
|
|
191
|
+
font?: SnapcompactShape["font"];
|
|
192
|
+
variant?: SnapcompactShape["variant"];
|
|
193
|
+
lineRepeat?: number;
|
|
194
|
+
/** Resolution hint forwarded to the provider when re-attaching. */
|
|
195
|
+
detail?: ImageContent["detail"];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Frame archive persisted under `preserveData[SNAPCOMPACT_PRESERVE_KEY]`. */
|
|
199
|
+
export interface SnapcompactArchive {
|
|
200
|
+
/** Frames ordered oldest to newest. */
|
|
201
|
+
frames: SnapcompactFrame[];
|
|
202
|
+
/** Characters currently readable across all frames. */
|
|
203
|
+
totalChars: number;
|
|
204
|
+
/** Characters dropped so far to respect the frame budget. */
|
|
205
|
+
truncatedChars: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface SnapcompactGeometry {
|
|
209
|
+
cols: number;
|
|
210
|
+
rows: number;
|
|
211
|
+
/** Characters that fit one frame (cols * rows). */
|
|
212
|
+
capacity: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface SnapcompactOptions<TMessage = Message> extends SnapcompactSerializeOptions {
|
|
216
|
+
/** App-level message transformer (same contract as agent-core's `SummaryOptions.convertToLlm`). */
|
|
217
|
+
convertToLlm?: SnapcompactConvertToLlm<TMessage>;
|
|
218
|
+
/** Model whose provider API selects the frame shape. */
|
|
219
|
+
model?: Pick<Model, "api">;
|
|
220
|
+
/** Explicit shape override; wins over `model`. */
|
|
221
|
+
shape?: SnapcompactShape;
|
|
222
|
+
/** Frame edge in pixels. Defaults to the shape's `frameSize`. */
|
|
223
|
+
frameSize?: number;
|
|
224
|
+
/** Frame budget. Defaults to {@link SNAPCOMPACT_MAX_FRAMES}. */
|
|
225
|
+
maxFrames?: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Result of rendering one frame. */
|
|
229
|
+
export interface RenderedFrame {
|
|
230
|
+
/** Base64-encoded PNG, as returned by the native renderer. */
|
|
231
|
+
data: string;
|
|
232
|
+
cols: number;
|
|
233
|
+
rows: number;
|
|
234
|
+
/** Characters printed (ink toggles excluded; input may be shorter than capacity). */
|
|
235
|
+
chars: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ============================================================================
|
|
239
|
+
// Compaction data contracts
|
|
240
|
+
// ============================================================================
|
|
241
|
+
|
|
242
|
+
export interface SnapcompactFileOperations {
|
|
243
|
+
read: Set<string>;
|
|
244
|
+
written: Set<string>;
|
|
245
|
+
edited: Set<string>;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface SnapcompactCompactionDetails {
|
|
249
|
+
readFiles: string[];
|
|
250
|
+
modifiedFiles: string[];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface SnapcompactCompactionPreparation<TMessage = Message> {
|
|
254
|
+
/** UUID of first entry to keep. */
|
|
255
|
+
firstKeptEntryId: string;
|
|
256
|
+
/** Messages that will be archived and discarded. */
|
|
257
|
+
messagesToSummarize: TMessage[];
|
|
258
|
+
/** Messages that will be archived as the split-turn prefix, if any. */
|
|
259
|
+
turnPrefixMessages: TMessage[];
|
|
260
|
+
tokensBefore: number;
|
|
261
|
+
/** Summary from previous compaction, for continuity when no prior snapcompact archive exists. */
|
|
262
|
+
previousSummary?: string;
|
|
263
|
+
/** Preserved opaque compaction payload from the previous compaction, if any. */
|
|
264
|
+
previousPreserveData?: Record<string, unknown>;
|
|
265
|
+
/** File operations extracted by the host agent. */
|
|
266
|
+
fileOps: SnapcompactFileOperations;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface SnapcompactCompactionResult<T = SnapcompactCompactionDetails> {
|
|
270
|
+
summary: string;
|
|
271
|
+
shortSummary?: string;
|
|
272
|
+
firstKeptEntryId: string;
|
|
273
|
+
tokensBefore: number;
|
|
274
|
+
details?: T;
|
|
275
|
+
preserveData?: Record<string, unknown>;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export type SnapcompactConvertToLlm<TMessage = Message> = (messages: TMessage[]) => Message[];
|
|
279
|
+
|
|
280
|
+
function defaultConvertToLlm<TMessage>(messages: TMessage[]): Message[] {
|
|
281
|
+
return messages as unknown as Message[];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ============================================================================
|
|
285
|
+
// File operation helpers
|
|
286
|
+
// ============================================================================
|
|
287
|
+
|
|
288
|
+
export function createSnapcompactFileOps(): SnapcompactFileOperations {
|
|
289
|
+
return {
|
|
290
|
+
read: new Set(),
|
|
291
|
+
written: new Set(),
|
|
292
|
+
edited: new Set(),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function computeSnapcompactFileLists(fileOps: SnapcompactFileOperations): SnapcompactCompactionDetails {
|
|
297
|
+
const modified = new Set([...fileOps.edited, ...fileOps.written]);
|
|
298
|
+
const readFiles = [...fileOps.read].filter(file => !modified.has(file)).sort();
|
|
299
|
+
const modifiedFiles = [...modified].sort();
|
|
300
|
+
return { readFiles, modifiedFiles };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Format file operations as one `<files>` tag: a grouped, prefix-folded
|
|
305
|
+
* directory tree (find-tool shape) with a ` (Read)` / ` (Write)` / ` (RW)`
|
|
306
|
+
* marker per file. `readSet` is the cumulative read set (`fileOps.read`),
|
|
307
|
+
* used to tell modified files that were also read (RW) from blind writes.
|
|
308
|
+
*/
|
|
309
|
+
const FILE_OPERATION_SUMMARY_LIMIT = 20;
|
|
310
|
+
|
|
311
|
+
function stripFileOperationTags(summary: string): string {
|
|
312
|
+
// Legacy <read-files>/<modified-files> tags are still stripped so summaries
|
|
313
|
+
// written before the combined <files> tag self-heal on the next compaction.
|
|
314
|
+
return summary
|
|
315
|
+
.replace(/<files>[\s\S]*?<\/files>\s*/g, "")
|
|
316
|
+
.replace(/<read-files>[\s\S]*?<\/read-files>\s*/g, "")
|
|
317
|
+
.replace(/<modified-files>[\s\S]*?<\/modified-files>\s*/g, "")
|
|
318
|
+
.trimEnd();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string {
|
|
322
|
+
if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
|
|
323
|
+
const mode = new Map<string, "Read" | "Write" | "RW">();
|
|
324
|
+
for (const file of readFiles) mode.set(file, "Read");
|
|
325
|
+
for (const file of modifiedFiles) mode.set(file, readSet?.has(file) ? "RW" : "Write");
|
|
326
|
+
const all = [...mode.keys()].sort();
|
|
327
|
+
let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
|
|
328
|
+
if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
|
|
329
|
+
files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`;
|
|
330
|
+
}
|
|
331
|
+
return prompt.render(fileOperationsTemplate, { files });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function upsertSnapcompactFileOperations(
|
|
335
|
+
summary: string,
|
|
336
|
+
readFiles: string[],
|
|
337
|
+
modifiedFiles: string[],
|
|
338
|
+
readSet?: ReadonlySet<string>,
|
|
339
|
+
): string {
|
|
340
|
+
const baseSummary = stripFileOperationTags(summary);
|
|
341
|
+
const fileOperations = formatFileOperations(readFiles, modifiedFiles, readSet);
|
|
342
|
+
if (!fileOperations) return baseSummary;
|
|
343
|
+
if (!baseSummary) return fileOperations;
|
|
344
|
+
return `${baseSummary}\n\n${fileOperations}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ============================================================================
|
|
348
|
+
// Message serialization
|
|
349
|
+
// ============================================================================
|
|
350
|
+
|
|
351
|
+
/** Default per-tool-result character cap in serialized history. */
|
|
352
|
+
export const SNAPCOMPACT_TOOL_RESULT_MAX_CHARS = 2000;
|
|
353
|
+
|
|
354
|
+
/** Default per-argument-value character cap inside serialized tool calls
|
|
355
|
+
* (write/edit bodies otherwise dump whole files into the archive). */
|
|
356
|
+
export const SNAPCOMPACT_TOOL_ARG_MAX_CHARS = 500;
|
|
357
|
+
|
|
358
|
+
/** Default character cap across one tool call's full serialized argument list. */
|
|
359
|
+
export const SNAPCOMPACT_TOOL_CALL_MAX_CHARS = 2000;
|
|
360
|
+
|
|
361
|
+
/** Default fraction of a truncation budget spent on the head; the remainder
|
|
362
|
+
* keeps the tail, where command errors and test failures usually land. */
|
|
363
|
+
export const SNAPCOMPACT_TRUNCATE_HEAD_RATIO = 0.6;
|
|
364
|
+
|
|
365
|
+
/** Zero-width ink toggles understood by the native renderer (shift-out/in):
|
|
366
|
+
* text between them prints in dim gray ink without occupying a cell. */
|
|
367
|
+
export const SNAPCOMPACT_DIM_ON = "\u000e";
|
|
368
|
+
export const SNAPCOMPACT_DIM_OFF = "\u000f";
|
|
369
|
+
|
|
370
|
+
/** Character budgets applied while serializing discarded history for frame
|
|
371
|
+
* rendering. Pass `Infinity` to disable an individual cap. */
|
|
372
|
+
export interface SnapcompactSerializeOptions {
|
|
373
|
+
/** Per-tool-result cap. Defaults to {@link SNAPCOMPACT_TOOL_RESULT_MAX_CHARS}. */
|
|
374
|
+
toolResultMaxChars?: number;
|
|
375
|
+
/** Per-argument-value cap. Defaults to {@link SNAPCOMPACT_TOOL_ARG_MAX_CHARS}. */
|
|
376
|
+
toolArgMaxChars?: number;
|
|
377
|
+
/** Whole-argument-list cap per call. Defaults to {@link SNAPCOMPACT_TOOL_CALL_MAX_CHARS}. */
|
|
378
|
+
toolCallMaxChars?: number;
|
|
379
|
+
/** Head share of each budget, clamped to [0, 1]. Defaults to {@link SNAPCOMPACT_TRUNCATE_HEAD_RATIO}. */
|
|
380
|
+
truncateHeadRatio?: number;
|
|
381
|
+
/** Print tool-result text in dim gray ink so archived conversation reads
|
|
382
|
+
* louder than archived tool noise. Defaults to `true`. */
|
|
383
|
+
dimToolResults?: boolean;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Keep the head and tail of `text`, eliding the middle beyond `maxChars`. */
|
|
387
|
+
function truncateForSummary(text: string, maxChars: number, headRatio: number): string {
|
|
388
|
+
if (text.length <= maxChars) return text;
|
|
389
|
+
const ratio = Math.min(Math.max(headRatio, 0), 1);
|
|
390
|
+
const headChars = Math.round(maxChars * ratio);
|
|
391
|
+
const tailChars = maxChars - headChars;
|
|
392
|
+
const elided = text.length - maxChars;
|
|
393
|
+
const tail = tailChars > 0 ? text.slice(-tailChars) : "";
|
|
394
|
+
return `${text.slice(0, headChars)} [... ${elided} chars elided ...] ${tail}`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const DIM_MARKERS = /[\u000e\u000f]/g;
|
|
398
|
+
|
|
399
|
+
/** Strip stray ink toggles from raw content so it cannot forge dim spans. */
|
|
400
|
+
function stripDimMarkers(text: string): string {
|
|
401
|
+
return text.replace(DIM_MARKERS, "");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function serializeSnapcompactConversation(messages: Message[], options?: SnapcompactSerializeOptions): string {
|
|
405
|
+
const toolResultMaxChars = options?.toolResultMaxChars ?? SNAPCOMPACT_TOOL_RESULT_MAX_CHARS;
|
|
406
|
+
const toolArgMaxChars = options?.toolArgMaxChars ?? SNAPCOMPACT_TOOL_ARG_MAX_CHARS;
|
|
407
|
+
const toolCallMaxChars = options?.toolCallMaxChars ?? SNAPCOMPACT_TOOL_CALL_MAX_CHARS;
|
|
408
|
+
const headRatio = options?.truncateHeadRatio ?? SNAPCOMPACT_TRUNCATE_HEAD_RATIO;
|
|
409
|
+
const dimToolResults = options?.dimToolResults !== false;
|
|
410
|
+
const parts: string[] = [];
|
|
411
|
+
|
|
412
|
+
for (const msg of messages) {
|
|
413
|
+
if (msg.role === "user") {
|
|
414
|
+
const content =
|
|
415
|
+
typeof msg.content === "string"
|
|
416
|
+
? msg.content
|
|
417
|
+
: msg.content
|
|
418
|
+
.filter((content): content is { type: "text"; text: string } => content.type === "text")
|
|
419
|
+
.map(content => content.text)
|
|
420
|
+
.join("");
|
|
421
|
+
if (content) parts.push(`[User]: ${stripDimMarkers(content)}`);
|
|
422
|
+
} else if (msg.role === "assistant") {
|
|
423
|
+
const textParts: string[] = [];
|
|
424
|
+
const thinkingParts: string[] = [];
|
|
425
|
+
const toolCalls: string[] = [];
|
|
426
|
+
|
|
427
|
+
for (const block of msg.content) {
|
|
428
|
+
if (block.type === "text") {
|
|
429
|
+
textParts.push(stripDimMarkers(block.text));
|
|
430
|
+
} else if (block.type === "thinking") {
|
|
431
|
+
thinkingParts.push(stripDimMarkers(block.thinking));
|
|
432
|
+
} else if (block.type === "toolCall") {
|
|
433
|
+
const args = block.arguments as Record<string, unknown>;
|
|
434
|
+
const argsStr = truncateForSummary(
|
|
435
|
+
Object.entries(args)
|
|
436
|
+
.map(
|
|
437
|
+
([key, value]) =>
|
|
438
|
+
`${key}=${truncateForSummary(JSON.stringify(value) ?? "undefined", toolArgMaxChars, headRatio)}`,
|
|
439
|
+
)
|
|
440
|
+
.join(", "),
|
|
441
|
+
toolCallMaxChars,
|
|
442
|
+
headRatio,
|
|
443
|
+
);
|
|
444
|
+
toolCalls.push(`${block.name}(${argsStr})`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (thinkingParts.length > 0) {
|
|
449
|
+
parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
|
|
450
|
+
}
|
|
451
|
+
if (textParts.length > 0) {
|
|
452
|
+
parts.push(`[Assistant]: ${textParts.join("\n")}`);
|
|
453
|
+
}
|
|
454
|
+
if (toolCalls.length > 0) {
|
|
455
|
+
parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
|
|
456
|
+
}
|
|
457
|
+
} else if (msg.role === "toolResult") {
|
|
458
|
+
const content = msg.content
|
|
459
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
460
|
+
.map(block => block.text)
|
|
461
|
+
.join("");
|
|
462
|
+
if (content) {
|
|
463
|
+
// Args above are JSON-escaped, so only raw result text can carry toggles.
|
|
464
|
+
const body = truncateForSummary(stripDimMarkers(content), toolResultMaxChars, headRatio);
|
|
465
|
+
parts.push(
|
|
466
|
+
dimToolResults
|
|
467
|
+
? `[Tool result]: ${SNAPCOMPACT_DIM_ON}${body}${SNAPCOMPACT_DIM_OFF}`
|
|
468
|
+
: `[Tool result]: ${body}`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return parts.join("\n\n");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ============================================================================
|
|
478
|
+
// Preserve-data helpers
|
|
479
|
+
// ============================================================================
|
|
480
|
+
|
|
481
|
+
const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
|
|
482
|
+
|
|
483
|
+
function stripOpenAiRemoteCompactionPreserveData(
|
|
484
|
+
preserveData: Record<string, unknown> | undefined,
|
|
485
|
+
): Record<string, unknown> | undefined {
|
|
486
|
+
if (!preserveData || !(OPENAI_REMOTE_COMPACTION_PRESERVE_KEY in preserveData)) {
|
|
487
|
+
return preserveData;
|
|
488
|
+
}
|
|
489
|
+
const { [OPENAI_REMOTE_COMPACTION_PRESERVE_KEY]: _removed, ...rest } = preserveData;
|
|
490
|
+
return Object.keys(rest).length > 0 ? rest : undefined;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ============================================================================
|
|
494
|
+
// Text normalization
|
|
495
|
+
// ============================================================================
|
|
496
|
+
|
|
497
|
+
/** Folds for common non-Latin-1 characters the bundled fonts cannot draw. */
|
|
498
|
+
const CHAR_FOLD: Record<string, string> = {
|
|
499
|
+
"\u2018": "'",
|
|
500
|
+
"\u2019": "'",
|
|
501
|
+
"\u201a": "'",
|
|
502
|
+
"\u201b": "'",
|
|
503
|
+
"\u201c": '"',
|
|
504
|
+
"\u201d": '"',
|
|
505
|
+
"\u201e": '"',
|
|
506
|
+
"\u2013": "-",
|
|
507
|
+
"\u2014": "-",
|
|
508
|
+
"\u2015": "-",
|
|
509
|
+
"\u2212": "-",
|
|
510
|
+
"\u2026": "...",
|
|
511
|
+
"\u2022": "*",
|
|
512
|
+
"\u25cf": "*",
|
|
513
|
+
"\u25a0": "*",
|
|
514
|
+
"\u25aa": "*",
|
|
515
|
+
"\u2190": "<-",
|
|
516
|
+
"\u2192": "->",
|
|
517
|
+
"\u21d2": "=>",
|
|
518
|
+
"\u2713": "v",
|
|
519
|
+
"\u2714": "v",
|
|
520
|
+
"\u2717": "x",
|
|
521
|
+
"\u2718": "x",
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Prepare text for printing: collapse whitespace runs (incl. newlines) to
|
|
526
|
+
* single spaces — the eval's "paragraph breaks collapsed to spaces" format —
|
|
527
|
+
* then fold everything outside the fonts' ASCII + Latin-1 coverage to ASCII
|
|
528
|
+
* approximations (`?` as the last resort).
|
|
529
|
+
*/
|
|
530
|
+
export function normalizeForSnapcompact(text: string): string {
|
|
531
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
532
|
+
let out = "";
|
|
533
|
+
for (const ch of collapsed) {
|
|
534
|
+
const cp = ch.codePointAt(0) as number;
|
|
535
|
+
if (cp < 0x7f || (cp >= 0xa0 && cp <= 0xff)) {
|
|
536
|
+
out += ch;
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const fold = CHAR_FOLD[ch];
|
|
540
|
+
if (fold !== undefined) {
|
|
541
|
+
out += fold;
|
|
542
|
+
} else if (cp >= 0x2500 && cp <= 0x257f) {
|
|
543
|
+
// Box drawing: keep table skeletons legible.
|
|
544
|
+
out += cp === 0x2502 || cp === 0x2503 ? "|" : cp === 0x2500 || cp === 0x2501 ? "-" : "+";
|
|
545
|
+
} else {
|
|
546
|
+
out += "?";
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ============================================================================
|
|
553
|
+
// Rendering
|
|
554
|
+
// ============================================================================
|
|
555
|
+
|
|
556
|
+
export function snapcompactGeometry(shape: SnapcompactShape, size: number = shape.frameSize): SnapcompactGeometry {
|
|
557
|
+
const cols = Math.floor(size / shape.cellWidth);
|
|
558
|
+
const rows = Math.floor(size / shape.cellHeight / shape.lineRepeat);
|
|
559
|
+
return { cols, rows, capacity: cols * rows };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Render one snapcompact frame from already-normalized text. */
|
|
563
|
+
export function renderSnapcompactFrame(
|
|
564
|
+
text: string,
|
|
565
|
+
shape: SnapcompactShape,
|
|
566
|
+
size: number = shape.frameSize,
|
|
567
|
+
): RenderedFrame {
|
|
568
|
+
const { cols, rows, capacity } = snapcompactGeometry(shape, size);
|
|
569
|
+
const visible = text.length - (text.match(DIM_MARKERS)?.length ?? 0);
|
|
570
|
+
const chars = Math.min(visible, capacity);
|
|
571
|
+
const data = renderSnapcompactPng(text, {
|
|
572
|
+
size,
|
|
573
|
+
font: shape.font,
|
|
574
|
+
cellWidth: shape.cellWidth,
|
|
575
|
+
cellHeight: shape.cellHeight,
|
|
576
|
+
variant: shape.variant,
|
|
577
|
+
lineRepeat: shape.lineRepeat,
|
|
578
|
+
});
|
|
579
|
+
return { data, cols, rows, chars };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ============================================================================
|
|
583
|
+
// Archive helpers
|
|
584
|
+
// ============================================================================
|
|
585
|
+
|
|
586
|
+
/** Validate and extract a persisted frame archive from `preserveData`. */
|
|
587
|
+
export function getPreservedSnapcompactArchive(
|
|
588
|
+
preserveData: Record<string, unknown> | undefined,
|
|
589
|
+
): SnapcompactArchive | undefined {
|
|
590
|
+
const candidate = preserveData?.[SNAPCOMPACT_PRESERVE_KEY];
|
|
591
|
+
if (!candidate || typeof candidate !== "object") return undefined;
|
|
592
|
+
const archive = candidate as SnapcompactArchive;
|
|
593
|
+
if (!Array.isArray(archive.frames)) return undefined;
|
|
594
|
+
const frames = archive.frames.filter(
|
|
595
|
+
frame =>
|
|
596
|
+
!!frame &&
|
|
597
|
+
typeof frame.data === "string" &&
|
|
598
|
+
frame.data.length > 0 &&
|
|
599
|
+
typeof frame.mimeType === "string" &&
|
|
600
|
+
typeof frame.cols === "number" &&
|
|
601
|
+
typeof frame.rows === "number" &&
|
|
602
|
+
typeof frame.chars === "number",
|
|
603
|
+
);
|
|
604
|
+
if (frames.length === 0) return undefined;
|
|
605
|
+
return {
|
|
606
|
+
frames,
|
|
607
|
+
totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0,
|
|
608
|
+
truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Convert archive frames into LLM image blocks (oldest first). */
|
|
613
|
+
export function snapcompactImages(archive: SnapcompactArchive): ImageContent[] {
|
|
614
|
+
return archive.frames.map(frame => ({
|
|
615
|
+
type: "image",
|
|
616
|
+
data: frame.data,
|
|
617
|
+
mimeType: frame.mimeType,
|
|
618
|
+
...(frame.detail ? { detail: frame.detail } : {}),
|
|
619
|
+
}));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ============================================================================
|
|
623
|
+
// Compaction entry point
|
|
624
|
+
// ============================================================================
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Run a snapcompact compaction over prepared messages. Fully local: serializes
|
|
628
|
+
* the discarded history, prints it onto PNG frames in the provider-optimal
|
|
629
|
+
* shape, merges previously archived frames (oldest dropped beyond the
|
|
630
|
+
* budget), and produces a deterministic summary explaining how to read the
|
|
631
|
+
* frames.
|
|
632
|
+
*
|
|
633
|
+
* Frames archived under a different shape (provider switches, legacy 5x8
|
|
634
|
+
* sessions) are kept as-is — each frame carries its own geometry, and the
|
|
635
|
+
* summary describes the newest shape while noting that older frames may
|
|
636
|
+
* differ.
|
|
637
|
+
*
|
|
638
|
+
* If the previous compaction was text-based, its summary is printed at the
|
|
639
|
+
* head of the frame archive as `[Summary of earlier history]` so no continuity is lost.
|
|
640
|
+
*/
|
|
641
|
+
export async function snapcompactCompact<TMessage = Message>(
|
|
642
|
+
preparation: SnapcompactCompactionPreparation<TMessage>,
|
|
643
|
+
options?: SnapcompactOptions<TMessage>,
|
|
644
|
+
): Promise<SnapcompactCompactionResult> {
|
|
645
|
+
const { firstKeptEntryId, tokensBefore, previousSummary, previousPreserveData, fileOps } = preparation;
|
|
646
|
+
if (!firstKeptEntryId) {
|
|
647
|
+
throw new Error("First kept entry has no ID - session may need migration");
|
|
648
|
+
}
|
|
649
|
+
const shape = options?.shape ?? resolveSnapcompactShape(options?.model?.api);
|
|
650
|
+
const frameSize = options?.frameSize ?? shape.frameSize;
|
|
651
|
+
const maxFrames = Math.max(1, options?.maxFrames ?? SNAPCOMPACT_MAX_FRAMES);
|
|
652
|
+
const geometry = snapcompactGeometry(shape, frameSize);
|
|
653
|
+
|
|
654
|
+
const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
|
655
|
+
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
|
|
656
|
+
let archiveText = normalizeForSnapcompact(serializeSnapcompactConversation(llmMessages, options));
|
|
657
|
+
|
|
658
|
+
const previousArchive = getPreservedSnapcompactArchive(previousPreserveData);
|
|
659
|
+
const includedPreviousSummary = !previousArchive && !!previousSummary;
|
|
660
|
+
if (includedPreviousSummary && previousSummary) {
|
|
661
|
+
const head = `[Summary of earlier history] ${normalizeForSnapcompact(previousSummary)}`;
|
|
662
|
+
archiveText = archiveText.length > 0 ? `${head} [Recent conversation] ${archiveText}` : head;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
let truncatedChars = previousArchive?.truncatedChars ?? 0;
|
|
666
|
+
|
|
667
|
+
const newFrames: SnapcompactFrame[] = [];
|
|
668
|
+
let dimOpen = false;
|
|
669
|
+
for (let offset = 0; offset < archiveText.length; offset += geometry.capacity) {
|
|
670
|
+
let chunk = archiveText.slice(offset, offset + geometry.capacity);
|
|
671
|
+
// Re-open a dim span that the previous frame boundary cut through.
|
|
672
|
+
if (dimOpen) chunk = SNAPCOMPACT_DIM_ON + chunk;
|
|
673
|
+
dimOpen = chunk.lastIndexOf(SNAPCOMPACT_DIM_ON) > chunk.lastIndexOf(SNAPCOMPACT_DIM_OFF);
|
|
674
|
+
const rendered = renderSnapcompactFrame(chunk, shape, frameSize);
|
|
675
|
+
newFrames.push({
|
|
676
|
+
data: rendered.data,
|
|
677
|
+
mimeType: "image/png",
|
|
678
|
+
cols: rendered.cols,
|
|
679
|
+
rows: rendered.rows,
|
|
680
|
+
chars: rendered.chars,
|
|
681
|
+
font: shape.font,
|
|
682
|
+
variant: shape.variant,
|
|
683
|
+
lineRepeat: shape.lineRepeat,
|
|
684
|
+
...(shape.imageDetail ? { detail: shape.imageDetail } : {}),
|
|
685
|
+
});
|
|
686
|
+
// Keep the event loop responsive between native render passes.
|
|
687
|
+
await Bun.sleep(0);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const frames = [...(previousArchive?.frames ?? []), ...newFrames];
|
|
691
|
+
if (frames.length > maxFrames) {
|
|
692
|
+
// Pin the earliest frame: it anchors the session head (the original
|
|
693
|
+
// request, or the filmed summary of even older history) the way the
|
|
694
|
+
// LLM-summary strategies keep the original goal alive across rounds.
|
|
695
|
+
// Eviction removes the oldest *unpinned* frames, so the archive fades
|
|
696
|
+
// from the middle out — head and tail survive. With a budget of one
|
|
697
|
+
// frame the pin is moot; keep the newest frame instead.
|
|
698
|
+
const evictStart = maxFrames >= 2 ? 1 : 0;
|
|
699
|
+
const dropped = frames.splice(evictStart, frames.length - maxFrames);
|
|
700
|
+
for (const frame of dropped) truncatedChars += frame.chars;
|
|
701
|
+
}
|
|
702
|
+
const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0);
|
|
703
|
+
const mixedShapes = frames.some(
|
|
704
|
+
frame =>
|
|
705
|
+
frame.cols !== geometry.cols ||
|
|
706
|
+
frame.rows !== geometry.rows ||
|
|
707
|
+
(frame.variant ?? "sent") !== shape.variant ||
|
|
708
|
+
(frame.lineRepeat ?? 1) !== shape.lineRepeat,
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
let summary: string;
|
|
712
|
+
if (frames.length === 0) {
|
|
713
|
+
summary = "No prior history.";
|
|
714
|
+
} else {
|
|
715
|
+
summary = prompt.render(snapcompactSummaryPrompt, {
|
|
716
|
+
frameCount: frames.length,
|
|
717
|
+
multipleFrames: frames.length > 1,
|
|
718
|
+
fontCell: `${shape.cellWidth}x${shape.cellHeight}`,
|
|
719
|
+
cols: geometry.cols,
|
|
720
|
+
rows: geometry.rows,
|
|
721
|
+
sentenceInk: shape.variant === "sent",
|
|
722
|
+
lineRepeated: shape.lineRepeat > 1,
|
|
723
|
+
dimmedToolResults: options?.dimToolResults !== false,
|
|
724
|
+
mixedShapes,
|
|
725
|
+
totalChars,
|
|
726
|
+
truncatedChars,
|
|
727
|
+
includedPreviousSummary,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
const { readFiles, modifiedFiles } = computeSnapcompactFileLists(fileOps);
|
|
731
|
+
summary = upsertSnapcompactFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
|
|
732
|
+
|
|
733
|
+
// A snapcompact pass replaces any provider-side replacement history; strip the
|
|
734
|
+
// OpenAI remote-compaction payload like the default summarizer path does.
|
|
735
|
+
const basePreserve = stripOpenAiRemoteCompactionPreserveData(previousPreserveData) ?? {};
|
|
736
|
+
const archive: SnapcompactArchive = { frames, totalChars, truncatedChars };
|
|
737
|
+
|
|
738
|
+
return {
|
|
739
|
+
summary,
|
|
740
|
+
shortSummary: `Archived ${totalChars.toLocaleString()} chars of history onto ${frames.length} snapcompact frame${frames.length === 1 ? "" : "s"}`,
|
|
741
|
+
firstKeptEntryId,
|
|
742
|
+
tokensBefore,
|
|
743
|
+
details: { readFiles, modifiedFiles },
|
|
744
|
+
preserveData: { ...basePreserve, [SNAPCOMPACT_PRESERVE_KEY]: archive },
|
|
745
|
+
};
|
|
746
|
+
}
|