From 09fb339b398e5dce120f636c4a973e2726e45ad4 Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Mon, 25 May 2026 15:37:14 -0400 Subject: [PATCH] fix(render): use readable profile for dense images --- src/core/history.ts | 14 +++++--------- src/core/render.ts | 2 ++ src/core/transform.ts | 4 +++- tests/paging.test.ts | 26 +++++++++++++++++++++++++- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/core/history.ts b/src/core/history.ts index 6380a98..4aa8d9c 100644 --- a/src/core/history.ts +++ b/src/core/history.ts @@ -37,7 +37,7 @@ */ import type { ContentBlock, ImageBlock, Message, TextBlock, ToolUseBlock, ToolResultBlock } from './types.js'; -import { DENSE_CONTENT_CHARS_PER_IMAGE, renderTextToPngsWithCharLimit, shrinkColsToContent } from './render.js'; +import { DENSE_CONTENT_CHARS_PER_IMAGE, DENSE_CONTENT_COLS, DENSE_RENDER_STYLE, renderTextToPngsWithCharLimit } from './render.js'; import { bytesToBase64 } from './png.js'; /** Function predicate signature for the break-even gate. Passed in by the @@ -341,14 +341,10 @@ export async function collapseHistory( return { messages, info }; } // Render. No cache_control here — caller decides placement. - // Width-shrink: history collapse always renders a single image-set of - // collapsed conversation prose, which is typically much narrower than - // the configured `cols` (100 default). Shrinking the canvas to the - // longest wrapped line saves ~50% of pixel area on short collapsed - // history blocks. The gate's `imageTokensCost()` is computed with the - // same `shrinkColsToContent` so prediction matches reality. - const effCols = shrinkColsToContent(text, o.cols); - const imgs = await renderTextToPngsWithCharLimit(text, effCols, DENSE_CONTENT_CHARS_PER_IMAGE); + // Dense history is user-visible context, not the static system-slab cache + // anchor. Render it with the readable dense profile instead of the 313-col + // full-canvas profile; otherwise lockfiles/code/config collapse into pixel mush. + const imgs = await renderTextToPngsWithCharLimit(text, DENSE_CONTENT_COLS, DENSE_CONTENT_CHARS_PER_IMAGE, DENSE_RENDER_STYLE); if (imgs.length === 0) { info.reason = 'render_empty'; return { messages, info }; diff --git a/src/core/render.ts b/src/core/render.ts index 6658bcd..90272ba 100644 --- a/src/core/render.ts +++ b/src/core/render.ts @@ -44,6 +44,8 @@ export const READABLE_CHARS_PER_IMAGE = 50000; * dense for OCR on lockfiles/JSON/code. Keep those blocks paged into smaller * images so the model can read them reliably. */ export const DENSE_CONTENT_CHARS_PER_IMAGE = 6000; +export const DENSE_CONTENT_COLS = 180; +export const DENSE_RENDER_STYLE: RenderStyle = { cellWBonus: 2, cellHBonus: 2, aa: true }; /** Default columns per row. 1568 px / 5 px-per-cell = 313 cells. We render * at the full canvas width by default — no shrink-to-content. */ const DEFAULT_COLS = 313; diff --git a/src/core/transform.ts b/src/core/transform.ts index cee2e00..8ea77a7 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -35,6 +35,8 @@ import { CELL_H, READABLE_CHARS_PER_IMAGE, DENSE_CONTENT_CHARS_PER_IMAGE, + DENSE_CONTENT_COLS, + DENSE_RENDER_STYLE, renderTextToPngsWithCharLimit, } from './render.js'; import { bytesToBase64 } from './png.js'; @@ -1764,7 +1766,7 @@ async function textToImageBlocks( const imgs = effectiveNumCols > 1 ? await renderTextToPngsMultiCol(text, effectiveCols, effectiveNumCols) - : await renderTextToPngsWithCharLimit(text, effectiveCols, DENSE_CONTENT_CHARS_PER_IMAGE); + : await renderTextToPngsWithCharLimit(text, DENSE_CONTENT_COLS, DENSE_CONTENT_CHARS_PER_IMAGE, DENSE_RENDER_STYLE); let droppedChars = 0; let pixels = 0; const droppedCodepoints = new Map(); diff --git a/tests/paging.test.ts b/tests/paging.test.ts index a6edafb..5ced7c8 100644 --- a/tests/paging.test.ts +++ b/tests/paging.test.ts @@ -24,7 +24,12 @@ import { } from '../src/core/transform.js'; import { toTrackEvent } from '../src/core/tracker.js'; import type { ProxyEvent } from '../src/core/proxy.js'; -import { DENSE_CONTENT_CHARS_PER_IMAGE } from '../src/core/render.js'; +import { + DENSE_CONTENT_CHARS_PER_IMAGE, + DENSE_CONTENT_COLS, + DENSE_RENDER_STYLE, + renderTextToPngsWithCharLimit, +} from '../src/core/render.js'; // Default render config: cols=100, 195 lines/img → ~19,500 chars/img if // lines fully fill the width. For shorter lines, the budget is dominated @@ -70,6 +75,25 @@ describe('estimateImageCount', () => { }); }); +describe('dense readable render profile', () => { + it('uses larger glyph cells and multiple readable pages for lockfile-shaped text', async () => { + const lockish = Array.from({ length: 200 }, (_, i) => + ` pkg-${i}@npm:1.${i}.0(peer@npm:^${i}.0.0)(typescript@npm:^5.${i % 10}.0): checksum=${'a'.repeat(24)}`, + ).join('\n'); + + const imgs = await renderTextToPngsWithCharLimit( + lockish, + DENSE_CONTENT_COLS, + DENSE_CONTENT_CHARS_PER_IMAGE, + DENSE_RENDER_STYLE, + ); + + expect(imgs.length).toBeGreaterThanOrEqual(3); + expect(imgs[0]!.width).toBeGreaterThan(1000); + expect(imgs[0]!.width).toBeLessThanOrEqual(1568); + }); +}); + describe('classifyContent', () => { it('flags JSON objects as structured', () => { const json = JSON.stringify({ foo: 'bar', baz: [1, 2, 3] }, null, 2);