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.
9 files
changed +757
−158
README.md +41 −13
| @@ -1,11 +1,27 @@ | ||
| 1 | # oracle | |
| 1 | # the oracle | |
| 2 | 2 | |
| 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. | |
| 4 | 5 | |
| 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 | |
| 9 | 25 | |
| 10 | 26 | sh |
| 11 | 27 | 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 | ||
| 13 | 29 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface |
| 14 | 30 | |
| 15 | 31 | |
| 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. | |
| 20 | 36 | |
| 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 @@ | ||
| 2 | 2 | "app": { |
| 3 | 3 | "run": "bun run src/server.ts", |
| 4 | 4 | "test": "bun test", |
| 5 | "health": "/", | |
| 6 | "ready": 30, | |
| 5 | 7 | "tty": "bun run src/tty.ts" |
| 6 | 8 | } |
| 7 | 9 | } |
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 | ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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"; | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | |
| 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); | |
| 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "oracle-")); | |
| 7 | 7 | process.env.PORT = "0"; |
| 8 | 8 | const { server } = await import("./server.ts"); |
| 9 | 9 | const base = `http://${server.hostname}:${server.port}`; |
| 10 | 10 | |
| 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}/`); | |
| 13 | 19 | 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"); | |
| 15 | 23 | }); |
| 16 | 24 | |
| 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?"); | |
| 21 | 27 | 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"); | |
| 23 | 83 | }); |
| 24 | 84 | |
| 25 | 85 | test("stands on the security floor", async () => { |
| 26 | const response = await fetch(base + "/"); | |
| 86 | const response = await fetch(`${base}/`); | |
| 27 | 87 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 28 | 88 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 29 | 89 | }); |
| 30 | 90 | |
| 31 | 91 | 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`, { | |
| 34 | 93 | method: "POST", |
| 35 | 94 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 36 | body: "body=%E0%A4%A", | |
| 95 | body: "question=%E0%A4%A", | |
| 37 | 96 | }); |
| 38 | expect(response.status).toBe(200); | |
| 39 | 97 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 40 | 98 | }); |
| 41 | 99 | |
| 42 | 100 | 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`, { | |
| 46 | 102 | method: "POST", |
| 47 | 103 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 48 | body: "body=" + "x".repeat(1024 * 1024), | |
| 104 | body: "question=" + "x".repeat(1024 * 1024), | |
| 49 | 105 | }); |
| 50 | 106 | expect(response.status).toBe(413); |
| 51 | 107 | }); |
| 52 | 108 | |
| 53 | 109 | 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" })); | |
| 55 | 111 | expect(response.status).toBe(200); |
| 56 | 112 | expect(await response.text()).toBe(""); |
| 57 | 113 | }); |
| 58 | 114 | |
| 59 | test("refuses a cross-site post", async () => { | |
| 115 | test("refuses a cross-site ask", async () => { | |
| 60 | 116 | 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`, { | |
| 63 | 119 | method: "POST", |
| 64 | 120 | body: form, |
| 65 | 121 | redirect: "manual", |
| 66 | 122 | headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" }, |
| 67 | 123 | }); |
| 68 | 124 | 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("<img"); | |
| 132 | expect(page).not.toContain("<img src=x"); | |
| 70 | 133 | }); |
src/server.ts +65 −66
| @@ -1,29 +1,26 @@ | ||
| 1 | 1 | /** |
| 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`. | |
| 3 | 5 | * |
| 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. | |
| 7 | 9 | */ |
| 8 | 10 | |
| 9 | import { Database } from "bun:sqlite"; | |
| 10 | import { mkdirSync } from "node:fs"; | |
| 11 | 11 | 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"; | |
| 12 | 15 | |
| 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(); | |
| 20 | 17 | |
| 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 | }; | |
| 27 | 24 | |
| 28 | 25 | // The security floor every response stands on. Scripts and styles come from |
| 29 | 26 | // this origin (inline styles allowed, inline scripts not), nothing frames us, |
| @@ -104,43 +101,6 @@ async function readForm(request: Request): Promise<FormData | undefined> { | ||
| 104 | 101 | }).formData(); |
| 105 | 102 | } |
| 106 | 103 | |
| 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 | ||
| 144 | 104 | export const server = Bun.serve({ |
| 145 | 105 | port: Number(process.env.PORT ?? 8080), |
| 146 | 106 | hostname: process.env.HOST ?? "127.0.0.1", |
| @@ -148,32 +108,69 @@ export const server = Bun.serve({ | ||
| 148 | 108 | const url = new URL(request.url); |
| 149 | 109 | if (request.method === "POST" && !sameOrigin(request)) |
| 150 | 110 | 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") { | |
| 152 | 115 | const form = await readForm(request); |
| 153 | 116 | if (form === undefined) |
| 154 | 117 | 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); | |
| 158 | 122 | } |
| 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))); | |
| 162 | 143 | return request.method === "HEAD" |
| 163 | 144 | ? new Response(null, { status: full.status, headers: full.headers }) |
| 164 | 145 | : full; |
| 165 | 146 | } |
| 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 | ||
| 166 | 162 | // Files under ./static are served as they are, cached for a year: name |
| 167 | 163 | // them by content (app.3f2a9c.js) and a change is a new name, never a |
| 168 | 164 | // 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("..")) { | |
| 170 | 166 | const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1))); |
| 171 | 167 | if (await file.exists()) |
| 172 | 168 | return new Response(file, { |
| 173 | 169 | headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY }, |
| 174 | 170 | }); |
| 175 | 171 | } |
| 176 | return new Response("not found", { status: 404, headers: SECURITY }); | |
| 172 | ||
| 173 | return html(notFoundPage(chrome), 404); | |
| 177 | 174 | }, |
| 178 | 175 | // A bug answers with the floor and nothing else: never the runtime's own |
| 179 | 176 | // error page, which carries source and paths, and never without the |
| @@ -184,4 +181,6 @@ export const server = Bun.serve({ | ||
| 184 | 181 | }, |
| 185 | 182 | }); |
| 186 | 183 | |
| 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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 6 | 7 | */ |
| 7 | 8 | |
| 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"; | |
| 11 | 11 | |
| 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"); | |
| 20 | 17 | |
| 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`; | |
| 23 | 26 | |
| 24 | 27 | let cursor = 0; |
| 25 | 28 | let typing: string | undefined; |
| 26 | const out = (s: string): void => { process.stdout.write(s); }; | |
| 27 | const esc = "\x1b["; | |
| 29 | let flash = ""; | |
| 28 | 30 | |
| 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"); | |
| 43 | 35 | } |
| 44 | 36 | |
| 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"); | |
| 50 | 67 | 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(); | |
| 54 | 70 | 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; | |
| 59 | 79 | draw(); |
| 60 | 80 | return; |
| 61 | 81 | } |
| 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"); | |
| 72 | 93 | draw(); |
| 73 | }); | |
| 94 | } | |
| 95 | ||
| 96 | if (process.stdin.isTTY) process.stdin.setRawMode(true); | |
| 97 | process.stdin.on("data", key); | |
| 74 | 98 | process.stdout.on("resize", draw); |
| 99 | draw(); |