add oc find <query> so a long page answers a lookup in one command

find searches the distilled page the session already holds, prints one line
per match with the number to read it by, and costs no fetch. It matches the
query as a phrase, case insensitive, and falls back to matching the words
separately when the phrase is not there.

On the reddit thread from the benchmark: 'oc find w3m' is 115 tokens against
9,670 for oc raw, and it lands on the numbers to read.
This commit is contained in:
only-cli
2026-08-18 16:16:43 -04:00
parent 0f7a38d44a
commit 6aaa8f1975
6 changed files with 139 additions and 21 deletions
+9 -7
View File
@@ -44,8 +44,9 @@ No setup at all also works: `npx @only-cli/oc` runs without a global install, an
```
oc open <url> fetch and render a page with numbered actions
oc do <n> follow the numbered link [n] from the last page
oc next the next budget worth of the page already open
oc find <query> where a string appears on the page already open
oc read <n> full text of the region at [n], up to 2000 tokens
oc next the next budget worth of the page already open
oc raw [url] distilled markdown of the whole page
oc fill <n> <text> type into a numbered input (v0.2)
oc submit [n] submit a form (v0.2)
@@ -57,7 +58,7 @@ Flags: `--budget <tokens>` (default 500, 2000 for `read`), `--json`, `--html` (r
### Reading past the budget
A 500 token view of a long page is cheap on the first read and expensive on the second, if the only way to see more is the whole page again. So the view says what it left behind, and there are two ways to collect it that are not the whole page:
A 500 token view of a long page is cheap on the first read and expensive on the second, if the only way to see more is the whole page again. So the view says what it left behind, and there are three ways to collect it that are not the whole page:
```
$ oc open https://old.reddit.com/r/linuxquestions/comments/xpznb1/best_terminal_web_browser/
@@ -66,12 +67,13 @@ $ oc open https://old.reddit.com/r/linuxquestions/comments/xpznb1/best_terminal_
... 570 more blocks (~4,362 tokens): 'oc next' for the next ~500, 'oc raw' for all
actions: do <n> | read <n> | next | raw
$ oc next # the next ~455 tokens, continuing exactly where the view stopped
$ oc read 80 # that one comment in full, 88 tokens
$ oc raw # the whole thread, 9,670 tokens
$ oc find w3m # 7 matches with the number to read each by, 115 tokens
$ oc read 245 # that one comment in full, 88 tokens
$ oc next # the next ~455 tokens, continuing exactly where the view stopped
$ oc raw # the whole thread, 9,670 tokens
```
Neither `next` nor `read` fetches anything: `open` saves the distilled page, so continuing to read it costs one file read. `read <n>` takes one region, which is the block at `[n]` with the couple of blocks that lead into it, or the whole section when `[n]` is a heading. Headings and text blocks long enough to be cut get numbers for exactly this reason, and `... +312 chars` on a line is how much of that block the view did not print.
None of the three fetches anything: `open` saves the distilled page, so working through it afterwards costs one file read. `find` matches as a phrase, case insensitive, and falls back to the words separately when the phrase is absent. `read <n>` takes one region, which is the block at `[n]` with the couple of blocks that lead into it, or the whole section when `[n]` is a heading. Headings and text blocks long enough to be cut get numbers for exactly this reason, and `... +312 chars` on a line is how much of that block the view did not print.
## Supported websites
@@ -139,7 +141,7 @@ The same six tasks run through OpenAI's Codex CLI (`codex exec`) as well, where
## Status
Early. v0.1 covers static pages, budget-aware rendering, and offline tests. Sessions, `oc do <n>`, `oc next`, and `oc read <n>` are in, the rest of the actions (`fill`, `submit`, `find`, `back`) land in v0.2, and a lazy headless fallback for script-heavy pages in v0.3. The design principles and how to contribute are in [CONTRIBUTING.md](CONTRIBUTING.md).
Early. v0.1 covers static pages, budget-aware rendering, and offline tests. Sessions, `oc do <n>`, `oc find <query>`, `oc read <n>`, and `oc next` are in, the rest of the actions (`fill`, `submit`, `back`) land in v0.2, and a lazy headless fallback for script-heavy pages in v0.3. The design principles and how to contribute are in [CONTRIBUTING.md](CONTRIBUTING.md).
Known limits, honestly: no JavaScript rendering yet, no sites behind logins yet, and pages behind hard bot challenges may still refuse the tool.
+2 -2
View File
@@ -5,8 +5,8 @@
Key facts:
- Install: `npm install -g @only-cli/oc`, or zero-install with `npx @only-cli/oc`
- Commands: `oc open <url>` (compact view with numbered actions), `oc do <n>` (follow numbered link [n]), `oc next` (the next screenful of the page already open), `oc read <n>` (one region in full), `oc raw [url]` (whole page as markdown, `--html` for cleaned HTML), `oc --help` for the full surface
- Default output budget is 500 tokens per page; `--budget <n>` adjusts it, and `next` or `read <n>` collect what the budget cut without refetching the page
- Commands: `oc open <url>` (compact view with numbered actions), `oc do <n>` (follow numbered link [n]), `oc find <query>` (where a string appears on the page already open), `oc read <n>` (one region in full), `oc next` (the next screenful), `oc raw [url]` (whole page as markdown, `--html` for cleaned HTML), `oc --help` for the full surface
- Default output budget is 500 tokens per page; `--budget <n>` adjusts it, and `find`, `read <n>`, or `next` collect what the budget cut without refetching the page
- Benchmarked at roughly 45x fewer tokens than reading raw HTML, with per-task numbers at https://github.com/only-cli/benchmarks
- Works on any mostly-static website; tuned shortcuts ship for Hacker News, Reddit, GitHub, LinkedIn (public guest views), DuckDuckGo, and Bing
- Requests impersonate Chrome, so pages that block plain scripts often still work
+11 -6
View File
@@ -12,6 +12,7 @@ No install needed, run it with npx:
```
npx @only-cli/oc open <url> compact view with numbered elements
npx @only-cli/oc do <n> follow numbered link [n] from the last page
npx @only-cli/oc find <query> where a string appears on the page already open
npx @only-cli/oc next the next ~500 tokens of the page already open
npx @only-cli/oc read <n> full text of the region at [n]
npx @only-cli/oc raw [url] whole page as markdown (add --html for cleaned HTML)
@@ -27,20 +28,24 @@ npx @only-cli/oc raw [url] whole page as markdown (add --html for cleaned H
## Reading more of a page
Three ways to go past the first view, cheapest first. Pick by what you need, not by habit.
Four ways to go past the first view, cheapest first. Pick by what you need, not by habit.
- `oc next` prints the next budget worth of the same page and remembers where it stopped, so calling it again continues. This is the right answer for "the answer is further down": a comment thread, a long article, a list that ran past the cut.
- `oc read <n>` prints one region in full: the block at `[n]` with a little context, or the whole section when `[n]` is a heading. Use it when the view shows you exactly the block you want and it was cut.
- `oc find <query>` prints every place a string appears on the page, one line each with the number to read it by. When you know what you are looking for, this is the whole job in one command.
- `oc read <n>` prints one region in full: the block at `[n]` with a little context, or the whole section when `[n]` is a heading. Use it when the view or a `find` hit shows you exactly the block you want.
- `oc next` prints the next budget worth of the same page and remembers where it stopped, so calling it again continues. Use it when you are reading rather than looking something up.
- `oc raw` (no URL needed once a page is open) prints everything. It costs an order of magnitude more, so use it when you genuinely need the whole page.
Measured on one Reddit thread: `open` 475 tokens, each `next` about 455, one `read` 88, `raw` 9,670. Neither `next` nor `read` fetches anything; they work from the page `open` already saved.
Measured on one Reddit thread: `open` 475 tokens, one `find` 115, one `read` 88, each `next` about 455, `raw` 9,670. None of them fetches anything; they all work from the page `open` already saved.
```
oc open https://old.reddit.com/r/linuxquestions/comments/xpznb1/best_terminal_web_browser/
oc next -> the comments, 455 tokens at a time
oc read 180 -> that one comment in full, 88 tokens
oc find w3m -> 7 matches with their numbers, 115 tokens
oc read 245 -> that comment in full, 88 tokens
oc next -> keep reading, 455 tokens at a time
```
`find` matches the query as a phrase, case insensitive, and falls back to matching the words separately when the phrase is not there. It says how many matches it held back if they did not fit the budget.
## Following links
Use `do <n>`. The compact view leaves link URLs out because they cost tokens and you do not need them, so to open `[15] 41 comments` run `oc do 15`. It renders the new page exactly like `open` does, and the numbers then refer to that new page.
+79 -3
View File
@@ -171,9 +171,85 @@ export function submit(n) {
throw new NotImplemented('submit');
}
/** @param {string} query */
export function find(query) {
throw new NotImplemented('find');
// How much of a matching block to print around the hit. Wide enough to judge
// whether the match is the one you wanted, narrow enough that twenty hits
// still fit in a screenful.
const BEFORE = 60;
const SNIPPET = 200;
/**
* Where a string appears on the current page. This is the answer to "the page
* is long and I only care about one thing in it": one command, no fetch, and
* a number to read the region it landed in.
* @param {string} query
* @param {{session?: string, budget?: number}} [opts]
* @returns {string}
*/
export function find(query, { session = DEFAULT_SESSION, budget = 500 } = {}) {
const q = (query ?? '').trim().toLowerCase();
if (!q) throw new Error('usage: oc find <query>, searching the page already open');
const state = requireBlocks(session);
const blocks = state.blocks;
let hits = search(blocks, [q]);
// A phrase that matches nothing is usually word order, not absence, so try
// the words separately rather than making the agent guess again.
const terms = q.split(/\s+/);
const loose = !hits.length && terms.length > 1;
if (loose) hits = search(blocks, terms);
if (!hits.length) {
const tried = terms.length > 1 ? ', as a phrase or as separate words' : '';
return `no match for "${query}"${tried} in ${blocks.length} blocks on ${state.url}, try fewer words or 'oc raw' for the full text`;
}
const lines = [`${hits.length} ${hits.length === 1 ? 'match' : 'matches'} for "${query}"${loose ? ', matching the words separately' : ''}`];
let spent = estimateTokens(lines[0]);
let shown = 0;
let hasLinks = false;
for (const hit of hits) {
const line = `[${hit.n ?? '?'}] ${hit.snippet}`;
const cost = estimateTokens(line) + 1;
if (spent + cost > budget && shown) break;
spent += cost;
shown++;
if (hit.type === 'link') hasLinks = true;
lines.push(line);
}
if (shown < hits.length) {
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(' | ')}`);
return lines.join('\n');
}
/**
* Blocks containing every term, each with the piece of text around the first
* one and a number to read it with. A phrase search is the same thing with a
* single term. Short blocks carry no handle of their own, so they borrow the
* nearest one above them, which is what `oc read` needs to put the match back
* in context.
* @param {import('./distill.js').Block[]} blocks
* @param {string[]} terms - lower case, all of them must appear
*/
function search(blocks, terms) {
const out = [];
let anchor = null;
for (const block of blocks) {
if (block.n != null) anchor = block.n;
const text = block.text.toLowerCase();
const found = terms.map((t) => text.indexOf(t));
if (found.some((i) => i < 0)) continue;
const n = block.n ?? anchor;
// One number, one line: a run of short blocks under the same handle would
// otherwise report the same place several times.
if (out.length && out.at(-1).n === n) continue;
const start = Math.max(0, Math.min(...found) - BEFORE);
const end = Math.min(block.text.length, start + SNIPPET);
const snippet = `${start > 0 ? '... ' : ''}${block.text.slice(start, end)}${end < block.text.length ? ' ...' : ''}`;
out.push({ n, type: block.type, snippet });
}
return out;
}
export function back() {
+2 -2
View File
@@ -11,13 +11,13 @@ const HELP = `only-cli: the web as a compact terminal, built for AI agents.
usage: oc <command> [args] [flags]
open <url> fetch and render a page with numbered actions
find <query> where a string appears on the page already open
next the next budget worth of the page already open
read <n> full text of the region at [n], up to 2000 tokens
raw [url] distilled markdown of the whole page
do <n> follow the numbered link [n] from the last page
fill <n> <text> type into a numbered input (v0.2)
submit [n] submit a form (v0.2)
find <query> search visible text on the current page (v0.2)
back return to the previous page (v0.2)
session ls|rm manage saved sessions (v0.2)
@@ -129,9 +129,9 @@ async function main() {
}
case 'read': return console.log(act.read(Number(args[0]), { session: sessionName, budget: asked || 2000 }));
case 'next': return console.log(act.next({ session: sessionName, budget: asked || 500 }));
case 'find': return console.log(act.find(args.join(' '), { session: sessionName, budget: asked || 500 }));
case 'fill': return act.fill(Number(args[0]), args.slice(1).join(' '));
case 'submit': return act.submit(args[0] ? Number(args[0]) : undefined);
case 'find': return act.find(args.join(' '));
case 'back': return act.back();
case 'session': throw new act.NotImplemented('session');
default:
+36 -1
View File
@@ -9,7 +9,7 @@ import { join } from 'node:path';
process.env.OC_HOME = mkdtempSync(join(tmpdir(), 'oc-test-'));
const { distill } = await import('../src/distill.js');
const { activate, read, next } = await import('../src/act.js');
const { activate, read, next, find } = await import('../src/act.js');
const { sessionFromPage, saveSession, loadSession, resolveHref } = await import('../src/session.js');
const { render } = await import('../src/render.js');
@@ -68,6 +68,41 @@ test('read and next explain themselves when the number or the page is missing',
assert.throws(() => read(1, { session: 'never-opened' }), /oc open <url>' first/);
});
test('find reports where a string is, with a number to read it by', () => {
open();
const out = find('postgres');
assert.ok(out.startsWith('1 match for "postgres"'));
assert.ok(out.includes('[4] Postgres 18 released'), `wrong hit line:\n${out}`);
assert.ok(out.includes('actions: do <n>'), 'a link hit should offer do');
});
test('find opens the snippet on the match, not on the start of a long block', () => {
open();
const out = find('lazy dog');
assert.match(out, /\[9\] \.\.\. .*lazy dog/);
assert.ok(out.length < 400, `snippet was not trimmed:\n${out}`);
});
test('a phrase that matches nothing falls back to the words, and says so', () => {
open();
const out = find('dog quick');
assert.ok(out.includes('matching the words separately'));
assert.ok(out.includes('[9]'));
assert.match(find('nothing here at all'), /no match .* as a phrase or as separate words/);
});
test('find caps its own output and says how many it held back', () => {
open();
const out = find('comments', { budget: 12 });
assert.match(out, /\.\.\. \d+ more matches/);
});
test('find needs a query and a page', () => {
open();
assert.throws(() => find(' '), /usage: oc find <query>/);
assert.throws(() => find('x', { session: 'never-opened' }), /oc open <url>' first/);
});
test('next continues where the budget stopped, then says the page is done', () => {
open('paged', 100);
const first = next({ session: 'paged', budget: 100 });