diff --git a/.gitignore b/.gitignore index 94b814c3..4d338d1f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,8 @@ eval/.env eval/sandbox/ # The eval harness and its runs live in the private codefox-eval repo eval/ + +# The demo recorder's intermediates. The mp4 and its poster are committed; +# the 20MB capture and the speed badges are rebuilt on every run. +assets/demo-raw.webm +assets/.demo-badge-*.png diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..1661f1c4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CodeFox + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0ff2462b..aadda3b1 100644 --- a/README.md +++ b/README.md @@ -4,31 +4,56 @@ ![LOGO](./assets/badge.svg) -Welcome to CODEFOX! A next generation AI sequence full stack project generator with interactive chatbot. +Describe a page in a sentence. CodeFox scaffolds it on your disk, hands the +directory to a coding agent, and renders the result beside the chat — so what +you are looking at is the file the agent just wrote, not a picture of it. -## ⚠️ Experimental Stage +## Demo -> **Note**: This project is currently in experimental stage and will start workflow to agent mode refactoring. +One prompt on the landing page, the questions the agent asks before it builds, +the build itself, and the page it wrote. Recorded against a local run with +`pnpm demo:flow`, so it is the product as it stands today rather than a +mockup — re-run it after a UI change and the demo is current again. -## Demo +The two stretches where a human would only be watching the agent work are sped +up and say so on screen; everything else is real time. + +[![CodeFox: one prompt, one page](assets/demo-poster.jpg)](assets/demo.mp4) + +## What it does + +**It asks before it builds.** On the first message of a project the agent +answers with a question block instead of files — audience, visual direction, +call to action — and the UI renders it as a card, with the design systems as +colour swatches. Answer or skip; it asks once per project. + +**The preview is the file.** A page project renders the HTML the agent just +wrote; a `next` project gets a real `next dev` server on its own port. Both +update as the turn runs. + +**Every turn is a commit.** The project directory is a git repo. Each turn +snapshots, so the workbench can show what changed and restore the tree to any +earlier turn — including your own hand edits, which are committed as "Your +edits" before the agent starts. -https://github.com/user-attachments/assets/8c588e83-b155-445c-bfa7-ed67fb57e77f +**The project remembers.** Only the last 20 turns are replayed to the agent, +so decisions go in a `NOTES.md` the agent keeps and reads back in full every +turn. It never leaves the machine: `NOTES.md` is the one file the deploy and +share paths refuse to publish. -## Key Features +**Restyle without rewriting.** The design system lives in one `:root` block of +CSS variables; picking a different one swaps the tokens, not the markup. -💻 **Transforming Ideas into Projects** -🚀 **Extraordinary Modeling System**: Integrates an AI model to seamlessly connect every aspect of your project. -🤖 **Multi-Agent Generator**: Create and manage multiple intelligent agents to enhance project functionality. -⚡ **One-Click Deployment**: Deploy your project effortlessly to cloud services or clone it locally with ease. -✨ **Live Preview**: Interact with your project while engaging in AI-powered conversations to make real-time modifications. -🔧 **Precise Code Customization**: Leverage targeted and efficient visual tools for precise module adjustments. +**Ship it.** Publish a share link, download a zip, export a PDF, or deploy a +page to your own Vercel account in a single API call. Public projects land on +a wall others can remix, with attribution back to the original. ## Quick start Node.js >= 18 and pnpm. Nothing else — no database to install, no tmux. ```bash -git clone +git clone https://github.com/CodeFox-Repo/codefox.git cd codefox pnpm install pnpm dev @@ -53,11 +78,17 @@ Configured models (override with `LLM_MODELS`): - **Claude Sonnet 4.5** (default) — `anthropic/claude-sonnet-4.5` - **GPT-4o-mini** — `openai/gpt-4o-mini` +That key drives the default in-process agent. To run against a coding CLI +instead — including one already logged in on your machine — see +[Which agent runs](#which-agent-runs). + ### Other dev commands ```bash pnpm dev:tmux # same stack in a tmuxinator session (needs tmux + tmuxinator) pnpm demo:record # re-record the landing-page demo against the running app +pnpm demo:flow # re-record the README demo — drives a real build, so it + # needs a working LLM key and spends tokens ``` ### Checks @@ -79,219 +110,168 @@ Start services individually with `pnpm dev` inside `backend/` or `frontend/`. ## Architecture Overview -CodeFox consists of two main components that work together: - ``` +-------------+ - | Frontend | + | Frontend | Next.js — workbench, preview, question cards | (Next.js) | +------+------+ | - | GraphQL + | GraphQL for state, one ndjson stream per turn | +------v------+ - | Backend | + | Backend | NestJS — auth, projects, quota, telemetry | (NestJS) | +------+------+ | - | OpenAI API + | agent loop: file + shell tools, in the project directory + | + +------v------+ +--------------------+ + | Agent |------->| LLM endpoint | + | (AI SDK / | | OpenAI-compatible | + | claude-code | +--------------------+ + | / codex) | + +------+------+ + | + | writes real files | +------v------+ - | OpenRouter/ | - | OpenAI | + | Project dir | .codefox/projects/, a git repo +-------------+ ``` -- **Frontend (Next.js)**: Provides the user interface and handles client-side logic -- **Backend (NestJS)**: Manages business logic, authentication, project generation, and AI model interactions - -### Build System Architecture +- **Frontend (Next.js)** — the workbench: chat, preview, file tree, console, + and the toolbar (share, download, PDF, deploy, restyle, notes). +- **Backend (NestJS)** — accounts and sessions, project and chat ownership, + quota, the preview servers, and one `agent_turn` row per turn for telemetry. +- **Agent** — an in-process loop by default; the coding CLIs are alternatives, + not the default. See [How a turn runs](#how-a-turn-runs). -The backend includes a sophisticated build system that manages project generation through a sequence of dependent tasks. Here's how it works: +### How a turn runs ```mermaid sequenceDiagram participant User - participant Context as BuilderContext - participant Manager as HandlerManager - participant Handler as BuildHandler - participant Monitor as BuildMonitor - participant VDir as VirtualDirectory - - User->>Context: Create new build sequence - Context->>Manager: Initialize handlers - Context->>Monitor: Start sequence monitoring - - loop For each node in sequence - Context->>Context: Check dependencies - alt Dependencies met - Context->>Manager: Get handler instance - Manager-->>Context: Return handler - Context->>Monitor: Start node execution - Context->>Handler: Execute run() - - Handler->>VDir: Update virtual files - VDir-->>Handler: Files updated - - Handler-->>Context: Return result - Context->>Monitor: End node execution - else Dependencies not met - Context->>Context: Wait and retry - end + participant Web as Frontend + participant API as ChatController + participant Agent as Agent loop + participant Dir as Project directory + + User->>Web: describe a page + Web->>API: createProject (name generated by the model) + API->>Dir: scaffold index.html with the design system's :root + User->>Web: first message + Web->>API: POST /api/chat (ndjson response) + API->>API: assemble instructions (scenario, style, NOTES.md,
lint findings, hand edits, last 20 turns) + API->>Dir: commit "Your edits" if the user touched files + API->>Agent: run the turn + loop until the agent stops + Agent->>Dir: read / write / edit / bash + Agent-->>API: text and tool-call parts + API-->>Web: text and tool events end - - Context->>Monitor: Generate build report - Context-->>User: Return project UUID + API->>Dir: snapshot the turn as a commit + API->>API: record the agent_turn row + API-->>Web: design-lint findings, then close + Web->>User: reload the preview ``` -Key components: - -1. **BuilderContext** - - - Manages the execution state of build nodes - - Handles dependency resolution - - Coordinates between handlers and virtual filesystem +The first turn of a project usually ends without touching a file: the agent is +told to answer with a `codefox-questions` block instead, which the UI renders +as the question card. Answering sends the choices as the next message, and +that turn builds. -2. **BuildHandlerManager** +### Which agent runs - - Singleton managing handler instances - - Provides handler registration and retrieval - - Manages handler dependencies +`AGENT_HARNESS` picks the loop. All three see the same project directory and +emit the same stream parts, so the rest of the backend does not know which ran. -3. **BuildHandler** +| value | what it is | needs | +| --- | --- | --- | +| unset / `aisdk` (default) | in-process AI SDK loop — `streamText` plus read/write/edit/append/list/bash tools | `LLM_API_KEY` for any OpenAI-compatible endpoint | +| `claude-code` | the Claude Code CLI, embedded through the AI SDK harness | `ANTHROPIC_API_KEY`, or `ANTHROPIC_BASE_URL` at an Anthropic-compatible endpoint | +| `codex` | the Codex CLI, same harness | an OpenAI-compatible endpoint, including aggregators | - - Implements specific build tasks - - Can declare dependencies on other handlers - - Has access to virtual filesystem and model +The default is in-process for a reason: the CLI harnesses speak +`/v1/responses`, and aggregators translate that protocol imperfectly — 500s +from one provider, corrupted reasoning signatures from another, and a turn +that never produces a token. Plain `chat/completions` is served natively +everywhere, so that class of failure does not exist on the default path. -4. **BuildMonitor** +A model id may name its own provider with an `@suffix` +(`LLM_MODELS=gpt-5-mini,claude-sonnet-5@cpa` plus `LLM_BASE_URL_CPA` and +`LLM_API_KEY_CPA`), which is how one deployment serves models that do not +share a host. - - Tracks execution progress - - Records timing and success/failure - - Generates build reports +### Where the agent runs -5. **VirtualDirectory** - - Manages in-memory file structure - - Provides file operations during build - - Ensures atomic file updates +`SANDBOX_PROVIDER` picks the sandbox. -### Full-Stack Project Generation Workflow +- **`host` (default)** — the project directory on the backend's own disk, + under `.codefox/projects/`. Zero setup, and the preview is a + local dev server, which is what makes `pnpm dev` work with nothing + installed. It is **not** isolation: the agent's shell has the backend + process's privileges. Safe for your own laptop, wrong for untrusted users. +- **`vercel`** — a real microVM per session (`@vercel/sandbox`), which is what + multi-tenant needs, since there a prompt is untrusted input. Needs + `VERCEL_PROJECT_ID` and a token. -The build system follows a structured workflow to generate a complete full-stack project: - -```mermaid -graph TD - %% Project Initialization - Init[Project Initialization] --> Product[Product Requirements] - Product --> UX[UX Design] - - %% UX Design Flow - UX --> Sitemap[Sitemap Structure] - UX --> Datamap[Data Structure] - - %% Backend Development - Datamap --> DB[Database Schema] - DB --> BE[Backend Structure] - BE --> API[API Design] - BE --> BackendCode[Backend Code] - API --> BackendCode - - %% Frontend Development - Sitemap --> Routes[Route Structure] - Datamap --> Components[Component Design] - Components --> Views[View Implementation] - - %% File Management and Generation - Views --> FE[Frontend Code] - API --> FE - - %% Subgraphs for different roles - subgraph "Product Manager" - Init - Product - end - - subgraph "UX Designer" - UX - Sitemap - Datamap - end - - subgraph "Backend Engineer" - DB - BE - API - BackendCode - end - - subgraph "Frontend Engineer" - Routes - Components - Views - FE - end - - %% Styling - classDef product fill:#e1f5fe,stroke:#01579b - classDef ux fill:#f3e5f5,stroke:#4a148c - classDef backend fill:#e8f5e9,stroke:#1b5e20 - classDef frontend fill:#fff3e0,stroke:#e65100 - - class Init,Product product - class UX,Sitemap,Datamap ux - class DB,BE,API,BackendCode backend - class Routes,Components,Views,FE frontend -``` +Page (`html`) projects always run on the host: they are files the preview +reads directly, with no dev server to boot. ## Troubleshooting -### Common Issues +**"Error creating the project" right after you hit Create.** The backend log +says `OpenAI API key is missing`. Naming a project is a model call, so it +fails before the agent ever runs — set `LLM_API_KEY` (or `OPENROUTER_API_KEY`) +in `backend/.env` and restart. `.env` is read at boot; the watcher does not +reload it. -1. **Port Conflicts** +**The turn ends immediately with "The agent has no credentials".** Same cause, +different call site: the agent loop needs `LLM_API_KEY` pointing at whatever +`LLM_BASE_URL` serves. With `AGENT_HARNESS=claude-code` it is +`ANTHROPIC_API_KEY` instead. - - Ensure ports 3000, 8080, and 3001 are available - - Check for any running processes: `lsof -i :` +**Port conflicts.** 3000 (frontend) and 8080 (backend) are fixed; the rest are +not. A `next` project's preview takes a free ephemeral port per project, and +in `host` mode the CLI harnesses lease a bridge port from 3001-3003 — three, +which also caps how many agent sessions one project can run at once. Find the +holder with `lsof -i :`. -2. **Environment Issues** +**A clean rebuild.** - - Verify all environment variables are properly set - - Ensure model path is correct in LLM server configuration - - Verify model configurations in .codefox/config.json: - - Check model identifiers are correct - - Validate endpoint URLs for cloud-based models - - Ensure API tokens are valid - - Verify local model paths for non-cloud models - -3. **Build Issues** - - ```bash - # Clean installation - pnpm clean - rm -rf node_modules - pnpm install +```bash +rm -rf node_modules +pnpm install +pnpm build +``` - # Rebuild all packages - pnpm build - ``` +**Starting over.** All local state lives in `.codefox/`: the SQLite database +in `data/`, the generated projects in `projects/`, uploads in `media/`. +Deleting the directory resets everything, including your account. Deleting a +single project directory leaves a row pointing at nothing, so prefer deleting +the project in the UI. -4. **Tmuxinator Issues** - - Ensure Tmux version is >= 3.2: `tmux -V` - - Kill existing session: `tmux kill-session -t codefox` - - Check session status: `tmux ls` +**tmux.** `pnpm dev:tmux` needs tmux >= 3.2 (`tmux -V`) and tmuxinator. If a +session is stuck: `tmux kill-session -t codefox`. Plain `pnpm dev` needs +neither. ## Additional Resources -- [API Documentation](./docs/api.md) -- [Contributing Guidelines](./CONTRIBUTING.md) -- [Change Log](./CHANGELOG.md) +- [DEPLOY.md](./DEPLOY.md) — deploying CodeFox itself: frontend on Vercel, + backend on Railway (it cannot be serverless — a turn streams for minutes) +- [HANDOFF.md](./HANDOFF.md) — session-by-session engineering log: what was + found, what was fixed, what is still open. Long, and the most honest + description of the system's state. +- [docs/RSI-SIGNALS.md](./docs/RSI-SIGNALS.md) — whether a good turn can be + told from a bad one using data CodeFox already records, and what the + `agent_turn` rows are for +- `scripts/` — every check, probe and recorder, each with a header explaining + what it guards and how to run it ## Support -For support and questions: - -- GitHub Issues: [Create an issue](https://github.com/your-repo/issues) -- Documentation: [CodeFox Docs](./codefox-docs) +- Issues: [github.com/CodeFox-Repo/codefox/issues](https://github.com/CodeFox-Repo/codefox/issues) ## License -ISC +[MIT](./LICENSE). diff --git a/assets/demo-beats.json b/assets/demo-beats.json new file mode 100644 index 00000000..f59379d1 --- /dev/null +++ b/assets/demo-beats.json @@ -0,0 +1,8 @@ +{ + "create": 7.181, + "workbench": 19.817, + "questions": 50.931, + "building": 56.487, + "built": 258.055, + "end": 273.867 +} diff --git a/assets/demo-poster.jpg b/assets/demo-poster.jpg new file mode 100644 index 00000000..15000cc0 Binary files /dev/null and b/assets/demo-poster.jpg differ diff --git a/assets/demo.mp4 b/assets/demo.mp4 new file mode 100644 index 00000000..b6ed9651 Binary files /dev/null and b/assets/demo.mp4 differ diff --git a/backend/package.json b/backend/package.json index 8edacb3c..f515bfe5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "description": "", "author": "", "private": true, - "license": "UNLICENSED", + "license": "MIT", "packageManager": "pnpm@9.1.0", "scripts": { "build": "nest build", diff --git a/frontend/package.json b/frontend/package.json index 2fa5a630..dcb96771 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,6 +2,7 @@ "name": "codefox-web", "version": "0.1.0", "private": true, + "license": "MIT", "scripts": { "build": "next build", "build:frontend": "next build", diff --git a/package.json b/package.json index b05bf799..bdc6a741 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "format": "prettier --write .", "dev:backend": "turbo dev:backend", "demo:record": "node scripts/record-demo.mjs", + "demo:flow": "node scripts/record-flow.mjs", "test": "turbo test", "check": "node scripts/run-checks.mjs", "fix": "eslint . --ext .js,.ts,.tsx --fix", @@ -25,7 +26,7 @@ }, "keywords": [], "author": "", - "license": "ISC", + "license": "MIT", "devDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", diff --git a/scripts/record-flow.mjs b/scripts/record-flow.mjs new file mode 100644 index 00000000..23d46d3e --- /dev/null +++ b/scripts/record-flow.mjs @@ -0,0 +1,405 @@ +#!/usr/bin/env node +/* + * Records the README demo by driving the real product end to end: type a + * prompt on the landing page, answer the agent's question card, wait for the + * build, look at the result. Same idea as record-demo.mjs — a scripted, + * re-runnable capture rather than a hand-made screen recording — but this one + * films the whole loop instead of the landing page. + * + * A real build takes minutes and a README video may not. The script records + * at real speed and then compresses the waiting: the beats it timestamps + * while driving become ffmpeg trim/setpts segments, so typing and the reveal + * stay at 1x and the two stretches where a human would only be watching the + * agent work run fast, labelled with the speed on screen. + * + * Usage: pnpm dev (in another terminal, with a working + * LLM_API_KEY — this spends real tokens) + * pnpm demo:flow + * pnpm demo:flow -- --prompt "a pricing page" --keep-raw + * + * Needs ffmpeg twice over: puppeteer shells out to it to screencast, and the + * edit is one more invocation. `--ffmpeg /path/to/ffmpeg` covers the edit on a + * machine without a system install; the capture needs it on PATH either way, + * which `PATH=$(dirname $(node -p "require('ffmpeg-static')")):$PATH` gives you + * without root. + * + * ponytail: puppeteer's screencast + one ffmpeg filter_complex. No editor, no + * frame pipeline — the beats are known because the script caused them. + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + +// puppeteer is a backend dependency and pnpm does not hoist, so a bare import +// from the repo root does not resolve. Ask backend's resolver for it. +const fromBackend = createRequire(join(root, 'backend/package.json')); +const puppeteer = (await import(pathToFileURL(fromBackend.resolve('puppeteer')))).default; +const outDir = join(root, 'assets'); +const raw = join(outDir, 'demo-raw.webm'); +const mp4 = join(outDir, 'demo.mp4'); +const poster = join(outDir, 'demo-poster.jpg'); + +const arg = (name, fallback) => { + const i = process.argv.indexOf(`--${name}`); + return i === -1 ? fallback : process.argv[i + 1]; +}; +const url = arg('url', 'http://localhost:3000'); +const api = arg('api', 'http://localhost:8080'); +const prompt = arg('prompt', 'An analytics dashboard with KPI cards and a line chart'); +const keepRaw = process.argv.includes('--keep-raw'); +const ffmpeg = arg('ffmpeg', 'ffmpeg'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const started = Date.now(); +const say = (m) => console.log(`${String((Date.now() - started) / 1000).padStart(6)}s ${m}`); + +/* ── the account ────────────────────────────────────────────────────────── + * The dev credentials frontend/.env already carries, so the recording needs + * no account of its own and no secret lives in this file. + */ +const devEnv = () => { + const file = join(root, 'frontend/.env'); + if (!existsSync(file)) throw new Error('frontend/.env missing — run pnpm dev once'); + const read = (key) => + readFileSync(file, 'utf8').match(new RegExp(`^${key}=(.*)$`, 'm'))?.[1]?.trim(); + const email = read('NEXT_PUBLIC_DEV_EMAIL'); + const password = read('NEXT_PUBLIC_DEV_PASSWORD'); + if (!email || !password) throw new Error('NEXT_PUBLIC_DEV_EMAIL/PASSWORD not in frontend/.env'); + return { email, password }; +}; + +const gql = async (query, token) => { + const res = await fetch(`${api}/graphql`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ query }), + }); + return res.json(); +}; + +/** The page as the backend has it right now, or null before it exists. */ +const pageBytes = async (projectPath, token) => { + const res = await fetch(`${api}/api/file?path=${encodeURIComponent(`${projectPath}/index.html`)}`, { + headers: { authorization: `Bearer ${token}` }, + }); + if (!res.ok) return null; + return (await res.json())?.content?.length ?? null; +}; + +const signIn = async ({ email, password }) => { + const login = `mutation{login(input:{email:"${email}",password:"${password}"}){accessToken refreshToken}}`; + let { data } = await gql(login); + if (!data?.login) { + await gql( + `mutation{registerUser(input:{username:"demo",email:"${email}",password:"${password}",confirmPassword:"${password}"}){id}}` + ); + ({ data } = await gql(login)); + } + if (!data?.login) throw new Error(`cannot sign in as ${email}`); + return data.login; +}; + +/* ── driving ──────────────────────────────────────────────────────────── */ + +const buttonMatching = async (page, re) => { + for (const handle of await page.$$('button')) { + const text = (await page.evaluate((el) => el.textContent?.trim() ?? '', handle)) || ''; + if (re.test(text)) return handle; + } + return null; +}; + +/** A turn is running while the composer says so. */ +const composerBusy = (page) => + page.evaluate(() => { + const composer = document.querySelector('textarea'); + return ( + /keep typing/i.test(composer?.placeholder ?? '') || + Boolean(document.querySelector('[aria-label*="stop the agent" i]')) + ); + }); + +/** + * The build is over when the turn has ended AND the page has stopped growing. + * + * Either signal alone films the wrong thing. File size alone stops at the last + * write, while the agent is still checking its work — the recording then ends + * on the scaffold, because the preview only reloads once the turn does. The + * composer alone can stay stuck in its streaming state after a stream dies. + * Requiring both, twice in a row, is what makes the last shot the built page. + */ +const waitForSettle = async (page, projectPath, token, { minMs, quietMs, maxMs }) => { + const deadline = Date.now() + maxMs; + const floor = Date.now() + minMs; + let last = await pageBytes(projectPath, token); + let lastChange = Date.now(); + let grew = false; + let quiet = 0; + while (Date.now() < deadline) { + await sleep(5000); + const now = await pageBytes(projectPath, token); + if (now !== last) { + grew ||= now > (last ?? 0); + last = now; + lastChange = Date.now(); + } + const still = !(await composerBusy(page)) && Date.now() - lastChange > quietMs; + quiet = still ? quiet + 1 : 0; + if (grew && quiet >= 2 && Date.now() > floor) return true; + } + return false; +}; + +const run = async () => { + mkdirSync(outDir, { recursive: true }); + const auth = await signIn(devEnv()); + + const browser = await puppeteer.launch({ + headless: true, // new headless — the old shell cannot screencast + defaultViewport: { width: 1440, height: 900, deviceScaleFactor: 2 }, + args: ['--force-color-profile=srgb', '--hide-scrollbars'], + }); + + const beats = {}; + let clock = 0; + const beat = (name) => { + beats[name] = (Date.now() - clock) / 1000; + say(`beat ${name} @${beats[name].toFixed(1)}s`); + }; + + try { + const page = await browser.newPage(); + page.on('pageerror', (e) => say(`pageerror: ${e.message}`)); + await page.evaluateOnNewDocument((t) => { + localStorage.setItem('accessToken', t.accessToken); + localStorage.setItem('refreshToken', t.refreshToken); + }, auth); + // The floating dev auth toggle is a dev-environment artifact, not product. + await page.evaluateOnNewDocument(() => { + const hide = () => { + const style = document.createElement('style'); + style.textContent = '[aria-label*="dev" i]{display:none !important}'; + document.head.appendChild(style); + }; + document.readyState === 'loading' + ? document.addEventListener('DOMContentLoaded', hide) + : hide(); + }); + + const response = await page.goto(url, { waitUntil: 'networkidle2' }); + if (!response?.ok()) throw new Error(`${url} responded ${response?.status()}`); + await page.waitForSelector('textarea', { timeout: 90_000 }); + await sleep(2000); + + const recorder = await page.screencast({ path: raw }); + clock = Date.now(); + await sleep(1400); + + await page.click('textarea'); + await page.type('textarea', prompt, { delay: 45 }); + await sleep(1200); + (await buttonMatching(page, /^create$/i))?.click(); + beat('create'); + + await page.waitForFunction(() => location.pathname === '/chat', { timeout: 120_000 }); + await sleep(3500); + beat('workbench'); + + const chatId = new URL(page.url()).searchParams.get('id'); + const details = await gql( + `{getChatDetails(chatId:"${chatId}"){project{projectPath}}}`, + auth.accessToken + ); + const projectPath = details?.data?.getChatDetails?.project?.projectPath; + if (!projectPath) throw new Error(`no project behind chat ${chatId}`); + + // First turn is the agent's question block — the product asks before it builds. + await page.waitForFunction(() => /before building/i.test(document.body.innerText), { + timeout: 300_000, + polling: 1500, + }); + await sleep(2500); + beat('questions'); + + const groups = await page.$$('div.space-y-4 > div'); + const wanted = [0, 1, 0, 1]; + let index = 0; + for (const group of groups) { + const options = await group.$$('button[aria-pressed]'); + if (!options.length) continue; + await options[Math.min(wanted[index] ?? 0, options.length - 1)].click(); + index++; + await sleep(1100); + } + await sleep(1000); + (await buttonMatching(page, /start building/i))?.click(); + beat('building'); + + const settled = await waitForSettle(page, projectPath, auth.accessToken, { + minMs: 60_000, + quietMs: 20_000, + maxMs: 20 * 60_000, + }); + if (!settled) say('WARNING: agent still working at the cap — filming the reveal anyway'); + + // Ask the preview for the page as it is now. It reloads on its own when a + // turn ends, but the recording should not depend on catching that. + const refresh = await page.$('[aria-label="Refresh preview"]'); + await refresh?.click(); + await sleep(6000); + beat('built'); + + // The reveal: the built page, a scroll through it, then the file behind it. + // + // By title, not "the first frame that is not the main one" — in dev that + // one is Next's error overlay, and scrolling it looks exactly like a page + // that does not scroll. The preview can also reload underneath us, which + // detaches the frame mid-evaluate; a reveal is not worth failing a + // seven-minute recording over. + await sleep(2500); + try { + const frame = await (await page.$('iframe[title="preview"]'))?.contentFrame(); + await frame?.evaluate( + () => + new Promise((done) => { + const target = Math.min(1400, document.body.scrollHeight - innerHeight); + const start = performance.now(); + const step = (now) => { + const t = Math.min(1, (now - start) / 4200); + scrollTo(0, target * (1 - Math.pow(1 - t, 3))); + t < 1 ? requestAnimationFrame(step) : done(); + }; + requestAnimationFrame(step); + }) + ); + } catch (error) { + say(`preview scroll skipped: ${error.message}`); + } + await sleep(1500); + (await buttonMatching(page, /^code$/i))?.click(); + await sleep(4500); + (await buttonMatching(page, /^preview$/i))?.click(); + await sleep(3000); + beat('end'); + + await recorder.stop(); + await page.close(); + } finally { + await browser.close(); + } + + if (statSync(raw).size < 10_000) throw new Error(`capture looks empty (${statSync(raw).size} bytes)`); + writeFileSync(join(outDir, 'demo-beats.json'), `${JSON.stringify(beats, null, 2)}\n`); + await edit(beats); +}; + +/* ── editing ────────────────────────────────────────────────────────────── + * One filter graph: trim each stretch, scale its PTS, concat. The fast + * stretches carry a badge so the speed-up is visible, not implied. + * + * The badge is a PNG rendered in the same browser rather than ffmpeg's + * drawtext: a static ffmpeg build usually ships without libfreetype, and a + * demo recorder that dies on the label is worse than one that draws it the + * long way. It also gets the product's own type instead of a system font. + */ +const badgeFor = async (browser, text, file) => { + const page = await browser.newPage(); + // Screencast captures CSS pixels, so the badge is authored at 1x against a + // 1440-wide frame. Rendered at 2x it lands three times the size it should. + await page.setViewport({ width: 600, height: 120, deviceScaleFactor: 1 }); + await page.setContent( + ` +
${text}
+ ` + ); + const box = await (await page.$('div')).boundingBox(); + await page.screenshot({ path: file, omitBackground: true, clip: { ...box, x: 0, y: 0 } }); + await page.close(); +}; + +const edit = async (beats) => { + const plan = [ + { from: 0, to: beats.workbench, rate: 1 }, + { from: beats.workbench, to: beats.questions, rate: 6, label: 'agent thinking' }, + { from: beats.questions, to: beats.building, rate: 1.6 }, + { from: beats.building, to: beats.built, rate: 12, label: 'agent building' }, + { from: beats.built, to: beats.end, rate: 1 }, + ].filter((s) => s.to - s.from > 0.4); + + const labelled = plan.filter((s) => s.label); + const badges = labelled.map((_, i) => join(outDir, `.demo-badge-${i}.png`)); + if (labelled.length) { + const browser = await puppeteer.launch({ headless: true }); + try { + for (const [i, seg] of labelled.entries()) { + await badgeFor(browser, `${seg.label}  ${seg.rate}×`, badges[i]); + } + } finally { + await browser.close(); + } + } + + const parts = plan.map((seg, i) => { + const trimmed = + `[0:v]trim=start=${seg.from.toFixed(2)}:end=${seg.to.toFixed(2)},` + + `setpts=(PTS-STARTPTS)/${seg.rate}`; + const badge = labelled.indexOf(seg); + return badge === -1 + ? `${trimmed}[v${i}]` + // Bottom right: the top right is the workbench's own toolbar, and a + // badge sitting on the Notes and PDF buttons reads as part of the app. + : `${trimmed}[t${i}];[t${i}][${badge + 1}:v]overlay=x=W-w-28:y=H-h-28[v${i}]`; + }); + // Where the reveal starts once every earlier stretch has been compressed. + const revealAt = plan + .slice(0, -1) + .reduce((total, seg) => total + (seg.to - seg.from) / seg.rate, 0); + const posterAt = revealAt + 8; + + // Captured at deviceScaleFactor 2 and delivered at 1x: the downscale is + // what makes text in the video look like text rather than like pixels. + const graph = + `${parts.join(';')};${plan.map((_, i) => `[v${i}]`).join('')}` + + `concat=n=${plan.length}:v=1:a=0,scale=1440:-2[out]`; + + execFileSync( + ffmpeg, + [ + '-y', '-i', raw, + ...badges.flatMap((b) => ['-i', b]), + '-filter_complex', graph, + '-map', '[out]', + '-c:v', 'libx264', '-preset', 'slow', '-crf', '24', + '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-r', '30', '-an', + mp4, + ], + { stdio: 'inherit' } + ); + execFileSync( + ffmpeg, + // A few seconds into the reveal: the still that stands in for the video + // should be the page the agent built, not an empty prompt box. + ['-y', '-ss', String(posterAt.toFixed(1)), '-i', mp4, '-frames:v', '1', '-update', '1', '-q:v', '3', poster], + { stdio: 'inherit' } + ); + if (!keepRaw) rmSync(raw, { force: true }); + for (const badge of badges) rmSync(badge, { force: true }); + + const mb = (statSync(mp4).size / 1e6).toFixed(2); + console.log(`\nwrote assets/demo.mp4 (${mb} MB) + assets/demo-poster.jpg`); +}; + +run().catch((error) => { + console.error(`\nrecord-flow failed: ${error.message}`); + process.exit(1); +});