quc v0 (2/4): the agent and the run engine
agent.js reads a public repo from GitHub's tarball (nothing executed), plans up to 4 files and rewrites them with a coding model, and narrates with Grok, via Venice; offline answers without a key. engine.js runs one step per call (clone, plan, rewrite a file, wrap up), with the safeword, the three key phrases (notes, a file veto that reverts, the wheel) and the patch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVg99kQMY2Tumhobuyg7Fm
This commit is contained in:
1 parent
4895ac430f
commit
2ad896b9b5
2 files changed
+372
No files matched your search
@@ -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
|
||||
<the whole new file>
|
||||
\`\`\`
|
||||
COMMIT: <short commit message>
|
||||
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 };
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
Reference in new issue
Block a user