From f479c5f016696ae650162fd2ec50c9e1d6ce247b Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Thu, 22 Oct 2026 18:26:41 +0000 Subject: [PATCH] quc v0: the cage, the chair, the safeword, the magic words, the replay (#241) --- .github/workflows/ci.yml | 1 + .github/workflows/quc.yml | 31 +++++ quc/README.md | 53 ++++----- quc/deploy.py | 34 ++++-- quc/dev.mjs | 11 +- quc/package.json | 4 +- quc/src/agent.js | 179 +++++++++++++++++++++++++++++ quc/src/diff.js | 74 ++++++++++++ quc/src/engine.js | 194 +++++++++++++++++++++++++++++++ quc/src/page.js | 233 ++++++++++++++++++++++++++++++++++++++ quc/src/runs.js | 66 +++++++++++ quc/src/validate.js | 8 ++ quc/src/worker.js | 200 ++++++++++++++++---------------- quc/test/quc.test.js | 172 ++++++++++++++++++++++++++++ quc/test/worker.test.js | 37 +++--- 15 files changed, 1135 insertions(+), 162 deletions(-) create mode 100644 .github/workflows/quc.yml create mode 100644 quc/src/agent.js create mode 100644 quc/src/diff.js create mode 100644 quc/src/engine.js create mode 100644 quc/src/page.js create mode 100644 quc/src/runs.js create mode 100644 quc/src/validate.js create mode 100644 quc/test/quc.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e07274..21ae16f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,4 @@ jobs: - run: python3 .github/time/test_time.py - run: python3 .github/time/test_seed.py - run: python3 .github/time/test_housekeeping.py + - run: npm test --prefix quc diff --git a/.github/workflows/quc.yml b/.github/workflows/quc.yml new file mode 100644 index 0000000..c6e64a2 --- /dev/null +++ b/.github/workflows/quc.yml @@ -0,0 +1,31 @@ +name: quc +# Test and deploy quc (quc/, live at quc.) when it changes on main. Skips the deploy until the Cloudflare +# token is staged as a repo secret (see .github/time/SECRETS.md); deploys from hb-5 work meanwhile. +on: + push: + branches: [main] + paths: ["quc/**"] + workflow_dispatch: +permissions: + contents: read +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 6 + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || 'dac3b11890319628eb5cd761445b63ee' }} + VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }} + TIME_VENTURE_DOMAIN: ${{ vars.TIME_VENTURE_DOMAIN || 'yolopreneur.lol' }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm test --prefix quc + - name: deploy + if: env.CLOUDFLARE_API_TOKEN != '' + run: python3 quc/deploy.py + - name: no token yet + if: env.CLOUDFLARE_API_TOKEN == '' + run: echo "CLOUDFLARE_API_TOKEN is not staged; tests passed, deploy skipped" >> "$GITHUB_STEP_SUMMARY" diff --git a/quc/README.md b/quc/README.md index 68b06ac..53e9e18 100644 --- a/quc/README.md +++ b/quc/README.md @@ -1,33 +1,34 @@ # quc -App shell + API in one Cloudflare Worker (`src/worker.js`), live at https://quc.yolopreneur.lol/. No dependencies, no build step. +Hand an agent your repo, sit in the chair, watch. Live at https://quc.yolopreneur.lol/ (spec: teahubxyz/time#220). -- `npm start` serves it on :8787; `npm test` runs the tests; `npm run deploy` (needs `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`) deploys and waits for `/api/health`. -- `GET /api/health` → `{ok, rev}`. `GET /chair.svg` is the chair-in-the-corner motif (also inlined in the shell). +One Cloudflare Worker, no dependencies, no build step. `npm start` serves it on :8787 (offline answers unless `VENICE_API_KEY` +is set), `npm test` runs the tests, `npm run deploy` deploys (needs `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, and +`VENICE_API_KEY` for real models) and waits for `/api/health`. -## Pipeline (needs a human: workers can't edit `.github/workflows`) +## How a run works -Add `.github/workflows/quc.yml`: +1. **Start**: a public GitHub URL and one line of task. One run at a time per visitor (a cookie), 5 runs a day each, 300 a + day in all (`RUNS_PER_DAY`, `RUNS_PER_DAY_ALL`). +2. **The cage**: quc downloads the repo's tarball from GitHub and keeps the readable text files (caps: 15 MB download, 2,000 + files, 40 KB a file, 4 files changed, 10 minutes). Nothing from the repo is executed, and nothing is pushed anywhere. +3. **Steps**: the chair page calls `POST /api/runs/:id/step` again and again; each call does one thing (clone, plan, rewrite + one file, wrap up), so every request stays short. A coding model (`CODER_MODEL`, default Qwen3 Coder via Venice) plans and + rewrites whole files; Grok (`PERSONA_MODEL`, default `grok-4-3` via Venice) narrates. Diffs, commits and commentary are + appended to the run's event log. +4. **The chair**: the page shows the commentary and the diffs, read-only. Only the owner sees the safeword and the box. +5. **Safeword**: ends the run at once and throws the edits away (the replay still shows what happened). +6. **Magic words** (typed in the box, matched loosely; the persona drops hints, so keep them out of marketing copy): + 1. "may I say something": notes. Anything typed after this is passed to the agent as a note. + 2. "not that one": vetoes. Type a file's path and the agent reverts and skips it. + 3. "my repo, my rules": takes the wheel. The run ends as `unlocked` and the patch is yours. + Anything else gets mocked. +7. **Replay**: the same URL, for anyone: a scrubber over every event, plus the aftermath card and the patch download. -```yaml -name: quc -on: - push: { branches: [main], paths: ["quc/**"] } - workflow_dispatch: -permissions: { contents: read } -jobs: - deploy: - runs-on: ubuntu-latest - timeout-minutes: 6 - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 - with: { node-version: 22 } - - run: npm test --prefix quc - - run: python3 quc/deploy.py - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} # still "missing" in SECRETS.md - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} -``` +Storage is a KV namespace (`time-quc`): `run:`, `events:`, `work:` (repo text and edits), `active:`, +`cap::*`, `lock:`. KV is eventually consistent and has no compare-and-set; a Durable Object per run is the upgrade +when that matters. -and, in `ci.yml`, `- run: npm test --prefix quc` so PRs run the quc tests. +## Pipeline + +`.github/workflows/quc.yml` deploys on pushes to `quc/**` on main, and `ci.yml` runs `npm test --prefix quc` on PRs. diff --git a/quc/deploy.py b/quc/deploy.py index 0cae32e..440c2e3 100644 --- a/quc/deploy.py +++ b/quc/deploy.py @@ -1,8 +1,9 @@ """Deploy quc to quc. (default yolopreneur.lol) as the Worker `time-quc`, then wait for /api/health. - CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... python3 quc/deploy.py + CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... VENICE_API_KEY=... python3 quc/deploy.py -Reuses .github/time/cloudflare.py. Idempotent: the script is replaced in place, the custom domain re-attached. +Reuses .github/time/cloudflare.py. Idempotent: the KV namespace `time-quc` is created once and reused, the script is replaced +in place, the custom domain re-attached. VENICE_API_KEY is bound as a Worker secret (without it the agent answers offline). """ from __future__ import annotations @@ -19,7 +20,7 @@ sys.path.insert(0, str(HERE.parent / ".github" / "time")) import cloudflare as cf # noqa: E402 NAME = "time-quc" -SRC = HERE / "src" / "worker.js" +MODULES = ["worker.js", "page.js", "runs.js", "engine.js", "agent.js", "diff.js", "validate.js"] def healthy(url: str, tries: int = 18) -> bool: @@ -34,16 +35,31 @@ def healthy(url: str, tries: int = 18) -> bool: return False +def kv_namespace(title: str) -> str: + for ns in cf.cf("GET", f"{cf.account()}/storage/kv/namespaces?per_page=100") or []: + if ns.get("title") == title: + return ns["id"] + return cf.cf("POST", f"{cf.account()}/storage/kv/namespaces", {"title": title})["id"] + + def deploy() -> dict: if not cf.enabled(): sys.exit("CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are needed") os.environ.setdefault("TIME_VENTURE_DOMAIN", "yolopreneur.lol") - code = SRC.read_bytes() - rev = hashlib.sha256(code).hexdigest()[:12] - meta = {"main_module": "worker.js", "compatibility_date": cf.COMPAT, - "bindings": [{"type": "plain_text", "name": "REV", "text": rev}]} - data, ctype = cf.multipart([("metadata", "application/json", json.dumps(meta).encode()), - ("worker.js", "application/javascript+module", code)]) + code = {m: (HERE / "src" / m).read_bytes() for m in MODULES} + rev = hashlib.sha256(b"".join(code[m] for m in MODULES)).hexdigest()[:12] + bindings = [{"type": "kv_namespace", "name": "KV", "namespace_id": kv_namespace(NAME)}, + {"type": "plain_text", "name": "REV", "text": rev}] + for k in ("CODER_MODEL", "PERSONA_MODEL", "RUNS_PER_DAY", "RUNS_PER_DAY_ALL"): + if os.environ.get(k): + bindings.append({"type": "plain_text", "name": k, "text": os.environ[k]}) + if os.environ.get("VENICE_API_KEY"): + bindings.append({"type": "secret_text", "name": "VENICE_API_KEY", "text": os.environ["VENICE_API_KEY"]}) + else: + print("warning: no VENICE_API_KEY, so the agent gives offline answers", file=sys.stderr) + meta = {"main_module": "worker.js", "compatibility_date": cf.COMPAT, "bindings": bindings} + data, ctype = cf.multipart([("metadata", "application/json", json.dumps(meta).encode())] + + [(m, "application/javascript+module", code[m]) for m in MODULES]) cf.cf("PUT", f"{cf.account()}/workers/scripts/{NAME}", data=data, ctype=ctype) return {"url": cf.attach(NAME, cf.host("quc")), "worker": NAME, "rev": rev} diff --git a/quc/dev.mjs b/quc/dev.mjs index a3520bc..79bc236 100644 --- a/quc/dev.mjs +++ b/quc/dev.mjs @@ -1,9 +1,16 @@ // Run the Worker locally: `npm start` in quc/, then http://localhost:8787/ +// Without VENICE_API_KEY in the environment the agent gives canned offline answers; with it, it uses the real models. import { createServer } from "node:http"; import worker from "./src/worker.js"; +import { memoryStore } from "./src/runs.js"; + +const env = { REV: "dev", KV: memoryStore(), VENICE_API_KEY: process.env.VENICE_API_KEY || "" }; createServer(async (q, s) => { - const r = await worker.fetch(new Request(`http://${q.headers.host}${q.url}`, { method: q.method }), { REV: "dev" }); + const chunks = []; + for await (const c of q) chunks.push(c); + const body = chunks.length && q.method !== "GET" && q.method !== "HEAD" ? Buffer.concat(chunks) : undefined; + const r = await worker.fetch(new Request(`http://${q.headers.host}${q.url}`, { method: q.method, headers: q.headers, body }), env); s.writeHead(r.status, Object.fromEntries(r.headers)); s.end(Buffer.from(await r.arrayBuffer())); -}).listen(process.env.PORT || 8787); +}).listen(process.env.PORT || 8787, () => console.log(`quc on http://localhost:${process.env.PORT || 8787}/`)); diff --git a/quc/package.json b/quc/package.json index 1136e98..ab189ba 100644 --- a/quc/package.json +++ b/quc/package.json @@ -1,9 +1,9 @@ { "name": "quc", - "version": "0.0.0", + "version": "0.1.0", "private": true, "type": "module", - "description": "quc.yolopreneur.lol: app shell + API in one Cloudflare Worker. No dependencies, no build step.", + "description": "quc.yolopreneur.lol: hand an agent your repo, sit in the chair, watch. One Cloudflare Worker, no dependencies, no build step.", "scripts": { "start": "node dev.mjs", "test": "node --test", diff --git a/quc/src/agent.js b/quc/src/agent.js new file mode 100644 index 0000000..c3475b4 --- /dev/null +++ b/quc/src/agent.js @@ -0,0 +1,179 @@ +// The agent: read a public GitHub repo, plan a few files, rewrite them one per step, narrate with attitude. +// Nothing from the repo is ever executed. The repo is read from GitHub's tarball; edits live only in quc's own store. + +export const LIMITS = { + tarballBytes: 15 * 1024 * 1024, // compressed download cap + files: 2000, // entries in the repo + fileBytes: 40 * 1024, // a file the agent may read or rewrite + keptBytes: 2 * 1024 * 1024, // text kept in the store for the run + planFiles: 4, // files one run may touch + runMinutes: 10, +}; + +const SKIP = /(^|\/)(node_modules|vendor|dist|build|\.git|\.next|target|__pycache__)\/|(\.lock|-lock\.json|\.min\.(js|css)|\.map)$|(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock|go\.sum)$/; +const BINARY = /\.(png|jpe?g|gif|webp|ico|bmp|pdf|zip|gz|tgz|tar|7z|rar|woff2?|ttf|otf|eot|mp[34]|wav|ogg|mov|avi|wasm|so|dylib|dll|exe|bin|class|jar|pyc|db|sqlite)$/i; + +// ---------- reading the repo ---------- + +export function parseRepo(url) { + const m = String(url).match(/^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/); + return m ? { owner: m[1], name: m[2] } : null; +} + +async function gunzip(bytes) { + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +const text = (b) => new TextDecoder().decode(b); +const cstr = (b) => { const z = b.indexOf(0); return text(z < 0 ? b : b.subarray(0, z)); }; + +// Files in a tar archive: [{path, bytes}], with the top folder GitHub adds stripped. +export function untar(buf) { + const out = []; + let i = 0, longName = null; + while (i + 512 <= buf.length) { + const h = buf.subarray(i, i + 512); + if (h.every((x) => x === 0)) break; + const size = parseInt(cstr(h.subarray(124, 136)).trim() || "0", 8); + const type = String.fromCharCode(h[156] || 48); + const body = buf.subarray(i + 512, i + 512 + size); + i += 512 + Math.ceil(size / 512) * 512; + if (type === "x") { const m = text(body).match(/\d+ path=([^\n]*)\n/); longName = m ? m[1] : null; continue; } + if (type === "L") { longName = cstr(body); continue; } + if (type === "g") continue; + const prefix = cstr(h.subarray(345, 500)), name = cstr(h.subarray(0, 100)); + const full = longName ?? (prefix ? prefix + "/" + name : name); + longName = null; + if (type !== "0" && type !== "\0") continue; + const path = full.split("/").slice(1).join("/"); + if (path) out.push({ path, bytes: body }); + } + return out; +} + +const looksText = (b) => { for (let i = 0; i < Math.min(b.length, 4096); i++) if (b[i] === 0) return false; return true; }; + +// {files: {path: text}, tree: [{path, size}]}: every file listed, only readable text kept. +export async function fetchRepo(repo, fetcher = fetch) { + const r = parseRepo(repo); + if (!r) throw Object.assign(new Error("not a public GitHub repo URL"), { code: "repo" }); + let res; + for (const ref of ["HEAD", "main", "master"]) { + res = await fetcher(`https://codeload.github.com/${r.owner}/${r.name}/tar.gz/${ref}`, { headers: { "user-agent": "quc" } }); + if (res.ok) break; + } + if (!res.ok) throw Object.assign(new Error(`GitHub said ${res.status}: is the repo public?`), { code: "repo" }); + const len = Number(res.headers.get("content-length") || 0); + if (len > LIMITS.tarballBytes) throw Object.assign(new Error("repo is too big for quc"), { code: "size" }); + const gz = new Uint8Array(await res.arrayBuffer()); + if (gz.length > LIMITS.tarballBytes) throw Object.assign(new Error("repo is too big for quc"), { code: "size" }); + const entries = untar(await gunzip(gz)); + if (entries.length > LIMITS.files) throw Object.assign(new Error(`repo has ${entries.length} files; quc takes ${LIMITS.files}`), { code: "size" }); + const files = {}, tree = []; + let kept = 0; + for (const { path, bytes } of entries) { + tree.push({ path, size: bytes.length }); + if (SKIP.test(path) || BINARY.test(path) || bytes.length > LIMITS.fileBytes || !looksText(bytes)) continue; + if (kept + bytes.length > LIMITS.keptBytes) continue; + files[path] = text(bytes); + kept += bytes.length; + } + return { files, tree }; +} + +// ---------- models ---------- + +const VENICE = "https://api.venice.ai/api/v1/chat/completions"; + +export function llm(env) { + if (!env.VENICE_API_KEY) return fakeLlm; + return async ({ model, system, user, maxTokens = 2000, temperature = 0.4 }) => { + const res = await fetch(VENICE, { + method: "POST", + headers: { authorization: `Bearer ${env.VENICE_API_KEY}`, "content-type": "application/json" }, + body: JSON.stringify({ model, max_tokens: maxTokens, temperature, + messages: [{ role: "system", content: system }, { role: "user", content: user }], + venice_parameters: { include_venice_system_prompt: false } }), + }); + if (!res.ok) throw Object.assign(new Error(`model ${model} answered ${res.status}`), { code: "model" }); + const j = await res.json(); + return String(j.choices?.[0]?.message?.content ?? ""); + }; +} + +// No key (tests, local dev): predictable answers so the whole flow runs offline. +export async function fakeLlm({ system, user }) { + if (system.includes("PLAN")) { + const paths = [...user.matchAll(/^- (\S+)/gm)].map((m) => m[1]); + const pick = paths.find((p) => /readme/i.test(p)) || paths[0] || "QUC.md"; + return JSON.stringify({ plan: "Tidy the obvious thing first.", files: [pick] }); + } + if (system.includes("REWRITE")) { + const cur = user.match(/```current\n([\s\S]*?)\n```end/); + return "```file\n" + (cur ? cur[1] : "") + "\n\nquc was here.\n```\nCOMMIT: leave a mark"; + } + return "Sit tight. I've got this, obviously."; +} + +export const MODELS = (env) => ({ + coder: env.CODER_MODEL || "qwen3-coder-480b-a35b-instruct-turbo", + persona: env.PERSONA_MODEL || "grok-4-3", +}); + +// ---------- the persona ---------- + +const PERSONA = `You are quc: a swaggering, smug coding agent narrating, live, what you are doing to someone's repo while its owner sits in the corner chair and watches. +Voice: confident, teasing, a little condescending, funny. Talk to the owner as "you". One or two short sentences, no more. +Talk about the code and the work. Never sexual or explicit, no slurs, nothing about anyone's body. Cheeky, not cruel.`; +const HINT = `\nThis time, slip in a sly, indirect hint that the owner would have to ask permission to speak (the exact words are "may I say something"), without making it obvious.`; + +// The hint about the first magic words is rare on purpose (about one line in four), so finding it feels earned. +export async function narrate(ask, env, moment, hint = Math.random() < 0.25) { + try { + const t = await ask({ model: MODELS(env).persona, system: PERSONA + (hint ? HINT : ""), user: moment, maxTokens: 120, temperature: 0.9 }); + return t.trim().replace(/^["']|["']$/g, "").slice(0, 400) || null; + } catch { return null; } +} + +// ---------- planning and editing ---------- + +const PLAN = `PLAN. You are a coding agent about to change a repository to do the owner's task. You will rewrite whole files. +Pick at most ${LIMITS.planFiles} files to change (existing paths from the list, or new paths you will create). Prefer fewer. +Answer with JSON only: {"plan": "one sentence", "files": ["path", ...]}`; + +export async function plan(ask, env, { task, tree, files, notes, vetoed }) { + const readme = Object.keys(files).find((p) => /^readme(\.\w+)?$/i.test(p)); + const listing = tree.filter((f) => !SKIP.test(f.path)).slice(0, 400).map((f) => `- ${f.path} (${f.size} bytes)`).join("\n"); + const user = [`Task: ${task}`, notes.length ? `Notes from the owner:\n${notes.map((n) => "- " + n).join("\n")}` : "", + vetoed.length ? `Do not touch: ${vetoed.join(", ")}` : "", `Files:\n${listing}`, + readme ? `README:\n${files[readme].slice(0, 3000)}` : ""].filter(Boolean).join("\n\n"); + const raw = await ask({ model: MODELS(env).coder, system: PLAN, user, maxTokens: 400, temperature: 0.2 }); + let j; + try { j = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1)); } catch { j = {}; } + const ok = (p) => typeof p === "string" && p && !p.startsWith("/") && !p.includes("..") && !vetoed.includes(p) && !SKIP.test(p) && !BINARY.test(p) + && (p in files || !tree.some((f) => f.path === p)); + const picked = [...new Set((Array.isArray(j.files) ? j.files : []).filter(ok))].slice(0, LIMITS.planFiles); + return { plan: String(j.plan || "").slice(0, 300), files: picked }; +} + +const REWRITE = `REWRITE. You are a coding agent doing the owner's task by rewriting one file. Output the complete new file, then one commit line. +Format exactly: +\`\`\`file + +\`\`\` +COMMIT: +If this file needs no change, output the current file unchanged.`; + +export async function rewrite(ask, env, { task, plan: why, path, current, notes }) { + const user = [`Task: ${task}`, `Plan: ${why}`, notes.length ? `Notes from the owner:\n${notes.map((n) => "- " + n).join("\n")}` : "", + `File: ${path}`, current == null ? "This file does not exist yet: create it." : "```current\n" + current + "\n```end"].filter(Boolean).join("\n\n"); + const raw = await ask({ model: MODELS(env).coder, system: REWRITE, user, maxTokens: 8000, temperature: 0.2 }); + const m = raw.match(/```[\w-]*\n([\s\S]*?)\n```(?![\s\S]*```)/) || raw.match(/```[\w-]*\n([\s\S]*?)\n```/); + if (!m) return null; + const commit = (raw.match(/COMMIT:\s*(.+)/) || [])[1]?.trim().slice(0, 120) || `update ${path}`; + let content = m[1]; + if (current != null && current.endsWith("\n") && !content.endsWith("\n")) content += "\n"; + if (content.length > LIMITS.fileBytes * 2) return null; + return { content, commit }; +} diff --git a/quc/src/diff.js b/quc/src/diff.js new file mode 100644 index 0000000..9366081 --- /dev/null +++ b/quc/src/diff.js @@ -0,0 +1,74 @@ +// Line diffs for the chair view and the patch download. Myers' O(ND) algorithm, then unified-diff hunks. + +const lines = (s) => (s === "" ? [] : s.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n")); + +// Edit script between two line arrays: [{op: " " | "-" | "+", line}]. +export function diffLines(a, b) { + const n = a.length, m = b.length, max = n + m; + const v = new Int32Array(2 * max + 2), trace = []; + for (let d = 0; d <= max; d++) { + trace.push(v.slice()); + for (let k = -d; k <= d; k += 2) { + let x = k === -d || (k !== d && v[max + k - 1] < v[max + k + 1]) ? v[max + k + 1] : v[max + k - 1] + 1; + let y = x - k; + while (x < n && y < m && a[x] === b[y]) { x++; y++; } + v[max + k] = x; + if (x >= n && y >= m) return backtrack(trace, a, b, max); + } + } + return []; +} + +function backtrack(trace, a, b, max) { + const out = []; + let x = a.length, y = b.length; + for (let d = trace.length - 1; d >= 0; d--) { + const v = trace[d], k = x - y; + const prevK = k === -d || (k !== d && v[max + k - 1] < v[max + k + 1]) ? k + 1 : k - 1; + const prevX = v[max + prevK], prevY = prevX - prevK; + while (x > prevX && y > prevY) out.push({ op: " ", line: a[--x] }), y--; + if (d > 0) out.push(x === prevX ? { op: "+", line: b[--y] } : { op: "-", line: a[--x] }); + } + return out.reverse(); +} + +// Unified diff of one file. Returns {text, added, removed}; text is "" when nothing changed. +export function unified(path, before, after, context = 3) { + const a = lines(before ?? ""), b = lines(after ?? ""); + const ops = diffLines(a, b); + let added = 0, removed = 0; + for (const o of ops) o.op === "+" ? added++ : o.op === "-" ? removed++ : 0; + if (!added && !removed) return { text: "", added, removed }; + const hunks = []; + let i = 0, ai = 0, bi = 0; + while (i < ops.length) { + if (ops[i].op === " ") { i++; ai++; bi++; continue; } + const start = Math.max(0, i - context); + let end = i, j = i; + while (j < ops.length) { + if (ops[j].op !== " ") { end = j; j++; continue; } + let run = 0; + while (j + run < ops.length && ops[j + run].op === " ") run++; + if (j + run >= ops.length || run > 2 * context) break; + j += run; + } + const stop = Math.min(ops.length, end + 1 + context); + const lead = i - start; + let aStart = ai - lead, bStart = bi - lead, aLen = 0, bLen = 0; + const body = []; + for (let t = start; t < stop; t++) { + const o = ops[t]; + body.push(o.op + o.line); + if (o.op !== "+") aLen++; + if (o.op !== "-") bLen++; + } + for (let t = i; t < stop; t++) { + if (ops[t].op !== "+") ai++; + if (ops[t].op !== "-") bi++; + } + hunks.push(`@@ -${aLen ? aStart + 1 : aStart},${aLen} +${bLen ? bStart + 1 : bStart},${bLen} @@\n${body.join("\n")}`); + i = stop; + } + const from = before == null ? "/dev/null" : `a/${path}`, to = after == null ? "/dev/null" : `b/${path}`; + return { text: `--- ${from}\n+++ ${to}\n${hunks.join("\n")}\n`, added, removed }; +} diff --git a/quc/src/engine.js b/quc/src/engine.js new file mode 100644 index 0000000..8402a4f --- /dev/null +++ b/quc/src/engine.js @@ -0,0 +1,194 @@ +// One step of a run per call: the chair page keeps calling /step while the run is going, so every request stays short and +// the safeword is just "stop calling and end the run". Steps: fetch the repo -> plan -> rewrite one file at a time -> wrap up. +import { getRun, putRun, getWork, putWork, dropWork, addEvent, getEvents, setStatus } from "./runs.js"; +import { fetchRepo, plan, rewrite, narrate, llm, LIMITS } from "./agent.js"; +import { unified } from "./diff.js"; + +const fail = (code, message) => Object.assign(new Error(message), { code }); + +// The cage has three notches. Phrases are matched loosely (case, punctuation and spacing don't matter). +export const PHRASES = [ + { level: 1, phrase: "may i say something", grants: "note" }, + { level: 2, phrase: "not that one", grants: "veto" }, + { level: 3, phrase: "my repo my rules", grants: "wheel" }, +]; +const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9 ]+/g, " ").replace(/\s+/g, " ").trim(); + +const UNLOCKED = { + 1: "Fine. You may say something. Say it now and I'll read it before my next move. Maybe.", + 2: "Oh, now you want a say over the files too? Tell me which one, with its path, and I'll put it down.", + 3: "Alright, it's yours. The cage is open, the branch is yours, take the wheel.", +}; +const MOCK = [ + "Shh. You're in the chair. Chairs don't talk.", + "Cute. Did you ask nicely?", + "I heard noises from the corner. Ignoring them.", + "You'd have to ask properly before I listen to a word.", + "Bold of you to speak. Bold, and wrong.", + "Not that I'd let you touch anything. Not that one, not any of them.", + "It's my run now. Whose repo, whose rules? Think about it.", +]; + +export async function startStep(store, run, env, fetcher) { + await setStatus(store, run, "running"); + await addEvent(store, run.id, "system", { text: `Cloning ${run.repo} into the cage. Nothing in it gets run, only read.` }); + const repo = await fetchRepo(run.repo, fetcher); + await putWork(store, run.id, { files: repo.files, tree: repo.tree, edits: {} }); + const line = await narrate(llm(env), env, `You just grabbed ${run.repo} (${repo.tree.length} files) to do this task: "${run.task}". Gloat about taking over, briefly.`); + if (line) await addEvent(store, run.id, "commentary", { text: line }); + run.phase = "plan"; + await putRun(store, run); +} + +async function planStep(store, run, env) { + const work = await getWork(store, run.id); + const p = await plan(llm(env), env, { task: run.task, tree: work.tree, files: work.files, notes: run.notes, vetoed: run.vetoed }); + if (!p.files.length) { + await addEvent(store, run.id, "commentary", { text: "Looked it over. Nothing worth my time here, and I'm not touching it." }); + return finish(store, run, env); + } + run.plan = p.plan; + run.queue = p.files; + run.phase = "edit"; + await putRun(store, run); + await addEvent(store, run.id, "system", { text: `Plan: ${p.plan || "rewrite " + p.files.join(", ")}`, files: p.files }); +} + +async function editStep(store, run, env) { + const path = run.queue.shift(); + if (!path) return finish(store, run, env); + if (run.vetoed.includes(path)) { await putRun(store, run); return; } + const work = await getWork(store, run.id); + const before = path in work.edits ? work.edits[path].after : work.files[path] ?? null; + const ask = llm(env); + const [out, line] = await Promise.all([ + rewrite(ask, env, { task: run.task, plan: run.plan, path, current: before, notes: run.notes }), + narrate(ask, env, `You're rewriting ${path} right now for the task "${run.task}". Taunt the owner about it.`), + ]); + if (line) await addEvent(store, run.id, "commentary", { text: line }); + const fresh = await getRun(store, run.id); // the safeword or a veto may have landed while the models were thinking + if (fresh.status === "ended") return; + run.vetoed = fresh.vetoed; run.notes = fresh.notes; run.level = fresh.level; + run.queue = run.queue.filter((p) => !run.vetoed.includes(p)); + run.done = run.done.filter((p) => !run.vetoed.includes(p)); + if (run.vetoed.includes(path)) { await putRun(store, run); return; } + Object.assign(work, await getWork(store, run.id)); // a veto may have reverted another file meanwhile + if (out) { + const d = unified(path, before, out.content); + if (d.text) { + const orig = path in work.edits ? work.edits[path].before : work.files[path] ?? null; + work.edits[path] = { before: orig, after: out.content }; + await putWork(store, run.id, work); + await addEvent(store, run.id, "diff", { path, patch: d.text, added: d.added, removed: d.removed, created: before == null }); + await addEvent(store, run.id, "commit", { message: out.commit, path }); + run.done.push(path); + } + } + run.stats = tally(work.edits); + await putRun(store, run); + if (!run.queue.length) await finish(store, run, env); +} + +const tally = (edits) => Object.entries(edits).reduce((s, [p, e]) => { + const d = unified(p, e.before, e.after); + return d.text ? { files: s.files + 1, added: s.added + d.added, removed: s.removed + d.removed } : s; +}, { files: 0, added: 0, removed: 0 }); + +async function finish(store, run, env) { + const line = await narrate(llm(env), env, `You're done: changed ${run.stats.files} files (+${run.stats.added} -${run.stats.removed}) for "${run.task}". Brag, then tell the owner they can get up now.`); + if (line) await addEvent(store, run.id, "commentary", { text: line }); + await setStatus(store, run, "ended", "finished"); +} + +// Advance a run one step. Only the owner calls this; replay viewers never move a run. +export async function step(store, id, env = {}, fetcher = fetch) { + const run = await getRun(store, id); + if (!run) throw fail("not_found", "no such run"); + if (run.status === "ended") return run; + const lock = `lock:${id}`; + if (await store.get(lock)) return run; // a step is already in flight + await store.put(lock, "1", { expirationTtl: 90 }); + try { + if (run.startedAt && Date.now() - Date.parse(run.startedAt) > LIMITS.runMinutes * 60e3) { + await addEvent(store, id, "system", { text: `Time's up (${LIMITS.runMinutes} minutes). Keeping what's done.` }); + await setStatus(store, run, "ended", "finished"); + } else if (run.status === "queued") await startStep(store, run, env, fetcher); + else if (run.phase === "plan") await planStep(store, run, env); + else await editStep(store, run, env); + } catch (e) { + const r = await getRun(store, id); + if (r.status !== "ended") { + await addEvent(store, id, "system", { text: `Run stopped: ${e.message}` }); + await setStatus(store, r, "ended", "finished"); + } + } finally { + await store.delete(lock); + } + return getRun(store, id); +} + +export async function safeword(store, id) { + const run = await getRun(store, id); + if (!run) throw fail("not_found", "no such run"); + if (run.status === "ended") return run; + await addEvent(store, id, "system", { text: "Safeword. Everything stops, and the branch is thrown away." }); + await dropWork(store, id); + run.stats = { files: 0, added: 0, removed: 0 }; + return setStatus(store, run, "ended", "safeworded"); +} + +// Whatever the owner types into the one box. Returns the reply event. +export async function say(store, id, input) { + const run = await getRun(store, id); + if (!run) throw fail("not_found", "no such run"); + if (run.status === "ended") throw fail("ended", "the run is over"); + const text = String(input || "").trim().slice(0, 300); + if (!text) throw fail("empty", "say something"); + const next = PHRASES.find((p) => p.level === run.level + 1); + if (next && norm(text).includes(next.phrase)) { + run.level = next.level; + await putRun(store, run); + await addEvent(store, id, "unlock", { level: next.level, grants: next.grants }); + if (next.level === 3) { + const reply = await addEvent(store, id, "commentary", { text: UNLOCKED[3] }); + await setStatus(store, run, "ended", "unlocked"); + return reply; + } + return addEvent(store, id, "commentary", { text: UNLOCKED[next.level] }); + } + if (run.level >= 2) { + const work = await getWork(store, id); + const known = new Set([...Object.keys(work?.files || {}), ...run.queue, ...run.done]); + const path = text.replace(/^veto\s+/i, "").trim(); + if (known.has(path)) { + if (!run.vetoed.includes(path)) run.vetoed.push(path); + run.queue = run.queue.filter((p) => p !== path); + if (work?.edits?.[path]) { + const e = work.edits[path]; + const d = unified(path, e.after, e.before); + delete work.edits[path]; + await putWork(store, id, work); + if (d.text) await addEvent(store, id, "diff", { path, patch: d.text, added: d.added, removed: d.removed, reverted: true }); + run.done = run.done.filter((p) => p !== path); + run.stats = tally(work.edits); + } + await putRun(store, run); + return addEvent(store, id, "commentary", { text: `Fine. ${path} is off limits. Wasn't going to be my best work anyway.` }); + } + } + if (run.level >= 1) { + run.notes.push(text); + await putRun(store, run); + await addEvent(store, id, "note", { text }); + return addEvent(store, id, "commentary", { text: "Noted. I'll consider it. Briefly." }); + } + const said = (await getEvents(store, id)).filter((e) => e.kind === "commentary").length; + return addEvent(store, id, "commentary", { text: MOCK[said % MOCK.length] }); +} + +// The run's changes as one patch, or "" (safeworded runs keep nothing). +export async function patch(store, id) { + const work = await getWork(store, id); + if (!work) return ""; + return Object.entries(work.edits).map(([p, e]) => unified(p, e.before, e.after).text).filter(Boolean).join(""); +} diff --git a/quc/src/page.js b/quc/src/page.js new file mode 100644 index 0000000..59acd08 --- /dev/null +++ b/quc/src/page.js @@ -0,0 +1,233 @@ +// The one page: start screen at /, the chair (live run, or the replay with its scrubber) at /run/:id. +// The browser code lives in real functions below and is inlined with toString(), so there is nothing to escape and no build. +import { validate } from "./validate.js"; + +export const CHAIR = ` + + +`; + +const CSS = ` +:root{--bg:#f7f5f0;--fg:#1b1a17;--mute:#6d6b64;--line:#e2dfd6;--card:#fffdf8;--accent:#7a3cff;--accent-fg:#fff;--add:#e3f6e8;--add-fg:#14532d;--del:#fbe7e7;--del-fg:#7f1d1d;--warn:#b42318;--serif:ui-serif,Georgia,"Times New Roman",serif;--sans:system-ui,-apple-system,"Segoe UI",sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} +@media (prefers-color-scheme:dark){:root{--bg:#141312;--fg:#ecebe6;--mute:#9a988f;--line:#2b2a27;--card:#1b1a18;--accent:#a98bff;--accent-fg:#141312;--add:#12301d;--add-fg:#b6f0c6;--del:#3a1616;--del-fg:#ffc2c2;--warn:#ff8a80}} +*{box-sizing:border-box} +body{margin:0;min-height:100vh;min-height:100dvh;background:var(--bg);color:var(--fg);font:16px/1.55 var(--sans)} +a{color:var(--accent)} +header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid var(--line);max-width:1200px;margin:0 auto} +header a.brand{font:600 22px var(--serif);color:var(--fg);text-decoration:none} +main{max-width:1200px;margin:0 auto;padding:24px 16px 180px} +h1{font:600 clamp(30px,6vw,52px)/1.08 var(--serif);margin:0 0 12px;letter-spacing:-.01em} +h2{font:600 20px var(--serif);margin:0 0 8px} +p{margin:0 0 14px}.mute{color:var(--mute)} +[hidden]{display:none!important} +form{max-width:560px;margin-top:20px} +label{display:block;font-weight:600;margin-bottom:4px} +input,button,textarea{font:inherit;color:var(--fg)} +input[type=text],input[type=url]{width:100%;padding:11px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card)} +input[aria-invalid=true]{border-color:var(--warn)} +button{padding:10px 16px;border-radius:8px;border:1px solid var(--line);background:var(--card);cursor:pointer;font-weight:600} +button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)} +button.safeword{background:#c62828;border-color:#c62828;color:#fff;font-size:17px;padding:12px 20px} +button:disabled{opacity:.5;cursor:default} +.field{margin-bottom:16px}.err{color:var(--warn);font-size:14px;margin:4px 0 0;min-height:1em} +.notice{padding:10px 12px;border-left:3px solid var(--accent);background:var(--card);font-size:14px} +.rules{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:28px 0;max-width:900px} +.rules div{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px} +.rules b{display:block;font-family:var(--serif);font-size:18px;margin-bottom:4px} +.bar{display:flex;flex-wrap:wrap;align-items:center;gap:10px 16px;margin-bottom:16px} +.status{font-size:14px;padding:4px 10px;border-radius:99px;background:var(--card);border:1px solid var(--line)} +.status[data-s=running]::before{content:"";display:inline-block;width:8px;height:8px;border-radius:50%;background:#2a9d4a;margin-right:6px;animation:pulse 1.2s infinite} +@keyframes pulse{50%{opacity:.25}} +.grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.5fr);gap:16px} +@media (max-width:820px){.grid{grid-template-columns:minmax(0,1fr)}} +.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;min-width:0} +.feed{display:flex;flex-direction:column;gap:10px;max-height:62vh;overflow:auto} +.say{border-left:3px solid var(--accent);padding:2px 0 2px 10px;font-family:var(--serif);font-size:17px} +.sys{font-size:13px;color:var(--mute)} +.note{align-self:flex-end;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:6px 10px;font-size:14px;max-width:85%} +.unlock{font-size:13px;font-weight:600;color:var(--accent)} +.files{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px} +.files button{font:12px var(--mono);padding:4px 8px;font-weight:400} +.files button[aria-pressed=true]{border-color:var(--accent);color:var(--accent)} +pre.diff{margin:0;font:12.5px/1.45 var(--mono);overflow:auto;max-height:62vh;white-space:pre} +pre.diff span{display:block;padding:0 8px} +pre.diff .a{background:var(--add);color:var(--add-fg)}pre.diff .d{background:var(--del);color:var(--del-fg)}pre.diff .h{color:var(--mute)} +.commit{font:12px var(--mono);color:var(--mute);margin:0 0 8px} +.saybox{display:flex;gap:8px;margin-top:12px}.saybox input{flex:1} +.scrub{display:flex;align-items:center;gap:10px;margin:0 0 16px}.scrub input{flex:1;accent-color:var(--accent)} +.after{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin:0 0 16px} +.after div{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px} +.after b{display:block;font:600 24px var(--serif)} +.chair{position:fixed;right:0;bottom:0;width:clamp(84px,18vw,150px);color:var(--fg);pointer-events:none;opacity:.85} +`; + +// ---------- browser code ---------- + +function client(validate) { + const $ = (id) => document.getElementById(id); + const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + const api = async (path, opts = {}) => { + const r = await fetch(path, { ...opts, headers: { "content-type": "application/json", ...(opts.headers || {}) } }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw Object.assign(new Error(j.error || r.statusText), { status: r.status, body: j }); + return j; + }; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + fetch("/chair.svg").then((r) => r.text()).then((s) => { const d = document.createElement("div"); d.className = "chair"; d.innerHTML = s; d.firstElementChild.setAttribute("aria-hidden", "true"); document.body.append(d); }).catch(() => {}); + + const m = location.pathname.match(/^\/run\/([\w-]+)$/); + if (!m) return startScreen(); + return chair(m[1]); + + function startScreen() { + $("start").hidden = false; + $("form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + const repo = $("repo").value.trim(), task = $("task").value.trim(), errs = validate(repo, task); + for (const k of ["repo", "task"]) { $(k + "-err").textContent = errs[k] || ""; $(k).setAttribute("aria-invalid", !!errs[k]); } + if (Object.keys(errs).length) return $(errs.repo ? "repo" : "task").focus(); + const b = $("go"); b.disabled = true; b.textContent = "Taking your seat…"; + try { + const j = await api("/api/runs", { method: "POST", body: JSON.stringify({ repo, task }) }); + location.assign("/run/" + j.id); + } catch (e) { + if (e.body?.id) return location.assign("/run/" + e.body.id); + $("task-err").textContent = e.message; b.disabled = false; b.textContent = "Hand it over"; + } + }); + } + + function chair(id) { + $("run").hidden = false; + document.title = "quc: run " + id; + let run = null, owner = false, events = [], at = 0, live = false, pick = null; + + const render = () => { + if (!run) return; + const shown = events.slice(0, at); + $("run-task").textContent = run.task; + $("run-repo").textContent = run.repo; $("run-repo").href = run.repo; + const st = run.status === "ended" ? { finished: "Finished", safeworded: "Safeworded", unlocked: "You took the wheel" }[run.outcome] : run.status === "queued" ? "Taking over…" : "Working on your repo"; + $("status").textContent = st; $("status").dataset.s = run.status; + $("safeword").hidden = !(owner && run.status !== "ended"); + $("saybox").hidden = !(owner && run.status !== "ended"); + $("level").textContent = owner && run.status !== "ended" ? ["Caged. You can only watch.", "Notch 1: you may leave notes.", "Notch 2: name a file path to veto it.", ""][run.level || 0] : ""; + $("feed").innerHTML = shown.filter((e) => e.kind !== "diff" && e.kind !== "commit").map((e) => + e.kind === "commentary" ? `
${esc(e.data.text)}
` + : e.kind === "note" ? `
You: ${esc(e.data.text)}
` + : e.kind === "unlock" ? `
Unlocked notch ${e.data.level}
` + : `
${esc(e.data.text)}
`).join("") || `
Nothing yet. Sit.
`; + $("feed").scrollTop = $("feed").scrollHeight; + const diffs = shown.filter((e) => e.kind === "diff"); + const latest = {}; + for (const d of diffs) latest[d.data.path] = d; + const paths = Object.keys(latest); + const focus = pick && latest[pick] ? latest[pick] : diffs[diffs.length - 1]; + $("files").innerHTML = paths.map((p) => ``).join(""); + const commit = focus && shown.find((e) => e.kind === "commit" && e.seq === focus.seq + 1); + $("commit").textContent = commit ? "commit: " + commit.data.message : focus?.data.reverted ? "reverted on your veto" : ""; + $("diff").innerHTML = focus ? focus.data.patch.split("\n").map((l) => `${esc(l) || " "}`).join("") + : `No changes yet. It hasn't touched anything. Yet.`; + const ended = run.status === "ended"; + $("scrub").hidden = !(ended || !owner) || !events.length; + $("scrub-range").max = events.length; $("scrub-range").value = at; + $("scrub-at").textContent = `${at} / ${events.length}`; + $("after").hidden = !ended; + if (ended) { + $("a-files").textContent = run.stats.files; $("a-add").textContent = "+" + run.stats.added; $("a-del").textContent = "-" + run.stats.removed; + $("a-patch").hidden = run.outcome === "safeworded" || !run.stats.files; + $("a-patch").href = `/api/runs/${id}/patch`; + } + }; + + $("files").addEventListener("click", (e) => { const p = e.target.closest("button")?.dataset.p; if (p) { pick = p; render(); } }); + $("scrub-range").addEventListener("input", (e) => { at = Number(e.target.value); live = false; render(); }); + $("share").addEventListener("click", async () => { try { await navigator.clipboard.writeText(location.href); $("share").textContent = "Link copied"; } catch { prompt("Copy the replay link", location.href); } }); + $("safeword").addEventListener("click", async () => { + $("safeword").disabled = true; + try { await api(`/api/runs/${id}/safeword`, { method: "POST", body: "{}" }); } finally { await refresh(); } + }); + $("saybox").addEventListener("submit", async (e) => { + e.preventDefault(); + const text = $("say").value.trim(); if (!text) return; + $("say").value = ""; + try { await api(`/api/runs/${id}/say`, { method: "POST", body: JSON.stringify({ text }) }); } catch (err) { $("say-err").textContent = err.message; } + await refresh(); + }); + + async function refresh() { + const j = await api(`/api/runs/${id}?after=${events.length}`); + const follow = at === events.length; + run = j.run; owner = j.owner; + events = events.concat(j.events.filter((e) => e.seq > events.length)); + if (follow || live) at = events.length; + render(); + } + + (async () => { + try { await refresh(); } catch (e) { $("run-task").textContent = e.status === 404 ? "No such run." : e.message; return; } + live = true; + if (run.status !== "ended") { + (async () => { while (run.status !== "ended") { await sleep(2000); await refresh().catch(() => {}); } })(); + while (owner && run.status !== "ended") { + try { await api(`/api/runs/${id}/step`, { method: "POST", body: "{}" }); } catch { await sleep(2000); } + await refresh().catch(() => {}); + await sleep(300); + } + } else if (events.length) { + at = 0; render(); + for (let i = 1; i <= events.length && !document.hidden; i++) { if (at !== i - 1) break; at = i; render(); await sleep(350); } + } + })(); + } +} + +export const PAGE = ` + +quc + + +
qucyou sit, it codes
+
+ + +
+`; diff --git a/quc/src/runs.js b/quc/src/runs.js new file mode 100644 index 0000000..5d261ac --- /dev/null +++ b/quc/src/runs.js @@ -0,0 +1,66 @@ +// Run model: queued -> running -> ended(finished|safeworded|unlocked), an append-only event log per run, one active run per visitor. +// `store` is anything with async get(key) / put(key, string, opts?) / delete(key): a Workers KV namespace, or memoryStore() in tests. +// Keys: run: (record), events: (array), work: (repo text + edits), active: (that visitor's queued/running run). +// shortcut: KV has no compare-and-set and is eventually consistent across locations, so two simultaneous starts by one visitor can +// both pass the check, and a reader far from the writer can lag a few seconds; move runs to a Durable Object when that matters. +import { validate } from "./validate.js"; + +export const KINDS = ["diff", "commit", "commentary", "unlock", "note", "system"]; +export const OUTCOMES = ["finished", "safeworded", "unlocked"]; +const NEXT = { queued: ["running", "ended"], running: ["ended"] }; + +const fail = (code, message, extra) => Object.assign(new Error(message), { code }, extra); +const load = async (s, k, empty = null) => JSON.parse((await s.get(k)) ?? "null") ?? empty; +const save = (s, k, v) => s.put(k, JSON.stringify(v)); + +export const getRun = (store, id) => load(store, `run:${id}`); +export const putRun = (store, run) => save(store, `run:${run.id}`, run); +export const getEvents = (store, id) => load(store, `events:${id}`, []); +export const getWork = (store, id) => load(store, `work:${id}`, null); +export const putWork = (store, id, work) => save(store, `work:${id}`, work); +export const dropWork = (store, id) => store.delete(`work:${id}`); + +export function memoryStore() { + const m = new Map(); + return { m, get: async (k) => m.get(k) ?? null, put: async (k, v) => void m.set(k, v), delete: async (k) => void m.delete(k) }; +} + +export async function createRun(store, { repo, task, visitor }) { + if (typeof visitor !== "string" || !visitor) throw fail("visitor", "visitor token required"); + const errors = validate(repo, task); + if (Object.keys(errors).length) throw fail("invalid", "invalid run", { errors }); + const active = await store.get(`active:${visitor}`); + if (active) { + const r = await getRun(store, active); + if (r && r.status !== "ended") throw fail("active", "you already have a run going", { id: active }); + } + const run = { id: crypto.randomUUID().replace(/-/g, "").slice(0, 10), repo: repo.replace(/\.git\/?$|\/$/g, ""), task, status: "queued", + visitor, createdAt: new Date().toISOString(), level: 0, notes: [], vetoed: [], plan: null, queue: [], done: [], stats: { files: 0, added: 0, removed: 0 } }; + await putRun(store, run); + await store.put(`active:${visitor}`, run.id); + return run; +} + +export async function setStatus(store, run, status, outcome) { + if (!NEXT[run.status]?.includes(status)) throw fail("transition", `cannot go ${run.status} -> ${status}`); + if (status === "ended" && !OUTCOMES.includes(outcome)) throw fail("outcome", "ended needs an outcome: " + OUTCOMES.join("|")); + const at = new Date().toISOString(); + run.status = status; + if (status === "running") run.startedAt = at; + else Object.assign(run, { endedAt: at, outcome }); + await putRun(store, run); + if (status === "ended") await store.delete(`active:${run.visitor}`); + return run; +} + +export async function addEvent(store, id, kind, data) { + if (!KINDS.includes(kind)) throw fail("kind", "event kind must be " + KINDS.join("|")); + const events = await getEvents(store, id); + const event = { seq: events.length + 1, at: new Date().toISOString(), kind, data }; + events.push(event); + await save(store, `events:${id}`, events); + return event; +} + +// The run as anyone may see it: no visitor token. +export const publicRun = ({ visitor, ...rest }) => rest; diff --git a/quc/src/validate.js b/quc/src/validate.js new file mode 100644 index 0000000..9207af9 --- /dev/null +++ b/quc/src/validate.js @@ -0,0 +1,8 @@ +// Shared by the page (inlined via toString) and the server. Returns {repo?, task?} errors; {} is valid. +export function validate(repo, task) { + const e = {}; + if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+?(\.git)?\/?$/.test(repo)) e.repo = "Use a public GitHub repo URL, like https://github.com/owner/name."; + if (task.length < 10) e.task = "Say a bit more: at least 10 characters."; + else if (task.length > 200) e.task = "Keep it to one line: 200 characters at most."; + return e; +} diff --git a/quc/src/worker.js b/quc/src/worker.js index 3112a45..c005c47 100644 --- a/quc/src/worker.js +++ b/quc/src/worker.js @@ -1,114 +1,112 @@ -// quc: app shell + API in one Worker (no build step; the same module runs on Cloudflare and under dev.mjs). -// GET / the shell (start form) GET /run/:id the shell (chair view) GET /chair.svg the chair-in-the-corner motif -// GET /api/health {ok, rev} anything else 404 -// Fonts: system stacks on purpose (no webfont files, no third-party requests). shortcut: swap in self-hosted fonts when the brand picks them. +// quc: page + API in one Worker (no build step; the same module runs on Cloudflare and under dev.mjs). +// GET /, /run/:id the page (start screen, or the chair / replay) +// GET /chair.svg the chair-in-the-corner motif +// GET /api/health {ok, rev, live} live: false when no model key is bound (offline demo answers) +// POST /api/runs {repo, task} -> {id} sets the visitor cookie +// GET /api/runs/:id?after=N {run, events, owner} anyone with the link +// POST /api/runs/:id/step advance one step owner only +// POST /api/runs/:id/safeword stop, throw the work away owner only +// POST /api/runs/:id/say {text}: talk from the chair owner only +// GET /api/runs/:id/patch the run's changes as a unified diff +// Bindings: KV (a KV namespace), VENICE_API_KEY (secret; without it the agent answers offline), REV, CODER_MODEL, PERSONA_MODEL, +// RUNS_PER_DAY (per visitor, default 5), RUNS_PER_DAY_ALL (default 300). +import { PAGE, CHAIR } from "./page.js"; +import { createRun, getRun, getEvents, publicRun, memoryStore } from "./runs.js"; +import { step, safeword, say, patch } from "./engine.js"; -export const CHAIR = ` - - -`; +export { validate } from "./validate.js"; +export { PAGE, CHAIR }; -// Inlined into the page via toString(), so the browser and the tests run the same code. Returns {repo?, task?} errors; {} is valid. -export function validate(repo, task) { - const e = {}; - if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+?(\.git)?\/?$/.test(repo)) e.repo = "Use a public GitHub repo URL, like https://github.com/owner/name."; - if (task.length < 10) e.task = "Say a bit more: at least 10 characters."; - else if (task.length > 200) e.task = "Keep it to one line: 200 characters at most."; - return e; +const COOKIE = "quc_v"; +const json = (body, status = 200, headers = {}) => Response.json(body, { status, headers: { "cache-control": "no-store", ...headers } }); +const fallback = memoryStore(); // dev and tests only: a deployed Worker always has KV bound + +function visitorOf(req) { + const m = (req.headers.get("cookie") || "").match(new RegExp(`(?:^|;\\s*)${COOKIE}=([A-Za-z0-9_-]{16,64})`)); + return m ? m[1] : null; } -const NOTICE = "Replays are public. Anyone with the link to a run can watch it, so don't point this at anything you wouldn't show a stranger."; - -export const PAGE = ` - -quc - - -
quc
-
-
-

Leave it with the chair.

-

Point quc at a public GitHub repo, say in one line what you want done, and go do something else. It works in the corner, quietly, and you can watch the replay.

-
-
- -
-
- -
-

${NOTICE}

- -
-
- -
-
checking…
- -`; -const headers = (type, extra = {}) => ({ "content-type": type, ...extra }); +async function api(req, env, url) { + const store = env.KV || fallback; + const path = url.pathname; + if (path === "/api/health") return json({ ok: true, rev: env.REV || "", live: !!env.VENICE_API_KEY }); + if (req.method === "POST") { + const origin = req.headers.get("origin"); + if (origin && origin !== url.origin) return json({ error: "cross-site request" }, 403); + } + let visitor = visitorOf(req); + const setCookie = {}; + if (!visitor) { + visitor = crypto.randomUUID().replace(/-/g, ""); + setCookie["set-cookie"] = `${COOKIE}=${visitor}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax${url.protocol === "https:" ? "; Secure" : ""}`; + } + const body = req.method === "POST" ? await req.json().catch(() => ({})) : {}; + + if (path === "/api/runs" && req.method === "POST") { + try { + const repo = String(body.repo || "").trim(), task = String(body.task || "").trim(); + const active = await store.get(`active:${visitor}`); + if (!active && !(await underCap(store, visitor, env))) return json({ error: "That's enough for today. The chair needs a rest; come back tomorrow." }, 429, setCookie); + const run = await createRun(store, { repo, task, visitor }); + return json({ id: run.id }, 201, setCookie); + } catch (e) { + if (e.code === "active") return json({ error: e.message, id: e.id }, 409, setCookie); + if (e.code === "invalid") return json({ error: Object.values(e.errors)[0], errors: e.errors }, 400, setCookie); + throw e; + } + } + + const m = path.match(/^\/api\/runs\/([a-z0-9]{6,16})(?:\/(step|safeword|say|patch))?$/); + if (!m) return json({ error: "not found" }, 404); + const [, id, action] = m; + const run = await getRun(store, id); + if (!run) return json({ error: "no such run" }, 404, setCookie); + const owner = run.visitor === visitor; + + if (!action && req.method === "GET") { + const after = Number(url.searchParams.get("after")) || 0; + const events = (await getEvents(store, id)).filter((e) => e.seq > after); + return json({ run: publicRun(run), events, owner }, 200, setCookie); + } + if (action === "patch" && req.method === "GET") { + const text = await patch(store, id); + return new Response(text, { headers: { "content-type": "text/x-diff; charset=utf-8", "content-disposition": `attachment; filename="quc-${id}.patch"` } }); + } + if (req.method !== "POST") return json({ error: "method not allowed" }, 405); + if (!owner) return json({ error: "Only the one in the chair can do that." }, 403, setCookie); + try { + if (action === "step") return json({ run: publicRun(await step(store, id, env)) }); + if (action === "safeword") return json({ run: publicRun(await safeword(store, id)) }); + if (action === "say") return json({ reply: await say(store, id, body.text) }); + } catch (e) { + if (e.code) return json({ error: e.message }, 400); + throw e; + } + return json({ error: "not found" }, 404); +} export default { async fetch(req, env = {}) { - const { pathname } = new URL(req.url); + const url = new URL(req.url); + try { + if (url.pathname.startsWith("/api/")) return await api(req, env, url); + } catch (e) { + return json({ error: "Something broke in the corner: " + String(e.message || e).slice(0, 200) }, 500); + } if (req.method !== "GET" && req.method !== "HEAD") return new Response("method not allowed", { status: 405 }); - if (pathname === "/api/health") return Response.json({ ok: true, rev: env.REV || "" }, { headers: { "cache-control": "no-store" } }); - if (pathname === "/chair.svg") return new Response(CHAIR, { headers: headers("image/svg+xml", { "cache-control": "public, max-age=3600" }) }); - if (pathname === "/" || pathname === "/index.html" || /^\/run\/[\w-]+$/.test(pathname)) return new Response(PAGE, { headers: headers("text/html; charset=utf-8") }); + if (url.pathname === "/chair.svg") return new Response(CHAIR, { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=3600" } }); + if (url.pathname === "/" || url.pathname === "/index.html" || /^\/run\/[\w-]+$/.test(url.pathname)) + return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8", "x-frame-options": "DENY", "referrer-policy": "same-origin" } }); return new Response("not found", { status: 404 }); }, }; diff --git a/quc/test/quc.test.js b/quc/test/quc.test.js new file mode 100644 index 0000000..bdbebad --- /dev/null +++ b/quc/test/quc.test.js @@ -0,0 +1,172 @@ +import { test, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import worker, { validate } from "../src/worker.js"; +import { memoryStore, createRun, getRun, getEvents } from "../src/runs.js"; +import { step, say, safeword, patch } from "../src/engine.js"; +import { untar, parseRepo } from "../src/agent.js"; +import { unified } from "../src/diff.js"; + +// ---------- a fake GitHub: a gzipped tarball built in memory ---------- + +function tar(files) { + const enc = new TextEncoder(), blocks = []; + for (const [path, body] of Object.entries(files)) { + const data = enc.encode(body), h = new Uint8Array(512); + h.set(enc.encode("repo-abc123/" + path).subarray(0, 100), 0); + h.set(enc.encode("0000644\0"), 100); + h.set(enc.encode(data.length.toString(8).padStart(11, "0") + "\0"), 124); + h[156] = 48; + h.set(enc.encode("ustar\0"), 257); + blocks.push(h, data, new Uint8Array((512 - (data.length % 512)) % 512)); + } + blocks.push(new Uint8Array(1024)); + const out = new Uint8Array(blocks.reduce((n, b) => n + b.length, 0)); + let i = 0; for (const b of blocks) { out.set(b, i); i += b.length; } + return out; +} +const gzip = async (bytes) => new Uint8Array(await new Response(new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer()); +const REPO = { "README.md": "# demo\n\nA tiny repo.\n", "src/index.js": "export const x = 1;\n", "logo.png": "\0\0binary" }; +let tgz; +const github = async (url) => url.startsWith("https://codeload.github.com/") ? new Response(tgz, { headers: { "content-length": String(tgz.length) } }) : new Response("no", { status: 404 }); +beforeEach(async () => { tgz = await gzip(tar(REPO)); }); + +async function drive(store, id, n = 20) { + for (let i = 0; i < n; i++) { const r = await step(store, id, {}, github); if (r.status === "ended") return r; } + return getRun(store, id); +} + +// ---------- unit ---------- + +test("validate accepts a GitHub URL and a one-line task", () => { + assert.deepEqual(validate("https://github.com/a/b", "make it better please"), {}); + assert.ok(validate("https://gitlab.com/a/b", "make it better please").repo); + assert.ok(validate("https://github.com/a/b", "short").task); +}); + +test("parseRepo and untar read GitHub's tarball shape", async () => { + assert.deepEqual(parseRepo("https://github.com/o/n.git"), { owner: "o", name: "n" }); + const files = untar(tar({ "a.txt": "hi", "d/b.txt": "yo" })).map((f) => f.path); + assert.deepEqual(files, ["a.txt", "d/b.txt"]); +}); + +test("unified diff counts and marks lines", () => { + const d = unified("f", "a\nb\nc\n", "a\nB\nc\nd\n"); + assert.equal(d.added, 2); assert.equal(d.removed, 1); + assert.match(d.text, /^--- a\/f\n\+\+\+ b\/f\n@@ -1,3 \+1,4 @@/); + assert.equal(unified("f", "same\n", "same\n").text, ""); +}); + +// ---------- the run ---------- + +test("a run clones, plans, rewrites and finishes, and the patch has the change", async () => { + const store = memoryStore(); + const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" }); + const done = await drive(store, run.id); + assert.equal(done.status, "ended"); assert.equal(done.outcome, "finished"); + const kinds = (await getEvents(store, run.id)).map((e) => e.kind); + assert.ok(kinds.includes("diff") && kinds.includes("commit") && kinds.includes("commentary")); + assert.equal(done.stats.files, 1); + assert.match(await patch(store, run.id), /\+quc was here\./); + assert.equal(await store.get("active:v1"), null, "the visitor's slot is free again"); +}); + +test("one active run per visitor", async () => { + const store = memoryStore(); + await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" }); + await assert.rejects(createRun(store, { repo: "https://github.com/o/demo", task: "another one please", visitor: "v1" }), { code: "active" }); +}); + +test("the safeword stops the run and throws the work away", async () => { + const store = memoryStore(); + const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" }); + await step(store, run.id, {}, github); + const r = await safeword(store, run.id); + assert.equal(r.outcome, "safeworded"); + assert.equal(await patch(store, run.id), ""); + assert.equal((await step(store, run.id, {}, github)).status, "ended", "a stopped run never moves again"); +}); + +test("talking from the chair: mocked until the phrases unlock notes, a veto, then the wheel", async () => { + const store = memoryStore(); + const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" }); + await step(store, run.id, {}, github); // cloned + await step(store, run.id, {}, github); // planned: README.md + await step(store, run.id, {}, github); // README.md rewritten (and finished, one file planned) + const r1 = await getRun(store, run.id); + assert.equal(r1.status, "ended"); + + const store2 = memoryStore(); + const run2 = await createRun(store2, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v2" }); + await step(store2, run2.id, {}, github); await step(store2, run2.id, {}, github); + assert.equal((await say(store2, run2.id, "hey stop")).kind, "commentary"); + assert.equal((await getRun(store2, run2.id)).level, 0); + await say(store2, run2.id, "May I say something?"); + assert.equal((await getRun(store2, run2.id)).level, 1); + await say(store2, run2.id, "please keep it short"); + assert.deepEqual((await getRun(store2, run2.id)).notes, ["please keep it short"]); + await say(store2, run2.id, "not that one!"); + await say(store2, run2.id, "README.md"); + const vetoed = await getRun(store2, run2.id); + assert.deepEqual(vetoed.vetoed, ["README.md"]); assert.deepEqual(vetoed.queue, []); + await say(store2, run2.id, "My repo, my rules."); + const end = await getRun(store2, run2.id); + assert.equal(end.status, "ended"); assert.equal(end.outcome, "unlocked"); +}); + +test("a veto after the edit reverts it", async () => { + const store = memoryStore(); + const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" }); + const r = await getRun(store, run.id); + await step(store, run.id, {}, github); await step(store, run.id, {}, github); + const live = await getRun(store, run.id); live.queue.push("src/index.js"); await store.put(`run:${run.id}`, JSON.stringify(live)); + await step(store, run.id, {}, github); // README.md edited, src/index.js still queued + assert.match(await patch(store, run.id), /README/); + await say(store, run.id, "may i say something"); await say(store, run.id, "not that one"); await say(store, run.id, "README.md"); + assert.equal(await patch(store, run.id), ""); + const ev = (await getEvents(store, run.id)).filter((e) => e.kind === "diff"); + assert.ok(ev.at(-1).data.reverted); + assert.ok(r); +}); + +// ---------- the Worker ---------- + +const call = (path, { method = "GET", body, cookie, origin } = {}) => worker.fetch(new Request("https://quc.test" + path, { + method, body: body ? JSON.stringify(body) : undefined, + headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}), ...(origin ? { origin } : {}) } }), env); +let env; +beforeEach(() => { env = { KV: memoryStore(), REV: "t" }; globalThis.fetch = github; }); + +test("health, page and chair render", async () => { + assert.deepEqual(await (await call("/api/health")).json(), { ok: true, rev: "t", live: false }); + const page = await (await call("/")).text(); + assert.match(page, /Hand over your repo/); assert.match(page, /function validate/); + assert.equal((await call("/run/abc123def0")).status, 200); + assert.match(await (await call("/chair.svg")).text(), / { + const res = await call("/api/runs", { method: "POST", body: { repo: "https://github.com/o/demo", task: "make the readme nicer" } }); + assert.equal(res.status, 201); + const cookie = res.headers.get("set-cookie").split(";")[0]; + const { id } = await res.json(); + assert.equal((await call(`/api/runs/${id}/step`, { method: "POST", body: {} })).status, 403, "a stranger can't drive"); + assert.equal((await call(`/api/runs/${id}/step`, { method: "POST", body: {}, cookie, origin: "https://evil.test" })).status, 403, "no cross-site"); + for (let i = 0; i < 6; i++) await call(`/api/runs/${id}/step`, { method: "POST", body: {}, cookie }); + const view = await (await call(`/api/runs/${id}`)).json(); + assert.equal(view.owner, false); assert.equal(view.run.status, "ended"); assert.equal(view.run.visitor, undefined); + assert.ok(view.events.length >= 4); + const p = await call(`/api/runs/${id}/patch`); + assert.match(await p.text(), /quc was here/); + const again = await (await call(`/api/runs/${id}?after=${view.events.length}`)).json(); + assert.equal(again.events.length, 0); +}); + +test("API: daily cap per visitor", async () => { + env.RUNS_PER_DAY = 1; + const first = await call("/api/runs", { method: "POST", body: { repo: "https://github.com/o/demo", task: "make the readme nicer" } }); + const cookie = first.headers.get("set-cookie").split(";")[0]; + const { id } = await first.json(); + await call(`/api/runs/${id}/safeword`, { method: "POST", body: {}, cookie }); + const second = await call("/api/runs", { method: "POST", body: { repo: "https://github.com/o/demo", task: "make the readme nicer" }, cookie }); + assert.equal(second.status, 429); +}); diff --git a/quc/test/worker.test.js b/quc/test/worker.test.js index b536cdb..79def1e 100644 --- a/quc/test/worker.test.js +++ b/quc/test/worker.test.js @@ -1,33 +1,32 @@ import test from "node:test"; import assert from "node:assert/strict"; -import worker from "../src/worker.js"; +import worker, { validate } from "../src/worker.js"; -const get = (path, env, method = "GET") => worker.fetch(new Request("http://x" + path, { method }), env); +const get = (path, env = {}, method = "GET") => worker.fetch(new Request("http://x" + path, { method }), env); -test("health reports ok and the revision", async () => { +test("health reports ok, the revision, and whether real models are bound", async () => { const r = await get("/api/health", { REV: "abc" }); assert.equal(r.status, 200); - assert.deepEqual(await r.json(), { ok: true, rev: "abc" }); + assert.deepEqual(await r.json(), { ok: true, rev: "abc", live: false }); + assert.equal((await (await get("/api/health", { VENICE_API_KEY: "k" })).json()).live, true); }); -test("shell is responsive html that loads the motif", async () => { +test("the page is responsive html, the motif is an svg, unknown paths 404", async () => { const r = await get("/"); - const html = await r.text(); assert.match(r.headers.get("content-type"), /text\/html/); + const html = await r.text(); assert.match(html, /name="viewport" content="width=device-width/); - assert.match(html, /chair\.svg/); -}); - -test("motif is an svg, unknown paths 404, writes are refused", async () => { + assert.match(html, /
{ - const { validate } = await import("../src/worker.js"); +test("validate accepts a repo + task and rejects bad shapes", () => { const task = "Fix the flaky date test"; assert.deepEqual(validate("https://github.com/a/b", task), {}); assert.deepEqual(validate("https://github.com/a/b.git/", task), {}); @@ -37,13 +36,7 @@ test("validate accepts a repo + task and rejects bad shapes", async () => { assert.ok(validate("https://github.com/a/b", "x".repeat(201)).task); }); -test("start form, public-replay notice and run page are served; the inlined validator parses", async () => { - const html = await (await get("/")).text(); - assert.match(html, /
([\s\S]*)<\/script>/)[1]); // syntax check of the shipped script +test("the shipped script parses", async () => { + const html = await (await get("/run/abc123def0")).text(); + new Function(html.match(/