diff --git a/demo/deck/.gitignore b/demo/deck/.gitignore new file mode 100644 index 0000000..c5301df --- /dev/null +++ b/demo/deck/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +out/ +.wrangler/ +.dev.vars diff --git a/demo/deck/README.md b/demo/deck/README.md new file mode 100644 index 0000000..2224cac --- /dev/null +++ b/demo/deck/README.md @@ -0,0 +1,56 @@ +# Demo deck + +The demo as a slide deck that a Code Mode agent builds and edits. Each slide is a small program in a Cloudflare Artifacts git repo. The presenter renders slides from that repo, and you change them by chatting with the deck agent, which writes code against the repo. + +- **`deck-slides` repo:** `slides/.js` holds the program that draws a slide and `slides/.json` holds what it returned, which is what the presenter renders. `deck.json` holds the slide order, and defaults to [outline.md](outline.md). +- **`deck-agent` repo:** the agent's own Code Mode history. `GitHistoryConnector` commits every program the agent runs, the same as for any codemode-git agent. +- **The agent:** a Workers AI tool-calling loop ([src/worker/agent.ts](src/worker/agent.ts)) around the Code Mode runtime, with a `slides` connector ([src/worker/slides.ts](src/worker/slides.ts)): `outline`, `list`, `read`, `write`, `remove`, `order`, `data`. `slides.write` runs the slide's program in a fresh Dynamic Worker, checks the layout ([src/spec.ts](src/spec.ts)), and commits only when it passes. +- **Slides:** a spec of text, boxes, arrows, agents, charts and code panels on a 1920×1080 canvas, drawn in the demo video's style ([src/client/SlideView.tsx](src/client/SlideView.tsx)). Charts read the real numbers from [../video/src/data.ts](../video/src/data.ts), and speaker notes come from [../video/transcript.md](../video/transcript.md). + +A slide that has a program renders straight from the repo. Only a slide without one shows its outline prompt and waits for you to press **G**. + +## Run it + +It uses the same Artifacts namespace as the example agent. Create the namespace first if you haven't: + +```bash +npx cf artifacts namespaces create --namespace codemode-git +``` + +```bash +npm install # from the repo root: the deck is an npm workspace +npm run dev -w demo/deck +``` + +Open http://localhost:5173. Locally anyone can edit; no sign-in. + +```bash +npm run deploy -w demo/deck +``` + +The deployed deck is public to view at https://deck.clay.gripe. Editing goes through **Cloudflare Access**: an Access app covers `deck.clay.gripe/edit` and `deck.clay.gripe/api/agent` with the "only-me" policy. Press **E** (or click "sign in to edit"), sign in, and you're back on the same slide in edit mode. The Worker also checks the Access JWT itself ([src/worker/access.ts](src/worker/access.ts)): its signature, the app's audience (`ACCESS_AUD` in [wrangler.jsonc](wrangler.jsonc)) and expiry. Without those vars, only localhost can edit. Access can't scope itself to paths on `workers.dev`, which is why the deck needs a hostname on a zone. The `workers.dev` URL stays up as a view-only mirror. + +`npm run build-slides -w demo/deck` asks the agent to build every slide that has no program yet, one message per slide, at high reasoning effort (a few minutes each). It talks to local dev, since the deployed agent is behind Access. Chat edits use low effort, so they take under a minute. + +The local and deployed decks share the `deck-slides` repo, but each keeps its own copy. After committing slides from one, `POST /api/agent/reset` on the other reloads them. Avoid having both write at once: the repo is meant to have one writer. + +## Presenting + +| Key | | +|---|---| +| → / space / click, ← | next, back | +| N | speaker notes and the slide's prompt | +| F | fullscreen | +| E | sign in to edit (Cloudflare Access) | +| C | chat with the deck agent: "make the numbers bigger", "add a slide after this one about pricing", "delete this slide", "move the credits before close" | +| G | have the agent build or rewrite the current slide from its outline prompt | + +The agent adds a slide by writing a program under a new id, removes one with `slides.remove` (the files leave the repo, and git history keeps them), and reorders with `slides.order`. + +## Offline + +`npm run pdf -w demo/deck` saves every built slide to `demo/deck/out/deck.pdf`, one slide per page, from the `?print` view (it needs `npm run dev` running, or set `DECK_URL`). Keep a copy in case the network goes down. + +## Editing the outline + +[outline.md](outline.md) has one `## ` section per slide, and the prompt goes under it. The agent reads it through `slides.outline()`. A changed prompt doesn't rewrite a slide by itself: press **G** on that slide, or ask the agent. diff --git a/demo/deck/index.html b/demo/deck/index.html new file mode 100644 index 0000000..cd68c07 --- /dev/null +++ b/demo/deck/index.html @@ -0,0 +1,13 @@ + + + + + + codemode-git deck + + + +
+ + + diff --git a/demo/deck/outline.md b/demo/deck/outline.md new file mode 100644 index 0000000..c18c4aa --- /dev/null +++ b/demo/deck/outline.md @@ -0,0 +1,84 @@ +# Deck outline + + + +## thesis + +Title slide. Ask the question "What if Cloudflare Artifacts could make Code Mode in Workers self-improving?" over three big lines, with "self-improving?" in amber. Under it, the title "Self-Improving Code Mode Workers, with Artifacts Memory" and the package name codemode-git in mono. + +## hook + +The headline result: DABstep accuracy went from 32.2% to 46.2% with the same model, prompt and 450 tasks. Two huge numbers with an arrow between them, the new one in orange, and the hard-task numbers smaller underneath. The only change was "+ memory". + +## vanish + +Today every Code Mode agent writes code, runs it and throws it away. Show a grid of agents, each with a little code snippet, and the caption "every agent starts from zero". + +## code-mode + +Before and after Code Mode. Left: model and tools with many round-trip arrows, one tool call at a time. Right: the model writes one program (show a few lines of code in a dashed "isolate" box) that calls the tools. + +## gap + +The isolate is thrown away after every run, and the code goes with it. Show the code box fading out and "the code is gone" in red. + +## code-memory + +Phase 1, code memory: four agents (michelle, matthew, lena, omar), each with an arrow to its own Artifacts git repo shown as a row of commit dots. Caption: one repo per agent, one writer per repo, zero merges; 1,000 agents made 1,000 commits in 27s with 0 errors. + +## under-the-hood + +Under the hood, as a left-to-right pipeline of four boxes: execution ends (disposeExecution()), Durable Object (SQLite outbox), isomorphic-git (push, in order), Artifacts repo (one commit per run). Below it, a mono `git clone` line. + +## repo + +Every attempt, in git: a terminal panel with `git log --oneline` showing three commits for "Count flowing lava lamps", the oldest one an error, the newest two completed. + +## recall + +Phase 2, learning: michelle's repo feeds Workers AI (embed question + code), which feeds Vectorize (nearest runs), which hands "unverified examples" to the next agent, matthew. + +## walkthrough + +One real DABstep task (use deck.data("walkthrough")): without memory, 10 programs, 279k tokens, wrong; with memory, 2 programs, 8k tokens, right. Two lanes, one per run, comparing them. + +## provenance + +Provenance: matthew's commit names the exact runs it learned from. A terminal panel with `git show` output that has a `Codemode-Examples:` trailer pointing at agent-michelle@3f90b41. + +## curve + +The learning curve (use deck.data("learningCurve")): median tokens per task by position in the run, baseline vs with memory. A line chart; call out that memory costs about a third of the tokens once it has a few dozen runs. + +## accuracy + +Accuracy by DABstep's official scorer (use deck.data("accuracy")): a bar chart of baseline vs with memory, for Overall and Hard tasks. + +## contagion + +Which memory works: a shared library of verified skills spread one wrong skill to every agent (show a hub with red spokes), while raw runs labeled unverified worked better (separate agents, mostly green). + +## next-steps + +Next steps, as three cards: record outcomes (a git note on each commit), index on push (Artifacts events), and learn between tasks (agents review their own history). + +## new-worker + +Getting started in about a minute: a terminal panel with `npx cf init lava-agent`, `npm install @cloudflare/codemode @smithclay/codemode-git`, the connector plus ARTIFACTS, LOADER and AGENT bindings, then `npx cf deploy --dry-run`. + +## close + +Close: `npm install @smithclay/codemode-git` in green mono, then exactly these three lines as code (don't change them): `const history = new GitHistoryConnector(ctx, env, { sink });`, `const runtime = history.bind(createCodemodeRuntime({`, ` ctx, executor, connectors: [lamps, history] }));`. Then the tagline "Keep the code." in big amber with "Your Workers get smarter over time." under it. + +## credits + +Built by Clay Smith, on Cloudflare. A big name, then the tech stack in three short columns: Code (Workers, Code Mode, Artifacts, Durable Objects, Vectorize), Benchmark (DABstep by Adyen, GLM-5.3 Flash), and Presentation (this deck, written by Code Mode). End with github.com/smithclay/codemode-git. diff --git a/demo/deck/package.json b/demo/deck/package.json new file mode 100644 index 0000000..5debd30 --- /dev/null +++ b/demo/deck/package.json @@ -0,0 +1,30 @@ +{ + "name": "codemode-git-deck", + "private": true, + "type": "module", + "description": "The demo as a slide deck: a Code Mode deck agent keeps one program per slide in an Artifacts repo, the presenter renders from it, and you chat with the agent to change slides.", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "deploy": "vite build && wrangler deploy", + "typecheck": "tsc -p .", + "pdf": "node scripts/pdf.mjs", + "build-slides": "node scripts/build-slides.mjs" + }, + "dependencies": { + "@cloudflare/codemode": "^0.5.3", + "@smithclay/codemode-git": "^0.1.0", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.60.0", + "@cloudflare/workers-types": "^5.20261011.1", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "^5.9.0", + "vite": "^7.3.7", + "wrangler": "^4.149.0" + } +} diff --git a/demo/deck/scripts/build-slides.mjs b/demo/deck/scripts/build-slides.mjs new file mode 100644 index 0000000..99b94ff --- /dev/null +++ b/demo/deck/scripts/build-slides.mjs @@ -0,0 +1,27 @@ +// Asks the deck agent to build every slide that has no program yet, one +// message per slide, the same as pressing G on each. Slides that already +// exist are left alone; pass ids to rebuild specific ones. Builds use high +// reasoning effort: slower, but low effort cuts corners on a blank slide. +// +// npm run build-slides (npm run dev in another terminal) +// npm run build-slides -- hook curve (rebuild these) +// It talks to local dev: a deployed deck's agent is behind Cloudflare Access. +// Both write to the same deck-slides repo; reload the deployed deck after. + +const base = (process.env.DECK_URL ?? "http://localhost:5173").replace(/\/$/, ""); +const headers = { "content-type": "application/json" }; +const only = process.argv.slice(2); + +const deck = await (await fetch(`${base}/api/deck`)).json(); +const todo = deck.slides.filter((s) => (only.length ? only.includes(s.id) : !s.slide)); +console.log(`${todo.length} slides to build: ${todo.map((s) => s.id).join(", ") || "none"}`); + +for (const s of todo) { + const started = Date.now(); + const verb = s.slide ? "Rewrite" : "Build"; + const res = await fetch(`${base}/api/agent`, { method: "POST", headers, body: JSON.stringify({ message: `${verb} slide ${s.id} from its outline prompt.`, current: s.id, effort: "high" }) }); + const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + const secs = Math.round((Date.now() - started) / 1000); + if (body.error) console.log(`✗ ${s.id} (${secs}s): ${body.error}`); + else console.log(`${body.changed.includes(s.id) ? "✓" : "·"} ${s.id} (${secs}s, ${body.steps} steps, ${body.tokens} tokens): ${body.reply}`); +} diff --git a/demo/deck/scripts/pdf.mjs b/demo/deck/scripts/pdf.mjs new file mode 100644 index 0000000..d87615a --- /dev/null +++ b/demo/deck/scripts/pdf.mjs @@ -0,0 +1,27 @@ +// Saves the deck as a PDF, one slide per 16:9 page, from the ?print view, for +// presenting offline. Uses a local Chrome in headless mode. +// +// npm run pdf (npm run dev in another terminal) +// DECK_URL=https://… npm run pdf (a deployed deck) +// CHROME=/path/to/chrome npm run pdf + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; + +const base = (process.env.DECK_URL ?? "http://localhost:5173").replace(/\/$/, ""); +const chrome = + process.env.CHROME ?? + ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/usr/bin/google-chrome", "/usr/bin/chromium"].find((p) => existsSync(p)); +if (!chrome) throw new Error("no Chrome found; set CHROME=/path/to/chrome"); + +const out = new URL("../out/deck.pdf", import.meta.url).pathname; +mkdirSync(new URL("../out/", import.meta.url), { recursive: true }); +execFileSync(chrome, [ + "--headless", + "--disable-gpu", + "--no-pdf-header-footer", + "--virtual-time-budget=15000", + `--print-to-pdf=${out}`, + `${base}/?print` +], { stdio: "ignore" }); // Chrome logs harmless GPU noise on stderr; a failure still throws. +console.log(`wrote ${out}`); diff --git a/demo/deck/src/client/Deck.tsx b/demo/deck/src/client/Deck.tsx new file mode 100644 index 0000000..0c76777 --- /dev/null +++ b/demo/deck/src/client/Deck.tsx @@ -0,0 +1,253 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Turn } from "../worker/agent"; +import type { AgentReply, Deck, DeckSlide } from "../worker/index"; +import { SlideView } from "./SlideView"; + +// The presenter. Slides render from the deck-slides repo. Anyone can view; +// someone signed in through Cloudflare Access (or anyone, in local dev) can +// edit: the chat panel talks to the deck agent, which changes slides with +// Code Mode programs. +// +// Keys: → / space / click next · ← back · N notes · F fullscreen · E sign in +// to edit · C chat · G build/rewrite this slide. Add ?print for a +// one-slide-per-page view to save as PDF (npm run pdf). + +const fetchDeck = () => fetch("/api/deck").then((r) => (r.ok ? (r.json() as Promise) : null)).catch(() => null); +const printing = new URLSearchParams(location.search).has("print"); + +export function DeckApp() { + const [deck, setDeck] = useState(undefined); + useEffect(() => { + void fetchDeck().then(setDeck); + }, []); + if (deck === undefined) return
; + if (!deck) return
Couldn't load /api/deck.
; + return printing ? : ; +} + +/** Every built slide, one per page, without animation: for the PDF. */ +function Print({ deck }: { deck: Deck }) { + useEffect(() => { + document.body.classList.add("print"); + // A hook for scripts/pdf.mjs: printing waits until the slides are drawn. + (window as unknown as { deckReady?: boolean }).deckReady = true; + }, []); + return ( + <> + {deck.slides.filter((s) => s.slide).map((s) => ( +
+ +
+ ))} + + ); +} + +function Presenter({ deck, setDeck }: { deck: Deck; setDeck: (d: Deck) => void }) { + const slides = deck.slides; + const [i, setI] = useState(() => Math.min(Math.max(0, Number(location.hash.slice(1)) - 1 || 0), Math.max(0, slides.length - 1))); + const [notes, setNotes] = useState(false); + const [chatOpen, setChatOpen] = useState(false); + const [toast, setToast] = useState(null); + const [turns, setTurns] = useState([]); + const [working, setWorking] = useState<{ since: number; message: string } | null>(null); + const current: DeckSlide | undefined = slides[i]; + + const say = useCallback((text: string) => { + setToast(text); + setTimeout(() => setToast((t) => (t === text ? null : t)), 4000); + }, []); + + // Editing is on when /api/agent answers. Signed out, Cloudflare Access + // redirects the request to its login page instead (seen here as an opaque + // redirect), and the deck stays view-only. + const [editor, setEditor] = useState(null); + const signIn = useCallback(() => location.assign(`/edit?slide=${i + 1}`), [i]); + useEffect(() => { + void (async () => { + const me = await fetch("/api/agent/me", { redirect: "manual" }).catch(() => null); + if (!me?.ok) return; + setEditor(((await me.json()) as { editor: string }).editor); + const res = await fetch("/api/agent", { redirect: "manual" }).catch(() => null); + if (res?.ok) setTurns((await res.json()) as Turn[]); + })(); + }, []); + + const send = useCallback( + async (message: string, effort: "low" | "high" = "low") => { + if (!editor) return say("Press E to sign in and edit"); + if (working) return say("The agent is still working"); + setWorking({ since: Date.now(), message }); + setTurns((t) => [...t, { role: "user", content: message }]); + try { + const res = await fetch("/api/agent", { + method: "POST", + redirect: "manual", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message, current: current?.id, effort }) + }); + // The Access session expired: Access redirects instead of answering. + if (res.type === "opaqueredirect" || res.status === 401) { + setEditor(null); + setTurns((t) => [...t, { role: "assistant", content: "⚠ Your sign-in expired. Press E to sign in again." }]); + return; + } + const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` }))) as AgentReply | { error: string }; + if ("error" in body) { + setTurns((t) => [...t, { role: "assistant", content: `⚠ ${body.error}` }]); + return; + } + setTurns((t) => [...t, { role: "assistant", content: body.reply }]); + // Slides may have been added, edited, removed or reordered. + const next = await fetchDeck(); + if (next) setDeck(next); + if (body.changed.length) say(`Changed ${body.changed.join(", ")}`); + } catch (err) { + setTurns((t) => [...t, { role: "assistant", content: `⚠ ${err instanceof Error ? err.message : String(err)}` }]); + } finally { + setWorking(null); + } + }, + [editor, working, current, say, setDeck] + ); + + // Keep the position in range when slides are removed. + useEffect(() => { + if (i > slides.length - 1) setI(Math.max(0, slides.length - 1)); + }, [i, slides.length]); + useEffect(() => { + history.replaceState(null, "", `#${i + 1}`); + }, [i]); + useEffect(() => { + const onHash = () => setI(Math.min(Math.max(0, Number(location.hash.slice(1)) - 1 || 0), Math.max(0, slides.length - 1))); + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, [slides.length]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + if ((e.target as HTMLElement).closest("textarea, input")) { + if (e.key === "Escape") setChatOpen(false); + return; + } + const k = e.key.toLowerCase(); + if (k === "arrowright" || k === " " || k === "pagedown") setI((n) => Math.min(n + 1, slides.length - 1)); + else if (k === "arrowleft" || k === "pageup") setI((n) => Math.max(n - 1, 0)); + else if (k === "home") setI(0); + else if (k === "end") setI(slides.length - 1); + else if (k === "n") setNotes((v) => !v); + else if (k === "e" && !editor) signIn(); + else if (k === "c") { + if (editor) setChatOpen((v) => !v); + else say("Press E to sign in and edit"); + } + else if (k === "f") void (document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen()); + else if (k === "g" && current) { + if (!editor) return say("Press E to sign in and edit"); + setChatOpen(true); + void send(current.slide ? `Rewrite slide ${current.id} from its outline prompt.` : `Build slide ${current.id} from its outline prompt.`, "high"); + } else return; + e.preventDefault(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [slides.length, current, editor, send, say, signIn]); + + if (!current) return
No slides yet: {editor ? "press C and ask the agent to build the deck." : "sign in (E) to have the agent build it."}
; + + return ( +
setI((n) => Math.min(n + 1, slides.length - 1))}> + {current.slide ? ( + + ) : ( +
+
NOT BUILT YET{editor ? " · PRESS G TO HAVE THE AGENT WRITE IT" : ""}
+

{current.prompt}

+
+ )} +
+ + {i + 1} / {slides.length} + + {current.sha && ( + + {deck.repo}@{current.sha.slice(0, 7)} + + )} + {working && } + {editor ? ( + editing{editor.includes("@") ? ` as ${editor}` : ""} · C chat · G rewrite + ) : ( + e.stopPropagation()} + > + sign in to edit + + )} +
+ {notes && ( + + )} + {chatOpen && editor && } + {toast &&
{toast}
} +
+ ); +} + +function Working({ since }: { since: number }) { + const [, tick] = useState(0); + useEffect(() => { + const id = setInterval(() => tick((n) => n + 1), 1000); + return () => clearInterval(id); + }, []); + return agent working · {Math.floor((Date.now() - since) / 1000)}s; +} + +function Chat({ turns, working, onSend, slide }: { turns: Turn[]; working: boolean; onSend: (m: string) => unknown; slide: string }) { + const [draft, setDraft] = useState(""); + const log = useRef(null); + useEffect(() => { + log.current?.scrollTo(0, log.current.scrollHeight); + }, [turns, working]); + const submit = () => { + const m = draft.trim(); + if (!m || working) return; + onSend(m); + setDraft(""); + }; + return ( +