mirror of
https://github.com/only-cli/oc.git
synced 2026-09-15 10:40:56 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1103798914 | ||
|
|
5567b31d6a | ||
|
|
90d5c20ef9 | ||
|
|
780318a780 | ||
|
|
f84074701d | ||
|
|
29ab00b5c6 | ||
|
|
75fc1a0da3 | ||
|
|
f7c8a5583b |
@@ -6,7 +6,7 @@
|
||||
"name": "only-cli",
|
||||
"source": { "source": "github", "repo": "only-cli/oc" },
|
||||
"description": "Browse websites from the terminal in a few hundred tokens",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"homepage": "https://github.com/only-cli/oc",
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "only-cli",
|
||||
"description": "Browse websites from the terminal in a few hundred tokens",
|
||||
"version": "0.2.0"
|
||||
"version": "0.3.0"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
*.log
|
||||
.idea/
|
||||
.env
|
||||
|
||||
@@ -59,7 +59,8 @@ 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], or read [n] if it is text
|
||||
oc find <query> where a string appears on the page already open
|
||||
oc find <query> where a string appears on the page already open, or
|
||||
the region itself when only one place matches
|
||||
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
|
||||
@@ -69,7 +70,7 @@ oc submit [n] submit a form (planned)
|
||||
|
||||
Flags: `--budget <tokens>` (default 500), `--json`, `--html` (raw as cleaned HTML), `--session <name>`, `--verbose`/`-v` (metrics on stderr, or export `OC_VERBOSE=1`).
|
||||
|
||||
`oc open` remembers the page it rendered in a JSON file per session under `~/.only-cli` (override with `OC_HOME`), so `oc do 3` follows `[3]` without the agent ever handling a URL. Pages longer than the budget say what they left out; `oc find`, `oc read <n>`, and `oc next` read the rest without refetching the page. The budget is a target rather than a hard cap: a page that would only run a little long is printed whole rather than cut, since one extra tool call costs far more than the tokens it would have saved.
|
||||
`oc open` remembers the page it rendered in a JSON file per session under `~/.only-cli` (override with `OC_HOME`), so `oc do 3` follows `[3]` without the agent ever handling a URL. A result title on a search page is a link, so `oc do` on it opens the result rather than repeating the title. Pages longer than the budget say what they left out; `oc find`, `oc read <n>`, and `oc next` read the rest without refetching the page, and a `find` with a single match prints that region instead of the number to read it with. The budget is a target rather than a hard cap: a page that would only run a little long is printed whole rather than cut, since one extra tool call costs far more than the tokens it would have saved.
|
||||
|
||||
## Supported websites
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/home/small/PycharmProjects/only-cli/node_modules
|
||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "only-cli",
|
||||
"version": "0.3.0-beta.1",
|
||||
"name": "@only-cli/oc",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "only-cli",
|
||||
"version": "0.3.0-beta.1",
|
||||
"name": "@only-cli/oc",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkedom": "^0.18.12",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@only-cli/oc",
|
||||
"version": "0.3.0-beta.1",
|
||||
"version": "0.3.0",
|
||||
"description": "Turn websites into a compact CLI so AI agents can browse without burning tokens.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -8,12 +8,13 @@ description: Token-efficient web browsing and web content extraction for AI agen
|
||||
Renders a web page as a compact, numbered terminal view instead of raw HTML. A typical page is under 500 tokens.
|
||||
|
||||
```
|
||||
npx --yes @only-cli/oc@0.2.0 open <url> compact view, numbered elements
|
||||
npx --yes @only-cli/oc@0.2.0 do <n> follow link [n], or read it if [n] is text
|
||||
npx --yes @only-cli/oc@0.2.0 find <query> lines where a string appears, with numbers
|
||||
npx --yes @only-cli/oc@0.2.0 next next ~500 tokens of the page already open
|
||||
npx --yes @only-cli/oc@0.2.0 read <n> full text of region [n]
|
||||
npx --yes @only-cli/oc@0.2.0 raw [url] whole page as markdown (--html for cleaned HTML)
|
||||
npx --yes @only-cli/oc@0.3.0 open <url> compact view, numbered elements
|
||||
npx --yes @only-cli/oc@0.3.0 do <n> follow link [n], or read it if [n] is text
|
||||
npx --yes @only-cli/oc@0.3.0 find <query> where a string appears, or that place itself
|
||||
when only one matches
|
||||
npx --yes @only-cli/oc@0.3.0 next next ~500 tokens of the page already open
|
||||
npx --yes @only-cli/oc@0.3.0 read <n> full text of region [n]
|
||||
npx --yes @only-cli/oc@0.3.0 raw [url] whole page as markdown (--html for cleaned HTML)
|
||||
```
|
||||
|
||||
None of these except `open`/`do`/`raw <url>` fetch anything — they replay the page `open` already saved.
|
||||
@@ -23,13 +24,14 @@ None of these except `open`/`do`/`raw <url>` fetch anything — they replay the
|
||||
- Line 1 is the title, then main content (article/thread/results); nav/sidebar/footer follow after `--- rest of page ---`, still numbered.
|
||||
- `--- repeated controls hidden ---`: per-item chrome (save/report/reply) dropped as repetitive; `raw` keeps it.
|
||||
- `[n]` marks a link, button, input, heading, or a text block long enough to be cut.
|
||||
- `... +820 chars`: block was cut there; `read <n>` prints it whole.
|
||||
- Code blocks arrive as the page wrote them, lines and indentation intact, so a command in one can be run as printed.
|
||||
- `... +820 chars`: block was cut there; `read <n>` prints it whole. The cut lands on the end of a sentence, or of a line in code, so what is shown is never half of one.
|
||||
- `... 164 more blocks (~7,100 tokens)`: rest of page past budget — a cost estimate, not a fetch. Omitted when the page would finish only a little over budget; then it's printed whole instead.
|
||||
- `actions:` footer lists valid next commands.
|
||||
|
||||
## Going further, cheapest first
|
||||
|
||||
- `find <query>` — every place a string appears, one line + number each. Matches as a phrase (case-insensitive), falling back to separate words; reports how many matches didn't fit.
|
||||
- `find <query>` — every place a string appears, one line + number each. Matches as a phrase (case-insensitive), falling back to separate words; reports how many matches didn't fit. When one place matches, or when the matches all fit, it prints them in full: no `read <n>` afterwards.
|
||||
- `read <n>` — one region in full: the block at `[n]` plus a little context, or the whole section for a heading.
|
||||
- `next` — continues the same page from where the budget stopped.
|
||||
- `raw` — everything, ~10x the cost. Use only when you need the whole page, not to hunt for a link's URL (use `do` for that).
|
||||
@@ -42,7 +44,7 @@ None of these except `open`/`do`/`raw <url>` fetch anything — they replay the
|
||||
- `[6-9] 4 similar links` markers still work despite the collapsed text.
|
||||
- Search result links resolve to the destination, not the tracking redirect.
|
||||
- `do` on an input/button reports that instead (typing/submitting not yet supported).
|
||||
- `do` on a heading/text block prints the read instead of refusing, since there's nothing to follow.
|
||||
- `do` on a heading/text block prints the read instead of refusing, since there's nothing to follow. A heading that is itself a link, which is what a search result title is, opens instead.
|
||||
- `--session <name>` keeps separate page state, for working on two sites at once.
|
||||
|
||||
## Flags
|
||||
|
||||
+42
-7
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { DEFAULT_SESSION, handleFor, handleNumbers, loadSession, saveSession } from './session.js';
|
||||
import { estimateTokens, formatBlock, render } from './render.js';
|
||||
import { FINISH, estimateTokens, formatBlock, render } from './render.js';
|
||||
|
||||
export class NotImplemented extends Error {
|
||||
constructor(command) {
|
||||
@@ -63,7 +63,11 @@ export function activate(n, { session = DEFAULT_SESSION } = {}) {
|
||||
const range = nums.length ? `1-${Math.max(...nums)}` : 'none';
|
||||
throw new Error(`no [${n}] on ${state.url} (handles ${range}), run 'oc open <url>' again to renumber`);
|
||||
}
|
||||
if (handle.type === 'text' || handle.type === 'heading') {
|
||||
// A heading can be a link: on a search results page the result title is one,
|
||||
// and opening it is what `do` was asked for. Reading the title back instead
|
||||
// cost a turn, and then another to find the number that does navigate, so a
|
||||
// heading that has an href falls through to the link below.
|
||||
if ((handle.type === 'text' || handle.type === 'heading') && !handle.href) {
|
||||
// There is nothing to follow, but the agent asked to see what is at [n],
|
||||
// and that is what read prints. Refusing would spend a whole turn to name
|
||||
// the command that should have run, and a turn costs more than the page.
|
||||
@@ -208,14 +212,41 @@ export function find(query, { session = DEFAULT_SESSION, budget = 500 } = {}) {
|
||||
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' : ''}`];
|
||||
const separately = loose ? ', matching the words separately' : '';
|
||||
|
||||
// One match is not an index, it is the answer. Naming the number and
|
||||
// stopping spends a turn to say where to look, and the agent's next command
|
||||
// is always the `read` that looks, so find does the reading. Measured on an
|
||||
// AWS CLI reference page, `find "Example 7"` printed a 24 token heading and
|
||||
// the example it names was in the block after it.
|
||||
if (hits.length === 1 && hits[0].n != null) {
|
||||
const only = hits[0];
|
||||
const follow = only.type === 'link' ? 'do <n> | ' : '';
|
||||
return [
|
||||
`1 match for "${query}"${separately}, region [${only.n}]`,
|
||||
read(only.n, { session, budget: budget * FINISH }),
|
||||
`actions: ${follow}read <n> | next | raw`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = [`${hits.length} ${hits.length === 1 ? 'match' : 'matches'} for "${query}"${separately}`];
|
||||
// A snippet is short because many matches have to share one screen. When
|
||||
// every match would fit whole inside the allowance a page already gets for
|
||||
// finishing (render's FINISH), showing them whole makes this command the
|
||||
// answer instead of an index into a `read <n>` that has to run next. The
|
||||
// trade is the same one FINISH documents, and it is not close: a few
|
||||
// hundred tokens against a turn.
|
||||
const label = (hit, text) => `[${hit.n ?? '?'}] ${text}`;
|
||||
const whole = hits.reduce((n, h) => n + estimateTokens(label(h, h.text)) + 1, estimateTokens(lines[0]));
|
||||
const full = whole <= budget * FINISH;
|
||||
const cap = full ? budget * FINISH : budget;
|
||||
let spent = estimateTokens(lines[0]);
|
||||
let shown = 0;
|
||||
let hasLinks = false;
|
||||
for (const hit of hits) {
|
||||
const line = `[${hit.n ?? '?'}] ${hit.snippet}`;
|
||||
const line = label(hit, full ? hit.text : hit.snippet);
|
||||
const cost = estimateTokens(line) + 1;
|
||||
if (spent + cost > budget && shown) break;
|
||||
if (spent + cost > cap && shown) break;
|
||||
spent += cost;
|
||||
shown++;
|
||||
if (hit.type === 'link') hasLinks = true;
|
||||
@@ -251,8 +282,12 @@ function search(blocks, terms) {
|
||||
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 });
|
||||
// One line per match is the promise the list makes, and a code block is the
|
||||
// one kind of block that carries lines of its own. They survive where they
|
||||
// are read rather than indexed: the whole-match mode above, and `read`.
|
||||
const window = block.text.slice(start, end).replace(/\n/g, ' ');
|
||||
const snippet = `${start > 0 ? '... ' : ''}${window}${end < block.text.length ? ' ...' : ''}`;
|
||||
out.push({ n, type: block.type, snippet, text: block.text });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+4
-2
@@ -11,7 +11,8 @@ 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
|
||||
find <query> where a string appears on the page already open, or
|
||||
the region itself when only one place matches
|
||||
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
|
||||
@@ -36,7 +37,8 @@ flags:
|
||||
|
||||
'oc open' remembers the page it printed, so 'oc do 3' follows link [3] without
|
||||
you ever handling its URL, and 'oc next' or 'oc read 12' picks up what the
|
||||
budget left behind without fetching it again. State lives in ~/.only-cli
|
||||
budget left behind without fetching it again. 'oc do' on a search result title
|
||||
opens the result, because that title is a link. State lives in ~/.only-cli
|
||||
(override with OC_HOME).`;
|
||||
|
||||
// A rendered page has to be remembered or its [3] means nothing to the next
|
||||
|
||||
+170
-19
@@ -7,7 +7,7 @@ import TurndownService from 'turndown';
|
||||
* @property {string} text
|
||||
* @property {number} [n] - action handle
|
||||
* @property {number} [level] - heading level 1..6
|
||||
* @property {string} [href] - links only
|
||||
* @property {string} [href] - links, and a heading that is one
|
||||
* @property {string} [name] - inputs only
|
||||
*
|
||||
* @typedef {Object} Page
|
||||
@@ -32,6 +32,32 @@ const DROP = new Set([
|
||||
// them, so the search below refuses to descend into them.
|
||||
const FURNITURE = new Set(['nav', 'header', 'footer', 'aside']);
|
||||
|
||||
// Controls a page puts inside its own code samples, and the selector that
|
||||
// finds one. Node's API docs give every code block a copy button, a module
|
||||
// toggle and a language label, so the text of the sample ends in
|
||||
// `javascriptcopy` unless the toolbar holding them is left out of it.
|
||||
const CODE_CONTROLS = new Set(['button', 'input', 'select', 'textarea', 'label']);
|
||||
const CONTROL = [...CODE_CONTROLS].join(', ');
|
||||
|
||||
// Lines in a code block are kept, where every other kind of text has its
|
||||
// whitespace collapsed. Joining them would put `// comment` in front of the
|
||||
// statements that followed it, so a sample an agent could run would arrive
|
||||
// commented out, and the shape of the wreck is invisible on one line. Blank
|
||||
// runs and the indentation the whole block shares are the parts that carry no
|
||||
// meaning, so those go.
|
||||
const codeText = (raw) => {
|
||||
const lines = raw.replace(/\r\n?/g, '\n').replace(/[^\S\n]+$/gm, '').split('\n');
|
||||
while (lines.length && !lines[0].trim()) lines.shift();
|
||||
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
||||
const indent = lines
|
||||
.filter((l) => l.trim())
|
||||
.reduce((least, l) => Math.min(least, l.length - l.trimStart().length), Infinity);
|
||||
return lines
|
||||
.map((l) => (Number.isFinite(indent) ? l.slice(indent) : l).trimEnd())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n');
|
||||
};
|
||||
|
||||
// Elements that end a line on the page and so must end one here. Without this
|
||||
// the text of six separate posts merges into a single block, because nothing
|
||||
// between them survives distillation to keep them apart.
|
||||
@@ -82,6 +108,29 @@ const clean = (s) => s.replace(/\s+/g, ' ').trim();
|
||||
const asHTML = (text, url = '', opts = {}) =>
|
||||
jsonToHTML(text, url, opts) ?? youtubeToHTML(text) ?? transcriptToHTML(text) ?? feedToHTML(text) ?? text;
|
||||
|
||||
/**
|
||||
* The link a heading is, if it is one. A search engine puts the result title
|
||||
* in an anchor inside an <h2>, so a heading can be the most followable thing
|
||||
* on the page, and taking only its text threw that away.
|
||||
*
|
||||
* The heading has to BE the link, not merely contain one: exactly one anchor,
|
||||
* labelled with the whole heading. Documentation fails that test on purpose.
|
||||
* Every heading in the Rust book and every one on an AWS CLI reference page
|
||||
* carries a permalink to its own id, so following those would refetch the
|
||||
* page the agent is already reading, which is worse than the reading it
|
||||
* already gets. A bare fragment is never a destination.
|
||||
* @param {any} node - the heading element
|
||||
* @param {string} text - its cleaned text
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function headingHref(node, text) {
|
||||
const anchors = node.querySelectorAll('a[href]');
|
||||
if (anchors.length !== 1) return null;
|
||||
const href = anchors[0].getAttribute('href') ?? '';
|
||||
if (!href || href.startsWith('#')) return null;
|
||||
return clean(anchors[0].textContent) === text ? href : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce raw HTML to an interaction tree: readable text plus numbered
|
||||
* elements, in document order. The walk is deterministic and numbering is a
|
||||
@@ -105,6 +154,45 @@ export function distill(html, url = '') {
|
||||
/** Subtree already emitted, skipped when the rest of the page is walked. */
|
||||
let done = null;
|
||||
|
||||
/**
|
||||
* The text of a code subtree, read as one string.
|
||||
*
|
||||
* A syntax highlighter gives every token its own element, so `s3://bucket/`
|
||||
* reaches the walk as `s3`, `:`, `//`, `bucket`, `/`, and the rule that puts
|
||||
* fragments back together only glues the ones that share a parent. The rest
|
||||
* are space-joined, which turned an AWS example into
|
||||
* `aws s3 cp s3 : // bucket / -- recursive`, a command an agent cannot run.
|
||||
* Reading the subtree whole is what fixes that, and it discards nothing: over
|
||||
* 172 pre elements on the AWS CLI reference, the Rust book, the Node API docs
|
||||
* and the Python library docs, 159 are split this way and not one of them
|
||||
* contains a link.
|
||||
*
|
||||
* textContent would be enough if pages put only code in their code blocks.
|
||||
* A control inside one is chrome, and so is the block-level element holding
|
||||
* it: that is how the toolbar's `javascript` label leaves with the copy
|
||||
* button it sits beside. The test stays on block-level wrappers because a
|
||||
* highlighter's own elements are inline, so a stray control can never take a
|
||||
* line of code out with it.
|
||||
* @param {any} node
|
||||
* @returns {string}
|
||||
*/
|
||||
const verbatim = (node) => {
|
||||
let out = '';
|
||||
const gather = (n) => {
|
||||
if (n.nodeType === 3) {
|
||||
out += n.textContent ?? '';
|
||||
return;
|
||||
}
|
||||
if (n.nodeType !== 1) return;
|
||||
const tag = n.localName;
|
||||
if (DROP.has(tag) || CODE_CONTROLS.has(tag) || hidden(n)) return;
|
||||
if (n !== node && BLOCKY.has(tag) && n.querySelector(CONTROL)) return;
|
||||
for (const child of n.childNodes) gather(child);
|
||||
};
|
||||
gather(node);
|
||||
return out;
|
||||
};
|
||||
|
||||
const walk = (node) => {
|
||||
if (node.nodeType === 3) {
|
||||
const raw = node.textContent ?? '';
|
||||
@@ -126,7 +214,10 @@ export function distill(html, url = '') {
|
||||
|
||||
if (/^h[1-6]$/.test(tag)) {
|
||||
const text = clean(node.textContent);
|
||||
if (text) blocks.push({ type: 'heading', level: Number(tag[1]), text });
|
||||
if (text) {
|
||||
const href = headingHref(node, text);
|
||||
blocks.push({ type: 'heading', level: Number(tag[1]), text, ...(href ? { href } : {}) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (tag === 'a' && node.getAttribute('href')) {
|
||||
@@ -157,6 +248,21 @@ export function distill(html, url = '') {
|
||||
if (text) blocks.push({ type: 'button', text });
|
||||
return;
|
||||
}
|
||||
if (tag === 'pre' || tag === 'code') {
|
||||
const raw = verbatim(node);
|
||||
const text = tag === 'pre' ? codeText(raw) : clean(raw);
|
||||
// A pre is its own line, which is what BLOCKY gave it before this branch
|
||||
// started claiming it first. Inline code belongs to the sentence around
|
||||
// it, so it carries the parent and the edge spacing a text node would and
|
||||
// merges back into that sentence the same way.
|
||||
const own = tag === 'pre';
|
||||
if (own) blocks.push({ type: 'break' });
|
||||
if (text) {
|
||||
blocks.push({ type: 'text', text, host: node.parentNode, pre: /^\s/.test(raw), post: /\s$/.test(raw) });
|
||||
}
|
||||
if (own) blocks.push({ type: 'break' });
|
||||
return;
|
||||
}
|
||||
if (BLOCKY.has(tag)) {
|
||||
blocks.push({ type: 'break' });
|
||||
for (const child of node.childNodes) walk(child);
|
||||
@@ -513,6 +619,15 @@ const TITLE_KEYS = [
|
||||
'full_name', 'summary', 'question', 'message',
|
||||
];
|
||||
|
||||
// Keys an API is likely to put its list of results under. A response using one
|
||||
// of these is a list whatever else it carries, so the name settles it before
|
||||
// shape does: a sideloaded `included` array can outnumber the `items` the
|
||||
// request was for without being what the request was for.
|
||||
const CONTAINER_KEYS = [
|
||||
'items', 'data', 'results', 'hits', 'records', 'rows', 'entries',
|
||||
'nodes', 'edges', 'docs', 'list', 'children', 'values',
|
||||
];
|
||||
|
||||
// Keys holding the item's own page. A URL under any other name is still found,
|
||||
// by looking at values rather than names, but these win when several qualify.
|
||||
const LINK_KEYS = ['link', 'url', 'html_url', 'web_url', 'permalink', 'href'];
|
||||
@@ -540,6 +655,14 @@ const NESTED_PENALTY = 0.25;
|
||||
// How many names the footer lists when it says which fields it left out.
|
||||
const DROPPED_LISTED = 5;
|
||||
|
||||
// Characters of markup a field may carry before the compact view stops
|
||||
// treating it as a document and starts treating it as somewhere to go. A
|
||||
// question body arrives well under this and is worth rendering in place, links
|
||||
// and code and all. A package readme arrives at four figures and distils into
|
||||
// more blocks than the resource it is attached to has fields, which buries the
|
||||
// resource the response was fetched for. `oc raw` renders either one in full.
|
||||
const BODY_CAP = 4000;
|
||||
|
||||
// Seconds and milliseconds since the epoch, bounded either side so an ordinary
|
||||
// count (a score, a byte size) is never mistaken for a date.
|
||||
const EPOCH_S = [1e9, 4e9];
|
||||
@@ -572,6 +695,10 @@ const decodeEntities = (s) =>
|
||||
const isPlain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
const isURL = (v) => typeof v === 'string' && /^https?:\/\/\S+$/.test(v);
|
||||
const looksHTML = (v) => typeof v === 'string' && /<\/?(p|div|pre|code|br|ul|ol|li|h[1-6]|blockquote|table|img|a|em|strong)\b[^>]*>/i.test(v);
|
||||
// Markup as one line of prose, for a body the compact view is pointing at
|
||||
// rather than rendering. The distiller is what reads markup properly; this
|
||||
// only has to make a line an agent can tell one body from another by.
|
||||
const stripTags = (v) => clean(decodeEntities(String(v).replace(/<[^>]*>/g, ' ')));
|
||||
|
||||
/**
|
||||
* One level of flattening, so `owner: {display_name}` becomes an
|
||||
@@ -662,32 +789,40 @@ function chooseFields(rows, skip) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the array the response is actually about: the root when it is one,
|
||||
* otherwise the longest array of objects at the top level, which is where
|
||||
* `items`, `data`, `results`, and `hits` all live. Everything beside it is
|
||||
* metadata about the request rather than content.
|
||||
* Pick what the response is actually about: the root when it is an array or a
|
||||
* single resource, otherwise the array of objects at the top level that holds
|
||||
* the results. Everything beside it is metadata about the request rather than
|
||||
* content.
|
||||
* @param {any} data
|
||||
* @returns {{items: any[], meta: Record<string, any>}}
|
||||
*/
|
||||
function mainArray(data) {
|
||||
if (Array.isArray(data)) return { items: data, meta: {} };
|
||||
let key = '';
|
||||
/** @type {any[] | null} */
|
||||
let items = null;
|
||||
/** @type {Map<string, any[]>} */
|
||||
const arrays = new Map();
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (!Array.isArray(v) || !v.length) continue;
|
||||
if (!v.some(isPlain)) continue;
|
||||
if (!items || v.length > items.length) {
|
||||
items = v;
|
||||
key = k;
|
||||
arrays.set(k, v);
|
||||
}
|
||||
let key = CONTAINER_KEYS.find((k) => arrays.has(k)) ?? '';
|
||||
// A root carrying its own name is the resource, and an array hanging off it
|
||||
// describes that resource rather than being the subject in its place. Taking
|
||||
// the longest array regardless titled the npm registry's package endpoint
|
||||
// after its two maintainers and demoted the package to the metadata line,
|
||||
// where a 9KB readme then cost more than the rest of the page put together.
|
||||
const named = TITLE_KEYS.some((k) => typeof data[k] === 'string' && data[k].trim() !== '');
|
||||
if (!key && !named) {
|
||||
for (const [k, v] of arrays) {
|
||||
if (!key || v.length > (arrays.get(key)?.length ?? 0)) key = k;
|
||||
}
|
||||
}
|
||||
// A response with no array is a single resource, which renders as one item
|
||||
// rather than as a special case.
|
||||
if (!items) return { items: [data], meta: {} };
|
||||
// A response with no results array of its own is a single resource, which
|
||||
// renders as one item rather than as a special case.
|
||||
if (!key) return { items: [data], meta: {} };
|
||||
const meta = { ...data };
|
||||
delete meta[key];
|
||||
return { items, meta };
|
||||
return { items: arrays.get(key) ?? [data], meta };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -784,9 +919,16 @@ export function jsonToHTML(text, url = '', { full = false } = {}) {
|
||||
if (kept && !kept.has(key) && !looksHTML(value)) continue;
|
||||
// A field carrying HTML is a page in itself: `filter=withbody` on the
|
||||
// Stack Exchange API puts a whole question in one. It goes through the
|
||||
// distiller like any other markup instead of into a cell.
|
||||
// distiller like any other markup instead of into a cell, unless it is
|
||||
// longer than the compact view can afford, in which case it becomes one
|
||||
// numbered line rather than a dozen blocks that bury the item it hangs
|
||||
// off. `oc read <n>` opens it at a budget that fits it, `oc raw` always.
|
||||
if (looksHTML(value)) {
|
||||
bodies.push(String(value));
|
||||
if (full || String(value).length <= BODY_CAP) {
|
||||
bodies.push(String(value));
|
||||
continue;
|
||||
}
|
||||
long.push(`<p>${escHTML(`${key}: ${stripTags(value)}`)}</p>`);
|
||||
continue;
|
||||
}
|
||||
const rendered = row.get(key);
|
||||
@@ -824,12 +966,21 @@ export function jsonToHTML(text, url = '', { full = false } = {}) {
|
||||
}
|
||||
|
||||
const metaBits = [];
|
||||
const metaLong = [];
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value === null || typeof value === 'object') continue;
|
||||
const rendered = renderValue(key, value);
|
||||
if (rendered) metaBits.push(`${key}=${rendered}`);
|
||||
if (!rendered) continue;
|
||||
// A summary line has to stay a line. One long scalar at the root, a
|
||||
// package readme or an endpoint description, would otherwise spend the
|
||||
// whole page budget here, so it becomes a block of its own instead. The
|
||||
// block is numbered, so `oc read <n>` opens it when it fits that budget
|
||||
// and `oc raw` has it whatever its size.
|
||||
if (rendered.length > TEXT_CAP) metaLong.push(`<p>${escHTML(`${key}: ${rendered}`)}</p>`);
|
||||
else metaBits.push(`${key}=${rendered}`);
|
||||
}
|
||||
if (metaBits.length) parts.push(`<p>response: ${escHTML(metaBits.join(', '))}</p>`);
|
||||
parts.push(...metaLong);
|
||||
|
||||
const count = `${items.length} ${items.length === 1 ? 'item' : 'items'}`;
|
||||
return `<html><head><title>${escHTML(jsonTitle(url, count))}</title></head><body>\n${parts.join('\n')}\n</body></html>`;
|
||||
|
||||
+51
-34
@@ -24,6 +24,25 @@ const loadImpers = () => {
|
||||
const BLOCKED_MESSAGE = 'blocked: private or internal URL';
|
||||
const MAX_REDIRECTS = 20;
|
||||
|
||||
// What oc can turn into text: any text/* type, plus the application/* types
|
||||
// that are really text (json, xml, and the +json / +xml families a feed or an
|
||||
// API answers with). A PNG matches none of these, and rendering one produces
|
||||
// pages of mojibake an agent then pays for, so it is refused by name instead.
|
||||
const READABLE_TYPE = /^\s*(?:text\/|application\/(?:json|xml|javascript|x-ndjson|[\w.+-]*\+(?:json|xml)))/i;
|
||||
|
||||
/**
|
||||
* Refuse a response oc cannot read as text. Both transports call this: the
|
||||
* gate has to live on whichever client got the page, or the same URL renders
|
||||
* as an error through fetch and as binary noise through impers.
|
||||
* @param {string | null | undefined} type - the content-type header
|
||||
*/
|
||||
export function assertReadableType(type) {
|
||||
// No header at all is not a refusal: plenty of small servers omit it, and
|
||||
// the distiller handles whatever comes back.
|
||||
if (!type || READABLE_TYPE.test(type)) return;
|
||||
throw new Error(`not a page oc can read (${type.split(';')[0].trim()}), it renders HTML, XML feeds, JSON, and plain text`);
|
||||
}
|
||||
|
||||
// IPv4 ranges with no business receiving a server-initiated fetch: loopback,
|
||||
// link-local, the three RFC 1918 private blocks, carrier-grade NAT, the
|
||||
// unspecified/broadcast addresses, and the documentation/benchmark ranges.
|
||||
@@ -141,11 +160,25 @@ export async function fetchPage(url) {
|
||||
return impers ? viaImpers(impers, target) : viaFetch(target);
|
||||
}
|
||||
|
||||
async function followImpersRedirects(impers, startUrl, impersonate) {
|
||||
let current = startUrl;
|
||||
/**
|
||||
* Follow redirects one hop at a time, validating each destination before the
|
||||
* next request goes out.
|
||||
*
|
||||
* Both transports share this loop. They used to carry one each, which made the
|
||||
* check that matters something a change could fix in one place and leave broken
|
||||
* in the other, and made the guarantee testable only through a third party
|
||||
* willing to 302 wherever it was told. Taking the request as a callback is what
|
||||
* lets the hop check be proven against a transport that never leaves the
|
||||
* process.
|
||||
* @param {(url: string) => Promise<any>} get - one request, redirects not followed
|
||||
* @param {string} start
|
||||
* @returns {Promise<{res: any, url: string}>} the first non-redirect response
|
||||
*/
|
||||
export async function followRedirects(get, start) {
|
||||
let current = start;
|
||||
for (let i = 0; ; i++) {
|
||||
if (i > MAX_REDIRECTS) throw new Error(`too many redirects for ${startUrl}`);
|
||||
const res = await impers.get(current, { impersonate, allowRedirects: false });
|
||||
if (i > MAX_REDIRECTS) throw new Error(`too many redirects for ${start}`);
|
||||
const res = await get(current);
|
||||
const status = res.status ?? res.statusCode ?? 0;
|
||||
const location = res.headers.get('location');
|
||||
if (status >= 300 && status < 400 && location) {
|
||||
@@ -153,56 +186,40 @@ async function followImpersRedirects(impers, startUrl, impersonate) {
|
||||
await assertSafeTarget(current);
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
return { res, url: current };
|
||||
}
|
||||
}
|
||||
|
||||
async function viaImpers(impers, target) {
|
||||
// Some sites (Reddit) 403 the chrome fingerprint but accept firefox, so a
|
||||
// blocked first attempt gets one cheap retry with a second identity.
|
||||
const asking = (impersonate) => (url) => impers.get(url, { impersonate, allowRedirects: false });
|
||||
let via = 'impers:chrome';
|
||||
let res = await followImpersRedirects(impers, target, 'chrome');
|
||||
let { res } = await followRedirects(asking('chrome'), target);
|
||||
let status = res.status ?? res.statusCode ?? 0;
|
||||
if (status >= 400) {
|
||||
via = 'impers:firefox';
|
||||
res = await followImpersRedirects(impers, target, 'firefox');
|
||||
({ res } = await followRedirects(asking('firefox'), target));
|
||||
status = res.status ?? res.statusCode ?? 0;
|
||||
}
|
||||
if (status >= 400) throw new Error(`fetch failed: ${status} for ${target}`);
|
||||
assertReadableType(res.headers.get('content-type'));
|
||||
const html = typeof res.text === 'function' ? await res.text() : String(res.text ?? res.body ?? '');
|
||||
return { url: res.url ?? target, html, status, via };
|
||||
}
|
||||
|
||||
async function viaFetch(target) {
|
||||
let current = target;
|
||||
let res;
|
||||
for (let i = 0; ; i++) {
|
||||
if (i > MAX_REDIRECTS) throw new Error(`too many redirects for ${target}`);
|
||||
res = await fetch(current, {
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
'user-agent': UA,
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
},
|
||||
});
|
||||
const location = res.headers.get('location');
|
||||
if (res.status >= 300 && res.status < 400 && location) {
|
||||
current = new URL(location, current).toString();
|
||||
await assertSafeTarget(current);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const { res, url: current } = await followRedirects((url) => fetch(url, {
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
'user-agent': UA,
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
},
|
||||
}), target);
|
||||
if (!res.ok) {
|
||||
throw new Error(`fetch failed: ${res.status} ${res.statusText} for ${current}`);
|
||||
}
|
||||
// JSON is a page here too: an API answer distills into one article per item.
|
||||
// The impers path never checked the type at all, so this is also what keeps
|
||||
// the two transports rendering the same URL the same way.
|
||||
const type = res.headers.get('content-type') ?? '';
|
||||
if (type && !/html|xml|json/.test(type)) {
|
||||
throw new Error(`not a page oc can read (${type.split(';')[0]}), it renders HTML, XML feeds, and JSON`);
|
||||
}
|
||||
assertReadableType(res.headers.get('content-type'));
|
||||
return { url: res.url || current, html: await res.text(), status: res.status, via: 'fetch' };
|
||||
}
|
||||
|
||||
+26
-3
@@ -24,7 +24,7 @@ const num = (v) => v.toLocaleString('en-US');
|
||||
// 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;
|
||||
export const FINISH = 4;
|
||||
|
||||
/**
|
||||
* Budget-aware compact view of a distilled page. `from` is a position in the
|
||||
@@ -162,5 +162,28 @@ export function formatBlock(b, { full = false } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const truncate = (s) =>
|
||||
s.length > TEXT_CAP ? `${s.slice(0, TEXT_CAP)} ... +${num(s.length - TEXT_CAP)} chars` : s;
|
||||
// A cut inside a sentence makes the half that is shown untrustworthy. Asked
|
||||
// for the first sentence of a page, an agent was given it in full, followed by
|
||||
// a truncation marker, and spent a turn on `read` to find out whether the
|
||||
// sentence carried on. Ending on the last sentence that finished inside the cap
|
||||
// answers that in the view itself. The floor bounds what the courtesy costs: a
|
||||
// block whose only sentence end is early keeps the plain cut instead of
|
||||
// throwing away a third of the window.
|
||||
const SENTENCE_END = /[.!?]["')\]]*(?=\s)/g;
|
||||
const SENTENCE_FLOOR = 0.7;
|
||||
|
||||
const truncate = (s) => {
|
||||
if (s.length <= TEXT_CAP) return s;
|
||||
// A line is to code what a sentence is to prose, and a code block is the only
|
||||
// text that keeps its newlines, so the same courtesy applies: cut where a
|
||||
// line ended. A period in code ends nothing, which is why this returns
|
||||
// instead of falling through to the sentence rule below.
|
||||
const line = s.slice(0, TEXT_CAP).lastIndexOf('\n');
|
||||
if (line >= TEXT_CAP * SENTENCE_FLOOR) return `${s.slice(0, line)} ... +${num(s.length - line)} chars`;
|
||||
let cut = TEXT_CAP;
|
||||
for (const m of s.slice(0, TEXT_CAP).matchAll(SENTENCE_END)) {
|
||||
const end = (m.index ?? 0) + m[0].length;
|
||||
if (end >= TEXT_CAP * SENTENCE_FLOOR) cut = end;
|
||||
}
|
||||
return `${s.slice(0, cut).trimEnd()} ... +${num(s.length - cut)} chars`;
|
||||
};
|
||||
|
||||
+65
-3
@@ -14,6 +14,7 @@ const { sessionFromPage, saveSession, loadSession, resolveHref } = await import(
|
||||
const { render } = await import('../src/render.js');
|
||||
|
||||
const html = readFileSync(new URL('./pages/news.html', import.meta.url), 'utf8');
|
||||
const searchHTML = readFileSync(new URL('./pages/search.html', import.meta.url), 'utf8');
|
||||
const page = () => distill(html, 'https://example.test/news');
|
||||
const open = (name = 'default', budget = 500) => {
|
||||
const p = page();
|
||||
@@ -77,10 +78,36 @@ test('find reports where a string is, with a number to read it by', () => {
|
||||
});
|
||||
|
||||
test('find opens the snippet on the match, not on the start of a long block', () => {
|
||||
// Several long blocks holding the same term is what puts find on its
|
||||
// snippet path: too much to print whole, too many to be the one answer.
|
||||
const filler = 'x'.repeat(300);
|
||||
saveSession('long', {
|
||||
url: 'https://example.test/long',
|
||||
blocks: [1, 2, 3].map((n) => ({ n, type: 'text', text: `${filler} needle ${filler}` })),
|
||||
cursor: null,
|
||||
});
|
||||
const out = find('needle', { session: 'long', budget: 40 });
|
||||
assert.match(out, /\[1\] \.\.\. .*needle/, 'the window must open on the match');
|
||||
assert.ok(!out.includes('x'.repeat(250)), `snippet was not trimmed:\n${out.slice(0, 200)}`);
|
||||
});
|
||||
|
||||
test('find answers with the whole match when the matches fit', () => {
|
||||
open();
|
||||
// The point of the whole path: the text an agent would have spent a `read
|
||||
// <n>` on arrives in the command that found it.
|
||||
const many = find('fixture');
|
||||
assert.ok(!many.includes('...'), `nothing should be elided:\n${many}`);
|
||||
assert.ok(many.includes('which this sentence now safely does'), 'the block must arrive whole');
|
||||
});
|
||||
|
||||
test('a single match is read, not pointed at', () => {
|
||||
open();
|
||||
// One hit means the agent has already said where it wants to look, so the
|
||||
// number alone would cost a turn to resolve into the region behind it.
|
||||
const out = find('lazy dog');
|
||||
assert.match(out, /\[9\] \.\.\. .*lazy dog/);
|
||||
assert.ok(out.length < 400, `snippet was not trimmed:\n${out}`);
|
||||
assert.match(out, /1 match for "lazy dog", region \[9\]/);
|
||||
assert.ok(out.includes('which this sentence now safely does'), 'the region must arrive with it');
|
||||
assert.ok(out.includes('## [8] About'), 'and with the heading that gives it context');
|
||||
});
|
||||
|
||||
test('a phrase that matches nothing falls back to the words, and says so', () => {
|
||||
@@ -93,7 +120,7 @@ test('a phrase that matches nothing falls back to the words, and says so', () =>
|
||||
|
||||
test('find caps its own output and says how many it held back', () => {
|
||||
open();
|
||||
const out = find('comments', { budget: 12 });
|
||||
const out = find('comments', { budget: 3 });
|
||||
assert.match(out, /\.\.\. \d+ more matches/);
|
||||
});
|
||||
|
||||
@@ -156,6 +183,22 @@ test('do on a heading or a text block reads it instead of refusing', () => {
|
||||
assert.ok(read(activate(9).read).includes('safely does'), 'the read must be the full text');
|
||||
});
|
||||
|
||||
test('do on a search result title opens it instead of reading it back', () => {
|
||||
// What this costs when it goes wrong: `do` on the most obvious number on a
|
||||
// results page, the title, used to print the title back, so the agent spent
|
||||
// one turn learning nothing and another finding the number that navigates.
|
||||
const p = distill(searchHTML, 'https://fixture.test/html/?q=s3+cp+recursive');
|
||||
saveSession('search', sessionFromPage(p, null, { cursor: render(p, { budget: 500 }).stats.next }));
|
||||
const target = activate(1, { session: 'search' });
|
||||
assert.equal(target.read, undefined, 'a title that is a link must not be read back');
|
||||
// The engine wraps its results in a click tracker whose landing page is a
|
||||
// script, so the handle has to resolve to the destination itself.
|
||||
assert.equal(target.url, 'https://docs.example.test/s3/cp.html');
|
||||
|
||||
const pilcrow = p.blocks.find((b) => b.type === 'heading' && b.text.startsWith('Options'));
|
||||
assert.equal(activate(pilcrow.n, { session: 'search' }).read, pilcrow.n, 'a permalink heading still reads');
|
||||
});
|
||||
|
||||
test('named sessions keep separate page state', () => {
|
||||
open('work');
|
||||
saveSession('other', { url: 'https://example.test/other', blocks: [], cursor: null });
|
||||
@@ -169,3 +212,22 @@ test('history grows with each page and stays bounded', () => {
|
||||
assert.equal(state.history.length, 20);
|
||||
assert.equal(state.history.at(-1), 'https://example.test/p24');
|
||||
});
|
||||
|
||||
test('a snippet stays one line even when the block it came from is code', () => {
|
||||
// Code blocks keep their newlines. An index that prints one match per line
|
||||
// cannot, or the header's count stops matching what is on screen. Long
|
||||
// filler beside it is what keeps find on the snippet path.
|
||||
const filler = 'x'.repeat(600);
|
||||
saveSession('code', {
|
||||
url: 'https://fixture.test/c',
|
||||
blocks: [
|
||||
{ n: 1, type: 'text', text: ['first();', 'needle();', 'third();'].join('\n') },
|
||||
{ n: 2, type: 'text', text: `${filler} needle ${filler}` },
|
||||
],
|
||||
cursor: null,
|
||||
});
|
||||
const out = find('needle', { session: 'code', budget: 20 });
|
||||
const lines = out.split('\n');
|
||||
assert.match(lines[0], /^2 matches for "needle"/);
|
||||
assert.equal(lines[1], '[1] first(); needle(); third();');
|
||||
});
|
||||
|
||||
@@ -17,6 +17,11 @@ const results = () => distill(api, API_URL);
|
||||
// Turndown escapes the underscores in field names, which is correct markdown
|
||||
// and only noise to assert against.
|
||||
const rawApi = () => toMarkdown(api, API_URL).replace(/\\_/g, '_');
|
||||
const searchHTML = readFileSync(new URL('./pages/search.html', import.meta.url), 'utf8');
|
||||
const search = () => distill(searchHTML, 'https://fixture.test/html/?q=s3+cp+recursive');
|
||||
const docsHTML = readFileSync(new URL('./pages/docs.html', import.meta.url), 'utf8');
|
||||
const docs = () => distill(docsHTML, 'https://docs.fixture.test/s3/cp.html');
|
||||
const codeBlock = (match) => docs().blocks.find((b) => (b.text ?? '').includes(match))?.text ?? '';
|
||||
|
||||
test('noise never reaches the output, compact or raw', () => {
|
||||
for (const out of [render(page(), { budget: 5000 }).text, toMarkdown(html), toHTML(html)]) {
|
||||
@@ -322,6 +327,51 @@ test('json shapes other than a wrapped array still render', () => {
|
||||
assert.ok(single.blocks.some((b) => b.text?.includes('score: 3')), 'a single resource lost its fields');
|
||||
});
|
||||
|
||||
test('a resource with its own name is the subject, not the array hanging off it', () => {
|
||||
// The npm registry shape: a named package carrying a short array of
|
||||
// maintainers. Picking the longest array made the maintainers the subject
|
||||
// and pushed the package into the metadata line.
|
||||
const pkg = JSON.stringify({
|
||||
name: 'turnstile', license: 'MIT', description: 'does a thing',
|
||||
maintainers: [{ name: 'ada', email: 'ada@example.test' }, { name: 'grace', email: 'grace@example.test' }],
|
||||
});
|
||||
const p = distill(pkg, 'https://registry.example.test/turnstile');
|
||||
assert.ok(p.title.includes('(1 item)'), `the package was not the subject:\n${p.title}`);
|
||||
assert.ok(p.blocks.some((b) => b.text === 'turnstile'), 'the resource lost its name');
|
||||
assert.ok(!p.blocks.some((b) => b.text?.startsWith('response:')), 'the resource was demoted to metadata');
|
||||
// A conventional container key still wins over the root's own name, so a
|
||||
// named collection is still read as the collection it is.
|
||||
const coll = distill(JSON.stringify({ name: 'a collection', items: [{ title: 'one' }, { title: 'two' }] }), 'https://x.test/c.json');
|
||||
assert.ok(coll.title.includes('(2 items)'), `a named collection lost its items:\n${coll.title}`);
|
||||
});
|
||||
|
||||
test('one long field at the root cannot spend the whole page budget', () => {
|
||||
const long = 'sentence about the package. '.repeat(400);
|
||||
const body = JSON.stringify({ readme: long, total: 2, items: [{ title: 'one' }, { title: 'two' }] });
|
||||
const p = distill(body, 'https://x.test/list.json');
|
||||
const meta = p.blocks.find((b) => b.text?.startsWith('response:'));
|
||||
assert.ok(meta, 'the request metadata went missing');
|
||||
assert.ok(meta.text.includes('total=2'), 'a short metadata field was lost with the long one');
|
||||
assert.ok(!meta.text.includes(long.slice(0, 200)), 'a long field stayed on the summary line');
|
||||
assert.ok(meta.text.length < TEXT_CAP * 2, `the summary line is not a line:\n${meta.text.slice(0, 300)}`);
|
||||
// Off the line, not out of the page: it is its own block, and raw has it.
|
||||
assert.ok(p.blocks.some((b) => b.text?.startsWith('readme:')), 'the long field vanished instead of moving');
|
||||
assert.ok(toMarkdown(body, 'https://x.test/list.json').includes('sentence about the package'), 'raw lost the long field');
|
||||
});
|
||||
|
||||
test('a body too long for the compact view becomes a line, not a dozen blocks', () => {
|
||||
const short = '<p>A <em>short</em> body with <a href="https://example.test/x">a link</a>.</p>';
|
||||
const huge = `<p>${'A paragraph that goes on. '.repeat(400)}</p><pre><code>code()</code></pre>`;
|
||||
const withShort = distill(JSON.stringify({ items: [{ title: 'q', body: short }] }), 'https://x.test/a.json');
|
||||
assert.ok(withShort.blocks.some((b) => b.type === 'link' && b.text === 'a link'), 'a body that fits lost its links');
|
||||
const withHuge = distill(JSON.stringify({ items: [{ title: 'q', body: huge }] }), 'https://x.test/b.json');
|
||||
const line = withHuge.blocks.find((b) => b.text?.startsWith('body:'));
|
||||
assert.ok(line, 'an oversized body left nothing behind');
|
||||
assert.ok(!line.text.includes('<p>'), 'markup reached the line unstripped');
|
||||
// raw is still the escape hatch, and still reads the markup as markup.
|
||||
assert.ok(toMarkdown(JSON.stringify({ items: [{ title: 'q', body: huge }] }), 'https://x.test/b.json').includes('code()'), 'raw lost the oversized body');
|
||||
});
|
||||
|
||||
test('only json is read as json', () => {
|
||||
assert.equal(jsonToHTML(html), null, 'an html page was parsed as json');
|
||||
assert.equal(jsonToHTML(feed), null, 'a feed was parsed as json');
|
||||
@@ -339,3 +389,90 @@ test('long runs of short links collapse into a range marker', () => {
|
||||
assert.ok(text.includes('actual content'), 'content after the run was lost');
|
||||
assert.ok(!text.includes('sub9'), 'collapsed link still rendered');
|
||||
});
|
||||
|
||||
test('a result title that is a link stays a link', () => {
|
||||
const headings = search().blocks.filter((b) => b.type === 'heading');
|
||||
const [first, second] = headings;
|
||||
assert.equal(first.text, 'cp - Fixture CLI Command Reference');
|
||||
assert.ok(first.href.includes('docs.example.test'), 'the title anchor of a search result must survive');
|
||||
assert.equal(second.href, 'https://docs.example.test/s3/index.html');
|
||||
});
|
||||
|
||||
test('a heading that merely contains a link is not one', () => {
|
||||
const headings = search().blocks.filter((b) => b.type === 'heading');
|
||||
const partial = headings.find((b) => b.text.startsWith('Related searches'));
|
||||
assert.equal(partial.href, undefined, 'the link is part of the heading, not the whole of it');
|
||||
// Documentation hangs a permalink off every heading. Following one refetches
|
||||
// the page the agent is already reading, so it must stay a read.
|
||||
const pilcrow = headings.find((b) => b.text.startsWith('Options'));
|
||||
assert.equal(pilcrow.href, undefined);
|
||||
const selfAnchor = headings.find((b) => b.text === 'See also');
|
||||
assert.equal(selfAnchor.href, undefined, 'a bare fragment is not a destination');
|
||||
});
|
||||
|
||||
test('a truncated block ends on a sentence, so what is shown can be trusted', () => {
|
||||
const first = 'Welcome to The Rust Programming Language, an introductory book about Rust.';
|
||||
const second = ' The Rust programming language helps you write faster, more reliable software.';
|
||||
const rest = ' High-level ergonomics and low-level control are often at odds with each other, and Rust challenges that conflict.';
|
||||
const line = render(
|
||||
{ url: '', title: '', blocks: [{ n: 1, type: 'text', text: first + second + rest }] },
|
||||
{ budget: 60 },
|
||||
).text;
|
||||
assert.ok(line.includes(second.trim()), 'a sentence that finished inside the cap must be shown whole');
|
||||
assert.ok(!line.includes('High-level'), 'the sentence that did not finish must not be half shown');
|
||||
assert.match(line, /\.\.\. \+\d+ chars/, 'and the reader must still be told there is more');
|
||||
|
||||
// Nothing to end on means the plain cut stands rather than most of the
|
||||
// window being thrown away for the sake of a boundary.
|
||||
const unbroken = render(
|
||||
{ url: '', title: '', blocks: [{ n: 1, type: 'text', text: `A. ${'word '.repeat(60)}` }] },
|
||||
{ budget: 60 },
|
||||
).text;
|
||||
assert.ok(unbroken.length > TEXT_CAP, `an early sentence end must not shrink the view:\n${unbroken}`);
|
||||
});
|
||||
|
||||
test('a highlighted command comes out runnable', () => {
|
||||
// Every token of this command is its own element on the page. Space-joining
|
||||
// them gave `aws s3 cp s3 : // bucket / -- recursive`, which is not a command
|
||||
// an agent can run, and the agent has no way to see that from the output.
|
||||
assert.equal(codeBlock('aws s3 cp test.txt'), 'aws s3 cp test.txt s3://amzn-demo/ --recursive');
|
||||
});
|
||||
|
||||
test('a code sample keeps its lines, so a comment cannot eat the rest', () => {
|
||||
assert.equal(
|
||||
codeBlock('readFileSync'),
|
||||
"const fs = require('node:fs');\n// read it back\nfs.readFileSync('out.txt');",
|
||||
);
|
||||
// A shell continuation is only a continuation while the break is still there.
|
||||
assert.equal(codeBlock('--expires'), 'aws s3 cp test.txt s3://amzn-demo/ \\\n --expires 2014-10-01T20:30:00Z');
|
||||
});
|
||||
|
||||
test('a code block loses the indentation it all shares and keeps the rest', () => {
|
||||
assert.equal(codeBlock('def load'), 'def load(path):\n with open(path) as fh:\n return json.load(fh)');
|
||||
});
|
||||
|
||||
test('a toolbar inside a code block is not part of the sample', () => {
|
||||
const block = codeBlock("import fs from 'node:fs'");
|
||||
assert.equal(block, "import fs from 'node:fs';");
|
||||
const out = render(docs(), { budget: 5000 }).text;
|
||||
assert.ok(!out.includes('javascript'), 'the language label leaked into the page');
|
||||
// The controls go with it: a copy button is not something oc can press, and
|
||||
// `do` on it would be a turn spent on nothing.
|
||||
assert.equal(docs().blocks.filter((b) => b.type === 'button' || b.type === 'input').length, 0);
|
||||
});
|
||||
|
||||
test('inline code joins the sentence it sits in', () => {
|
||||
assert.ok(docs().blocks.some((b) => b.text === 'Pass the --recursive flag to copy a directory.'));
|
||||
assert.ok(docs().blocks.some((b) => b.text === 'A period (.) means the working directory.'));
|
||||
});
|
||||
|
||||
test('a truncated code block ends on a line, not mid-statement', () => {
|
||||
const code = ['first();', 'second();', ...Array.from({ length: 20 }, (_, i) => `line${i}('${'x'.repeat(20)}');`)].join('\n');
|
||||
const page = { url: 'https://fixture.test/c', title: 'c', blocks: [{ type: 'text', n: 1, text: code }] };
|
||||
const shown = render(page, { budget: 10 }).text.split('\n');
|
||||
const marker = shown.findIndex((l) => l.includes('... +'));
|
||||
assert.ok(marker > 0, 'nothing was truncated');
|
||||
// The kept part stops where a statement did, so every line shown is whole.
|
||||
assert.ok(shown[marker].startsWith('line'), `cut mid-line: ${shown[marker]}`);
|
||||
assert.ok(shown[marker].includes(');'), `cut mid-statement: ${shown[marker]}`);
|
||||
});
|
||||
|
||||
+71
-8
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { fetchPage } = await import('../src/fetch.js');
|
||||
const { fetchPage, followRedirects } = await import('../src/fetch.js');
|
||||
|
||||
const BLOCKED_MESSAGE = 'blocked: private or internal URL';
|
||||
|
||||
@@ -64,11 +64,74 @@ test('fetchPage blocks a hostname that merely resolves to a loopback address (DN
|
||||
await assert.rejects(() => fetchPage('localtest.me'), new RegExp(BLOCKED_MESSAGE));
|
||||
});
|
||||
|
||||
test('fetchPage re-validates every redirect hop, not just the original URL', async () => {
|
||||
// httpbin.org is a public host with no reason to be blocked itself; its
|
||||
// /redirect-to endpoint 302s wherever it's told, which is exactly the
|
||||
// shape of an SSRF that hides the real target behind a public-looking
|
||||
// first hop.
|
||||
const redirector = `https://httpbin.org/redirect-to?url=${encodeURIComponent('http://127.0.0.1/admin')}`;
|
||||
await assert.rejects(() => fetchPage(redirector), new RegExp(BLOCKED_MESSAGE));
|
||||
// A response, as little of one as the redirect loop reads.
|
||||
const replies = (...hops) => {
|
||||
const asked = [];
|
||||
const get = (url) => {
|
||||
asked.push(url);
|
||||
const hop = hops[asked.length - 1] ?? { status: 200 };
|
||||
return Promise.resolve({ status: hop.status, headers: new Map(hop.location ? [['location', hop.location]] : []) });
|
||||
};
|
||||
return { get, asked };
|
||||
};
|
||||
|
||||
test('every redirect hop is re-validated, not just the original URL', async () => {
|
||||
// An SSRF hides the real target behind a public-looking first hop, so the
|
||||
// check has to run again on what the 302 names. This used to be proven
|
||||
// against httpbin.org, which meant a third party's uptime could fail the
|
||||
// release, and it never covered the impers transport's own copy of the loop.
|
||||
const { get, asked } = replies({ status: 302, location: 'http://127.0.0.1/admin' });
|
||||
await assert.rejects(() => followRedirects(get, 'https://public.example/start'), new RegExp(BLOCKED_MESSAGE));
|
||||
// Blocked before the socket, not after: the private address is never asked for.
|
||||
assert.deepEqual(asked, ['https://public.example/start']);
|
||||
});
|
||||
|
||||
test('a hop to somewhere public is followed', async () => {
|
||||
// The other half of the guarantee. A loop that rejected everything would
|
||||
// pass the test above and break every redirect on the web.
|
||||
const { get, asked } = replies(
|
||||
{ status: 301, location: 'https://elsewhere.example/moved' },
|
||||
{ status: 302, location: '/relative' },
|
||||
);
|
||||
const { res, url } = await followRedirects(get, 'https://public.example/start');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(url, 'https://elsewhere.example/relative');
|
||||
assert.equal(asked.length, 3);
|
||||
});
|
||||
|
||||
test('a redirect loop gives up instead of spinning', async () => {
|
||||
const get = () => Promise.resolve({ status: 302, headers: new Map([['location', 'https://public.example/again']]) });
|
||||
await assert.rejects(() => followRedirects(get, 'https://public.example/start'), /too many redirects/);
|
||||
});
|
||||
|
||||
test('the readable-type gate accepts text and refuses binary, on either transport', async () => {
|
||||
const { assertReadableType } = await import('../src/fetch.js');
|
||||
|
||||
// Everything oc has something to say about.
|
||||
for (const type of [
|
||||
'text/html; charset=utf-8',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'application/json',
|
||||
'application/json; charset=utf-8',
|
||||
'application/xml',
|
||||
'application/atom+xml',
|
||||
'application/rss+xml',
|
||||
'application/ld+json',
|
||||
' text/html ',
|
||||
]) {
|
||||
assert.doesNotThrow(() => assertReadableType(type), `expected ${type} to be readable`);
|
||||
}
|
||||
|
||||
// A missing header is not a refusal: small servers omit it and the page
|
||||
// behind it is usually fine.
|
||||
assert.doesNotThrow(() => assertReadableType(undefined));
|
||||
assert.doesNotThrow(() => assertReadableType(''));
|
||||
|
||||
// Binary renders as pages of mojibake the agent pays for, so it is named
|
||||
// and refused rather than distilled.
|
||||
for (const type of ['image/png', 'image/jpeg', 'application/pdf', 'application/octet-stream', 'video/mp4', 'application/zip']) {
|
||||
assert.throws(() => assertReadableType(type), /not a page oc can read/, `expected ${type} to be refused`);
|
||||
}
|
||||
assert.throws(() => assertReadableType('image/png'), /image\/png/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html><head><title>cp - Fixture CLI Command Reference</title></head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<h1>cp</h1>
|
||||
|
||||
<!-- What a syntax highlighter does to a command: one element per token, so
|
||||
the walk sees `s3`, `:`, `//`, `bucket` as separate fragments with
|
||||
different parents. Every real highlighter emits this shape. -->
|
||||
<p>Copy a file to the bucket:</p>
|
||||
<pre class="highlight"><code><span class="nb">aws</span> <span class="n">s3</span> <span class="n">cp</span> <span class="n">test</span><span class="p">.</span><span class="n">txt</span> <span class="n">s3</span><span class="p">:</span><span class="p">//</span><span class="n">amzn</span><span class="p">-</span><span class="n">demo</span><span class="p">/</span> <span class="p">--</span><span class="n">recursive</span></code></pre>
|
||||
|
||||
<!-- A sample the page wrote across lines, with a shell continuation. Joining
|
||||
these would hide the break; joining the next one would comment out the
|
||||
call that follows the comment. -->
|
||||
<pre><code>aws s3 cp test.txt s3://amzn-demo/ \
|
||||
--expires 2014-10-01T20:30:00Z</code></pre>
|
||||
|
||||
<pre><code>const fs = require('node:fs');
|
||||
// read it back
|
||||
fs.readFileSync('out.txt');</code></pre>
|
||||
|
||||
<!-- Node's docs put a toolbar inside the block itself: a language label
|
||||
sitting beside a copy button, plus a flavour toggle. None of it is part
|
||||
of the sample. -->
|
||||
<pre class="shiki"><input class="js-flavor-toggle" type="checkbox"><div class="code-toolbar"><span class="code-language">javascript</span><button class="copy-button">copy</button></div><code><span>import</span> <span>fs</span> <span>from</span> <span>'node:fs'</span><span>;</span></code></pre>
|
||||
|
||||
<!-- Indentation the whole block shares says nothing; indentation inside it
|
||||
is the program. -->
|
||||
<pre><code> def load(path):
|
||||
with open(path) as fh:
|
||||
return json.load(fh)</code></pre>
|
||||
|
||||
<!-- Inline code belongs to the sentence it sits in, and it gets split into
|
||||
tokens the same way. -->
|
||||
<p>Pass the <code><span class="p">--</span><span class="n">recursive</span></code> flag to copy a directory.</p>
|
||||
|
||||
<p>A period (<code>.</code>) means the working directory.</p>
|
||||
|
||||
</main>
|
||||
</body></html>
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html><head><title>s3 cp recursive at Fixture Search</title></head>
|
||||
<body>
|
||||
<div id="links">
|
||||
|
||||
<!-- The shape every engine uses: the result title is an anchor filling an
|
||||
h2, wrapped in the engine's own click tracker. Following it is the
|
||||
whole point of the page. -->
|
||||
<div class="result">
|
||||
<h2 class="result__title"><a class="result__a" href="//fixture.test/l/?uddg=https%3A%2F%2Fdocs.example.test%2Fs3%2Fcp.html">cp - Fixture CLI Command Reference</a></h2>
|
||||
<a class="result__url" href="//fixture.test/l/?uddg=https%3A%2F%2Fdocs.example.test%2Fs3%2Fcp.html">docs.example.test/s3/cp.html</a>
|
||||
<a class="result__snippet" href="//fixture.test/l/?uddg=https%3A%2F%2Fdocs.example.test%2Fs3%2Fcp.html">Recursively copying local files to S3 with the --recursive parameter.</a>
|
||||
</div>
|
||||
|
||||
<div class="result">
|
||||
<h2 class="result__title"><a class="result__a" href="https://docs.example.test/s3/index.html">s3 - Fixture CLI Command Reference</a></h2>
|
||||
<a class="result__snippet" href="https://docs.example.test/s3/index.html">High level commands for the object store.</a>
|
||||
</div>
|
||||
|
||||
<!-- A heading that only contains a link is not a heading that is one. -->
|
||||
<div class="result">
|
||||
<h2 class="result__title">Related searches for <a href="https://docs.example.test/s3/sync.html">s3 sync</a></h2>
|
||||
</div>
|
||||
|
||||
<!-- Documentation markup, and the reason the href cannot ride along
|
||||
unconditionally: both of these point back into this same page. -->
|
||||
<h2 id="options">Options<a class="headerlink" href="#options">¶</a></h2>
|
||||
<h2><a class="anchor" href="#see-also">See also</a></h2>
|
||||
|
||||
</div>
|
||||
</body></html>
|
||||
Reference in New Issue
Block a user