mirror of
https://github.com/only-cli/oc.git
synced 2026-09-15 10:40:56 +02:00
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:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user