fix: cap page-written scalars at the render boundary

The title and every heading are the page's to write, and both skipped
truncate(), so one hostile scalar could print unbounded output whatever
the budget said. The compact view now cuts titles, headings, and input
names at the same cap and marker every other block gets, and read cuts
even a first block bigger than its whole budget, since 'up to N tokens'
is a promise the page must not be able to break. The distilled page
keeps the full values: --json stays the machine-stable view, bounded by
the fetch cap, and machines cut for themselves.

Fixes #28
This commit is contained in:
only-cli
2026-08-24 13:59:27 -04:00
parent 886ce58e27
commit b18e9e5179
4 changed files with 44 additions and 3 deletions
+9
View File
@@ -136,6 +136,15 @@ export function read(n, { session = DEFAULT_SESSION, budget = 2000 } = {}) {
if (!line) continue;
const cost = estimateTokens(line) + 1;
if (spent + cost > budget && lines.length) break;
// The first line always prints so read never answers with nothing, but
// its text is the page's to write and so has no natural size. Alone over
// budget it still gets cut: 'up to N tokens' is a promise the page must
// not be able to break.
if (!lines.length && cost > budget) {
lines.push(`${line.slice(0, budget * 4)} ... cut at ~${budget} tokens, raise --budget for the rest`);
spent += budget;
continue;
}
spent += cost;
lines.push(line);
}
+3 -3
View File
@@ -97,7 +97,7 @@ export const FINISH = 4;
*/
export function render(page, { budget = 500, from = 0 } = {}) {
const blocks = collapseRuns(page.blocks);
const head = page.title ? [from > 0 ? `# ${page.title} (continued)` : `# ${page.title}`] : [];
const head = page.title ? [from > 0 ? `# ${truncate(page.title)} (continued)` : `# ${truncate(page.title)}`] : [];
const lines = [...head];
let spent = estimateTokens(lines.join('\n'));
let hasLinks = false;
@@ -209,13 +209,13 @@ export function formatBlock(b, { full = false } = {}) {
const tag = b.n == null ? '' : `[${b.n}] `;
switch (b.type) {
case 'heading':
return `${'#'.repeat(Math.min(b.level ?? 2, 3))} ${tag}${b.text}`;
return `${'#'.repeat(Math.min(b.level ?? 2, 3))} ${tag}${full ? b.text : truncate(b.text)}`;
case 'link':
return `${tag}${full ? b.text : truncate(b.text)}`;
case 'button':
return `${tag}button "${full ? b.text : truncate(b.text)}"`;
case 'input':
return `${tag}input ${b.name} (${b.text})`;
return `${tag}input ${truncate(b.name ?? '')} (${truncate(b.text ?? '')})`;
case 'divider':
return b.text;
default:
+13
View File
@@ -21,6 +21,19 @@ const open = (name = 'default', budget = 500) => {
saveSession(name, sessionFromPage(p, loadSession(name), { cursor: render(p, { budget }).stats.next }));
};
test("read cuts even a first block bigger than its whole budget", () => {
// The first line of a read always prints, but its text is the page's to
// write, so alone-over-budget still cuts: 'up to N tokens' is a promise the
// page must not be able to break.
const wall = 'sentence after sentence of the same thing. '.repeat(500);
const p = distill(`<html><body><p id="wall">${wall}</p></body></html>`, 'https://example.test/wall');
saveSession('wall', sessionFromPage(p, null, { cursor: null }));
const n = p.blocks.find((b) => b.type === 'text').n;
const out = read(n, { session: 'wall', budget: 100 });
assert.ok(out.length < 100 * 4 + 200, `read printed ${out.length} chars against a budget of 100 tokens`);
assert.match(out, /cut at ~100 tokens, raise --budget/);
});
test('a rendered page is remembered with absolute URLs for every handle', () => {
open();
const state = loadSession('default');
+19
View File
@@ -519,6 +519,25 @@ test('a terse page that arrived terse is content, not a failed render', () => {
assert.equal(contentFailure(contentTokens(json), 4), null);
});
test('page-written scalars are capped at the render boundary', () => {
// The title and every heading are the page's to write, so without a cap one
// hostile scalar prints unbounded output whatever the budget says.
const bigTitle = 'title word '.repeat(1000).trim();
const bigHeading = 'heading word '.repeat(1000).trim();
const page = distill(
`<html><head><title>${bigTitle}</title></head><body><h1>${bigHeading}</h1><p>short</p></body></html>`,
'https://fixture.test/big');
const { text } = render(page, { budget: 100 });
for (const line of text.split('\n')) {
assert.ok(line.length < 300, `a render line ran to ${line.length} chars`);
}
assert.match(text, /\.\.\. \+[\d,]+ chars/);
// The distilled page keeps the full values: --json is the machine-stable
// view, its size is bounded by the fetch cap, and machines cut for
// themselves.
assert.equal(page.title, bigTitle);
});
test('a link-list page counts as content even with no prose on it', () => {
// Hacker News and search results are links and nothing else, so a rule that
// counted only prose would call the tool's best pages empty.