feat: offer find in every actions footer

find is the cheapest way to go deeper on a rendered page: one command lands
on the block that matters, where read needs the right number first and next
pages toward it. SKILL.md already lists it first under "going further,
cheapest first", but no footer offered it, and the footer is what an agent
actually reads, so callers were steered toward read, next, and raw instead.

The entry now appears in all three footers, after do and before read, so the
order matches the skill. On find's own output it turns the "narrow the
query" hint into an action.

Cost on the fixtures is 3 or 4 tokens per render (news 127 to 131, login 30
to 33, forum 695 to 699). Skipping one next on a long page pays for a
hundred of those.

The test checks the same three footer sites as the stub probe: find is
present and listed ahead of read. It fails on main.

Closes #46.
This commit is contained in:
only-cli
2026-09-02 09:38:07 -04:00
parent 4aabae8340
commit e1ff0f63c4
4 changed files with 32 additions and 3 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ $ oc open news.ycombinator.com
[1] Show HN: I built a tiny CSV toolkit [1] Show HN: I built a tiny CSV toolkit
[2] 312 comments [2] 312 comments
... ...
actions: do <n> | read <n> | next | raw actions: do <n> | find <query> | read <n> | next | raw
$ oc do 1 $ oc do 1
``` ```
+4 -2
View File
@@ -234,7 +234,7 @@ export function find(query, { session = DEFAULT_SESSION, budget = 500 } = {}) {
return [ return [
`1 match for "${query}"${separately}, region [${only.n}]`, `1 match for "${query}"${separately}, region [${only.n}]`,
read(only.n, { session, budget: budget * FINISH }), read(only.n, { session, budget: budget * FINISH }),
`actions: ${follow}read <n> | next | raw`, `actions: ${follow}find <query> | read <n> | next | raw`,
].join('\n'); ].join('\n');
} }
@@ -264,7 +264,9 @@ export function find(query, { session = DEFAULT_SESSION, budget = 500 } = {}) {
if (shown < hits.length) { if (shown < hits.length) {
lines.push(`... ${hits.length - shown} more matches, narrow the query or raise --budget`); lines.push(`... ${hits.length - shown} more matches, narrow the query or raise --budget`);
} }
lines.push(`actions: ${[hasLinks && 'do <n>', 'read <n>', 'next', 'raw'].filter(Boolean).join(' | ')}`); // Offering find on its own output is what makes "narrow the query" above an
// action rather than advice.
lines.push(`actions: ${[hasLinks && 'do <n>', 'find <query>', 'read <n>', 'next', 'raw'].filter(Boolean).join(' | ')}`);
return lines.join('\n'); return lines.join('\n');
} }
+7
View File
@@ -141,8 +141,15 @@ export function render(page, { budget = 500, from = 0 } = {}) {
// An input on the page adds no action. 'fill' and 'submit' are still stubs // An input on the page adds no action. 'fill' and 'submit' are still stubs
// that throw, and this footer is the line an agent trusts for what to run // that throw, and this footer is the line an agent trusts for what to run
// next, so naming one of them costs a turn and returns nothing. // next, so naming one of them costs a turn and returns nothing.
//
// 'find' comes before 'read' because it is the cheaper way to go deeper: one
// command lands on the block that matters, where 'read' needs the right
// number first and 'next' pages toward it. The entry costs about three
// tokens on every render, and skipping one 'next' on a long page pays for
// a hundred of them.
const actions = [ const actions = [
hasLinks && 'do <n>', hasLinks && 'do <n>',
'find <query>',
'read <n>', 'read <n>',
rest.length && 'next', rest.length && 'next',
'raw', 'raw',
+20
View File
@@ -282,3 +282,23 @@ test('no footer names a command that is not available yet', async () => {
} }
} }
}); });
test('every footer offers find, the cheapest way to go deeper on a page', () => {
// SKILL.md lists find first under "going further, cheapest first", and the
// footer is what an agent actually reads, so the two have to agree. Same
// three footer sites as the stub probe above: a render, and both of find's
// paths.
open();
const outputs = [
render(page(), { budget: 500 }).text,
find('postgres'),
find('a'),
];
const footers = outputs.map((out) => out.split('\n').find((line) => line.startsWith('actions:')));
assert.equal(footers.filter(Boolean).length, outputs.length, `every output should carry a footer:\n${footers.join('\n')}`);
for (const footer of footers) {
assert.ok(footer.includes('find <query>'), `footer should offer find:\n${footer}`);
// Cheapest first: find is listed ahead of read.
assert.ok(footer.indexOf('find <query>') < footer.indexOf('read <n>'), `find should come before read:\n${footer}`);
}
});