diff --git a/docs/compaction.md b/docs/compaction.md index f19b7847e..93df614b7 100644 --- a/docs/compaction.md +++ b/docs/compaction.md @@ -132,6 +132,7 @@ The automatic paths are intentionally different: `compaction.strategy: "snapcompact"` replaces the LLM summarization call with a local, deterministic archival pass (`snapcompactCompact` from `@oh-my-pi/snapcompact`): - The discarded history is serialized, whitespace-collapsed, and printed onto provider-aware square PNG frames using bundled public-domain pixel fonts. Anthropic-family and unknown APIs use repeated black `8x8` cells, Google uses repeated sentence-colored `8x8` cells, and OpenAI uses dense stretched `6x6` cells with `detail: "original"`. +- Serialization keeps the archive conversation-dense: tool results are truncated head+tail (default 2,000 chars at a 0.6 head ratio), tool-call argument values are capped per value (500) and per call (2,000), and tool output is printed in dim gray ink so conversation reads louder than tool noise. All budgets and the dimming are configurable via `SnapcompactSerializeOptions` (`toolResultMaxChars`, `toolArgMaxChars`, `toolCallMaxChars`, `truncateHeadRatio`, `dimToolResults`). - Frames persist under `CompactionEntry.preserveData.snapcompact` and are re-attached to the `compactionSummary` message as image blocks on every context rebuild; the entry's `summary` is a deterministic reading guide (grid geometry, role tags, truncation notes) plus the usual file-operation lists. - Later compactions carry earlier frames forward. Beyond an 8-frame budget the archive fades from the middle out: the earliest frame (session head — the original request, or the filmed summary of older history) is pinned, and the oldest *unpinned* frames are evicted, so head and tail both survive. If the previous compaction was text-based, its summary is printed at the head of the frame archive as `[Summary of earlier history]`. - No model, API key, or network is involved, so snapcompact is also safe for overflow recovery. It requires a vision-capable current model (`model.input` includes `"image"`); otherwise the run falls back to context-full and emits a warning notice (auto and manual paths). Manual `/compact` honors the strategy unless custom instructions are given (those imply a directed LLM summary). diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index d65b8e03e..256edc556 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -7,6 +7,7 @@ ### Added +- Added dim-span ink toggles to `renderSnapcompactPng`: `U+000E`/`U+000F` in the input switch to a dim gray ink (palette index 9) and back without occupying a glyph cell, letting callers visually de-emphasize spans such as archived tool output - Added `renderSnapcompactPng(text, options)`: rasterizes pre-normalized text onto a square PNG in an eval-validated snapcompact shape. Options select the bundled font (`5x8` X.org BDF or `8x8` unscii-8, both public domain, shipped in `crates/pi-natives/src/fonts/`), the ink variant (`sent` six-hue sentence cycling or `bw` black), line repetition (each text line printed N times, copies on a pale highlight band), and a target cell size — cells differing from the font's natural cell render via Lanczos3 stretch into an anti-aliased RGB frame (e.g. the OpenAI-optimal 6x6 unscii shape); native-cell shapes encode as 4-bit indexed PNG. Replaces the JS rasterizer/PNG writer previously in `@oh-my-pi/pi-agent-core`. ## [15.10.12] - 2026-06-10 diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index 6e0d0b5fa..bf7c9b067 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -7,11 +7,14 @@ import { normalizeForSnapcompact, renderSnapcompactFrame, resolveSnapcompactShape, + SNAPCOMPACT_DIM_OFF, + SNAPCOMPACT_DIM_ON, SNAPCOMPACT_PRESERVE_KEY, SNAPCOMPACT_SHAPES, type SnapcompactArchive, type SnapcompactCompactionPreparation, type SnapcompactCompactionResult, + serializeSnapcompactConversation, snapcompactCompact, snapcompactGeometry, snapcompactImages, @@ -53,6 +56,17 @@ function createAssistantMessage(content: AssistantMessage["content"]): Message { }; } +function createToolResultMessage(text: string): Message { + return { + role: "toolResult", + toolCallId: "call-1", + toolName: "bash", + content: [{ type: "text", text }], + isError: false, + timestamp: 0, + }; +} + function makePreparation( overrides: Partial> = {}, ): SnapcompactCompactionPreparation { @@ -226,6 +240,69 @@ describe("renderSnapcompactFrame", () => { }); }); +describe("serializeSnapcompactConversation", () => { + it("truncates oversized tool results keeping head and tail", () => { + const text = `HEAD-${"x".repeat(5000)}-TAIL`; + const out = serializeSnapcompactConversation([createToolResultMessage(text)]); + // Default cap 2000 at 0.6 head ratio: 1200 head + 800 tail survive. + expect(out).toContain("[Tool result]: "); + expect(out).toContain("HEAD-"); + expect(out).toContain("[... 3010 chars elided ...]"); + expect(out.endsWith(`-TAIL${SNAPCOMPACT_DIM_OFF}`)).toBe(true); + }); + + it("honors configured budgets; Infinity disables a cap", () => { + const text = "a".repeat(100); + const tight = serializeSnapcompactConversation([createToolResultMessage(text)], { + toolResultMaxChars: 10, + truncateHeadRatio: 0.5, + }); + expect(tight).toContain("[... 90 chars elided ...]"); + const off = serializeSnapcompactConversation([createToolResultMessage(text)], { + toolResultMaxChars: Number.POSITIVE_INFINITY, + }); + expect(off).toContain(text); + }); + + it("caps oversized tool-call argument values without touching small ones", () => { + const out = serializeSnapcompactConversation([ + createAssistantMessage([ + { type: "toolCall", id: "c1", name: "write", arguments: { path: "a.ts", content: "y".repeat(3000) } }, + ]), + ]); + // JSON-encoded content is 3002 chars; per-value cap 500 elides 2502. + expect(out).toContain('write(path="a.ts", content='); + expect(out).toContain("[... 2502 chars elided ...]"); + }); + + it("caps the whole serialized argument list per call", () => { + const args: Record = {}; + for (let i = 0; i < 10; i++) args[`arg${i}`] = "z".repeat(400); + const out = serializeSnapcompactConversation([ + createAssistantMessage([{ type: "toolCall", id: "c1", name: "tool", arguments: args }]), + ]); + expect(out).toContain("arg0="); + expect(out).toContain("chars elided"); + // 10 values x ~400 chars collapse to the 2000-char call budget plus markers. + expect(out.length).toBeLessThan(2200); + }); + + it("wraps tool results in dim toggles by default and strips stray toggles from content", () => { + const out = serializeSnapcompactConversation([ + createUserMessage(`hello ${SNAPCOMPACT_DIM_ON}world`), + createToolResultMessage("ok"), + ]); + expect(out).toContain(`[Tool result]: ${SNAPCOMPACT_DIM_ON}ok${SNAPCOMPACT_DIM_OFF}`); + // A stray toggle in user content cannot forge a dim span. + expect(out).toContain("[User]: hello world"); + }); + + it("omits dim toggles when dimToolResults is false", () => { + const out = serializeSnapcompactConversation([createToolResultMessage("ok")], { dimToolResults: false }); + expect(out).toBe("[Tool result]: ok"); + }); +}); + describe("snapcompactCompact", () => { it("archives history onto frames with a self-describing summary", async () => { const fileOps = createSnapcompactFileOps(); @@ -259,6 +336,41 @@ describe("snapcompactCompact", () => { expect(decoded.width).toBe(TEST_FRAME_SIZE); }); + it("prints tool results in dim gray ink, persisting the span across frame boundaries", async () => { + // Anthropic shape at 320px holds 800 chars/frame; a 1650-char tool + // result spans three frames, so the reopened span must dim in each. + const result = await snapcompactCompact( + makePreparation({ + messagesToSummarize: [createUserMessage("Run the suite."), createToolResultMessage("FAIL ".repeat(330))], + }), + { frameSize: TEST_FRAME_SIZE }, + ); + const archive = getPreservedSnapcompactArchive(result.preserveData); + expect(archive?.frames.length).toBeGreaterThanOrEqual(2); + for (const frame of archive?.frames ?? []) { + const decoded = decodePng(Buffer.from(frame.data, "base64")); + // Palette index 9 is the dim tool-output ink. + expect(new Set(decoded.pixels).has(9)).toBe(true); + } + // Conversation text outside the span stays in black bw ink (frame 1). + const first = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); + expect(new Set(first.pixels).has(7)).toBe(true); + expect(result.summary).toContain("dim gray ink"); + }); + + it("keeps frames free of dim ink when dimToolResults is false", async () => { + const result = await snapcompactCompact( + makePreparation({ + messagesToSummarize: [createUserMessage("Run."), createToolResultMessage("all good")], + }), + { frameSize: TEST_FRAME_SIZE, dimToolResults: false }, + ); + const archive = getPreservedSnapcompactArchive(result.preserveData); + const decoded = decodePng(Buffer.from(archive?.frames[0].data ?? "", "base64")); + expect(new Set(decoded.pixels).has(9)).toBe(false); + expect(result.summary).not.toContain("dim gray ink"); + }); + it("splits oversized history across frames and evicts beyond the budget", async () => { const { capacity } = snapcompactGeometry(SNAPCOMPACT_SHAPES.anthropic, TEST_FRAME_SIZE); // Sentences avoid whitespace collapse shrinking the payload below 2.5 frames.