refactor(snapcompact): replaced legacy frame pinning with full text-sourced re-rendering

- Removed legacy `pinnedFrames` logic and state, simplifying compaction to unconditionally re-render from the full source archive.
- Updated `historyBlocks` to reliably order archive regions (text head, imaged middle, text tail) based on the re-rendered state.
- Updated documentation and summary templates to reflect the transition to text-first compaction and the use of the new `FILES` section.
This commit is contained in:
can1357
2026-06-19 07:46:41 +02:00
parent 64b81c3b8f
commit 63b2c9603a
8 changed files with 43 additions and 55 deletions
+1 -1
View File
@@ -263,7 +263,7 @@ Cumulative behavior:
- In split turns, includes turn-prefix file ops too.
- `details.readFiles` excludes files also modified; `details.modifiedFiles` carries the rest (persisted shape is unchanged).
Summary text gets one `<files>` tag appended via prompt template: a grouped, prefix-folded directory tree (find-tool shape) with a per-file access marker — `(Read)` for read-only files, `(Write)` for modified files never read, `(RW)` for modified files also present in the cumulative read set. Capped at 20 files with an `[…N files elided…]` line.
The file list is a grouped, prefix-folded directory tree (find-tool shape) with a per-file access marker — `(Read)` for read-only files, `(Write)` for modified files never read, `(RW)` for modified files also present in the cumulative read set. Capped at 20 files with an `[…N files elided…]` line. LLM-summary strategies append it as a `<files>` tag (via `upsertFileOperations`); snapcompact renders it inside its summary template as a `FILES` section instead.
```xml
<files>
+2
View File
@@ -8,6 +8,8 @@
- Updated skill invocation UI with a compact header, home-shortened paths, and dynamic line counts
- Refined session context to utilize history blocks instead of raw images for snapcompact summaries
- Refreshed the skill-prompt transcript message to match the rest of the TUI: an icon-tagged `✦ skill <name>` header (with invocation args trailing), a single meta line with a home-shortened, click-to-open path in the accent color and the prompt size in muted, and a rounded outline around the card. Replaces the old `[skill]` label with the flat `Skill:`/`Path:`/`Prompt:` key/value dump (which also leaked the absolute home directory).
- Refreshed the default custom/hook transcript message frame to a rounded, icon-tagged card: the `[customType]` bracket label is replaced by an `<icon> <type>` header (📦 for extension messages, 🪝 for legacy hook-role messages) with a subtle outline, matching the skill card. Covers every extension/hook custom type without a bespoke renderer.
- Refreshed the branch-summary transcript message to render as the same slim divider banner as `/compact`, handoff, and snapcompact (`⑂ branch · ctrl+o`, summary revealed on expand) instead of a `[branch]` box.
- Optimized network traffic by stripping tool descriptions from provider tool schemas
- Snapcompact compaction summaries now reach the model as ordered history blocks instead of one lead-in text block plus appended images: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. This matches the new text-first snapcompact archive layout and preserves chronological order in the provider prompt.
@@ -1,6 +1,6 @@
import { beforeAll, describe, expect, it } from "bun:test";
import * as os from "node:os";
import * as path from "node:path";
import { beforeAll, describe, expect, it } from "bun:test";
import { Settings } from "../../../config/settings";
import type { CustomMessage, SkillPromptDetails } from "../../../session/messages";
import { getThemeByName, setThemeInstance, type Theme } from "../../theme/theme";
+2 -5
View File
@@ -4,7 +4,6 @@
### Added
- Added `historyBlocks(archive)` to reconstruct ordered history blocks from archive data
- Added `historyBlocks(archive)`, which reconstructs the ordered prompt blocks for a snapcompact archive at rebuild time: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. This keeps the message ordering reconstructible from `preserveData` without duplicating image payloads onto compaction entries.
### Changed
@@ -13,10 +12,8 @@
- Raised `MAX_FRAMES_DEFAULT` to 80 and consolidated `PROVIDER_IMAGE_BUDGETS`
- Updated OpenRouter to use standard 90-image budget
- Updated prompt instructions to clearly distinguish between plain-text and image history regions
- Reworked snapcompact compaction to be text-sourced and text-first: the archive now persists bounded source text (`Archive.text`) and re-renders from it every compaction instead of blindly carrying PNGs forward. History is laid out middle-out as `text head → imaged middle → text tail`, and the imaged middle foveates internally (HQ/LQ/HQ) when it grows large.
- Renamed `MAX_FRAMES` to `MAX_FRAMES_DEFAULT`, raised the default cap to 80 (enough for ~400k tokens of high-res Opus image budget while staying under Anthropic's wire cap), and made `Options.maxFrames` a pure upper limit rather than a caller-supplied default.
- Raised `FRAME_TOKEN_ESTIMATE` to the true high-res Claude upper bound (5,024) so coding-agent overflow checks no longer undercount large snapcompact archives.
- Removed the old OpenRouter-specific 8-image special case from `PROVIDER_IMAGE_BUDGETS`; OpenRouter now uses the same permissive 90-image budget as Anthropic/Bedrock instead of being artificially clamped.
- `Options.maxFrames` is now an upper limit clamped to `MAX_FRAMES_DEFAULT`, not a per-call default
- 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
### Fixed
+1 -1
View File
@@ -50,7 +50,7 @@ Run a full compaction pass over prepared messages:
import { compact } from "@oh-my-pi/snapcompact";
const result = await compact(preparation, { model });
// result.summary — short "resume prior conversation" lead-in + <files> block
// result.summary — short "resume prior conversation" lead-in, reading guide, and FILES section
// result.preserveData — bounded archive source + rendered image middle
```
+15 -46
View File
@@ -471,13 +471,8 @@ export interface Archive {
/** Characters dropped so far to respect the archive budget. */
truncatedChars: number;
/** Full kept archive source (oldest to newest, normalized, bounded to the
* rendered budget) — the single source re-rendered each compaction. Absent
* on legacy archives persisted before text-sourced rendering, whose frames
* are carried verbatim (see {@link pinnedFrames}). */
* rendered budget) — the single source re-rendered each compaction. */
text?: string;
/** Count of leading {@link frames} carried verbatim from a legacy archive
* (one with no source {@link text}); never re-rendered. */
pinnedFrames?: number;
/** Oldest text region kept verbatim around the imaged middle. */
textHead?: string;
/** Newest text region kept verbatim around the imaged middle. */
@@ -1234,14 +1229,11 @@ export function getPreservedArchive(preserveData: Record<string, unknown> | unde
// A text-only archive (everything fit in the plain-text regions) is valid;
// only an archive carrying neither frames nor text is empty.
if (frames.length === 0 && text === undefined && textHead === undefined && textTail === undefined) return undefined;
const pinnedFrames =
typeof archive.pinnedFrames === "number" ? Math.max(0, Math.min(frames.length, archive.pinnedFrames)) : undefined;
return {
frames,
totalChars: typeof archive.totalChars === "number" ? archive.totalChars : 0,
truncatedChars: typeof archive.truncatedChars === "number" ? archive.truncatedChars : 0,
...(text !== undefined ? { text } : {}),
...(pinnedFrames !== undefined ? { pinnedFrames } : {}),
...(textHead !== undefined ? { textHead } : {}),
...(textTail !== undefined ? { textTail } : {}),
};
@@ -1256,10 +1248,10 @@ export function images(archive: Archive): ImageContent[] {
...(frame.detail ? { detail: frame.detail } : {}),
}));
}
/** Ordered archive blocks for a compaction summary message: old text region,
* imaged middle, then new text region. Runtime-only; reconstructed from
* {@link Archive} on each context rebuild instead of persisted on the session
* entry. */
/** Ordered archive blocks for a compaction summary message, oldest to newest:
* the oldest text region, the imaged middle, then the newest text region.
* Runtime-only; reconstructed from {@link Archive} on each context rebuild
* instead of persisted on the session entry. */
export function historyBlocks(archive: Archive): (TextContent | ImageContent)[] {
const blocks: (TextContent | ImageContent)[] = [];
const hasImages = archive.frames.length > 0;
@@ -1421,9 +1413,8 @@ function planArchive(text: string, high: Shape, low: Shape, maxFrames: number):
* oldest edge, imaged middle, then plain text at the newest edge. The imaged
* middle itself foveates (HQ/LQ/HQ) when it grows large.
*
* The full kept source persists on the archive (`text`) so each compaction can
* re-tier it; legacy archives predating text-sourced rendering keep their frames
* verbatim as a pinned head (`pinnedFrames`).
* The full kept source persists on the archive (`text`) so each later compaction
* unfolds and re-renders it coherently alongside the newly archived history.
*
* If the previous compaction was text-based, its summary is printed at the head
* of the archive as `[Summary of earlier history]` so no continuity is lost.
@@ -1458,36 +1449,15 @@ export async function compact<TMessage = Message>(
let truncatedChars = previousArchive?.truncatedChars ?? 0;
// Older archive source ages into this slice ahead of the new history. A
// text-sourced archive replays its full kept source; a legacy one (frames,
// no source text) keeps its frames verbatim as a pinned head and contributes
// only its recoverable text tail.
let pinned: Frame[] = [];
if (previousArchive?.text !== undefined) {
pinned = previousArchive.frames.slice(0, previousArchive.pinnedFrames ?? 0);
archiveText =
archiveText.length > 0 ? `${previousArchive.text}${NEWLINE_GLYPH}${archiveText}` : previousArchive.text;
} else if (previousArchive) {
const cap = Math.max(1, Math.floor(maxFrames / 2));
if (previousArchive.frames.length <= cap) {
pinned = previousArchive.frames;
} else {
const dropped = previousArchive.frames.slice(1, previousArchive.frames.length - (cap - 1));
for (const frame of dropped) truncatedChars += frame.chars;
pinned = [
...previousArchive.frames.slice(0, 1),
...previousArchive.frames.slice(previousArchive.frames.length - (cap - 1)),
];
}
if (previousArchive.textTail) {
archiveText =
archiveText.length > 0
? `${previousArchive.textTail}${NEWLINE_GLYPH}${archiveText}`
: previousArchive.textTail;
}
// Re-compacting a snapcompacted history unfolds the prior archive's source
// text and treats it as one coherent transcript: the previous kept source
// ages in ahead of the new history, then the whole thing is re-rendered.
const previousText = previousArchive?.text;
if (previousText) {
archiveText = archiveText.length > 0 ? `${previousText}${NEWLINE_GLYPH}${archiveText}` : previousText;
}
const layout = planArchive(archiveText, high, low, Math.max(0, maxFrames - pinned.length));
const layout = planArchive(archiveText, high, low, maxFrames);
truncatedChars += layout.truncatedChars;
// Re-render the planned frames, carrying any open dim span across every
@@ -1520,7 +1490,7 @@ export async function compact<TMessage = Message>(
const textTail = layout.textTail.length > 0 ? (dimOpen ? DIM_ON : "") + layout.textTail : "";
const textChars = textHead.length + textTail.length;
const frames = [...pinned, ...newFrames];
const frames = newFrames;
const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0) + textChars;
const mixedShapes = frames.some(
frame =>
@@ -1568,7 +1538,6 @@ export async function compact<TMessage = Message>(
totalChars,
truncatedChars,
...(persistedText.length > 0 ? { text: persistedText } : {}),
...(pinned.length > 0 ? { pinnedFrames: pinned.length } : {}),
...(textHead ? { textHead } : {}),
...(textTail ? { textTail } : {}),
};
@@ -832,6 +832,22 @@ describe("archive helpers", () => {
expect(snapcompact.getPreservedArchive({ [snapcompact.PRESERVE_KEY]: archive })).toEqual(archive);
});
it("historyBlocks orders text head, imaged middle, then text tail", () => {
const archive: snapcompact.Archive = {
frames: [{ data: btoa("middle"), mimeType: "image/png", cols: 8, rows: 8, chars: 4 }],
totalChars: 40,
truncatedChars: 0,
text: "head text middle tail text",
textHead: "head text",
textTail: "tail text",
};
const blocks = snapcompact.historyBlocks(archive);
expect(blocks.map(block => block.type)).toEqual(["text", "image", "text"]);
expect((blocks[0] as { text: string }).text).toContain("head text");
expect((blocks[1] as { data: string }).data).toBe(btoa("middle"));
expect((blocks[2] as { text: string }).text).toContain("tail text");
});
it("provider image budgets stay permissive while unknown providers keep the safe floor", () => {
expect(snapcompact.providerImageBudget("openrouter")).toBe(90);
// Unknown providers fall to the safe floor.
+5 -1
View File
@@ -13,7 +13,11 @@ const CHARS: BoxBorder["chars"] = {
function borderedBox(border?: Partial<BoxBorder>): Box {
// paddingY 0 keeps the row count predictable; ignoreTight pins paddingX to exactly 1.
// Real SGR escapes so Bun.stripANSI removes both bg and border color before width checks.
const box = new Box(1, 0, t => `\x1b[48;5;236m${t}\x1b[49m`, { chars: CHARS, color: t => `\x1b[31m${t}\x1b[39m`, ...border });
const box = new Box(1, 0, t => `\x1b[48;5;236m${t}\x1b[49m`, {
chars: CHARS,
color: t => `\x1b[31m${t}\x1b[39m`,
...border,
});
box.setIgnoreTight(true);
box.addChild(new Text("hi", 0, 0));
return box;