quill/oracle Public Public: anyone can read this; nobody can change it.

workbenchthis machine

the oracle: a verdict derived from the question, and a record it cannot dodge

Replaces the scaffold's notes with the app the repository is named after.

The conceit is the mechanism: sha256 of the question's normalized form picks
the stance, the confidence (52-98, never a coin flip) and the omen. So the
same question always gets the same answer, 'Will it rain?' and '  WILL   IT
RAIN!!! ' are one question, and a verdict can therefore be SCORED - each one
marked right or wrong afterwards, with the front page reporting what the
oracle achieved against what it claimed. The stances are theatre; the
calibration is arithmetic over what a person told it.

Shape follows AGENTS.md: src/oracle.ts is the mind (pure, no db), src/db.ts
the ledger (asking again is one more ask against the same row, not a new
verdict), src/pages.ts every page, src/server.ts only the door - the security
floor, the byte-counted body cap and the table of paths, all kept from the
scaffold. src/tty.ts is the same ledger in a terminal (a asks, r/w score, u
takes it back).

20 tests, no dependencies, no JavaScript on the page.
benjivers committed f5bda0f Browse files
9 files changed +757 −158
README.md +41 −13
@@ -1,11 +1,27 @@
1 # oracle
1 # the oracle
22
3 Ask it anything. It answers with conviction, remembers what it said, and keeps score of how often it was right.
3 Ask it anything. It answers with conviction, never twice differently, and
4 keeps score of how often it was right.
45
5 This is an app: the forge runs it. Production follows the default branch;
6 every open pull gets a preview at its own address with its own copy of the
7 data. Everything the app keeps lives under `FORGE_APP_DATA`, which is what
8 makes "production data" and "preview data" two different things.
6 The conceit is the mechanism: a verdict is **derived from the question**, not
7 drawn from chance. `sha256(normalized question)` picks the stance, the
8 confidence (52–98 — it never admits to a coin flip) and the omen it claims to
9 have read. So asking twice gets the same answer, "Will it rain?" and
10 ` WILL IT RAIN!!! ` are one question, and the ledger can be scored:
11 every verdict can be marked **right** or **wrong** afterwards, and the front
12 page reports what the oracle actually achieved against what it claimed —
13 `Right 61%, claimed 84%. It talks a better game than it plays.`
14
15 That last line is the only real thing here. The stances are theatre; the
16 calibration is arithmetic over what you told it.
17
18 ## Using it
19
20 Ask on the front page; you land on the verdict's own page, which is a
21 permalink you can come back to when you know how it turned out. `/ledger`
22 holds everything, filtered by all / unjudged / right / wrong.
23
24 ## Running it
925
1026 ```sh
1127 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
@@ -13,11 +29,23 @@ bun test # the check every pull r
1329 FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
1430 ```
1531
16 It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app
17 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge is
18 the host; `<name>--<pull>@<forge>` for a preview's). It opens `src/tty.ts` on
19 a real pty over the same data.
32 It also has a terminal — the exact command is in `FORGE_APP_SSH` when the app
33 runs, and the footer prints it. `j`/`k` move, `a` asks, `r`/`w` score the
34 verdict under the cursor, `u` takes a score back, `q` leaves. Same ledger as
35 the web page.
2036
21 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV`
22 (`production` or `preview`), `FORGE_APP_URL` and `FORGE_APP_COMMIT`
23 when it starts the app. `forge.json` says how.
37 ## Shape
38
39 | file | what it holds |
40 | --------------- | ---------------------------------------------------------- |
41 | `src/oracle.ts` | the mind: normalize, derive a verdict, score the record |
42 | `src/db.ts` | the ledger: one row per question, asking again is not new |
43 | `src/pages.ts` | every page, server-rendered, script-free |
44 | `src/server.ts` | the door: security floor, body caps, the table of paths |
45 | `src/tty.ts` | the same ledger in a terminal |
46
47 Everything it keeps lives under `FORGE_APP_DATA`, which is what makes
48 production data and preview data two different things: a preview starts as a
49 copy of production's verdicts and never writes back.
50
51 No dependencies. No JavaScript on the page.
forge.json +2
@@ -2,6 +2,8 @@
22 "app": {
33 "run": "bun run src/server.ts",
44 "test": "bun test",
5 "health": "/",
6 "ready": 30,
57 "tty": "bun run src/tty.ts"
68 }
79 }
src/db.ts added +112
@@ -0,0 +1,112 @@
1 /**
2 * The ledger. One row per question the oracle has ever been asked — asking
3 * again returns the same row, because the verdict cannot change — plus the
4 * outcome a person recorded afterwards.
5 */
6
7 import { Database } from "bun:sqlite";
8 import { mkdirSync } from "node:fs";
9 import { join } from "node:path";
10 import { normalize, verdictFor, type Outcome } from "./oracle.ts";
11
12 export interface Row {
13 id: number;
14 question: string;
15 normalized: string;
16 stance: string;
17 confidence: number;
18 auspice: string;
19 asked_count: number;
20 asked_at: string;
21 outcome: Outcome;
22 judged_at: string | null;
23 }
24
25 export function open(dataDir = process.env.FORGE_APP_DATA ?? "./data"): Database {
26 mkdirSync(dataDir, { recursive: true });
27 const db = new Database(join(dataDir, "oracle.db"));
28 db.exec("PRAGMA journal_mode = WAL");
29 db.exec(`CREATE TABLE IF NOT EXISTS verdict (
30 id INTEGER PRIMARY KEY,
31 question TEXT NOT NULL,
32 normalized TEXT NOT NULL UNIQUE,
33 stance TEXT NOT NULL,
34 confidence INTEGER NOT NULL,
35 auspice TEXT NOT NULL,
36 asked_count INTEGER NOT NULL DEFAULT 1,
37 asked_at TEXT NOT NULL DEFAULT (datetime('now')),
38 outcome TEXT NOT NULL DEFAULT 'unjudged' CHECK (outcome IN ('unjudged','right','wrong')),
39 judged_at TEXT
40 )`);
41 return db;
42 }
43
44 /**
45 * Ask. A question the ledger already holds is not answered again: its row
46 * comes back with one more ask against it, which is the whole conceit —
47 * the oracle does not get a second opinion either.
48 */
49 export function ask(db: Database, question: string): Row {
50 const trimmed = question.trim().slice(0, 280);
51 const key = normalize(trimmed);
52 const existing = db
53 .query<Row, [string]>("SELECT * FROM verdict WHERE normalized = ?1")
54 .get(key);
55 if (existing) {
56 db.query("UPDATE verdict SET asked_count = asked_count + 1 WHERE id = ?1").run(
57 existing.id,
58 );
59 return { ...existing, asked_count: existing.asked_count + 1 };
60 }
61 const verdict = verdictFor(trimmed);
62 const inserted = db
63 .query<Row, [string, string, string, number, string]>(
64 `INSERT INTO verdict (question, normalized, stance, confidence, auspice)
65 VALUES (?1, ?2, ?3, ?4, ?5) RETURNING *`,
66 )
67 .get(trimmed, key, verdict.stance, verdict.confidence, verdict.auspice);
68 if (!inserted) throw new Error("oracle: the ledger refused a verdict");
69 return inserted;
70 }
71
72 export function get(db: Database, id: number): Row | null {
73 return db.query<Row, [number]>("SELECT * FROM verdict WHERE id = ?1").get(id);
74 }
75
76 export function recent(db: Database, limit = 8): Row[] {
77 return db
78 .query<Row, [number]>("SELECT * FROM verdict ORDER BY id DESC LIMIT ?1")
79 .all(limit);
80 }
81
82 export type Filter = "all" | "unjudged" | "right" | "wrong";
83
84 export function isFilter(value: string): value is Filter {
85 return value === "all" || value === "unjudged" || value === "right" || value === "wrong";
86 }
87
88 export function ledger(db: Database, filter: Filter = "all", limit = 200): Row[] {
89 if (filter === "all")
90 return db.query<Row, [number]>("SELECT * FROM verdict ORDER BY id DESC LIMIT ?1").all(limit);
91 return db
92 .query<Row, [string, number]>(
93 "SELECT * FROM verdict WHERE outcome = ?1 ORDER BY id DESC LIMIT ?2",
94 )
95 .all(filter, limit);
96 }
97
98 /** Record how it turned out. `unjudged` takes a score back off the board. */
99 export function judge(db: Database, id: number, outcome: Outcome): Row | null {
100 db.query<Row, [string, string | null, number]>(
101 "UPDATE verdict SET outcome = ?1, judged_at = ?2 WHERE id = ?3",
102 ).run(outcome, outcome === "unjudged" ? null : new Date().toISOString(), id);
103 return get(db, id);
104 }
105
106 export function scored(db: Database): { confidence: number; outcome: string }[] {
107 return db
108 .query<{ confidence: number; outcome: string }, []>(
109 "SELECT confidence, outcome FROM verdict",
110 )
111 .all();
112 }
src/oracle.test.ts added +59
@@ -0,0 +1,59 @@
1 import { expect, test } from "bun:test";
2 import { calibrate, normalize, verdictFor, verdictOnItself } from "./oracle.ts";
3
4 test("the same question always gets the same verdict", () => {
5 const first = verdictFor("Should I ship it on Friday?");
6 const again = verdictFor("Should I ship it on Friday?");
7 expect(again).toEqual(first);
8 });
9
10 test("shouting, spacing and punctuation are the same question", () => {
11 const plain = verdictFor("will it rain");
12 expect(verdictFor("Will it rain?")).toEqual(plain);
13 expect(verdictFor(" WILL IT RAIN!!! ")).toEqual(plain);
14 expect(normalize(" Will it RAIN? ")).toBe("will it rain");
15 });
16
17 test("different questions get different verdicts", () => {
18 // Not a guarantee for every pair — a 16-stance table collides — but these
19 // two must not read as one question.
20 expect(verdictFor("should I quit")).not.toEqual(verdictFor("should I stay"));
21 });
22
23 test("it never admits to a coin flip", () => {
24 for (const q of ["a", "b", "c", "should I?", "是否", "x".repeat(280)]) {
25 const { confidence } = verdictFor(q);
26 expect(confidence).toBeGreaterThanOrEqual(52);
27 expect(confidence).toBeLessThanOrEqual(98);
28 }
29 });
30
31 test("scoring counts only what a person judged", () => {
32 const c = calibrate([
33 { confidence: 90, outcome: "right" },
34 { confidence: 70, outcome: "wrong" },
35 { confidence: 99, outcome: "unjudged" },
36 ]);
37 expect(c.judged).toBe(2);
38 expect(c.right).toBe(1);
39 expect(c.accuracy).toBe(50);
40 expect(c.claimed).toBe(80);
41 expect(c.gap).toBe(30);
42 });
43
44 test("nothing judged is not evidence either way", () => {
45 const c = calibrate([{ confidence: 98, outcome: "unjudged" }]);
46 expect(c.judged).toBe(0);
47 expect(c.accuracy).toBeUndefined();
48 expect(verdictOnItself(c)).toContain("Nothing judged yet");
49 });
50
51 test("it calls its own bluster", () => {
52 const bluster = calibrate(
53 Array.from({ length: 6 }, (_, i) => ({
54 confidence: 95,
55 outcome: i === 0 ? "right" : "wrong",
56 })),
57 );
58 expect(verdictOnItself(bluster)).toContain("talks a better game");
59 });
src/oracle.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * The oracle's mind. A question in, a verdict out, and nothing random about
3 * it: the same question always receives the same answer, because an oracle
4 * that equivocates is a weather vane. The verdict is derived from a hash of
5 * the question's normalized form, so conviction is reproducible and the
6 * ledger can be scored honestly.
7 */
8
9 export interface Verdict {
10 /** What the oracle says. */
11 stance: string;
12 /** How sure it claims to be, 52–98. It never admits to a coin flip. */
13 confidence: number;
14 /** The sign it read on the way to the answer. */
15 auspice: string;
16 }
17
18 /**
19 * Questions that differ only in shouting, spacing or punctuation are one
20 * question — otherwise "will it rain?" and "Will it rain" would get two
21 * verdicts and the oracle would look like it hedges.
22 */
23 export function normalize(question: string): string {
24 return question
25 .toLowerCase()
26 .replace(/\s+/g, " ")
27 .trim()
28 .replace(/[?!.,;:…]+$/u, "")
29 .trim();
30 }
31
32 const STANCES: readonly string[] = [
33 "Yes. Stop hunting for a reason not to.",
34 "No — and the part of you that already knew is the part worth listening to.",
35 "Yes, but later than you want and smaller than you pictured.",
36 "Not yet. The piece you are missing arrives on its own.",
37 "Yes, if you say it out loud to one person before the week turns.",
38 "No. You are asking about the wrong half of the problem.",
39 "It happens twice. The second time is the one that counts.",
40 "Yes, at a price you will call fair afterwards and unfair throughout.",
41 "No, and it is a mercy wearing a loss for a coat.",
42 "Yes — you began already, which is why you are asking.",
43 "The answer changes if you wait a week. Wait the week.",
44 "No. Do the smaller version: it is the same wish, honestly sized.",
45 "Yes, and it will be boring, which is how you will know it worked.",
46 "Ask again once you have slept. Tonight's answer belongs to fear.",
47 "Yes. The obstacle you keep naming is a door with a dull handle.",
48 "No — but the person you would become by trying is worth the no.",
49 ];
50
51 const AUSPICES: readonly string[] = [
52 "a crow lands on the north gate and does not look back",
53 "steam off a cup, drifting left",
54 "three lights out on the same street",
55 "the tide goes further out than the almanac promised",
56 "a dog barks once, then thinks better of it",
57 "wet ink on a folded page",
58 "the last seat on a full train",
59 "a moth at the window, patient",
60 "salt spilled and swept in the same minute",
61 "two clocks in the house disagree by four minutes",
62 "a key that turns the wrong way first",
63 "bread risen past the rim of the tin",
64 "a letter delivered to the flat below",
65 "frost on one side of the roof only",
66 "the radio finds a station between stations",
67 "a bicycle left unlocked, still there at dusk",
68 ];
69
70 /** The verdict a question has always had and will always have. */
71 export function verdictFor(question: string): Verdict {
72 const digest = new Bun.CryptoHasher("sha256")
73 .update(normalize(question))
74 .digest();
75 const byte = (i: number): number => digest[i] ?? 0;
76 return {
77 stance: STANCES[byte(0) % STANCES.length] as string,
78 // 52–98: enough spread to be scoreable, never a shrug.
79 confidence: 52 + (byte(1) % 47),
80 auspice: AUSPICES[byte(2) % AUSPICES.length] as string,
81 };
82 }
83
84 export type Outcome = "unjudged" | "right" | "wrong";
85
86 export function isOutcome(value: string): value is Outcome {
87 return value === "unjudged" || value === "right" || value === "wrong";
88 }
89
90 export interface Calibration {
91 /** Verdicts a person has since scored. */
92 judged: number;
93 right: number;
94 /** Share it actually got right, as a percentage, or undefined at zero. */
95 accuracy: number | undefined;
96 /** Mean confidence it claimed on those same verdicts. */
97 claimed: number | undefined;
98 /** claimed − accuracy: positive is bluster, negative is modesty. */
99 gap: number | undefined;
100 }
101
102 /**
103 * Scoring is only over verdicts a person judged — an unjudged verdict is not
104 * evidence of anything, and counting it either way would flatter or libel the
105 * oracle for free.
106 */
107 export function calibrate(
108 rows: readonly { confidence: number; outcome: string }[],
109 ): Calibration {
110 const judged = rows.filter((r) => r.outcome === "right" || r.outcome === "wrong");
111 const right = judged.filter((r) => r.outcome === "right").length;
112 if (judged.length === 0)
113 return { judged: 0, right: 0, accuracy: undefined, claimed: undefined, gap: undefined };
114 const accuracy = Math.round((right / judged.length) * 100);
115 const claimed = Math.round(
116 judged.reduce((sum, r) => sum + r.confidence, 0) / judged.length,
117 );
118 return { judged: judged.length, right, accuracy, claimed, gap: claimed - accuracy };
119 }
120
121 /** How the oracle is doing, in a sentence, without a chart. */
122 export function verdictOnItself(c: Calibration): string {
123 if (c.accuracy === undefined || c.claimed === undefined || c.gap === undefined)
124 return "Nothing judged yet. Score a verdict and the oracle starts keeping itself honest.";
125 if (c.judged < 5)
126 return `Right ${c.right} of ${c.judged}. Too few to mean much — keep scoring.`;
127 if (c.gap > 15) return `Right ${c.accuracy}% while claiming ${c.claimed}%. It talks a better game than it plays.`;
128 if (c.gap < -15) return `Right ${c.accuracy}% while claiming only ${c.claimed}%. It undersells itself.`;
129 return `Right ${c.accuracy}%, claimed ${c.claimed}%. Close to honest.`;
130 }
src/pages.ts added +181
@@ -0,0 +1,181 @@
1 /**
2 * Every page, server-rendered, script-free. The oracle speaks in a serif and
3 * keeps its bookkeeping in a sans — the voice and the ledger should not look
4 * like the same thing.
5 */
6
7 import { calibrate, verdictOnItself, type Calibration } from "./oracle.ts";
8 import type { Filter, Row } from "./db.ts";
9
10 export const escape = (s: string): string =>
11 s.replace(
12 /[&<>"']/g,
13 (c) =>
14 ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ??
15 c,
16 );
17
18 const STYLE = `
19 :root {
20 color-scheme: light dark;
21 --ink: light-dark(#1b1a17, #ece7dd);
22 --dim: light-dark(#6b665c, #9a9387);
23 --rule: light-dark(#1b1a1722, #ece7dd22);
24 --ground: light-dark(#faf7f1, #16151a);
25 font: 17px/1.6 system-ui, sans-serif;
26 }
27 * { box-sizing: border-box; }
28 body { max-width: 44rem; margin: 0 auto; padding: 2rem 1rem 4rem; background: var(--ground); color: var(--ink); }
29 a { color: inherit; }
30 h1 { font: 600 1.5rem/1.2 ui-serif, Georgia, serif; letter-spacing: .02em; margin: 0; }
31 h1 a { text-decoration: none; }
32 .sub { color: var(--dim); margin: .25rem 0 2rem; font-size: .95rem; }
33 .stance { font: 400 1.6rem/1.35 ui-serif, Georgia, serif; margin: .5rem 0 1rem; }
34 .auspice { font: italic 400 1.05rem/1.5 ui-serif, Georgia, serif; color: var(--dim); }
35 .label { font-size: .72rem; letter-spacing: .14em; text-transform: uppercase; color: var(--dim); }
36 form.ask { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0 0 2rem; }
37 input[type=text] { flex: 1 1 14rem; min-height: 48px; padding: .7rem .9rem; font: inherit;
38 color: var(--ink); background: transparent; border: 1px solid var(--rule); border-radius: .6rem; }
39 button { min-height: 48px; padding: .7rem 1.1rem; font: inherit; cursor: pointer;
40 border: 1px solid var(--rule); border-radius: .6rem; background: var(--ink); color: var(--ground); }
41 button.quiet { background: transparent; color: var(--ink); }
42 .meter { height: 6px; border-radius: 999px; background: var(--rule); overflow: hidden; margin: .4rem 0 1rem; }
43 .meter div { height: 100%; background: var(--ink); }
44 .card { border-top: 1px solid var(--rule); padding: 1.1rem 0; }
45 .card p { margin: .3rem 0; }
46 .q { font-weight: 600; }
47 .row { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
48 .tag { font-size: .78rem; padding: .15rem .5rem; border: 1px solid var(--rule); border-radius: 999px; color: var(--dim); }
49 .tag.right { color: light-dark(#166534, #86efac); }
50 .tag.wrong { color: light-dark(#9f1239, #fda4af); }
51 footer { margin-top: 3rem; color: var(--dim); font-size: .85rem; border-top: 1px solid var(--rule); padding-top: 1rem; }
52 nav { display: flex; gap: .75rem; flex-wrap: wrap; margin-bottom: 1.5rem; font-size: .9rem; }
53 ul { padding: 0; margin: 0; list-style: none; }
54 `;
55
56 interface Chrome {
57 env: string;
58 pull: string | undefined;
59 commit: string;
60 ssh: string | undefined;
61 }
62
63 function where(chrome: Chrome): string {
64 return chrome.env === "preview"
65 ? `preview of pull #${chrome.pull ?? "?"}`
66 : chrome.env;
67 }
68
69 function layout(chrome: Chrome, title: string, body: string): string {
70 return `<!doctype html>
71 <meta charset="utf-8">
72 <meta name="viewport" content="width=device-width, initial-scale=1">
73 <title>${escape(title)}</title>
74 <style>${STYLE}</style>
75 <h1><a href="/">the oracle</a></h1>
76 <p class="sub">Ask once. It answers with conviction, never twice differently, and keeps score of how often it was right.</p>
77 ${body}
78 <footer>
79 <p><span class="tag">${escape(where(chrome))}</span>${chrome.commit ? ` <span class="tag">@ ${escape(chrome.commit)}</span>` : ""}</p>
80 ${chrome.ssh ? `<p>Same ledger in a terminal: <code>${escape(chrome.ssh)}</code></p>` : ""}
81 <p>Verdicts are derived from the question itself, not from chance — the joke and the honesty are the same mechanism.</p>
82 </footer>
83 `;
84 }
85
86 function meter(confidence: number): string {
87 return `<div class="meter"><div style="width:${Math.max(0, Math.min(100, confidence))}%"></div></div>`;
88 }
89
90 function outcomeTag(row: Row): string {
91 if (row.outcome === "right") return `<span class="tag right">judged right</span>`;
92 if (row.outcome === "wrong") return `<span class="tag wrong">judged wrong</span>`;
93 return `<span class="tag">unjudged</span>`;
94 }
95
96 function card(row: Row): string {
97 return `<li class="card">
98 <p class="q"><a href="/v/${row.id}">${escape(row.question)}</a></p>
99 <p>${escape(row.stance)}</p>
100 <p class="row"><span class="tag">${row.confidence}% sure</span>${outcomeTag(row)}${
101 row.asked_count > 1 ? `<span class="tag">asked ${row.asked_count}×</span>` : ""
102 }</p>
103 </li>`;
104 }
105
106 function calibrationBlock(c: Calibration): string {
107 const line = escape(verdictOnItself(c));
108 if (c.accuracy === undefined) return `<p class="label">its record</p><p>${line}</p>`;
109 return `<p class="label">its record</p>
110 <p>${line}</p>
111 ${meter(c.accuracy)}
112 <p class="row"><span class="tag">${c.right} right of ${c.judged}</span><span class="tag">claimed ${c.claimed}%</span><span class="tag">${
113 (c.gap ?? 0) > 0 ? `overconfident by ${c.gap}` : (c.gap ?? 0) < 0 ? `modest by ${-(c.gap ?? 0)}` : "square"
114 }</span></p>`;
115 }
116
117 export function homePage(
118 chrome: Chrome,
119 rows: readonly Row[],
120 scores: readonly { confidence: number; outcome: string }[],
121 ): string {
122 const recent =
123 rows.length === 0
124 ? `<p class="auspice">No one has asked it anything yet. Be the first; it has been waiting.</p>`
125 : `<p class="label">lately</p><ul>${rows.map(card).join("")}</ul>`;
126 return layout(
127 chrome,
128 "the oracle",
129 `<form class="ask" method="post" action="/ask">
130 <input type="text" name="question" placeholder="Should I ship it on Friday?" required maxlength="280" autofocus>
131 <button>Ask</button>
132 </form>
133 ${calibrationBlock(calibrate(scores))}
134 ${recent}
135 <nav><a href="/ledger">the whole ledger →</a></nav>`,
136 );
137 }
138
139 export function verdictPage(chrome: Chrome, row: Row): string {
140 return layout(
141 chrome,
142 `${row.question} — the oracle`,
143 `<p class="label">you asked</p>
144 <p class="q">${escape(row.question)}</p>
145 <p class="stance">${escape(row.stance)}</p>
146 ${meter(row.confidence)}
147 <p class="row"><span class="tag">${row.confidence}% sure</span>${outcomeTag(row)}${
148 row.asked_count > 1 ? `<span class="tag">asked ${row.asked_count}×</span>` : ""
149 }<span class="tag">${escape(row.asked_at)}</span></p>
150 <p class="label">the sign it read</p>
151 <p class="auspice">${escape(row.auspice)}</p>
152 <p class="label">how did it turn out?</p>
153 <form class="ask" method="post" action="/v/${row.id}/judge">
154 <button name="outcome" value="right">It was right</button>
155 <button class="quiet" name="outcome" value="wrong">It was wrong</button>
156 ${row.outcome === "unjudged" ? "" : `<button class="quiet" name="outcome" value="unjudged">Take that back</button>`}
157 </form>
158 <nav><a href="/">ask another</a> <a href="/ledger">the ledger</a></nav>`,
159 );
160 }
161
162 export function ledgerPage(chrome: Chrome, rows: readonly Row[], filter: Filter): string {
163 const tab = (f: Filter, label: string): string =>
164 f === filter ? `<strong>${label}</strong>` : `<a href="/ledger?show=${f}">${label}</a>`;
165 return layout(
166 chrome,
167 "the ledger — the oracle",
168 `<nav>${tab("all", "all")} ${tab("unjudged", "unjudged")} ${tab("right", "right")} ${tab("wrong", "wrong")} <a href="/">ask</a></nav>
169 ${rows.length === 0 ? `<p class="auspice">Nothing here.</p>` : `<ul>${rows.map(card).join("")}</ul>`}`,
170 );
171 }
172
173 export function notFoundPage(chrome: Chrome): string {
174 return layout(
175 chrome,
176 "not found — the oracle",
177 `<p class="stance">There is no such verdict.</p>
178 <p class="auspice">It may never have been asked, or you may have invented the number.</p>
179 <nav><a href="/">ask something real</a></nav>`,
180 );
181 }
src/server.test.ts +86 −23
@@ -3,68 +3,131 @@ import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55
6 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
6 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "oracle-"));
77 process.env.PORT = "0";
88 const { server } = await import("./server.ts");
99 const base = `http://${server.hostname}:${server.port}`;
1010
11 test("serves the page", async () => {
12 const response = await fetch(base + "/");
11 const ask = async (question: string): Promise<Response> => {
12 const form = new FormData();
13 form.set("question", question);
14 return fetch(`${base}/ask`, { method: "POST", body: form, redirect: "manual" });
15 };
16
17 test("serves the door", async () => {
18 const response = await fetch(`${base}/`);
1319 expect(response.status).toBe(200);
14 expect(await response.text()).toContain("<h1>");
20 const text = await response.text();
21 expect(text).toContain("the oracle");
22 expect(text).toContain("Ask once");
1523 });
1624
17 test("keeps a note", async () => {
18 const form = new FormData();
19 form.set("body", "hello from a test");
20 const posted = await fetch(base + "/notes", { method: "POST", body: form, redirect: "manual" });
25 test("asking lands on the verdict's own page", async () => {
26 const posted = await ask("Should I ship it on Friday?");
2127 expect(posted.status).toBe(303);
22 expect(await (await fetch(base + "/")).text()).toContain("hello from a test");
28 const location = posted.headers.get("location") ?? "";
29 expect(location).toMatch(/^\/v\/\d+$/);
30 const page = await (await fetch(base + location)).text();
31 expect(page).toContain("Should I ship it on Friday?");
32 expect(page).toContain("% sure");
33 expect(page).toContain("the sign it read");
34 });
35
36 test("asking the same thing twice does not get a second opinion", async () => {
37 const first = (await ask("Is the milk still good")).headers.get("location");
38 const second = (await ask("is the MILK still good?")).headers.get("location");
39 expect(second).toBe(first);
40 const page = await (await fetch(base + first)).text();
41 expect(page).toContain("asked 2×");
42 });
43
44 test("an empty question is not a question", async () => {
45 const posted = await ask(" ");
46 expect(posted.status).toBe(303);
47 expect(posted.headers.get("location")).toBe("/");
48 });
49
50 test("a judged verdict moves the oracle's record", async () => {
51 const location = (await ask("Will the deploy hold overnight?")).headers.get("location") ?? "";
52 const id = location.split("/").pop();
53 const form = new FormData();
54 form.set("outcome", "right");
55 const judged = await fetch(`${base}/v/${id}/judge`, {
56 method: "POST",
57 body: form,
58 redirect: "manual",
59 });
60 expect(judged.status).toBe(303);
61 const page = await (await fetch(base + location)).text();
62 expect(page).toContain("judged right");
63 expect(await (await fetch(`${base}/`)).text()).toContain("right of");
64 expect(await (await fetch(`${base}/ledger?show=right`)).text()).toContain(
65 "Will the deploy hold overnight?",
66 );
67 });
68
69 test("the ledger only holds the three words it knows", async () => {
70 const location = (await ask("Is this outcome inventable?")).headers.get("location") ?? "";
71 const id = location.split("/").pop();
72 const form = new FormData();
73 form.set("outcome", "brilliant");
74 const judged = await fetch(`${base}/v/${id}/judge`, { method: "POST", body: form });
75 expect(judged.status).toBe(404);
76 expect(await (await fetch(base + location)).text()).toContain("unjudged");
77 });
78
79 test("a verdict that was never asked for is not found", async () => {
80 const response = await fetch(`${base}/v/999999`);
81 expect(response.status).toBe(404);
82 expect(await response.text()).toContain("no such verdict");
2383 });
2484
2585 test("stands on the security floor", async () => {
26 const response = await fetch(base + "/");
86 const response = await fetch(`${base}/`);
2787 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
2888 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
2989 });
3090
3191 test("a bug answers with the floor, not the runtime's page", async () => {
32 // A percent-escape that is not one makes the search params throw.
33 const response = await fetch(base + "/notes", {
92 const response = await fetch(`${base}/ask`, {
3493 method: "POST",
3594 headers: { "content-type": "application/x-www-form-urlencoded" },
36 body: "body=%E0%A4%A",
95 body: "question=%E0%A4%A",
3796 });
38 expect(response.status).toBe(200);
3997 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
4098 });
4199
42100 test("a body is capped by the bytes read, whatever it claims", async () => {
43 // The cap counts bytes as they arrive and never reads Content-Length, so
44 // a chunked body with no length is refused the same way this one is.
45 const response = await fetch(base + "/notes", {
101 const response = await fetch(`${base}/ask`, {
46102 method: "POST",
47103 headers: { "content-type": "application/x-www-form-urlencoded" },
48 body: "body=" + "x".repeat(1024 * 1024),
104 body: "question=" + "x".repeat(1024 * 1024),
49105 });
50106 expect(response.status).toBe(413);
51107 });
52108
53109 test("HEAD sees the app up", async () => {
54 const response = await server.fetch(new Request(base + "/", { method: "HEAD" }));
110 const response = await server.fetch(new Request(`${base}/`, { method: "HEAD" }));
55111 expect(response.status).toBe(200);
56112 expect(await response.text()).toBe("");
57113 });
58114
59 test("refuses a cross-site post", async () => {
115 test("refuses a cross-site ask", async () => {
60116 const form = new FormData();
61 form.set("body", "from another site");
62 const posted = await fetch(base + "/notes", {
117 form.set("question", "did this come from another site?");
118 const posted = await fetch(`${base}/ask`, {
63119 method: "POST",
64120 body: form,
65121 redirect: "manual",
66122 headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" },
67123 });
68124 expect(posted.status).toBe(403);
69 expect(await (await fetch(base + "/")).text()).not.toContain("from another site");
125 expect(await (await fetch(`${base}/ledger`)).text()).not.toContain("another site");
126 });
127
128 test("a question is escaped on the way out", async () => {
129 const location = (await ask('<img src=x onerror="alert(1)">')).headers.get("location") ?? "";
130 const page = await (await fetch(base + location)).text();
131 expect(page).toContain("&lt;img");
132 expect(page).not.toContain("<img src=x");
70133 });
src/server.ts +65 −66
@@ -1,29 +1,26 @@
11 /**
2 * oracle — a small app with real data.
2 * oracle — the door. The security floor, the body caps and the table of
3 * paths live here; the mind is in `oracle.ts`, the ledger in `db.ts` and
4 * every page in `pages.ts`.
35 *
4 * Notes live in a SQLite file under FORGE_APP_DATA. Production and each
5 * preview get their own directory, so a preview starts with production's
6 * notes and never writes back to them.
6 * The ledger is a SQLite file under FORGE_APP_DATA. Production and each
7 * preview get their own directory, so a preview starts with a copy of
8 * production's verdicts and never writes back to them.
79 */
810
9 import { Database } from "bun:sqlite";
10 import { mkdirSync } from "node:fs";
1111 import { join } from "node:path";
12 import * as ledger from "./db.ts";
13 import { isOutcome } from "./oracle.ts";
14 import { homePage, ledgerPage, notFoundPage, verdictPage } from "./pages.ts";
1215
13 const dataDir = process.env.FORGE_APP_DATA ?? "./data";
14 mkdirSync(dataDir, { recursive: true });
15 const db = new Database(join(dataDir, "app.db"));
16 db.exec("PRAGMA journal_mode = WAL");
17 db.exec(
18 "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
19 );
16 const db = ledger.open();
2017
21 const env = process.env.FORGE_APP_ENV ?? "local";
22 const pull = process.env.FORGE_APP_PULL;
23 const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7);
24
25 const escape = (s: string): string =>
26 s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
18 const chrome = {
19 env: process.env.FORGE_APP_ENV ?? "local",
20 pull: process.env.FORGE_APP_PULL,
21 commit: (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7),
22 ssh: process.env.FORGE_APP_SSH,
23 };
2724
2825 // The security floor every response stands on. Scripts and styles come from
2926 // this origin (inline styles allowed, inline scripts not), nothing frames us,
@@ -104,43 +101,6 @@ async function readForm(request: Request): Promise<FormData | undefined> {
104101 }).formData();
105102 }
106103
107 function page(): string {
108 const notes = db
109 .query<{ id: number; body: string; created_at: string }, []>(
110 "SELECT id, body, created_at FROM note ORDER BY id DESC LIMIT 100",
111 )
112 .all();
113 const where =
114 env === "preview" ? `preview of pull #${pull ?? "?"}` : env;
115 return `<!doctype html>
116 <meta charset="utf-8">
117 <meta name="viewport" content="width=device-width, initial-scale=1">
118 <title>oracle</title>
119 <style>
120 :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; }
121 body { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }
122 .env { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8rem;
123 background: ${env === "production" ? "#16a34a" : "#2563eb"}; color: white; }
124 form { display: flex; gap: .5rem; margin: 1.5rem 0; }
125 input { flex: 1; padding: .6rem .8rem; font: inherit; border: 1px solid #8884; border-radius: .5rem; }
126 button { padding: .6rem 1rem; font: inherit; border: 0; border-radius: .5rem; background: #111; color: white; }
127 li { padding: .5rem 0; border-top: 1px solid #8883; list-style: none; }
128 small { opacity: .6; }
129 </style>
130 <h1>oracle</h1>
131 <p>Ask%20it%20anything.%20It%20answers%20with%20conviction%2C%20remembers%20what%20it%20said%2C%20and%20keeps%20score%20of%20how%20often%20it%20was%20right.</p>
132 <p><span class="env">${escape(where)}</span> <small>${commit ? "@ " + commit : ""}</small></p>
133 <form method="post" action="/notes">
134 <input name="body" placeholder="Write a note" required maxlength="500" autofocus>
135 <button>Add</button>
136 </form>
137 <ul>${notes
138 .map((n) => `<li>${escape(n.body)} <small>${n.created_at}</small></li>`)
139 .join("")}</ul>
140 <p><small>${notes.length === 0 ? "No notes yet. Notes written here stay in this environment's data." : `${notes.length} note${notes.length === 1 ? "" : "s"} in ${escape(where)} data.`}</small></p>
141 `;
142 }
143
144104 export const server = Bun.serve({
145105 port: Number(process.env.PORT ?? 8080),
146106 hostname: process.env.HOST ?? "127.0.0.1",
@@ -148,32 +108,69 @@ export const server = Bun.serve({
148108 const url = new URL(request.url);
149109 if (request.method === "POST" && !sameOrigin(request))
150110 return new Response("forbidden", { status: 403 });
151 if (request.method === "POST" && url.pathname === "/notes") {
111
112 // Ask. An empty question is not a question; the same question is not a
113 // new one, which `ledger.ask` enforces in one statement.
114 if (request.method === "POST" && url.pathname === "/ask") {
152115 const form = await readForm(request);
153116 if (form === undefined)
154117 return new Response("too large", { status: 413, headers: SECURITY });
155 const body = String(form.get("body") ?? "").trim().slice(0, 500);
156 if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body);
157 return Response.redirect("/", 303);
118 const question = String(form.get("question") ?? "").trim();
119 if (question === "") return Response.redirect("/", 303);
120 const row = ledger.ask(db, question);
121 return Response.redirect(`/v/${row.id}`, 303);
158122 }
159 // HEAD is GET without the body: a health check sees the app up.
160 if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
161 const full = html(page());
123
124 // Score a verdict. Only the three words count; anything else is a
125 // client inventing states for the ledger to hold.
126 const judging = url.pathname.match(/^\/v\/(\d{1,15})\/judge$/);
127 if (request.method === "POST" && judging) {
128 const form = await readForm(request);
129 if (form === undefined)
130 return new Response("too large", { status: 413, headers: SECURITY });
131 const outcome = String(form.get("outcome") ?? "");
132 const id = Number(judging[1]);
133 if (!isOutcome(outcome) || ledger.get(db, id) === null)
134 return html(notFoundPage(chrome), 404);
135 ledger.judge(db, id, outcome);
136 return Response.redirect(`/v/${id}`, 303);
137 }
138
139 const reading = request.method === "GET" || request.method === "HEAD";
140
141 if (reading && url.pathname === "/") {
142 const full = html(homePage(chrome, ledger.recent(db), ledger.scored(db)));
162143 return request.method === "HEAD"
163144 ? new Response(null, { status: full.status, headers: full.headers })
164145 : full;
165146 }
147
148 const permalink = url.pathname.match(/^\/v\/(\d{1,15})$/);
149 if (reading && permalink) {
150 const row = ledger.get(db, Number(permalink[1]));
151 return row === null
152 ? html(notFoundPage(chrome), 404)
153 : html(verdictPage(chrome, row));
154 }
155
156 if (reading && url.pathname === "/ledger") {
157 const show = url.searchParams.get("show") ?? "all";
158 const filter = ledger.isFilter(show) ? show : "all";
159 return html(ledgerPage(chrome, ledger.ledger(db, filter), filter));
160 }
161
166162 // Files under ./static are served as they are, cached for a year: name
167163 // them by content (app.3f2a9c.js) and a change is a new name, never a
168164 // stale cache. The path is confined to the directory.
169 if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) {
165 if (reading && url.pathname.startsWith("/static/") && !url.pathname.includes("..")) {
170166 const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1)));
171167 if (await file.exists())
172168 return new Response(file, {
173169 headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY },
174170 });
175171 }
176 return new Response("not found", { status: 404, headers: SECURITY });
172
173 return html(notFoundPage(chrome), 404);
177174 },
178175 // A bug answers with the floor and nothing else: never the runtime's own
179176 // error page, which carries source and paths, and never without the
@@ -184,4 +181,6 @@ export const server = Bun.serve({
184181 },
185182 });
186183
187 console.log(`oracle listening on http://${server.hostname}:${server.port} (${env})`);
184 console.log(
185 `the oracle is listening on http://${server.hostname}:${server.port} (${chrome.env})`,
186 );
src/tty.ts +81 −56
@@ -1,74 +1,99 @@
11 /**
2 * oracle, in a terminal. The forge runs this on a pty when someone
3 * `ssh`es to the app, with FORGE_APP_DATA pointing at the same data the
4 * web page uses. j/k move, a adds a note, d deletes the one under the
5 * cursor, q leaves. Plain ANSI, no dependencies.
2 * The oracle in a terminal. The forge runs this on a pty when someone
3 * `ssh`es to the app, with FORGE_APP_DATA pointing at the same ledger the
4 * web page reads: j/k move, enter opens nothing because everything is
5 * already on screen, `a` asks, `r`/`w` score the verdict under the cursor,
6 * `u` takes a score back, `q` leaves. Plain ANSI, no dependencies.
67 */
78
8 import { Database } from "bun:sqlite";
9 import { mkdirSync } from "node:fs";
10 import { join } from "node:path";
9 import * as ledger from "./db.ts";
10 import { calibrate, verdictOnItself } from "./oracle.ts";
1111
12 const dataDir = process.env.FORGE_APP_DATA ?? "./data";
13 mkdirSync(dataDir, { recursive: true });
14 const db = new Database(join(dataDir, "app.db"));
15 db.exec("PRAGMA journal_mode = WAL");
16 db.exec(
17 "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
18 );
19 const where = process.env.FORGE_APP_ENV === "preview" ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` : (process.env.FORGE_APP_ENV ?? "local");
12 const db = ledger.open();
13 const where =
14 process.env.FORGE_APP_ENV === "preview"
15 ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}`
16 : (process.env.FORGE_APP_ENV ?? "local");
2017
21 type Note = { id: number; body: string; created_at: string };
22 const notes = (): Note[] => db.query<Note, []>("SELECT id, body, created_at FROM note ORDER BY id DESC").all();
18 const esc = "\x1b[";
19 const out = (s: string): void => {
20 process.stdout.write(s);
21 };
22 const dim = (s: string): string => `${esc}2m${s}${esc}0m`;
23 const bold = (s: string): string => `${esc}1m${s}${esc}0m`;
24 const green = (s: string): string => `${esc}32m${s}${esc}0m`;
25 const red = (s: string): string => `${esc}31m${s}${esc}0m`;
2326
2427 let cursor = 0;
2528 let typing: string | undefined;
26 const out = (s: string): void => { process.stdout.write(s); };
27 const esc = "\x1b[";
29 let flash = "";
2830
29 function draw(): void {
30 const rows = notes();
31 const lines: string[] = [];
32 lines.push(`${esc}1moracle${esc}0m ${esc}2m${where}${esc}0m`);
33 lines.push("");
34 if (rows.length === 0) lines.push(`${esc}2mNo notes yet. Press a to add one.${esc}0m`);
35 rows.forEach((n, i) => {
36 const mark = i === cursor ? "▸" : " ";
37 const body = i === cursor ? `${esc}1m${n.body}${esc}0m` : n.body;
38 lines.push(`${mark} ${body} ${esc}2m${n.created_at}${esc}0m`);
39 });
40 lines.push("");
41 lines.push(typing === undefined ? `${esc}2mj/k move · a add · d delete · q quit${esc}0m` : `New note: ${typing}${esc}7m ${esc}0m`);
42 out(`${esc}2J${esc}H` + lines.join("\r\n"));
31 function mark(row: ledger.Row): string {
32 if (row.outcome === "right") return green("right");
33 if (row.outcome === "wrong") return red("wrong");
34 return dim("unjudged");
4335 }
4436
45 process.stdin.setRawMode?.(true);
46 process.stdin.resume();
47 process.stdin.setEncoding("utf8");
48 draw();
49 process.stdin.on("data", (key: string) => {
37 function draw(): void {
38 const rows = ledger.ledger(db, "all", 100);
39 if (cursor >= rows.length) cursor = Math.max(0, rows.length - 1);
40 const width = Math.max(40, (process.stdout.columns ?? 80) - 2);
41 const clip = (s: string, n: number): string =>
42 s.length <= n ? s : `${s.slice(0, Math.max(0, n - 1))}…`;
43 out(`${esc}2J${esc}H`);
44 out(`${bold("the oracle")} ${dim(`· ${where} · ${rows.length} verdict${rows.length === 1 ? "" : "s"}`)}\r\n`);
45 out(`${dim(verdictOnItself(calibrate(ledger.scored(db))))}\r\n\r\n`);
46 if (rows.length === 0) out(dim(" nothing asked yet — press a\r\n"));
47 for (const [i, row] of rows.entries()) {
48 const here = i === cursor;
49 out(`${here ? bold("▸ ") : " "}${clip(row.question, width - 22)}\r\n`);
50 out(` ${dim(`${row.confidence}% · ${mark(row)}`)}\r\n`);
51 if (here) out(` ${clip(row.stance, width - 6)}\r\n ${dim(clip(row.auspice, width - 6))}\r\n`);
52 }
53 out(`\r\n${flash === "" ? dim("j/k move · a ask · r right · w wrong · u unjudge · q quit") : flash}\r\n`);
54 if (typing !== undefined) out(`\r\n${bold("ask:")} ${typing}${dim("▏")}\r\n`);
55 }
56
57 function judgeUnderCursor(outcome: "right" | "wrong" | "unjudged"): void {
58 const rows = ledger.ledger(db, "all", 100);
59 const row = rows[cursor];
60 if (row === undefined) return;
61 ledger.judge(db, row.id, outcome);
62 flash = dim(`scored #${row.id} ${outcome}`);
63 }
64
65 function key(data: Buffer): void {
66 const s = data.toString("utf8");
5067 if (typing !== undefined) {
51 if (key === "\r") {
52 const body = typing.trim();
53 if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body.slice(0, 500));
68 if (s === "\r" || s === "\n") {
69 const question = typing.trim();
5470 typing = undefined;
55 cursor = 0;
56 } else if (key === "\x1b") typing = undefined;
57 else if (key === "\x7f") typing = typing.slice(0, -1);
58 else if (key >= " ") typing += key;
71 if (question !== "") {
72 const row = ledger.ask(db, question);
73 flash = dim(`the oracle: ${row.stance}`);
74 cursor = 0;
75 }
76 } else if (s === "\x7f") typing = typing.slice(0, -1);
77 else if (s === "\x1b" || s === "\x03") typing = undefined;
78 else if (s >= " " && typing.length < 280) typing += s;
5979 draw();
6080 return;
6181 }
62 if (key === "q" || key === "\x03") { out(`${esc}2J${esc}H`); process.exit(0); }
63 const count = notes().length;
64 if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1));
65 if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0);
66 if (key === "a") typing = "";
67 if (key === "d") {
68 const n = notes()[cursor];
69 if (n) db.query("DELETE FROM note WHERE id = ?1").run(n.id);
70 cursor = Math.min(cursor, Math.max(0, notes().length - 1));
71 }
82 flash = "";
83 if (s === "q" || s === "\x03" || s === "\x04") {
84 out(`${esc}2J${esc}H`);
85 db.close();
86 process.exit(0);
87 } else if (s === "j" || s === `${esc}B`) cursor += 1;
88 else if (s === "k" || s === `${esc}A`) cursor = Math.max(0, cursor - 1);
89 else if (s === "a") typing = "";
90 else if (s === "r") judgeUnderCursor("right");
91 else if (s === "w") judgeUnderCursor("wrong");
92 else if (s === "u") judgeUnderCursor("unjudged");
7293 draw();
73 });
94 }
95
96 if (process.stdin.isTTY) process.stdin.setRawMode(true);
97 process.stdin.on("data", key);
7498 process.stdout.on("resize", draw);
99 draw();
↑↓ navigate ↵ open esc close

Keyboard shortcuts