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.
This commit is contained in:
only-cli
2026-08-19 07:59:29 -04:00
parent 0435b386ae
commit 42906ced60
9 changed files with 319 additions and 46 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"domain": "x.com",
"commands": {
"user": { "open": "https://x.com/{name}", "args": ["name"] },
"post": { "open": "https://x.com/i/status/{id}", "args": ["id"] }
}
}
+4 -3
View File
@@ -21,9 +21,10 @@ npx @only-cli/oc raw [url] whole page as markdown (add --html for cleaned H
## Reading the output
- The first line is the page title, then the page's main content: the article, the comment thread, the results. Navigation, sidebar, and footer come after it, under a `--- rest of page ---` line, still numbered and still followable with `do <n>`.
- A `--- repeated controls hidden ---` line means per item chrome (save, report, reply, like) was removed because it repeated down the page. `oc raw` still has it.
- `[n]` marks a link, button, input, heading, or a text block long enough to be cut.
- `... +820 chars` at the end of a line means that block was cut there. `read <n>` prints it whole.
- `... 164 more blocks (~7,100 tokens)` means the page ran past the budget. That is the price of the rest, so you can decide before paying.
- `... 164 more blocks (~7,100 tokens)` means the page ran past the budget. That is the price of the rest, so you can decide before paying. A page that would have finished a little past the budget has no such line: it is printed whole, because a second command costs more than the lines it would have saved.
- The `actions:` line at the bottom lists valid next commands.
## Reading more of a page
@@ -61,14 +62,14 @@ Notes that save a round trip:
- Handles hidden behind a `[6-9] 4 similar links` marker still work, even though their text was collapsed.
- Search result links resolve to the destination, not the search engine's tracking redirect.
- `do` on an input or a button says so; typing and submitting are not available yet.
- `do` on a heading or a text block says to use `read <n>` instead.
- `do` on a heading or a text block has nothing to follow, so it prints the read instead of refusing.
- `--session <name>` keeps separate page state, for working on two sites at once.
Reach for `raw <url>` when you need the whole page text, not to hunt for a URL.
## Flags
- `--budget <tokens>` raise or lower the render budget (default 500, 2000 for `read`)
- `--budget <tokens>` raise or lower the render budget (default 500, 2000 for `read`). It is a target rather than a hard cap: a page that would finish within about four times it comes out whole instead of being cut.
- `--json` machine-stable JSON of the distilled page
- `--html` with raw: cleaned HTML instead of markdown, if markup suits your task better
- `--verbose` (`-v`, alias `--stats`) metrics on stderr: tokens saved vs the page HTML, HTTP status and which client identity got the page, fetch and processing time, bytes transferred, memory use. Only pass this when you are running in verbose mode or diagnosing a problem; the metrics line costs tokens like everything else. Users can export `OC_VERBOSE=1` to turn it on globally.
+7 -2
View File
@@ -46,9 +46,11 @@ function requireBlocks(session) {
* Resolve a numbered handle from the last render into something to open.
* Returns the target URL; the caller fetches and renders it exactly as
* `oc open` would, so `do` and `open` always agree on what a page looks like.
* When the number is text rather than a link it returns `{read}` instead, and
* the caller reads it.
* @param {number} n
* @param {{session?: string}} [opts]
* @returns {{url: string, text: string}}
* @returns {{url?: string, read?: number, text: string}}
*/
export function activate(n, { session = DEFAULT_SESSION } = {}) {
if (!Number.isInteger(n) || n < 1) {
@@ -62,7 +64,10 @@ export function activate(n, { session = DEFAULT_SESSION } = {}) {
throw new Error(`no [${n}] on ${state.url} (handles ${range}), run 'oc open <url>' again to renumber`);
}
if (handle.type === 'text' || handle.type === 'heading') {
throw new Error(`[${n}] is ${handle.type}, not a link, use 'oc read ${n}' for the full text there`);
// 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.
return { read: n, text: handle.text };
}
if (handle.type === 'input') {
throw new Error(`[${n}] is an input (${handle.name ?? 'text'}), typing needs 'oc fill', which is not available yet`);
+16 -5
View File
@@ -15,7 +15,7 @@ usage: oc <command> [args] [flags]
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
do <n> follow the numbered link [n], or read [n] if it is text
fill <n> <text> type into a numbered input (v0.2)
submit [n] submit a form (v0.2)
back return to the previous page (v0.2)
@@ -23,7 +23,9 @@ usage: oc <command> [args] [flags]
flags:
--budget <tokens> tighten or loosen the render budget (default 500,
2000 for read)
2000 for read). It is a target, not a ceiling: a page
that would finish within about four times it is printed
whole rather than costing you a second command
--json machine-stable JSON output
--html raw only: cleaned HTML instead of markdown
--verbose, -v metrics on stderr: tokens saved vs the page HTML, HTTP
@@ -90,9 +92,18 @@ async function main() {
// typed, so both commands share one fetch, render, and save path. `raw`
// with no URL means the page already open, which is what the compact
// view's footer offers when it has cut something.
const url = command === 'do'
? act.activate(Number(args[0]), { session: sessionName }).url
: args[0] ?? (command === 'raw' ? loadSession(sessionName)?.url : undefined);
let url;
if (command === 'do') {
const target = act.activate(Number(args[0]), { session: sessionName });
// A number that points at text has no page behind it, so `do` reads it
// rather than making the agent pay for a second command to be told.
if (target.read != null) {
return console.log(act.read(target.read, { session: sessionName, budget: asked || 2000 }));
}
url = target.url;
} else {
url = args[0] ?? (command === 'raw' ? loadSession(sessionName)?.url : undefined);
}
if (!url) throw new Error(`usage: oc ${command} <url>`);
const budget = asked || 500;
const t0 = performance.now();
+75 -21
View File
@@ -32,6 +32,15 @@ const DROP = new Set([
// them, so the search below refuses to descend into them.
const FURNITURE = new Set(['nav', 'header', 'footer', 'aside']);
// 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.
const BLOCKY = new Set([
'p', 'div', 'article', 'section', 'li', 'ul', 'ol', 'tr', 'td', 'th',
'blockquote', 'pre', 'figure', 'figcaption', 'dt', 'dd', 'form', 'br', 'hr',
'main', 'nav', 'header', 'footer', 'aside',
]);
// Selectors a page uses to say where its content is. One match is a claim
// worth believing; several <article> elements mean a listing, where the list
// is the content and taking the first would throw the rest away.
@@ -49,11 +58,14 @@ const DESCEND_SHARE = 0.6;
const KEEP_SHARE = 0.5;
const MAX_DEPTH = 12;
// A short link label repeated this many times down a page is per-item
// furniture (permalink, embed, save, reply, hide) rather than content. On a
// forum thread there is one set per comment, which costs more than the
// comments do.
// A short link or button label repeated this many times down a page is
// per-item furniture (permalink, save, reply, like, repost) rather than
// content. On a forum thread or a social timeline there is one set per item,
// which costs more than the items do.
// A link label can be content, so it gets the benefit of the doubt for longer
// than a button label, which never is.
const REPEAT_LIMIT = 5;
const REPEAT_LIMIT_BUTTON = 3;
const REPEAT_MAX_LEN = 25;
const clean = (s) => s.replace(/\s+/g, ' ').trim();
@@ -83,8 +95,16 @@ export function distill(html, url = '') {
const walk = (node) => {
if (node.nodeType === 3) {
const text = clean(node.textContent);
if (text) blocks.push({ type: 'text', text });
const raw = node.textContent ?? '';
const text = clean(raw);
// A parser is free to split one run of text into several nodes, and
// linkedom does it around apostrophes, so `what's` arrives as `what`,
// `'`, `s`. Remembering the parent and whether the fragment had space
// at its edges is what lets mergeText put it back without inventing a
// space that was never in the page.
if (text) {
blocks.push({ type: 'text', text, host: node.parentNode, pre: /^\s/.test(raw), post: /\s$/.test(raw) });
}
return;
}
if (node.nodeType !== 1) return;
@@ -116,8 +136,19 @@ export function distill(html, url = '') {
return;
}
if (tag === 'button') {
const text = clean(node.textContent) || 'button';
blocks.push({ type: 'button', text });
// An icon button carries its name in aria-label or title, and a button
// with none of the three cannot be described or pressed, so printing it
// spends tokens to say nothing. X puts seven of them under every post.
const text = clean(node.textContent)
|| clean(node.getAttribute('aria-label') ?? '')
|| clean(node.getAttribute('title') ?? '');
if (text) blocks.push({ type: 'button', text });
return;
}
if (BLOCKY.has(tag)) {
blocks.push({ type: 'break' });
for (const child of node.childNodes) walk(child);
blocks.push({ type: 'break' });
return;
}
for (const child of node.childNodes) walk(child);
@@ -153,19 +184,23 @@ export function distill(html, url = '') {
* @returns {Block[]}
*/
function dropRepeats(blocks) {
/** @type {Map<string, number>} */
const controls = (b) => b.type === 'link' || b.type === 'button';
/** @type {Map<string, {n: number, limit: number}>} */
const counts = new Map();
for (const b of blocks) {
if (b.type !== 'link' || b.text.length > REPEAT_MAX_LEN) continue;
if (!controls(b) || b.text.length > REPEAT_MAX_LEN) continue;
const key = b.text.toLowerCase();
counts.set(key, (counts.get(key) ?? 0) + 1);
const limit = b.type === 'button' ? REPEAT_LIMIT_BUTTON : REPEAT_LIMIT;
const seen = counts.get(key);
// A label worn by both a link and a button keeps the more patient limit.
counts.set(key, { n: (seen?.n ?? 0) + 1, limit: Math.max(seen?.limit ?? 0, limit) });
}
const repeated = new Set([...counts].filter(([, n]) => n >= REPEAT_LIMIT).map(([k]) => k));
const repeated = new Set([...counts].filter(([, c]) => c.n >= c.limit).map(([k]) => k));
if (!repeated.size) return blocks;
const kept = blocks.filter((b) => !(b.type === 'link' && repeated.has(b.text.toLowerCase())));
const kept = blocks.filter((b) => !(controls(b) && repeated.has(b.text.toLowerCase())));
const gone = blocks.length - kept.length;
const names = [...repeated].slice(0, 3).join(', ');
const note = { type: 'divider', text: `--- ${gone} repeated links hidden (${names}), 'oc raw' has them ---` };
const note = { type: 'divider', text: `--- ${gone} repeated controls hidden (${names}), 'oc raw' has them ---` };
// The note belongs with the content it was cut from, not after the chrome.
const boundary = kept.findIndex((b) => b.type === 'divider');
kept.splice(boundary === -1 ? kept.length : boundary, 0, note);
@@ -219,16 +254,21 @@ function densest(body, total) {
}
/**
* Text length minus link text. Link text is what navigation is made of, so
* subtracting it is what keeps a menu from outscoring an article.
* Text length minus the text of controls, which is what a page is steered by
* rather than what it says.
* @param {any} el
* @returns {number}
*/
function prose(el) {
const text = clean(el.textContent ?? '').length;
let links = 0;
for (const a of el.querySelectorAll?.('a') ?? []) links += clean(a.textContent ?? '').length;
return text - links;
let controls = 0;
// Link text is what navigation is made of. Option text is worse: a country
// dropdown is hundreds of characters that no link subtraction touches, and
// on a search results page it outscores results that are themselves links.
for (const node of el.querySelectorAll?.('a, select, button') ?? []) {
controls += clean(node.textContent ?? '').length;
}
return text - controls;
}
/**
@@ -362,13 +402,27 @@ function mergeText(blocks) {
/** @type {Block[]} */
const out = [];
for (const b of blocks) {
if (b.type === 'break') {
// A boundary only has to stop the merge, and it does that by being the
// last thing in the list when the next text block arrives.
if (out[out.length - 1]?.type === 'text') out.push(b);
continue;
}
const prev = out[out.length - 1];
if (b.type === 'text' && prev?.type === 'text') {
prev.text = `${prev.text} ${b.text}`;
// Two fragments of one element with no whitespace between them were one
// word before the parser split them. Anything else was separated on the
// page and stays separated here.
const glued = prev.host && prev.host === b.host && !prev.post && !b.pre;
prev.text = glued ? `${prev.text}${b.text}` : `${prev.text} ${b.text}`;
prev.post = b.post;
} else {
out.push(b);
}
}
// Single stray characters (list bullets, separators) cost tokens and say nothing.
return out.filter((b) => b.type !== 'text' || b.text.length > 1);
return out
.filter((b) => b.type !== 'break')
.filter((b) => b.type !== 'text' || b.text.length > 1)
.map(({ host, pre, post, ...block }) => block);
}
+20 -1
View File
@@ -16,6 +16,16 @@ 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
@@ -33,6 +43,15 @@ export function render(page, { budget = 500, from = 0 } = {}) {
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
@@ -45,7 +64,7 @@ export function render(page, { budget = 500, from = 0 } = {}) {
// 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 > budget && lines.length > head.length) break;
if (spent + cost > limit && lines.length > head.length) break;
spent += cost;
lines.push(line);
if (block.type === 'link' || block.type === 'button') hasLinks = true;
+14 -6
View File
@@ -104,13 +104,15 @@ test('find needs a query and a page', () => {
});
test('next continues where the budget stopped, then says the page is done', () => {
open('paged', 100);
const first = next({ session: 'paged', budget: 100 });
// Small enough that the fixture stays well past the budget, or the finish
// rule would hand the whole page over in one go and there would be no paging.
open('paged', 25);
const first = next({ session: 'paged', budget: 25 });
assert.ok(first.startsWith('# Fixture News (continued)'));
assert.ok(!first.includes('Show HN'), 'next must not reprint what open already charged for');
let out = first;
for (let i = 0; i < 10 && loadSession('paged').cursor != null; i++) {
out = next({ session: 'paged', budget: 100 });
out = next({ session: 'paged', budget: 25 });
}
assert.equal(loadSession('paged').cursor, null, 'paging must reach the end of the page');
assert.ok(out.includes('newest'), 'the last block of the page must come out eventually');
@@ -143,9 +145,15 @@ test('every failure names the command that fixes it', () => {
assert.throws(() => activate(input.n), /is an input.*oc fill/s);
const button = page().blocks.find((b) => b.type === 'button');
assert.throws(() => activate(button.n), /no link to follow/);
// The numbers that are not links point at the command that does use them.
assert.throws(() => activate(1), /is heading.*oc read 1/s);
assert.throws(() => activate(9), /is text.*oc read 9/s);
});
test('do on a heading or a text block reads it instead of refusing', () => {
open();
// Nothing to fetch, so the caller is told to read rather than to open. An
// error here would cost a turn to say what the next command should be.
assert.deepEqual(activate(1), { read: 1, text: 'Fixture News' });
assert.equal(activate(9).read, 9);
assert.ok(read(activate(9).read).includes('safely does'), 'the read must be the full text');
});
test('named sessions keep separate page state', () => {
+57 -8
View File
@@ -9,6 +9,8 @@ const page = () => distill(html, 'https://example.test/news');
const feed = readFileSync(new URL('./pages/feed.xml', import.meta.url), 'utf8');
const forum = readFileSync(new URL('./pages/forum.html', import.meta.url), 'utf8');
const thread = () => distill(forum, 'https://example.test/t/1');
const timeline = readFileSync(new URL('./pages/social.html', import.meta.url), 'utf8');
const social = () => distill(timeline, 'https://social.test/fixture');
test('noise never reaches the output, compact or raw', () => {
for (const out of [render(page(), { budget: 5000 }).text, toMarkdown(html), toHTML(html)]) {
@@ -58,24 +60,36 @@ test('same page yields the same output', () => {
assert.equal(render(page()).text, render(page()).text);
});
test('render respects the token budget', () => {
const { text, stats } = render(page(), { budget: 100 });
assert.ok(stats.tokens <= 120, `render cost ~${stats.tokens} tokens against a budget of 100`);
test('a page well past the budget is cut, and what was cut is priced', () => {
const { text, stats } = render(page(), { budget: 25 });
assert.ok(stats.tokens <= 60, `render cost ~${stats.tokens} tokens against a budget of 25`);
assert.match(text, /\.\.\. \d+ more blocks \(~\d+ tokens\)/, 'what was cut must be priced');
assert.ok(text.includes("'oc next'"), 'the cheapest way to the rest must be named');
assert.ok(text.includes('| next |'), 'next belongs in the actions of a cut page');
});
test('a page that ends just past the budget is finished instead of cut', () => {
// The fixture costs about 134 tokens whole. Cutting it at 40 would save 90
// tokens and charge a whole extra turn to get them back, which is a bad trade.
const { text, stats } = render(page(), { budget: 40 });
assert.equal(stats.next, null, 'a page within reach of the budget must come out whole');
assert.ok(!text.includes('more blocks'), 'nothing was cut, so nothing should be priced');
assert.ok(text.includes('newest'), 'the last block of the page must be there');
// The allowance is not unlimited: a page far past the budget still gets cut.
assert.equal(typeof render(thread(), { budget: 100 }).stats.next, 'number');
});
test('what a render stops at is where the next one starts', () => {
const p = page();
const first = render(p, { budget: 100 });
const first = render(p, { budget: 25 });
assert.equal(typeof first.stats.next, 'number');
const rest = render(p, { budget: 500, from: first.stats.next });
assert.ok(rest.text.startsWith('# Fixture News (continued)'));
assert.equal(rest.stats.next, null, 'the second render finishes the page');
// Nothing is printed twice and nothing is lost between the two.
assert.ok(!rest.text.includes('Postgres 18 released'));
assert.ok(first.text.includes('Postgres 18 released'));
assert.ok(!rest.text.includes('Show HN'));
assert.ok(first.text.includes('Show HN'));
assert.ok(rest.text.includes('Postgres 18 released'));
assert.ok(rest.text.includes('input q'));
});
@@ -161,11 +175,11 @@ test('nothing is dropped when the content is moved up, only reordered', () => {
assert.ok(text.includes('This sidebar exists on every page'), 'sidebar text went missing');
});
test('per item links that repeat down a page are dropped, and say so', () => {
test('per item controls that repeat down a page are dropped, and say so', () => {
const blocks = thread().blocks;
assert.ok(!blocks.some((b) => b.type === 'link' && b.text === 'permalink'), 'per comment chrome survived');
assert.ok(blocks.some((b) => b.type === 'link' && b.text === 'commenter3'), 'a unique link was dropped with them');
const note = blocks.find((b) => b.type === 'divider' && b.text.includes('repeated links hidden'));
const note = blocks.find((b) => b.type === 'divider' && b.text.includes('repeated controls hidden'));
assert.ok(note, 'links vanished with nothing said about it');
assert.ok(note.text.includes("'oc raw' has them"), 'the note does not say how to get them back');
assert.ok(toMarkdown(forum).includes('permalink'), 'raw lost them too, so the note lies');
@@ -177,6 +191,41 @@ test('a page that fits the budget is left in document order', () => {
assert.equal(blocks[0].text, 'Fixture News');
});
test('an entity does not put spaces inside a word', () => {
// linkedom splits a text node at every entity, so `isn&#x27;t` arrives as
// three nodes and used to come back out as `isn ' t`.
const text = social().blocks.map((b) => b.text).join('\n');
assert.ok(text.includes("isn't drawing"), `apostrophe split:\n${text.slice(0, 400)}`);
assert.ok(text.includes('interface & its restraint'), 'a real space was swallowed');
});
test('an icon button is named by its aria-label, a nameless one is dropped', () => {
const blocks = social().blocks;
assert.ok(blocks.some((b) => b.type === 'button' && b.text === 'Follow'), 'a labelled button went missing');
assert.ok(!blocks.some((b) => b.type === 'button' && b.text === 'button'), 'a button with no name was printed anyway');
const html = '<html><head><title>T</title></head><body><button aria-label="Reply"></button></body></html>';
const one = distill(html, 'https://x.test').blocks.find((b) => b.type === 'button');
assert.equal(one.text, 'Reply');
});
test('per item buttons repeat sooner than links before they count as furniture', () => {
const blocks = social().blocks;
// Six posts, six sets of Reply/Repost/Like/Bookmark/Share/More.
assert.ok(!blocks.some((b) => b.type === 'button' && b.text === 'Reply'), 'per post controls survived');
const note = blocks.find((b) => b.type === 'divider' && b.text.includes('repeated controls hidden'));
assert.ok(note, 'controls vanished with nothing said about it');
});
test('separate posts stay separate blocks', () => {
// With the per post controls gone there is nothing left between one post and
// the next, so without a block boundary six posts merge into one long line
// and `read <n>` can no longer address any single one of them.
const texts = social().blocks.filter((b) => b.type === 'text').map((b) => b.text);
const ncurses = texts.find((t) => t.includes('ncurses'));
assert.ok(ncurses, 'the first post went missing');
assert.ok(!ncurses.includes('1987 manual'), `two posts merged into one block:\n${ncurses}`);
});
test('long runs of short links collapse into a range marker', () => {
const nav = Array.from({ length: 15 }, (_, i) => `<a href="/s/${i}">sub${i}</a>`).join(' ');
const navHtml = `<html><head><title>T</title></head><body>${nav}<p>actual content</p></body></html>`;
+119
View File
@@ -0,0 +1,119 @@
<html>
<head><title>fixture (@fixture) / Fixture Social</title></head>
<body>
<nav>
<a href="/home">Home</a>
<a href="/explore">Explore</a>
<a href="/notifications">Notifications</a>
<a href="/messages">Messages</a>
<a href="/login">Log in</a>
<a href="/signup">Sign up</a>
</nav>
<div id="app">
<div id="profile">
<h1>fixture</h1>
<span>@fixture</span>
<p>Writes about terminals, feeds, and text. Somewhere with bad weather.</p>
<p>Joined March 2009</p>
<button>Follow</button>
</div>
<div id="timeline">
<article>
<a href="/fixture">fixture</a>
<span>2h</span>
<p><span>The thing nobody tells you about ncurses is that the hard part isn&#x27;t drawing, it&#x27;s deciding what not to draw. Half of a good terminal interface &amp; its restraint.</span></p>
<button aria-label="Reply"></button>
<span>14</span>
<button aria-label="Repost"></button>
<span>31</span>
<button aria-label="Like"></button>
<span>402</span>
<button aria-label="Bookmark"></button>
<button aria-label="Share"></button>
<button aria-label="More"></button>
</article>
<article>
<a href="/fixture">fixture</a>
<span>5h</span>
<p>Spent the morning reading a 1987 manual for a terminal that has not been manufactured since 1994. It is better written than most documentation shipped this year.</p>
<button aria-label="Reply"></button>
<span>3</span>
<button aria-label="Repost"></button>
<span>9</span>
<button aria-label="Like"></button>
<span>128</span>
<button aria-label="Bookmark"></button>
<button aria-label="Share"></button>
<button aria-label="More"></button>
</article>
<article>
<a href="/othername">othername</a>
<span>9h</span>
<p>A reply worth keeping: the reason plain text outlives every format built to replace it is that it never needed a reader to be written down. Everything else is a bet on software that still runs.</p>
<button aria-label="Reply"></button>
<span>7</span>
<button aria-label="Repost"></button>
<span>22</span>
<button aria-label="Like"></button>
<span>311</span>
<button aria-label="Bookmark"></button>
<button aria-label="Share"></button>
<button aria-label="More"></button>
</article>
<article>
<a href="/fixture">fixture</a>
<span>1d</span>
<p>Every few years someone rebuilds the feed reader and discovers, in order: that dates are hard, that encodings are worse, and that the people who wrote the original spec had already thought about it.</p>
<button aria-label="Reply"></button>
<span>11</span>
<button aria-label="Repost"></button>
<span>44</span>
<button aria-label="Like"></button>
<span>509</span>
<button aria-label="Bookmark"></button>
<button aria-label="Share"></button>
<button aria-label="More"></button>
</article>
<article>
<a href="/fixture">fixture</a>
<span>2d</span>
<p>The best argument for a small tool is that you can read all of it in an afternoon and still be surprised by none of it a year later. Very little software clears that bar, and most of it is old.</p>
<button aria-label="Reply"></button>
<span>6</span>
<button aria-label="Repost"></button>
<span>17</span>
<button aria-label="Like"></button>
<span>288</span>
<button aria-label="Bookmark"></button>
<button aria-label="Share"></button>
<button aria-label="More"></button>
</article>
<article>
<a href="/fixture">fixture</a>
<span>3d</span>
<p>Reminder that the terminal is not a nostalgia object. It is the only interface that has been continuously scriptable for fifty years, which is why every tool that wants to be automated ends up with one.</p>
<button aria-label="Reply"></button>
<span>19</span>
<button aria-label="Repost"></button>
<span>63</span>
<button aria-label="Like"></button>
<span>771</span>
<button aria-label="Bookmark"></button>
<button aria-label="Share"></button>
<button aria-label="More"></button>
</article>
</div>
<div id="sidebar">
<h2>Who to follow</h2>
<a href="/a">suggested account one</a>
<a href="/b">suggested account two</a>
<button aria-label=""></button>
<button></button>
</div>
</div>
<footer>
<a href="/terms">Terms</a>
<a href="/privacy">Privacy</a>
</footer>
</body>
</html>