Files
oc/src/render.js
T
only-cli 42906ced60 spend turns, not tokens: do <n> reads text, near-budget pages print whole, timelines render readably
Three changes with one motive: a second command costs an agent more than
the lines it saves. 'oc do' on a heading or text block now prints the read
instead of refusing, since refusing spends a whole turn naming the command
that should have run. A page that would finish within about four times the
budget is printed whole rather than cut, because the cut moves tokens into
a second command instead of saving them. And social timelines stopped
rendering as one fused paragraph: linkedom splits text nodes around
apostrophes, so fragments are merged back by parent node and edge
whitespace, block elements now end lines, and repeated button labels are
trimmed sooner than links because a button label is never the content.
With that, x.com profiles and posts read without a login, so a six line
cli definition ships for the two x.com pages that work.
2026-08-19 07:59:29 -04:00

167 lines
6.1 KiB
JavaScript

/**
* Rendering is where the token budget is enforced. Everything printed here
* gets read by a paying model, so the default view is dense, anything cut says
* how much was cut, and the footer names the cheapest command that gets it.
*/
import { TEXT_CAP } from './distill.js';
/**
* Rough but stable token estimate. Close enough for budgets; the point is
* that it never changes between runs, not that it matches any one tokenizer.
* @param {string} s
* @returns {number}
*/
export const estimateTokens = (s) => Math.ceil(s.length / 4);
const num = (v) => v.toLocaleString('en-US');
// How far past the budget a page may run and still be printed whole. Cutting a
// page that was nearly done costs the agent a second command, and a command is
// dear: measured inside Claude Code, one tool call is 23,000 to 33,000 tokens of
// session overhead no matter what it prints. Against that, break-even sits near
// fifty times the budget. The number is far lower than break-even because the
// saving is only collected when the agent would have paged at all, while the
// overspend is paid on every page that runs a little long, including the ones
// answered by their first few lines. Four caps that overspend near 1,500 tokens.
const FINISH = 4;
/**
* Budget-aware compact view of a distilled page. `from` is a position in the
* collapsed block list, which is how `oc next` resumes a page where the last
* render stopped.
* @param {import('./distill.js').Page} page
* @param {{budget?: number, from?: number}} [opts]
* @returns {{text: string, stats: {tokens: number, blocks: number, rendered: number, next: number|null, left: number, leftTokens: number}}}
*/
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 lines = [...head];
let spent = estimateTokens(lines.join('\n'));
let hasLinks = false;
let hasInputs = false;
let i = Math.max(0, from);
// What the rest of the page would cost if it were all printed. When that is
// within reach the budget stands aside, because stopping here would only
// move those tokens into a second command and add a turn's overhead on top.
const whole = blocks.slice(i).reduce((sum, b) => {
const line = formatBlock(b);
return line ? sum + estimateTokens(line) + 1 : sum;
}, spent);
const limit = whole <= budget * FINISH ? Infinity : budget;
for (; i < blocks.length; i++) {
const block = blocks[i];
// Never print the same content twice. Pages often repeat the title as
// their first heading.
if (block.type === 'heading' && block.text === page.title) continue;
const line = formatBlock(block);
if (!line) continue;
const cost = estimateTokens(line) + 1;
// Stop at the first block that does not fit instead of skipping past it:
// what is left has to stay one contiguous run for `oc next` to continue.
// The exception is a first block bigger than the whole budget, which is
// printed anyway so the cursor always moves.
if (spent + cost > limit && lines.length > head.length) break;
spent += cost;
lines.push(line);
if (block.type === 'link' || block.type === 'button') hasLinks = true;
if (block.type === 'input') hasInputs = true;
}
const rest = blocks.slice(i);
const leftTokens = rest.reduce((sum, b) => {
const line = formatBlock(b);
return line ? sum + estimateTokens(line) + 1 : sum;
}, 0);
if (rest.length) {
lines.push(`... ${num(rest.length)} more blocks (~${num(leftTokens)} tokens): 'oc next' for the next ~${num(budget)}, 'oc raw' for all`);
}
const actions = [
hasLinks && 'do <n>',
hasInputs && 'fill <n> <text>',
hasInputs && 'submit',
'read <n>',
rest.length && 'next',
'raw',
].filter(Boolean);
lines.push(`actions: ${actions.join(' | ')}`);
const text = lines.join('\n');
return {
text,
stats: {
tokens: estimateTokens(text),
blocks: blocks.length,
rendered: i - Math.max(0, from),
next: rest.length ? i : null,
left: rest.length,
leftTokens,
},
};
}
/**
* Collapse repeated siblings. Long runs of short
* links are almost always nav chrome (subreddit bars, tag clouds, footers)
* and would otherwise eat the whole budget before the content starts. Handles
* are assigned in distill, so the hidden links keep their numbers and the
* marker names the range.
* @param {import('./distill.js').Block[]} blocks
* @returns {import('./distill.js').Block[]}
*/
function collapseRuns(blocks) {
const SHORT = 20;
const RUN = 8;
const KEEP = 5;
const out = [];
let i = 0;
while (i < blocks.length) {
let j = i;
while (j < blocks.length && blocks[j].type === 'link' && blocks[j].text.length <= SHORT) j++;
const run = j - i;
if (run > RUN) {
out.push(...blocks.slice(i, i + KEEP));
const first = blocks[i + KEEP];
const last = blocks[j - 1];
out.push({ type: 'text', text: `[${first.n}-${last.n}] ${run - KEEP} similar links, expand with oc raw` });
i = j;
} else {
out.push(blocks[i]);
i++;
}
}
return out;
}
/**
* One line per block. `full` keeps the whole text, which is what `oc read`
* prints; the compact view cuts at TEXT_CAP and says how many characters went
* with the cut, so the agent can price the rest before asking for it.
* @param {import('./distill.js').Block} b
* @param {{full?: boolean}} [opts]
* @returns {string}
*/
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}`;
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})`;
case 'divider':
return b.text;
default:
return full ? `${tag}${b.text}` : `${tag}${truncate(b.text)}`;
}
}
const truncate = (s) =>
s.length > TEXT_CAP ? `${s.slice(0, TEXT_CAP)} ... +${num(s.length - TEXT_CAP)} chars` : s;