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:
+1
-1
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user