diff --git a/quc/src/agent.js b/quc/src/agent.js new file mode 100644 index 0000000..23cf4fb --- /dev/null +++ b/quc/src/agent.js @@ -0,0 +1,178 @@ +// 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. +Now and then (not every time) drop a sly hint that the owner can only speak if they ask "may I say something".`; + +export async function narrate(ask, env, moment) { + try { + const t = await ask({ model: MODELS(env).persona, system: PERSONA, 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/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(""); +}