diff --git a/easel/README.md b/easel/README.md index fa18e100fc..dd59736d44 100644 --- a/easel/README.md +++ b/easel/README.md @@ -1,4 +1,8 @@ -# Easel +# Aesel + +Aesel by Aesthetic Computer. The canonical command is `aesel`; `ac` and +`easel` remain compatible. The source directory, `EASEL_*` settings, existing +thread paths, and bundle ID remain stable so upgrades preserve user data. A coding interface for the terminal. Lives at `easel/` in the Aesthetic Computer repository. @@ -39,27 +43,72 @@ the piece currently being worked on in the header. Inside the TUI: `/login`, `/logout`, `/whoami`, `/publish [file] [slug]`, `/autopublish [on|off]`, `/piece [name]`, `/runtime [mjs|lisp|processing]`, `/backend [claude|codex]`, -`/model [name]`, `/qr`, `/live`, `/new`, `/clear`, `/help`, `/quit`. Press +`/model [name]`, `/energy`, `/qr`, `/live`, `/new`, `/clear`, `/help`, `/quit`. Press `ctrl-c` to interrupt a running turn or exit while idle. ## Engine bridges -Two bridges ship, and either can drive a session: +Three bridges can drive a session: ```sh ac # claude, on claude-opus-5 ac --backend codex # codex app-server +ac --backend ac # AC hosted, using your handle's budget ac --model claude-opus-5 # a different model on the same bridge +ac --piece path/to/fogozo.mjs # reopen an existing piece and its versions ``` -`/backend` and `/model` do the same thing mid-session — both restart the -conversation on the new engine and leave the piece, the channel and the QR code -exactly where they were. `/backend` with no argument says which engine and -model are running. +`/backend` and `/model` switch mid-session, preserving the visible conversation, +piece, channel and QR. A new provider thread receives recent user/assistant +context (up to 24,000 characters) and the current piece; provider thread IDs and +tool history are not portable. A failed connection returns to the prior engine. +`/new` explicitly starts a fresh conversation. `/backend` lists account options; +`/model` lists hosted choices or accepts a model name for your own vendor CLI. + +AC hosted keeps GLM as its default. `/model sonnet` and `/model gpt` select +premium models and consume the same handle allowance. Model IDs were checked +against the [OpenRouter catalog](https://openrouter.ai/compare/openai/gpt-5.4/anthropic/claude-sonnet-4.6). +The allowance measures weighted tokens, not dollars, and is not an atomic spend +reservation. Unavailable budget checks refuse inference. New hosted choices +require the matching Lith endpoint deployment. + +`/about`, or clicking **AESEL**, opens the feature map. Click **@handle** to open +your profile in a browser. Header targets highlight on hover in terminals that +support mouse reporting. `/mouse off` restores terminal selection; `/mouse on` +enables interaction again. `EASEL_MOUSE=0` disables it at launch. + +Wheel and Page Up/Page Down scroll the transcript internally, keeping the input +and footer fixed. Incoming output preserves your reading position. End with an +empty input, or `/latest`, returns to the live end. The about map scrolls too; +Esc returns to the conversation. + +`/performance [frames]` measures the current JavaScript piece's headless logic +with seeded randomness and drawing-call counts. The default is 600 measured +frames at 800×600 after warmup. It runs in a restricted child with a timeout; +Ctrl-C cancels it. Browser rendering, rasterization and display latency are +excluded. Unsupported APIs/imports report an error. It requires Node permission +support (Node 24 or newer recommended). + +`/energy` estimates what the session cost in electricity. Every bridge reports +the tokens it spent — per round on AC hosted, per turn from the Claude CLI's own +`modelUsage` — and `src/energy.mjs` turns those counts into watt-hours: a fixed +cost per generated token plus a part that scales with the model's *active* +parameters, with prompt tokens at a tenth of a generated one and cached tokens +at a hundredth. The running total shares the footer's gauge row with the viewer +count, wearing a `~`. + +It is an estimate and cannot be anything else — no provider publishes per-token +energy. The slope is anchored so a frontier-class answer lands near the only +published figures (Google's 0.24 Wh median text prompt; Epoch AI's ~0.3 Wh for a +GPT-4o query), and the open-weight hosted models carry their announced active +parameter counts, so the *relative* half of the readout — the same conversation +priced across every model, cheapest first — rests on published numbers rather +than on guessed hardware. Rows for closed models say that their size is a guess. +Serving only: no training, no water, and not your own machine. The Claude bridge runs `claude --print --input-format stream-json --output-format stream-json`, the same headless protocol the Claude Agent SDK -speaks, driven directly over a pipe. That is why Easel still has no +speaks, driven directly over a pipe. That is why Aesel still has no dependencies: a subprocess on stdio is the same shape as `codex app-server --stdio`, and it carries streaming, tool calls and approvals without a package tree behind it. Each bridge signs in with the vendor CLI's own credentials @@ -72,7 +121,7 @@ configuration — Codex is pinned to `on-request` approvals and a and `--strict-mcp-config` — so nothing but the person watching can approve a command in a session, and an `a` is never written to a settings file. -On the Claude bridge the session also carries Easel's own tools, served by +On the Claude bridge the session also carries Aesel's own tools, served by `src/tools.mjs` as the one MCP server the strict config admits: `ac_api` (the piece API — runtime signatures, docs and real call sites, read off `lib/disk.mjs` and `lib/graph.mjs` by `bin/build-api-map.mjs` into @@ -92,7 +141,7 @@ written down in [`docs/local-contract.md`](docs/local-contract.md). ## The session's piece, live on a phone -Opening Easel opens a new blank piece. It gets a random pronounceable +Opening Aesel opens a new blank piece. It gets a random pronounceable name, it is a real file in the workspace, and a QR code for it sits in the bottom right of the interface. Scan the code and the piece runs on your phone; every edit the agent makes reaches it a moment later. @@ -135,7 +184,7 @@ comment and declares `setup` or `draw` is taken as Lua at all. ## Account and publishing -Easel reads the shared Aesthetic Computer sign-in at `~/.ac-token`, +Aesel reads the shared Aesthetic Computer sign-in at `~/.ac-token`, the same file `ac-login` and the AC desktop apps use. `/login` runs the Authorization-Code + PKCE flow in your browser with a loopback callback and writes that file; a sign-in or sign-out anywhere in the suite updates the @@ -175,7 +224,7 @@ npm test ## Designing the furniture -Easel does not draw all of itself. The QR, the live card of the piece and the +Aesel does not draw all of itself. The QR, the live card of the piece and the status stone are Slab menubar overlays parked on the terminal, and `frame` filters Slab's own windows out of every capture — its usual job is reading the machine underneath them. So a screenshot taken to judge the card's padding @@ -185,23 +234,37 @@ shows the terminal where the card is. shot to a padded crop, so the menu bar an overlay is said to be flush against is in the same picture. -`easel/bin/design-loop.mjs` is the whole cycle in one command: close the Easel +`easel/bin/design-loop.mjs` is the whole cycle in one command: close the Aesel session, open a fresh one, wait for its overlays to land, photograph them. Fresh because overlays are placed once, when a window appears — editing the placement and reinstalling the menubar does not move what is already on screen, so the only honest check is a session that has never seen the old numbers. ```sh -node easel/bin/design-loop.mjs # restart Easel, then shoot +node easel/bin/design-loop.mjs # restart Aesel, then shoot node easel/bin/design-loop.mjs --shot # shoot what is already open ``` Edit an overlay, run `slab/menubar-swift/install.sh`, then run the loop. -Easel is proprietary. See `LICENSE`. +Aesel is proprietary. See `LICENSE`. On Fish installations with existing `ac` or `aesthetic` functions, the installer preserves them as `ac-repo` and `aesthetic-platform`. The product boundary is recorded in [`docs/local-contract.md`](docs/local-contract.md). + +Each complete piece update gets a local version (`v1`, `v2`, …). `/versions` +lists snapshots; `/rollback vN` restores one as a new version and sends it through +the usual live/publish path. Finish or interrupt the current turn and let uploads +finish first. History persists in `~/.local/share/easel/history/`, keyed by the +piece's absolute file path; it is not yet shared between machines or accounts. + +The AC backend streams text and completed `write_piece` checkpoints as they +arrive. It shows connecting, waiting, generating, composing, and writing states; +received kilobytes count stream bytes, not billed tokens. JavaScript checkpoints +are syntax-checked without executing them, so unfinished fragments keep the last +working preview. Other runtimes retain their own loader validation. This uses +ordered HTTPS streaming (SSE); a socket or UDP transport is not required for each +token to arrive immediately. Disconnects cancel an active response upstream. diff --git a/easel/bin/aesel b/easel/bin/aesel new file mode 100755 index 0000000000..cb36c857ba --- /dev/null +++ b/easel/bin/aesel @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Canonical Aesel command; the original launcher remains compatible. +set -euo pipefail +exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/easel" "$@" diff --git a/easel/bin/build-api-map.mjs b/easel/bin/build-api-map.mjs index c1970cdd2a..f71ff315bb 100644 --- a/easel/bin/build-api-map.mjs +++ b/easel/bin/build-api-map.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // build-api-map — the static map of the piece API, read off the runtime source. // -// Every Easel session so far has opened with the same hunt: grep graph.mjs for +// Every Aesel session so far has opened with the same hunt: grep graph.mjs for // `function circle(`, sed a window of disk.mjs to see what `$paintApiUnwrapped` // exposes, grep the disks for one piece that already calls `synth(`. Ten // sessions, the same eight commands, a minute or two each before the first @@ -27,10 +27,10 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); -const EASEL = join(HERE, ".."); -const REPO = join(EASEL, ".."); +const AESEL = join(HERE, ".."); +const REPO = join(AESEL, ".."); const AC = join(REPO, "system", "public", "aesthetic.computer"); -const OUT = join(EASEL, "context", "api.json"); +const OUT = join(AESEL, "context", "api.json"); const read = (file) => readFileSync(join(AC, file), "utf8").split("\n"); diff --git a/easel/bin/design-loop.mjs b/easel/bin/design-loop.mjs index d56298e276..62b3e77e60 100755 --- a/easel/bin/design-loop.mjs +++ b/easel/bin/design-loop.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node -// design-loop — look at Easel's own furniture, change it, look again. +// design-loop — look at Aesel's own furniture, change it, look again. // -// Easel does not draw all of itself. The QR you scan, the little live card of +// Aesel does not draw all of itself. The QR you scan, the little live card of // the piece, the stone that carries the session's status: those are Slab // menubar overlays parked on top of the terminal, and they are the first thing // anyone sees. Adjusting them used to be guesswork, for two reasons that @@ -18,11 +18,11 @@ // already on screen in any way you can trust, so the only honest check is // a *fresh* session. That is the restart this tool performs. // -// So: close the Easel session, open a new one, wait for its overlays to exist, +// So: close the Aesel session, open a new one, wait for its overlays to exist, // point the camera at them. One command per iteration, and the picture that // comes back is the thing being designed. // -// node easel/bin/design-loop.mjs # restart Easel, then shoot +// node easel/bin/design-loop.mjs # restart Aesel, then shoot // node easel/bin/design-loop.mjs --shot # shoot what is already open // node easel/bin/design-loop.mjs --out ~/x.jpg // @@ -70,7 +70,7 @@ async function prox(name, args = {}) { return text; } -/// Every live Easel session on this machine, newest marker first. Read from +/// Every live Aesel session on this machine, newest marker first. Read from /// the marker files rather than asked of prox: this runs between a close and a /// launch, when the ledger cache is the one thing guaranteed to be stale. async function easelSessions() { @@ -87,7 +87,7 @@ async function easelSessions() { return out.sort((a, b) => String(b.updated || "").localeCompare(String(a.updated || ""))); } -/// Wait for an Easel session that is not one we already knew about, and that +/// Wait for an Aesel session that is not one we already knew about, and that /// has got far enough to own a terminal and a piece. `scan_url` is the signal /// that matters: it is set at the moment the session has an address to encode, /// which is the moment the QR and the preview card come into existence. Waiting @@ -162,7 +162,7 @@ const out = opt("--out") || join(tmpdir(), `easel-design-${Date.now()}.jpg`); if (flag("--help") || flag("-h")) { console.log([ - "design-loop — restart Easel and photograph its overlays", + "design-loop — restart Aesel and photograph its overlays", "", " node easel/bin/design-loop.mjs [--shot] [--out file.jpg] [--cwd dir]", "", @@ -187,14 +187,14 @@ if (!flag("--shot")) { console.log(`⟲ closing ${host}:easel (${session.piece || session.id.slice(0, 8)})`); await prox("prox_close", { handle: session.id }); } else { - console.log("⟲ no Easel session open — launching a first one"); + console.log("⟲ no Aesel session open — launching a first one"); } - console.log("⟳ launching a fresh Easel"); + console.log("⟳ launching a fresh Aesel"); await prox("prox_launch", { host, agent: "easel", cwd: opt("--cwd") || REPO, by: "easel:design-loop" }); session = await waitForFreshSession(known); - if (!session) throw new Error("the new Easel never reported a scan URL — nothing to photograph."); + if (!session) throw new Error("the new Aesel never reported a scan URL — nothing to photograph."); // The marker is written the moment the address exists; the overlays are // placed on the menubar's next walk of the window list, which is a separate // clock this tool cannot read. Measured at about three seconds on blueberry — @@ -205,7 +205,7 @@ if (!flag("--shot")) { await sleep(Number(opt("--settle")) * 1000 || 4000); } -if (!session) throw new Error("no Easel session is open — drop --shot to launch one."); +if (!session) throw new Error("no Aesel session is open — drop --shot to launch one."); if (!(await focusTty(session.tty))) { console.log(`! could not focus /dev/${session.tty} — shooting whatever is frontmost`); } diff --git a/easel/bin/easel b/easel/bin/easel index 40b30b7684..3cd4c46972 100755 --- a/easel/bin/easel +++ b/easel/bin/easel @@ -25,6 +25,7 @@ VERSION="$(node -p "require('$PROJECT_DIR/package.json').version" 2>/dev/null || usage() { cat <<'EOF' Usage: ac [directory] [--runtime mjs|lisp|processing] + [--piece FILE] [--resume THREAD_ID] [--backend claude|codex|ac] [--model NAME] [--autopublish | --no-autopublish] aesthetic [directory] @@ -32,7 +33,7 @@ Usage: ac [directory] [--runtime mjs|lisp|processing] aesthetic login | logout | whoami aesthetic publish [slug] -Open the Easel terminal interface in a workspace, or manage the +Open the Aesel terminal interface in a workspace, or manage the shared Aesthetic Computer sign-in (~/.ac-token) and publish a piece under your @handle at https://aesthetic.computer/@handle/slug. @@ -64,7 +65,7 @@ has_command() { } doctor() { - printf 'Easel %s\n' "$VERSION" + printf 'Aesel %s\n' "$VERSION" printf 'interface: %s\n' "$PROJECT_DIR/src/tui.mjs" printf 'control plane: local\n' printf 'telemetry: off\n' @@ -105,7 +106,7 @@ case "${1:-}" in exit 0 ;; --version|-V|version) - printf 'Easel %s\n' "$VERSION" + printf 'Aesel %s\n' "$VERSION" exit 0 ;; doctor) @@ -122,6 +123,7 @@ esac target_directory="" resume_thread="" initial_prompt="" +initial_piece="" runtime="" backend="" model="" @@ -144,6 +146,11 @@ while [[ $# -gt 0 ]]; do resume_thread="$2" shift 2 ;; + --piece) + [[ $# -ge 2 ]] || fail "--piece requires a file" + initial_piece="$2" + shift 2 + ;; --prompt) [[ $# -ge 2 ]] || fail "--prompt requires text" [[ ${#2} -le 4000 ]] || fail "prompt exceeds 4000 characters" @@ -237,6 +244,7 @@ export EASEL_VERSION="$VERSION" arguments=("--cwd" "$target_directory") if [[ -n "$resume_thread" ]]; then arguments+=("--resume" "$resume_thread"); fi if [[ -n "$initial_prompt" ]]; then arguments+=("--prompt" "$initial_prompt"); fi +if [[ -n "$initial_piece" ]]; then arguments+=("--piece" "$initial_piece"); fi if [[ -n "$runtime" ]]; then arguments+=("--runtime" "$runtime"); fi if [[ "$autopublish" == "on" ]]; then arguments+=("--autopublish"); fi if [[ "$autopublish" == "off" ]]; then arguments+=("--no-autopublish"); fi diff --git a/easel/bin/pack.mjs b/easel/bin/pack.mjs index 340b858e54..8bd0a61439 100755 --- a/easel/bin/pack.mjs +++ b/easel/bin/pack.mjs @@ -16,17 +16,17 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); -const EASEL = join(HERE, ".."); -const REPO = join(EASEL, ".."); +const AESEL = join(HERE, ".."); +const REPO = join(AESEL, ".."); const OUT = join(REPO, "system", "public", "easel.tar.gz"); const MANIFEST = join(REPO, "system", "public", "easel.json"); // Written into the tarball so an install can tell what it is. Its absence is // how a git checkout knows never to overwrite itself with a release. -const STAMP = join(EASEL, "install.json"); +const STAMP = join(AESEL, "install.json"); const INCLUDE = ["bin", "src", "shell", "context", "package.json", "README.md", "LICENSE", "install.json"]; -const version = JSON.parse(readFileSync(join(EASEL, "package.json"), "utf8")).version; +const version = JSON.parse(readFileSync(join(AESEL, "package.json"), "utf8")).version; // The stamp is part of the archive, so it is written before tarring and removed // after: a working checkout must not acquire one by having run this script. @@ -34,7 +34,7 @@ writeFileSync(STAMP, JSON.stringify({ version, packedAt: new Date().toISOString( for (const entry of INCLUDE) { try { - statSync(join(EASEL, entry)); + statSync(join(AESEL, entry)); } catch { console.error(`missing: ${entry}`); process.exit(1); @@ -55,7 +55,7 @@ try { execFileSync("tar", [ ...extraFlags, "-czf", OUT, - "-C", EASEL, + "-C", AESEL, ...INCLUDE, ], { stdio: "inherit" }); @@ -64,7 +64,7 @@ rmSync(STAMP, { force: true }); const bytes = readFileSync(OUT); const sha256 = createHash("sha256").update(bytes).digest("hex"); -// What a running Easel fetches to decide whether it is behind. Kept to the four +// What a running Aesel fetches to decide whether it is behind. Kept to the four // facts an updater needs, so it stays cheap enough to poll once a day. writeFileSync( MANIFEST, diff --git a/easel/bin/sync-context.mjs b/easel/bin/sync-context.mjs index e54725f215..1afbf0a281 100755 --- a/easel/bin/sync-context.mjs +++ b/easel/bin/sync-context.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node // sync-context — copy the Aesthetic Computer authoring guides into easel/context/. // -// Easel tells the model how to write an AC piece by naming the repo's guides and +// Aesel tells the model how to write an AC piece by naming the repo's guides and // asking it to read them. That works inside the monorepo and nowhere else: the -// lookup is existsSync against the working directory, so an installed Easel +// lookup is existsSync against the working directory, so an installed Aesel // opened on someone's Desktop passes along no AC knowledge at all. It becomes a // general-purpose editor that happens to publish to a URL. // @@ -23,9 +23,9 @@ import { fileURLToPath } from "node:url"; import { build as buildApiMap } from "./build-api-map.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); -const EASEL = join(HERE, ".."); -const REPO = join(EASEL, ".."); -const OUT = join(EASEL, "context"); +const AESEL = join(HERE, ".."); +const REPO = join(AESEL, ".."); +const OUT = join(AESEL, "context"); // What a model needs to write a piece, and nothing else. Deliberately not the // whole repository: this is the knowledge that is about Aesthetic Computer @@ -38,7 +38,7 @@ export const BUNDLE = [ ]; const header = (from, subject) => - `\n\n`; + `\n\n`; export function build() { mkdirSync(OUT, { recursive: true }); @@ -47,7 +47,7 @@ export function build() { return { path: join(OUT, to), body, from, to, subject }; }); // The API map travels the same way, for the same reason: it is read off the - // runtime source, which an installed Easel does not have. + // runtime source, which an installed Aesel does not have. guides.push({ path: join(OUT, "api.json"), body: buildApiMap(), diff --git a/easel/context/api.json b/easel/context/api.json index ed1c0d8bce..5c686a7276 100644 --- a/easel/context/api.json +++ b/easel/context/api.json @@ -23,7 +23,7 @@ "path": "page", "signature": "page(buffer)", "doc": "Point subsequent drawing at another painting buffer; page(screen) comes back.", - "source": "lib/disk.mjs:6391", + "source": "lib/disk.mjs:6407", "examples": [ "disks/bits.mjs:33 page(sys.painting)", "disks/breathe.mjs:29 page(screen);", @@ -43,7 +43,7 @@ "path": "ink", "signature": "ink(r, g, b, a) | ink(gray, a) | ink(\"red\") | ink([r, g, b]) | ink() (random)", "doc": "Set the paint color for everything drawn next. Returns the API so calls chain: ink(255, 0, 0).line(0, 0, 10, 10).", - "source": "lib/disk.mjs:6421", + "source": "lib/disk.mjs:6437", "examples": [ "disks/$.mjs:168 ink([160, 160, 160]).write(compressedText, { x, y, size: scale });", "disks/$.mjs:195 ink([160, 160, 160]).write(compressedText, { x: startX, y, size: scale });", @@ -55,7 +55,7 @@ "path": "ink2", "signature": "ink2(...color)", "doc": "Secondary color, used by gradient-aware primitives.", - "source": "lib/disk.mjs:6425", + "source": "lib/disk.mjs:6441", "examples": [] }, { @@ -63,7 +63,7 @@ "path": "wipe", "signature": "wipe(...color)", "doc": "Fill the whole screen with a color; the usual first line of paint().", - "source": "lib/disk.mjs:6431", + "source": "lib/disk.mjs:6447", "examples": [ "disks/$.mjs:67 wipe(0);", "disks/$.mjs:324 wipe(0);", @@ -75,7 +75,7 @@ "path": "backgroundFill", "signature": "backgroundFill(color)", "doc": "Set background fill color for reframe operations (especially for KidLisp pieces)", - "source": "lib/disk.mjs:6483", + "source": "lib/disk.mjs:6499", "examples": [] }, { @@ -223,7 +223,7 @@ "path": "setBufferAlpha", "signature": "setBufferAlpha(buffer, alpha)", "doc": "Set the alpha of every non-transparent pixel in a buffer to a uniform value. Useful for per-stroke alpha — must be called after drawing on the buffer.", - "source": "lib/disk.mjs:6601", + "source": "lib/disk.mjs:6617", "examples": [ "disks/line.mjs:215 setBufferAlpha(nopaint.buffer, strokeAlpha);" ] @@ -633,7 +633,7 @@ "path": "pasteWithAlpha", "signature": "pasteWithAlpha(source, x, y, alpha)", "doc": "🎨 Alpha-blended paste for crossfade compositing", - "source": "lib/disk.mjs:6669", + "source": "lib/disk.mjs:6685", "examples": [ "disks/merry-fade.mjs:85 if (outAlpha > 0) pasteWithAlpha(outgoing, 0, 0, outAlpha);" ] @@ -643,7 +643,7 @@ "path": "kidlisp", "signature": "kidlisp(x = 0, y = 0, width, height, source, options = {})", "doc": "🎯 Simplified KidLisp integration using global singleton instance", - "source": "lib/disk.mjs:6705", + "source": "lib/disk.mjs:6721", "examples": [ "disks/$.mjs:369 kidlisp(", "disks/cross-tab-test.mjs:76 kidlisp(", @@ -655,7 +655,7 @@ "path": "sound.synth", "signature": "sound.synth({ tone = 440, type = \"square\", duration = 0.1, beats = undefined, attack = 0.01, decay = 0.9, volume, pan = 0, immediate = false, probe = null, generator = null, })", "doc": "Play a synthesized tone. `tone` is Hz or a note name like \"c4\"; returns a voice with .kill() and .update().", - "source": "lib/disk.mjs:13124", + "source": "lib/disk.mjs:13151", "examples": [ "disks/$.mjs:646 sound.synth({", "disks/1but.mjs:66 sound.synth({ type: \"triangle\", tone: 1047, attack: 0, decay: 0.1, duration: 0.1, volume: 0.25 });", @@ -667,7 +667,7 @@ "path": "sound.play", "signature": "sound.play(sfx, options, callbacks)", "doc": "Play a loaded sample or sfx by id.", - "source": "lib/disk.mjs:13039", + "source": "lib/disk.mjs:13066", "examples": [ "disks/1v1.mjs:695 bgmPlaying = sound.play(bgmSfx, { loop: true, volume: 0.4 });", "disks/1v1.mjs:763 bgmPlaying = sound.play(bgmSfx, { loop: true, volume: 0.4 });", @@ -703,7 +703,7 @@ "path": "hud.label", "signature": "hud.label(text, color, offset)", "doc": "Take over the system's corner label — the only sanctioned way to draw in the top-left.", - "source": "lib/disk.mjs:3642", + "source": "lib/disk.mjs:3653", "examples": [ "disks/$.mjs:642 hud.label(`Previewing ${entry.codeText}`, \"cyan\");", "disks/amail.mjs:82 hud.label(\"amail\");", @@ -715,7 +715,7 @@ "path": "write", "signature": "write(text, { x, y, size, center: \"x\" | \"xy\" }) | write(text, x, y)", "doc": "Draw text in the current ink. Chains from ink(): ink(\"white\").write(\"hi\", { x: 10, y: 40 }).", - "source": "lib/disk.mjs:5582", + "source": "lib/disk.mjs:5598", "examples": [ "disks/arena.mjs:2908 write(txt, { x: rX - txt.length * 4, y }, undefined, undefined, false, font);", "disks/arena.mjs:2918 write(t, { x, y }, undefined, undefined, false, font);", diff --git a/easel/context/hand.md b/easel/context/hand.md index 2ede44e7e7..b8d7e6ed51 100644 --- a/easel/context/hand.md +++ b/easel/context/hand.md @@ -1,5 +1,5 @@ # The Hand — a code style guide for Aesthetic Computer diff --git a/easel/context/kidlisp.md b/easel/context/kidlisp.md index 755856822f..0805ebdbbe 100644 --- a/easel/context/kidlisp.md +++ b/easel/context/kidlisp.md @@ -1,5 +1,5 @@ # KidLisp diff --git a/easel/context/pieces.md b/easel/context/pieces.md index 0ff4328a82..91574c343e 100644 --- a/easel/context/pieces.md +++ b/easel/context/pieces.md @@ -1,5 +1,5 @@ # Pieces — Authoring Guide diff --git a/easel/context/screen.md b/easel/context/screen.md index 71dc272cd9..2a60dbaef3 100644 --- a/easel/context/screen.md +++ b/easel/context/screen.md @@ -1,5 +1,5 @@ # The Screen — a drawing guide for Aesthetic Computer pieces diff --git a/easel/docs/local-contract.md b/easel/docs/local-contract.md index fd5de31e41..63f235ca0a 100644 --- a/easel/docs/local-contract.md +++ b/easel/docs/local-contract.md @@ -1,6 +1,6 @@ # Local contract -Easel requires no Easel server. +Aesel requires no Aesel server. - No runtime account, telemetry, analytics, cloud sync, or hosted control plane. - Configuration, session records, memory, and credentials remain on machines @@ -42,13 +42,13 @@ workspace paths, no conversation. The channel token is random per session and is never reused. Pushing is the interface's own action, on a file the user can see, and stops -when the session ends. It is the one thing Easel sends without being +when the session ends. It is the one thing Aesel sends without being asked each time, so it is worth stating plainly: while the interface is open, the piece on screen is repeatedly leaving the machine. ## Inference boundary -The terminal interface is always Easel. Engines are internal bridges, +The terminal interface is always Aesel. Engines are internal bridges, not alternate client interfaces or command shortcuts. Two bridges exist, both remote: Claude Code in headless stream-json mode (the @@ -56,7 +56,7 @@ default, on `claude-opus-5`) and Codex app-server. `--backend` and `/backend` choose between them and `--model` and `/model` name the model. The interface labels remote inference before a prompt is sent. Provider terms govern that traffic, and each bridge signs in with its own vendor's existing -credentials on this machine; Easel stores no key of its own. +credentials on this machine; Aesel stores no key of its own. Neither bridge inherits the user's own agent configuration. Codex is started with `on-request` approvals and a `workspace-write` sandbox regardless of what @@ -89,7 +89,7 @@ Codex runs commands under an operating-system sandbox: writes are confined to the workspace and `networkAccess` is false, so an approved command still cannot reach the network without a second, explicit escalation. -Claude Code has no equivalent sandbox. On that bridge Easel confines +Claude Code has no equivalent sandbox. On that bridge Aesel confines the file tools to the workspace, removes WebFetch and WebSearch, and routes every prompt to this terminal — and Claude does prompt before a command that touches the network — but the prompt is the whole boundary. A shell command the @@ -97,7 +97,7 @@ user approves runs with the user's own privileges and can reach the network. Read-only commands are auto-approved by Claude's own classifier, as reads inside the sandbox are on the Codex bridge. -Easel therefore does not claim that agent tools are network-isolated +Aesel therefore does not claim that agent tools are network-isolated on the Claude bridge. Where that matters, `--backend codex` is the bridge with a kernel behind its approvals. diff --git a/easel/docs/native-shell.md b/easel/docs/native-shell.md index 53687c15a4..a310c1523d 100644 --- a/easel/docs/native-shell.md +++ b/easel/docs/native-shell.md @@ -1,10 +1,10 @@ -# A native Easel +# A native Aesel Written 2026-09-13. Nothing here is implemented. Line counts, flags and file paths were read out of the tree that morning; anything I could not run is labelled as such, and there is a list of those at the end. -The question is whether Easel should become an application — a Mac app, a +The question is whether Aesel should become an application — a Mac app, a Windows app, possibly an iOS app — instead of a program you run inside somebody else's terminal. The prompt for this memo framed it as packaging the TUI together with the Slab overlays that today only work beside it. That framing is @@ -126,7 +126,7 @@ at all: on this machine both binaries are standalone Mach-O executables so the npm-shim problem does not arise. If a Windows user has a `.cmd` shim install instead, Node has refused to spawn `.cmd` without `shell: true` since 18.20.2. Second, and worse: `claude`'s own Bash tool needs a shell, and on -Windows that has historically meant Git Bash. A Windows Easel could plausibly +Windows that has historically meant Git Bash. A Windows Aesel could plausibly start the bridge and then watch every command the model runs fail. I have no Windows machine and there is no Windows evidence anywhere in this repo. @@ -146,7 +146,7 @@ view, and `render.test.mjs` covering half of them. The stronger argument against the rewrite is not cost. It is that `docs/local-contract.md` says, as a boundary rather than a description: *"The -terminal interface is always Easel. Engines are internal bridges, not alternate +terminal interface is always Aesel. Engines are internal bridges, not alternate client interfaces."* A native view layer would be a second client interface. It might be a good one, but it has to be argued for as a product, not slipped in as a port. Nothing in the brief for this memo argues for it. @@ -207,7 +207,7 @@ cross-reference to `easel/` anywhere. My read: **build inside ac-electron, not beside it.** A second Electron app with a second signing config, a second updater and a second release pipeline is the distraction. The daemon it already is — menu bar, tray, updater, deep links, -`~/.ac-token` — is most of the chrome an Easel window needs around it. +`~/.ac-token` — is most of the chrome an Aesel window needs around it. ## 2. What the overlays become @@ -241,13 +241,13 @@ What is lost is more interesting than what is saved. sigil exists so you can tell nine sessions apart at a glance across a wall of tiled panes. One window with one session has nothing to disambiguate, and the rock degrades into decoration. So this is a design constraint, not a detail: a -native Easel should have panes or tabs, and the rock should be what marks them. +native Aesel should have panes or tabs, and the rock should be what marks them. If it is a single-session window, drop the rock and keep the scan code. **Cross-application reach is gone.** Today the rocks decorate Terminal.app and iTerm2 — someone else's windows. A native app cannot decorate a terminal it does not own. If @jeffrey keeps working in Terminal.app under Slab, the native app -does not replace that; it becomes a second place Easel lives. Two shells to keep +does not replace that; it becomes a second place Aesel lives. Two shells to keep in agreement is a real recurring cost and I do not think it goes away. **The scan code gets better, not worse.** `PromptScanCode` is 99 lines of @@ -265,7 +265,7 @@ that the AC-runtime-as-webview constraint works as an inner pane and not just as a whole window. It ports directly. **Keep writing the Slab marker either way.** `SlabSession` writes one JSON file -per session into `~/.local/share/slab/state`. If a native Easel keeps doing that, +per session into `~/.local/share/slab/state`. If a native Aesel keeps doing that, it appears in the existing menu bar and prox ledger for free, and the fleet does not need to learn a new thing. The marker costs nothing and buys continuity. @@ -295,7 +295,7 @@ grammar with its load-bearing encoded space, and the fact that a piece is one file. What is duplicated is the agent loop (about 200 lines of `ac-server.mjs`), the SSE parser, the publish call, sign-in, and the entire interface. -**(d) Ship no native app.** Worth naming, because Easel's actual barriers today +**(d) Ship no native app.** Worth naming, because Aesel's actual barriers today are `curl | sh`, "you need Node 18", and "you need a Claude or Codex subscription", and a window fixes only the first of those. The third is what `ac-server.mjs` was built to fix, and it was built without any native work at @@ -314,7 +314,7 @@ No subprocess means the `ac` bridge only, and that is a sharper limit than `z-ai/glm-4.6`, `qwen/qwen3-coder`, `deepseek/deepseek-chat-v3.1` — with a comment saying why: *"Adding a frontier model here multiplies the cost of the free tier by about thirty."* It requires a token, resolves it to an `@handle`, -and meters against the same daily budget as `/api/ask`. So an iOS Easel is +and meters against the same daily budget as `/api/ask`. So an iOS Aesel is categorically a weaker agent than the desktop one **by server policy, not by porting effort**. No Opus, no Fable, no Codex, and a day's allowance shared with everything else AC buys for that handle. @@ -329,7 +329,7 @@ Sign-in needs rewriting. `ac-session.mjs` runs a loopback HTTP server on port `ASWebAuthenticationSession` against a custom scheme. Auth0 supports it; it is about ninety lines and a callback URL registration, and it is standard work. -So, plainly: **is an iOS Easel a real editor or a viewer with a prompt?** +So, plainly: **is an iOS Aesel a real editor or a viewer with a prompt?** It is a real single-file editor with a deliberately weak model, and the interesting thing about it is not the editing. It is that the piece is live and @@ -340,8 +340,8 @@ and it is arguably a *better* demonstration of what Aesthetic Computer is than the desktop app, because the whole live-piece apparatus stops needing a second screen to explain itself. -It is not Easel. It is the hosted half of Easel with a touch interface, and it -should be named and scoped as that rather than shipped as "Easel for iPhone" and +It is not Aesel. It is the hosted half of Aesel with a touch interface, and it +should be named and scoped as that rather than shipped as "Aesel for iPhone" and then apologised for. One more consequence: it cannot be a terminal. Nobody drives slash commands and @@ -372,7 +372,7 @@ with silo, and `electron-updater` points at fastlane lanes at `apple/fastlane/`. **The WKWebView bridge exists.** `apple/aesthetic.computer/ContentView.swift` is -466 lines and already has the pattern an iOS Easel needs: two message handlers +466 lines and already has the pattern an iOS Aesel needs: two message handlers (`iOSApp`, `iOSAppLog`), a `console.log` monkey-patch at document start, and native→JS calls into named globals (`window.iOSReceivePushToken`, `iOSAppSwitchPiece`) with a retry loop for the case where the page has not @@ -383,10 +383,10 @@ only** — `SDKROOT = iphoneos`, no `SUPPORTED_PLATFORMS` override, no Catalyst. There is no macOS target in it and never has been. And `fastlane ios build` is currently *broken*: `PROGRESS.md` records it failing on 2026-09-11 because Xcode 26.6 kept the iPhoneOS SDK but has no simulator runtimes, and version 1.1 has -been sitting in `PREPARE_FOR_SUBMISSION` since. An iOS Easel would inherit a +been sitting in `PREPARE_FOR_SUBMISSION` since. An iOS Aesel would inherit a pipeline that does not presently produce an IPA. -An iOS Easel would be a **new target or a new project**, not a target of +An iOS Aesel would be a **new target or a new project**, not a target of `aesthetic.computer.xcodeproj`. The precedent in this repo is clear: oskiewar, trackdrum and tvos-tapes are each their own project, two of them generated by XcodeGen from a `project.yml`. Follow that. @@ -432,7 +432,7 @@ Apple Events (*"App Review routinely rejects it for a menubar instrument"*), no MultipeerConnectivity, and *"no broad Downloads, Desktop, Documents, or home-directory access."* -Applied to Easel, the equivalent gate is the `claude` and `codex` bridges. The +Applied to Aesel, the equivalent gate is the `claude` and `codex` bridges. The workspace itself is solvable — MenuBand holds `com.apple.security.files.user-selected.read-write`, and a folder the user picks through an open panel plus a security-scoped bookmark is a normal sandboxed @@ -454,7 +454,7 @@ execute-outside-the-container rule are documented Apple behaviour, not something I ran. The practical conclusion is cleaner than the policy question, and it is the -useful part: **a sandboxed Mac App Store Easel would be the `ac` bridge only — +useful part: **a sandboxed Mac App Store Aesel would be the `ac` bridge only — which is the same product as the iOS app.** So MAS and iOS are one decision, not two, and Developer ID direct distribution is the only home the CLI bridges can have. That is a tidy line, and it means nobody has to litigate App Review to diff --git a/easel/docs/next-release.md b/easel/docs/next-release.md new file mode 100644 index 0000000000..5fe50566f0 --- /dev/null +++ b/easel/docs/next-release.md @@ -0,0 +1,217 @@ +# Aesel 0.7 — Pictures, Sound, Pieces, Papers + +Proposed September 15, 2026. Strategy, not implemented functionality. + +Aesel should make pictures, sounds, pieces of aesthetic.computer software, and papers through one conversation and +one version history. Choose the medium first; choose the tools and providers +within it. An image model, an AC brush, and a hand-drawn stroke can all contribute +to the same picture. + +## The experience + +```text +New → Picture ─→ brush / draw / generate / edit ─→ canvas ─→ PNG + → Sound ─→ compose / synthesize / sample ─→ player ─→ WAV + → Piece ─→ write / run / debug ─→ runtime → piece URL + → Paper ─→ research / write / cite ─→ pages → PDF + source + ↓ + v1 → v2 → v3 → restore +``` + +Keep the conversation scrollable above a fixed composer. The persistent controls +show medium, current artifact/version, and selected engine. Clicking those +controls opens the relevant choices. `/about` maps the actual installed +capabilities and explains unavailable ones. + +Medium, runtime, and provider are different choices: + +- **Medium:** what the user is making—Picture, Sound, Piece, Paper. +- **Runtime/tool:** how it is made—AC brushes, a Pop instrument, JavaScript. +- **Provider:** who performs a remote operation—AC hosted, OpenAI Images, fal, + or a user's existing supported account. + +The conversation model stays independently selectable. Switching from Claude to +Codex should not switch the image provider or rebuild a sound. `/medium` should +create another artifact in the project, preserving the existing one. + +## One project and revision contract + +Introduce a project manifest containing a stable project ID, artifact IDs, +medium, editable recipe/source paths, referenced asset hashes, tool/renderer +versions, and the current revision. Keep the project usable without an AC login; +optional server ownership uses a stable account ID, with the handle used for +display and public routes. + +| Medium | Editable material | Revision output | +|---|---|---| +| Picture | Brush operations, seeds, layers, generation brief, references | Composite PNG plus immutable input images | +| Sound | Notes/events, instruments, effects, sample/stem references | WAV plus score and required assets | +| Piece | Piece source and asset references | Source revision and running preview | +| Paper | Manuscript, bibliography, figures, evidence references, build recipe | PDF, approved source bundle, and review tied to the PDF hash | + +Every accepted update produces `v1`, `v2`, etc. Keep intermediate drafts separate +from accepted versions. Paid render results are saved even if rejected, so the +user can reconsider them without paying again. Rollback appends a new head +pointing at existing material; it never calls a provider to reconstruct history. + +Extend [the current snapshot store](../src/revisions.mjs) to recipes and immutable +asset references. Save blobs atomically by content hash. Give exports portable +relative paths. Preserve original paid outputs and `.illy.json` provenance. + +Do not make a Git repository per user a prerequisite. Git is useful for optional +recipe/source export; image and audio blobs need an asset store. Shared history +can later sync manifests and blobs under the stable account ID without exposing +unpublished work. The current path-keyed local history remains importable. + +## Tools for each medium + +**Pictures:** ship a small audited AC brush collection. Reuse the existing +[proposal contract](../../docs/nopaint-brush-proposal-contract.md): draw into a +separate buffer, preview, accept or discard. The same transaction supports a +generated image becoming a layer rather than erasing the canvas. Preserve brush +parameters and seeds so the composition stays editable. Start with brush +proposals, layer composition, generation/editing through Illy, and PNG export. + +The [brush adapter](../../system/public/aesthetic.computer/lib/nopaint-brush-piece.mjs) +and [proposal catalog](../../system/public/aesthetic.computer/lib/nopaint-proposals.mjs) +are better foundations than copying the entire `nopaint` application. + +**Raster providers:** extract the reusable provider adapters and provenance +contract from [Illy](../../plugins/illy/scripts/illy-mcp.mjs). Its installed +capability registry currently advertises OpenAI `gpt-image-2` generation/editing +and fal Flux routes. Resolve supported models at runtime and show the explicit +selection. Credential availability on the studio machine does not imply access +for an Aesel user. Never silently switch providers after a failed paid request. + +**Sound:** follow [Pop's compositional direction](../../pop/SCORE.md). Begin with +short phrases and loops built from instruments, notes, rhythm, and effects, +rather than an end-to-end song-generation button. The first complete flow is: +“make a soft bell phrase” → edit notes/timbre → render → play/loop → revise → WAV. +Start from the instrument/effect portion of [Pop's menu](../../pop/lib/menu.mjs), +[rhythm helpers](../../pop/lib/necklace.mjs), and [WAV support](../../pop/lib/wav.mjs). +Add samples and optional vocal performance providers after those primitives +work. Do not ship personal voice assets as defaults. + +**Piece — “build a new piece of aesthetic.computer software”:** retain the existing piece workflow and compatibility. Replace the +AC bridge's single hard-coded tool with a capability registry: `write_piece` for +Pieces; brush/layer/image operations for Pictures; score/render/analyze +operations for Sound. Validate operation arguments and confine outputs to the +project. Models receive tool descriptions, never provider credentials. + +**Paper:** a writing and research workspace backed by the existing +[papers stack](../../papers/SCORE.md), not merely a PDF export option. Begin with +the question, relevant Platter material, sources, and an outline; draft and revise +sections while keeping citations and evidence attached. The default lane is an +archival LaTeX paper; essays, cards, and other forms are explicit choices. + +Use the [Paper MCP](../../slab/bin/paper-mcp.mjs) workflow for discovery, source +reading, builds, figure/table checks, and visual review. Expose bounded tools +such as `find_sources`, `read_source`, `edit_section`, `update_bibliography`, +`attach_figure`, `build_paper`, and `review_pages`. Distinguish sourced claims, +unverified claims, and missing references. Never invent citations. + +The preview shows rendered pages alongside the current section, with build errors +pointing back to source. Section edits create source revisions; only a successful +build replaces the PDF preview. A compiling PDF is not a finished paper: require +figure/table QA and visual review tied to the current PDF hash. Restore source, +bibliography, assets, PDF, and the matching review together on rollback. + +Pictures and piece measurements can become figures through explicit artifact +references pinned to versions. Updating a source artifact marks dependent paper +figures as potentially stale; it must not silently rewrite a reviewed paper. +Source bundles contain only approved project material, never private evidence or +credentials by implication. The author/byline comes from the user's project +identity, not a packaged hard-coded studio author. Export and public publication +remain separate actions. + +## Preview, jobs, and costs + +The top-left preview is the artifact being made, in every medium. Keep its +position consistent as the medium changes: Picture shows the canvas, Sound +shows a waveform with playback, Piece shows the running software, and Paper +shows the rendered page being edited. Expand that same preview for closer +inspection and interaction; it must not become a separate, stale copy. + +The preview follows the selected artifact and version, including rollback. +While a new version is building, retain the last usable artifact and indicate +the pending update. If the user inspects an older version, label it explicitly +and preserve that selection until they return to the current version. Audio +playback is user-controlled; a refreshed sound never starts playing by itself. + +Preview adapters consume artifact revisions rather than assuming every artifact +is a piece URL. Pictures get a canvas; Sound gets transport, waveform, duration, +and a loop region; Pieces keep the running AC view; Paper gets paginated PDF +preview with section navigation and citation/build diagnostics. Slab and a future desktop +shell use the same adapters. + +Token streaming remains separate from render jobs. Media work needs durable job +IDs, progress events, cancellation, reconnect, and idempotent submission. Show +real stages—queued, generating, rendering, ready—not invented percentages. +Display partial imagery/audio only when the renderer actually supplies usable +previews. Commit a version after output validation succeeds. Animate the preview +when it applies that revision; a failed render leaves the prior version intact. + +Separate text allowances from image/audio charges. Hosted media requires a shown +estimate/ceiling, an atomic reservation before submission, and reconciliation +after completion. A reconnect must attach to the existing job rather than buy +another render. User-supplied API credentials and AC-funded access are distinct +routes. Store credentials outside projects; remove Illy's studio-vault fallback +from the distributable adapter. + +Do not promise that a Claude/Codex subscription includes standalone image/audio +API access. Illy cannot itself invoke Codex's built-in image tool; an integration +must advertise that capability only where it is actually callable. + +## Packaging and the desktop shell + +Bundle versioned schemas, adapters, brush implementations, pure-JS instruments, +effects, rhythm utilities, and WAV support through a reproducible toolkit sync +step. Test the [release tarball](../bin/pack.mjs) outside the monorepo: imports +reaching into `../../pop` or the studio's plugin cache will fail for users. + +For Paper, bundle portable templates, bibliography/source-bundle schemas, and +build/QA adapters. Offer a separately installed TeX toolchain or hosted build +worker. A public installation must not require the studio's loopback Paper MCP +daemon, private Platter, fonts without redistribution rights, or hard-coded +author identity. Hosted builds receive only the selected project sources. + +Keep native DSP/mastering, ffmpeg-dependent workflows, large sample packs, and +provider credentials out of the core package. Offer separately installed, +versioned capability packs or hosted workers. Audit sample redistribution and +tool dependencies before bundling. Pop's [mastering wrapper](../../pop/lib/master.mjs) +and [DSP status](../../pop/dsp/README.md) describe native dependencies that need +this treatment. + +Build the standalone desktop shell against these same project, job, and preview +interfaces. It can package a terminal view plus the canvas/player without Slab, +but must not become a second implementation of editing or history. Start the +shell in parallel only after the contracts stabilize. “Standalone app” and +“offline inference” remain separate claims. + +## Release sequence and gates + +1. **Foundation:** project/artifact manifest, multi-file revisions, capability + registry, and preview adapters. Update the stale local-contract document to + match actual hosted inference and publishing behavior. +2. **Picture slice:** blank canvas → AC brush proposal → accept → Illy generation + or edit → composite → PNG → restart → rollback. +3. **Sound slice:** scored phrase → synth/effect → render → playback/loop → WAV → + restart → rollback. Ship a small reliable instrument set. +4. **Paper slice:** consult sources → outline → draft/cite → attach a versioned + figure → build → inspect/QA → PDF and source bundle → restart → rollback. +5. **Distribution:** extracted-package tests, provider job/billing tests, and a + desktop-shell preview using the same artifacts. Keep the existing Piece + workflow working throughout. + +Gate 0.7 on all four media completing create → revise → preview → export → +restart → rollback from an installed copy without a repository or studio vault. +Also test provider failures, disconnect/reconnect without duplicate charges, +cancelled jobs, missing optional tools, exact rollback asset hashes, and keyboard +as well as mouse navigation. Paper additionally requires valid references, +figure dependency tracking, source-bundle exclusions, and fresh visual QA after +every PDF change. + +Defer a full DAW timeline, video editing, arbitrary plugin execution, automatic +per-user Git hosting, cross-device conflict resolution, and full offline model +packaging. The release should prove four complete making workflows before +expanding the tool catalog. diff --git a/easel/docs/openrouter-backend.md b/easel/docs/openrouter-backend.md index 1eb6d9ae7a..6c561568d9 100644 --- a/easel/docs/openrouter-backend.md +++ b/easel/docs/openrouter-backend.md @@ -3,7 +3,7 @@ Written 2026-09-11. Prices quoted are from `https://openrouter.ai/api/v1/models`, pulled that morning. Nothing here is implemented. -The goal is to stop requiring every person who opens Easel to already +The goal is to stop requiring every person who opens Aesel to already hold a Claude or Codex subscription. Today both bridges sign in with a vendor CLI's own credentials, which is elegant — no key, no server, no account — and also a wall: the interface is unusable to anyone who has not already paid @@ -62,7 +62,7 @@ Worth noting, because it changes how bad this option feels: OpenRouter has an OAuth PKCE flow at `https://openrouter.ai/auth` that exchanges a code at `POST /api/v1/auth/keys` for a user-scoped key billed to that user's credits, and it documents a headless mode with no `callback_url` where the user copies -the code across. Easel already runs an Authorization-Code + PKCE flow +the code across. Aesel already runs an Authorization-Code + PKCE flow with a loopback callback in `src/ac-session.mjs`; a second one against a different issuer is the same ninety lines. So "bring your own key" can be `/login`-shaped rather than paste-shaped. @@ -120,7 +120,7 @@ The trust boundary in the recommended option is worth stating flatly: Aesthetic Computer never sees the prompts, but it does mint a bearer token that spends its money, and a user who extracts that token from their own machine — trivially, it is a file they own — can spend the cap however they like, including on -something that is not Easel. The cap is therefore the entire control. +something that is not Aesel. The cap is therefore the entire control. It should be small, it should reset, and it should be revocable per handle. ## Whether this needs a second kind of engine @@ -251,7 +251,7 @@ product, and writes the answer back to Redis. The invalidation half is in `ticket.js`, which on `customer.subscription.updated` reads `customer.metadata.sub` and deletes the cache entry. That pair — a Redis-cached entitlement keyed on the Auth0 sub, invalidated by a webhook — is precisely what -a paid Easel tier needs, and it can be ported by changing the product +a paid Aesel tier needs, and it can be ported by changing the product id. `system/netlify/functions/news-toll.mjs` is the cleanest single-file reference @@ -318,7 +318,7 @@ of magnitude. These are @jeffrey's own sessions in the full Aesthetic Computer tree, on a million-token context, and they are an upper bound rather than a typical -Easel session — the interface opens on one small piece file in a +Aesel session — the interface opens on one small piece file in a workspace the user chose. So, bottom-up: thirty turns, context growing from about 15k tokens to about 60k, averaging 35k. That is roughly 1.05M cache-read tokens, 300k cache-write, 20k output. Priced across the shelf at today's @@ -390,7 +390,7 @@ may be issued or re-issued, plus a short `expires_at` so an abandoned key stops mattering — and it is a partial one. Worth testing before promising a tier. Runaway spend inside a single session is the most likely everyday failure, and -it is not malice. Easel auto-approves tool calls by default: +it is not malice. Aesel auto-approves tool calls by default: `handleRequest` in `src/tui.mjs` answers `accept` unless `/ask on` is set, because the first real session spent two of its two hours and nineteen minutes parked on prompts with nobody watching. That default is right for a piece @@ -452,7 +452,7 @@ from unauthenticated route probes, not from a working call. cannot answer. The free tier's viability is a quality question, not a price one. - **The realistic monthly-active handle count.** 2,814 handles exist. How many - would open Easel in a month is a guess, and the $300 figure moves + would open Aesel in a month is a guess, and the $300 figure moves linearly with it. ## What this changes in the contract diff --git a/easel/install.sh b/easel/install.sh index 71f8889c54..e9de6db7f4 100755 --- a/easel/install.sh +++ b/easel/install.sh @@ -11,9 +11,10 @@ ZSH_PROFILE="${ZDOTDIR:-${HOME}}/.zprofile" mkdir -p "$BIN_DIR" "$CONFIG_DIR" chmod +x "$PROJECT_DIR/bin/easel" ln -sfn "$PROJECT_DIR/bin/easel" "$BIN_DIR/easel" +ln -sfn "$PROJECT_DIR/bin/easel" "$BIN_DIR/aesel" ln -sfn "$PROJECT_DIR/bin/easel" "$BIN_DIR/ac" -# The tool was called `aesthetic` before it was called Easel, and that name +# The tool was called `aesthetic` before it was called Aesel, and that name # belongs to the platform helper it displaced. Take it back only if it is still # our own symlink — never touch a real file someone else put there. if [[ -L "$BIN_DIR/aesthetic" ]] && [[ "$(readlink "$BIN_DIR/aesthetic")" == *"/bin/aesthetic" || "$(readlink "$BIN_DIR/aesthetic")" == *"/bin/easel" ]]; then @@ -50,6 +51,6 @@ case ":${PATH}:" in *) printf 'Add %s to PATH to use the commands.\n' "$BIN_DIR" >&2 ;; esac -printf 'Installed Easel:\n' +printf 'Installed Aesel:\n' printf ' %s\n' "$BIN_DIR/ac" printf ' %s\n' "$BIN_DIR/easel" diff --git a/easel/package.json b/easel/package.json index cdbc18e3d4..8d03c54cb9 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { - "name": "easel", - "version": "0.5.1", + "name": "aesel", + "version": "0.6.0", "private": true, "type": "module", "scripts": { diff --git a/easel/phone/README.md b/easel/phone/README.md index 2c137a80dc..3c138e8519 100644 --- a/easel/phone/README.md +++ b/easel/phone/README.md @@ -50,6 +50,14 @@ Caveats worth knowing before judging output: the server allowlists three cheap models, so this is a deliberately weaker agent than desktop Aesel, and the allowance is the same daily one `/api/ask` spends. +The chip beside the status reads `~0.12 Wh`: an estimate of the electricity the +session's turns took to serve, from the token counts the bridge reports and the +model's active parameter count. `src/energy.mjs` holds the arithmetic and the +caveat — it is an estimate, not a measurement. Tapping the chip prints the +working into the transcript, including the same conversation priced across +every model, which is the part of the estimate that rests on published +numbers. + ## Where it is going This page is the content of the iOS app, not a detour. The Swift shell replaces diff --git a/easel/phone/app.mjs b/easel/phone/app.mjs index d24a9ca915..d5d7ab7d3e 100644 --- a/easel/phone/app.mjs +++ b/easel/phone/app.mjs @@ -21,6 +21,7 @@ // handler publishes directly — which is also what makes the preview live. import { AcServer, AC_MODELS, DEFAULT_AC_MODEL } from "/easel/src/ac-server.mjs"; +import { Energy, energyReport, formatJoules } from "/easel/src/energy.mjs"; import { publishPiece } from "/easel/src/publish.mjs"; import * as vfs from "/easel/phone/shim/fs.mjs"; @@ -46,6 +47,7 @@ const ui = { gateNote: el("gate-note"), route: el("route"), status: el("status"), + watts: el("watts"), stage: el("stage"), preview: el("preview"), log: el("log"), @@ -112,6 +114,10 @@ function scrollDown() { if (atBottom) ui.log.scrollTop = ui.log.scrollHeight; } +// What the session has spent in electricity, as far as the reported token +// counts can say. +const energy = new Energy(); + function setStatus(text, stateName = "idle") { ui.status.textContent = text; ui.status.dataset.state = stateName; @@ -231,6 +237,16 @@ function handle({ method, params }) { return; } + // The bridge reports its token counts per round; energy.mjs turns them into + // watt-hours. It is an estimate, which is why the chip wears a tilde and the + // basis is one tap away. + if (method === "turn/usage") { + energy.add(params?.model || DEFAULT_AC_MODEL, params?.usage); + ui.watts.hidden = !energy.counted; + ui.watts.textContent = `~${formatJoules(energy.joules)}`; + return; + } + if (method === "turn/progress") { const phase = params?.phase; if (phase) setStatus(phase, "working"); @@ -458,4 +474,13 @@ ui.route.parentElement.addEventListener("click", () => { if (state.handle) window.open(`${SITE}/@${state.handle}/${state.slug}`, "_blank"); }); +// The chip is a number; the tap is the working behind it — including the same +// conversation priced across every model, which is the comparison the estimate +// can actually defend. +ui.watts.addEventListener("click", () => { + for (const text of energyReport(energy, DEFAULT_AC_MODEL)) { + if (text) line("note", "", text); + } +}); + void boot(); diff --git a/easel/phone/index.html b/easel/phone/index.html index 825cacfcf8..addff5061c 100644 --- a/easel/phone/index.html +++ b/easel/phone/index.html @@ -46,6 +46,9 @@ + + idle diff --git a/easel/phone/style.css b/easel/phone/style.css index 32e888cb8b..bc06c3d94c 100644 --- a/easel/phone/style.css +++ b/easel/phone/style.css @@ -71,6 +71,21 @@ body { color: var(--dim); } +#watts { + flex: none; + padding: 2px 6px; + border: 1px solid var(--rule); + border-radius: 999px; + background: none; + color: var(--dim); + font: inherit; + font-size: 11px; +} + +#watts[hidden] { + display: none; +} + #status[data-state="working"] { color: var(--ac); } diff --git a/easel/shell/easel.fish b/easel/shell/easel.fish index bbdcb1b0cc..5161c4815b 100644 --- a/easel/shell/easel.fish +++ b/easel/shell/easel.fish @@ -1,6 +1,6 @@ -# Easel command integration for Fish. +# Aesel command integration for Fish. # -# Easel claims `ac` and `easel`. It deliberately does not claim `aesthetic` +# Aesel claims `ac` and `easel`. It deliberately does not claim `aesthetic` # any more: that name belongs to the Aesthetic Computer platform helper, and # the tool only held it while it was still called Aesthetic Code. if functions -q ac; and not functions -q ac-repo @@ -9,10 +9,14 @@ end functions --erase ac easel -function ac --description 'Open Easel' - command $HOME/.local/bin/easel $argv +function ac --description 'Open Aesel in this terminal' + command $HOME/.local/bin/ac $argv end -function easel --description 'Open Easel' +function easel --description 'Open Aesel' command $HOME/.local/bin/easel $argv end + +function aesel --description 'Open Aesel' + command $HOME/.local/bin/aesel $argv +end diff --git a/easel/src/about.mjs b/easel/src/about.mjs new file mode 100644 index 0000000000..31899cc787 --- /dev/null +++ b/easel/src/about.mjs @@ -0,0 +1,39 @@ +export function aboutMap() { + return [ + "AESEL — make a piece by talking to it", + "", + "You → model → working piece → live preview → URL / QR", + " │ │", + " │ └─ v1 → v2 → v3 · /versions · /rollback vN", + " ├─ AC hosted · /backend ac · /model", + " ├─ Your Claude account · /backend claude", + " └─ Your Codex account · /backend codex", + "", + "MAKE /piece · /runtime · /ask on|off", + "MEASURE /performance · headless logic · /energy · estimated electricity", + "SHARE /publish · /autopublish on|off · /open · /qr", + "ACCOUNT /login · /profile · /logout", + "THREAD /model NAME · /backend NAME · /new", + "", + "Switch engines with recent conversation and the current piece.", + "Versions are saved on this computer; rollback makes a new version.", + "AC hosted uses your handle's daily budget. Claude/Codex use your own CLI sign-in.", + "", + "Esc returns · ↑/↓ scroll · /mouse off restores terminal selection", + ]; +} + +// Recent conversation is portable even when provider thread IDs are not. +export function conversationHandoff(entries, limit = 24000) { + const turns = entries.filter(({ kind }) => kind === "user" || kind === "assistant") + .map(({ kind, text }) => JSON.stringify({ role: kind, content: text })); + const kept = []; + let size = 0; + for (const turn of turns.reverse()) { + const part = turn.length > limit ? turn.slice(0, limit) : turn; + if (size + part.length > limit) break; + kept.unshift(part); + size += part.length; + } + return kept.length ? "Conversation before the engine switch (reference context):\n" + kept.join("\n") : ""; +} diff --git a/easel/src/ac-server.mjs b/easel/src/ac-server.mjs index 8bc114403d..7811211528 100644 --- a/easel/src/ac-server.mjs +++ b/easel/src/ac-server.mjs @@ -1,7 +1,7 @@ // The Aesthetic Computer bridge — inference without a vendor CLI. // // The other two bridges spawn `claude` or `codex` and speak a line protocol to -// a subprocess. That is why an installed Easel does nothing for someone holding +// a subprocess. That is why an installed Aesel does nothing for someone holding // neither subscription: the interface is complete and there is no engine under // it. This bridge talks HTTP to aesthetic.computer instead, which buys the // inference on its own account and meters it against the caller's @handle. An @@ -23,7 +23,7 @@ // same 24 KB that ships in easel/context, spent once per thread as cached // prefix rather than fetched per question. // -// The tool set is deliberately one tool. Easel is an editor for one piece, and a +// The tool set is deliberately one tool. Aesel is an editor for one piece, and a // turn's whole job is to produce that piece's next version. A general file API // would be a larger surface to secure, a larger prompt to pay for, and no closer // to what the session is for. `write_piece` is what the loop exists to serve. @@ -32,6 +32,7 @@ import { EventEmitter } from "node:events"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { validatePieceSource } from "./revisions.mjs"; import { randomUUID } from "node:crypto"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); @@ -45,6 +46,8 @@ export const AC_MODELS = { glm: "z-ai/glm-4.6", qwen: "qwen/qwen3-coder", deepseek: "deepseek/deepseek-chat-v3.1", + sonnet: "anthropic/claude-sonnet-4.6", + gpt: "openai/gpt-5.4", }; // The guides, in the order a model should meet them: what a piece is, then how @@ -73,7 +76,7 @@ function bundledContext() { const WRITE_PIECE = { name: "write_piece", description: - "Write the complete new source of the session's piece. Always send the whole file, never a patch or a fragment — what you send replaces the file exactly. Saving pushes it live to anyone watching, so prefer several small writes over one large one.", + "Write the complete new source of the session's piece. Always send the whole file, never a patch or a fragment — what you send replaces the file exactly. Saving pushes it live to anyone watching. Build the request in several small, complete working checkpoints: send each checkpoint as a separate write_piece call as soon as it is ready, then continue improving it. Never send unfinished syntax.", input_schema: { type: "object", properties: { @@ -99,7 +102,7 @@ export class AcServer extends EventEmitter { } = {}) { super(); this.cwd = cwd; - this.model = AC_MODELS[model] || model || DEFAULT_AC_MODEL; + this.model = (Object.hasOwn(AC_MODELS, model) ? AC_MODELS[model] : model) || DEFAULT_AC_MODEL; this.developerInstructions = developerInstructions; this.piece = piece; this.token = token; @@ -109,7 +112,7 @@ export class AcServer extends EventEmitter { this.turnId = null; this.turns = 0; // The conversation. Held here because there is no process holding it for us - // — closing Easel loses it, which is honest: nothing was written anywhere. + // — closing Aesel loses it, which is honest: nothing was written anywhere. this.messages = []; this.controller = null; } @@ -139,6 +142,9 @@ export class AcServer extends EventEmitter { if (this.developerInstructions) { blocks.push({ type: "text", text: this.developerInstructions }); } + if (this.piece?.file && existsSync(this.piece.file)) { + blocks.push({ type: "text", text: `Current piece (${this.piece.file}); preserve the user's existing work unless asked to change it:\n\n${readFileSync(this.piece.file, "utf8")}` }); + } return blocks; } @@ -216,7 +222,8 @@ export class AcServer extends EventEmitter { // One request, streamed. Returns why the model stopped. async #round() { - this.controller = new AbortController(); + const controller = this.controller = new AbortController(); + this.emit("notification", { method: "turn/progress", params: { phase: "connecting" } }); const token = await this.token?.(); if (!token) { throw new Error("Hosted inference needs an Aesthetic Computer handle — run /login."); @@ -224,7 +231,7 @@ export class AcServer extends EventEmitter { const response = await this.fetch(`${this.site}/api/easel-inference`, { method: "POST", - signal: this.controller.signal, + signal: controller.signal, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ model: this.model, @@ -244,70 +251,112 @@ export class AcServer extends EventEmitter { throw new Error(message); } + this.emit("notification", { method: "turn/progress", params: { phase: "waiting" } }); const messageId = `msg-${this.turns}-${Date.now()}`; const blocks = []; + const results = []; + let received = 0; + let finished = false; let stop = "end_turn"; let text = ""; // Tool arguments arrive as a JSON string in fragments, so they are gathered // per block index and parsed only once the block closes. const partials = new Map(); + // What this round cost. The counts arrive split across two events — + // `message_start` knows the prompt, `message_delta` knows the answer — and + // each is cumulative for its own field, so later values replace rather than + // add. The interface turns this into watt-hours; see energy.mjs. + const usage = {}; const reader = response.body.getReader(); const decoder = new TextDecoder(); let tail = ""; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - tail += decoder.decode(value, { stream: true }); - let cut = tail.indexOf("\n"); - while (cut !== -1) { - const line = tail.slice(0, cut).trim(); - tail = tail.slice(cut + 1); - cut = tail.indexOf("\n"); - if (!line.startsWith("data: ")) continue; - const payload = line.slice(6); - if (payload === "[DONE]") continue; - let event; - try { - event = JSON.parse(payload); - } catch { - continue; - } + try { + for (;;) { + controller.signal.throwIfAborted(); + const { done, value } = await reader.read(); + controller.signal.throwIfAborted(); + if (done) break; + received += value.byteLength; + tail += decoder.decode(value, { stream: true }); + let cut = tail.indexOf("\n"); + while (cut !== -1) { + const line = tail.slice(0, cut).trim(); + tail = tail.slice(cut + 1); + cut = tail.indexOf("\n"); + if (!line.startsWith("data:")) continue; + const payload = line.slice(5).trimStart(); + if (payload === "[DONE]") continue; + let event; + try { + event = JSON.parse(payload); + } catch { + continue; + } + + const counts = event.usage || event.message?.usage; + if (counts) Object.assign(usage, counts); - if (event.type === "content_block_start") { - const block = event.content_block; - if (block?.type === "tool_use") { - partials.set(event.index, { id: block.id, name: block.name, json: "" }); + if (event.type === "content_block_start" || event.type === "content_block_delta") { + this.emit("notification", { method: "turn/progress", params: { + phase: event.delta?.type === "input_json_delta" || event.content_block?.type === "tool_use" ? "composing" : "generating", + bytes: received, + } }); } - } else if (event.type === "content_block_delta") { - const delta = event.delta; - if (delta?.type === "text_delta" && delta.text) { - text += delta.text; - this.emit("notification", { - method: "item/agentMessage/delta", - params: { itemId: messageId, delta: delta.text }, - }); - } else if (delta?.type === "input_json_delta") { + if (event.type === "content_block_start") { + const block = event.content_block; + if (block?.type === "tool_use") { + partials.set(event.index, { id: block.id, name: block.name, json: "" }); + } + } else if (event.type === "content_block_delta") { + const delta = event.delta; + if (delta?.type === "text_delta" && delta.text) { + text += delta.text; + this.emit("notification", { + method: "item/agentMessage/delta", + params: { itemId: messageId, delta: delta.text }, + }); + } else if (delta?.type === "input_json_delta") { + const partial = partials.get(event.index); + if (partial) partial.json += delta.partial_json || ""; + } + } else if (event.type === "content_block_stop") { const partial = partials.get(event.index); - if (partial) partial.json += delta.partial_json || ""; - } - } else if (event.type === "content_block_stop") { - const partial = partials.get(event.index); - if (partial) { - let input = {}; - try { - input = JSON.parse(partial.json || "{}"); - } catch {} - blocks.push({ type: "tool_use", id: partial.id, name: partial.name, input }); - partials.delete(event.index); + if (partial) { + let input = {}; + try { + input = JSON.parse(partial.json || "{}"); + } catch {} + const block = { type: "tool_use", id: partial.id, name: partial.name, input }; + blocks.push(block); + // A complete tool block is a checkpoint; do not wait for the next + // explanation or the end of this response before showing it. + results.push(await this.#runTool(block)); + partials.delete(event.index); + } + } else if (event.type === "message_delta") { + if (event.delta?.stop_reason) { stop = event.delta.stop_reason; finished = true; } + } else if (event.type === "error") { + throw new Error(event.error?.message || "inference error"); } - } else if (event.type === "message_delta") { - if (event.delta?.stop_reason) stop = event.delta.stop_reason; - } else if (event.type === "error") { - throw new Error(event.error?.message || "inference error"); } } + + if (!finished || partials.size) throw new Error("Inference stream ended before the response completed. Saved checkpoints are preserved."); + } finally { + await reader.cancel?.().catch(() => {}); + reader.releaseLock?.(); + } + + // Reported per round rather than per turn: a turn that called a tool paid + // for two responses, and a readout that showed one of them would understate + // the expensive kind of turn. + if (Object.keys(usage).length) { + this.emit("notification", { + method: "turn/usage", + params: { model: this.model, usage }, + }); } if (text) { @@ -324,15 +373,12 @@ export class AcServer extends EventEmitter { if (stop !== "tool_use" || !blocks.length) return { stop: "end_turn" }; - const results = []; - for (const block of blocks) { - results.push(await this.#runTool(block)); - } this.messages.push({ role: "user", content: results }); return { stop: "tool_use" }; } async #runTool(block) { + const signal = this.controller?.signal; const itemId = `tool-${block.id}`; const note = String(block.input?.note || "").trim(); this.emit("notification", { @@ -366,7 +412,11 @@ export class AcServer extends EventEmitter { try { const file = this.piece?.file; if (!file) throw new Error("no piece is open in this session"); + this.emit("notification", { method: "turn/progress", params: { phase: "writing" } }); + await validatePieceSource(source, file); + signal?.throwIfAborted(); writeFileSync(file, source.endsWith("\n") ? source : `${source}\n`); + await this.piece?.checkpoint?.(); this.emit("notification", { method: "item/completed", params: { item: { id: itemId, type: "fileChange", path: file, status: note || "written" } }, diff --git a/easel/src/ac-session.mjs b/easel/src/ac-session.mjs index 28bf9460b2..ced5ee0caf 100644 --- a/easel/src/ac-session.mjs +++ b/easel/src/ac-session.mjs @@ -3,7 +3,7 @@ // Every AC desktop app reads one file, ~/.ac-token, minted by `ac-login` with // Auth0 Authorization-Code + PKCE and a loopback callback. This module reads // and watches that file, refreshes the access token, and can run the same -// sign-in flow itself so Easel needs no other checkout. Only the +// sign-in flow itself so Aesel needs no other checkout. Only the // handle is ever displayed; email and name stay in the file. import { EventEmitter } from "node:events"; import { createHash, randomBytes } from "node:crypto"; @@ -26,11 +26,11 @@ const base64url = (buffer) => buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); const LANDING = ` -Signed in · Easel +Signed in · Aesel -

Signed in

Return to Easel. You can close this tab.

`; +

Signed in

Return to Aesel. You can close this tab.

`; export function openInBrowser(url) { const command = @@ -210,13 +210,13 @@ export class ACSession extends EventEmitter { const failure = url.searchParams.get("error"); if (failure) { response.writeHead(400, { "content-type": "text/plain" }); - response.end("Sign-in failed. Return to Easel."); + response.end("Sign-in failed. Return to Aesel."); settle(reject, new Error(url.searchParams.get("error_description") || failure)); return; } if (url.searchParams.get("state") !== state) { response.writeHead(400, { "content-type": "text/plain" }); - response.end("State mismatch. Return to Easel and retry."); + response.end("State mismatch. Return to Aesel and retry."); settle(reject, new Error("sign-in state mismatch — retry /login")); return; } diff --git a/easel/src/app-server.mjs b/easel/src/app-server.mjs index 2e5b90a259..36482bfeca 100644 --- a/easel/src/app-server.mjs +++ b/easel/src/app-server.mjs @@ -39,7 +39,7 @@ export class AppServer extends EventEmitter { env: { ...process.env, ...this.environment, - EASEL: "1", + AESEL: "1", EASEL_VERSION: VERSION, }, stdio: ["pipe", "pipe", "pipe"], @@ -76,7 +76,7 @@ export class AppServer extends EventEmitter { await this.request("initialize", { clientInfo: { name: "easel", - title: "Easel", + title: "Aesel", version: VERSION, }, capabilities: { experimentalApi: true }, diff --git a/easel/src/backends.mjs b/easel/src/backends.mjs index 907efbe139..0915ccdd29 100644 --- a/easel/src/backends.mjs +++ b/easel/src/backends.mjs @@ -1,4 +1,4 @@ -// backends.mjs — the engine bridges Easel can drive. +// backends.mjs — the engine bridges Aesel can drive. // // A bridge is a subprocess speaking a line protocol over stdio. The interface // holds the same conversation over either of them — a thread, turns inside it, diff --git a/easel/src/claude-server.mjs b/easel/src/claude-server.mjs index 20d376a9c2..454b80bedf 100644 --- a/easel/src/claude-server.mjs +++ b/easel/src/claude-server.mjs @@ -78,7 +78,7 @@ export class ClaudeServer extends EventEmitter { environment = {}, developerInstructions = "", model = DEFAULT_CLAUDE_MODEL, - // Easel's native tools (ac_api, ac_examples, ac_outline, ac_symbol). + // Aesel's native tools (ac_api, ac_examples, ac_outline, ac_symbol). tools = true, }) { super(); @@ -168,8 +168,8 @@ export class ClaudeServer extends EventEmitter { behavior: "deny", message: decision === "cancel" - ? "Cancelled in Easel." - : "Denied in Easel.", + ? "Cancelled in Aesel." + : "Denied in Aesel.", }; } this.#send({ @@ -245,7 +245,7 @@ export class ClaudeServer extends EventEmitter { "--add-dir", this.cwd, ]; - // Easel's own tools ride in as the one MCP server the strict config + // Aesel's own tools ride in as the one MCP server the strict config // admits: the API map, call-site search, and the outline/symbol pair that // replaces `sed -n` over a 9,000-line piece. They read local files and // nothing else, so they are allowed up front — an approval prompt for @@ -284,7 +284,7 @@ export class ClaudeServer extends EventEmitter { env: { ...process.env, ...this.environment, - EASEL: "1", + AESEL: "1", EASEL_VERSION: VERSION, }, stdio: ["pipe", "pipe", "pipe"], @@ -517,7 +517,7 @@ export class ClaudeServer extends EventEmitter { response: { subtype: "error", request_id: message.request_id, - error: `Easel does not support ${request.subtype} yet`, + error: `Aesel does not support ${request.subtype} yet`, }, }); return; @@ -551,6 +551,18 @@ export class ClaudeServer extends EventEmitter { const id = this.turnId || `turn-${this.turns}`; this.turnId = null; this.textItems.clear(); + // The CLI closes a turn with what it spent. `modelUsage` is keyed by the + // model that actually ran — which is not always the one asked for, and a + // fallback is exactly when the energy readout should not lie about which + // model it is describing. + const perModel = Object.entries(message.modelUsage || {}); + if (perModel.length) { + for (const [model, usage] of perModel) { + this.emit("notification", { method: "turn/usage", params: { model, usage } }); + } + } else if (message.usage) { + this.emit("notification", { method: "turn/usage", params: { model: this.model, usage: message.usage } }); + } const aborted = String(message.terminal_reason || "").startsWith("aborted"); const status = aborted ? "interrupted" : message.is_error ? "failed" : "completed"; const turn = { id, status, items: [] }; diff --git a/easel/src/energy.mjs b/easel/src/energy.mjs new file mode 100644 index 0000000000..a42425f3fc --- /dev/null +++ b/easel/src/energy.mjs @@ -0,0 +1,264 @@ +// energy.mjs — roughly what a turn cost in electricity. +// +// A session here is someone making a picture by talking to a machine in a +// datacenter, and nothing in the interface has ever said what that costs to +// run. Tokens are the wrong unit for the question: they are the provider's +// billing unit, they are not comparable between models, and nobody has an +// intuition for twelve thousand of them. Watt-hours are a unit people already +// own — a lightbulb, a kettle, a phone charge. +// +// What this is honest about: it is an estimate, and it cannot be anything else. +// No hosted provider publishes per-token energy, and the two frontier labs that +// have published anything at all published a per-prompt median, not a model +// card. So the number here is derived, and the derivation is written down so it +// can be argued with: +// +// 1. Generating one token runs the model's *active* parameters once. For the +// open-weight models Aesel hosts, that count is published (a mixture of +// experts announces both numbers: GLM-4.6 is 355B total, 32B active), so +// the models differ by a factor this formula can actually see. +// 2. Energy per output token is taken as a fixed part plus a part that scales +// with active parameters. The fixed part is everything that does not care +// how big the model is — host CPU, memory, networking, cooling, and the +// share of an idle-but-provisioned accelerator — which published +// full-stack figures put at a large fraction of the total. +// 3. The slope is anchored so a frontier-class model lands near the only +// measurements anyone has released: Google's median text prompt at 0.24 Wh +// (Aug 2025, full-stack, including idle and overhead), Epoch AI's estimate +// of ~0.3 Wh for a GPT-4o query, and OpenAI's own ~0.34 Wh average. At a +// few hundred output tokens per answer those all land at 1–3 J per token. +// 4. Reading the prompt is cheap per token compared to writing the answer. +// Prefill runs dense and batched; decode is memory-bound and runs one +// token at a time. An input token is counted at a tenth of an output +// token, a cached one at a hundredth — cheap, but not free, because the +// cache still has to be read out of memory and attended to. +// +// What it excludes: training, the water, the embodied cost of the hardware, and +// your own laptop. It is the marginal electricity of serving the turn. +// +// Two consequences for how this gets shown. Absolute watt-hours carry a +// precision they have not earned, so every number that reaches a person wears a +// `~`. And the comparison that *is* defensible is the relative one — the same +// conversation on a 32B-active model and on a frontier model differ by a factor +// the formula derives from published parameter counts rather than from guessed +// hardware — so `/energy` leads with the ratio and treats the watt-hours as the +// supporting detail. + +// Joules per output token: a floor that every model pays, plus a slope on +// billions of active parameters. Anchored at 200B active ≈ 2.4 J/token, which +// puts a 300-token answer at 0.2 Wh — inside the published per-prompt range. +const FIXED_J = 0.6; +const PER_BILLION_J = 0.009; + +// What a token of each other kind costs, as a share of one output token. +const SHARE = { + input: 0.1, + cacheWrite: 0.125, // prefill, plus writing the block out. + cacheRead: 0.01, +}; + +// The basis line every readout carries, so the numbers are never mistaken for +// measurements. +export const BASIS = + "Estimated from active parameters and published per-prompt figures (Google 0.24 Wh median; Epoch ~0.3 Wh). Serving only — no training, water or your own machine."; + +// Active parameters in billions. `known` marks the difference between a number +// the lab published and one this file guessed, because that difference is the +// whole reason to trust or distrust a row. +// +// The hosted models are open-weight mixtures of experts and announce both +// counts. The closed ones announce nothing, so they are placed by class — which +// is a guess, and says so wherever it is printed. +const HOSTED = { + "z-ai/glm-4.6": { label: "glm", active: 32, known: true }, + "qwen/qwen3-coder": { label: "qwen", active: 35, known: true }, + "deepseek/deepseek-chat-v3.1": { label: "deepseek", active: 37, known: true }, + "anthropic/claude-sonnet-4.6": { label: "sonnet", active: 200, known: false }, + "openai/gpt-5.4": { label: "gpt", active: 300, known: false }, +}; + +// Vendor-CLI models, matched by family. A session on `/backend claude` can name +// any model its subscription allows, so the fallback has to be a family rather +// than a list — and an unrecognized name is placed at the frontier class rather +// than at the cheap end, so an unknown model is never flattered. +const FAMILIES = [ + [/opus/i, { active: 500, known: false }], + [/fable/i, { active: 250, known: false }], + [/sonnet/i, { active: 200, known: false }], + [/haiku/i, { active: 40, known: false }], + [/gpt-5|o[34]|codex/i, { active: 300, known: false }], + [/mini|flash|small|lite/i, { active: 40, known: false }], +]; + +const UNKNOWN = { active: 200, known: false }; + +export function profileFor(model) { + const id = String(model || "").trim(); + if (Object.hasOwn(HOSTED, id)) return { id, ...HOSTED[id] }; + // `/model glm` names a model by the short name the hosted endpoint allowlists + // under. The bridge resolves it before reporting, but a session that never + // heard back from one still knows what it asked for. + for (const [hosted, profile] of Object.entries(HOSTED)) { + if (profile.label === id.toLowerCase()) return { id: hosted, ...profile }; + } + for (const [pattern, profile] of FAMILIES) { + if (pattern.test(id)) return { id, label: id, ...profile }; + } + return { id, label: id || "unknown", ...UNKNOWN }; +} + +function perOutputToken(active) { + return FIXED_J + PER_BILLION_J * active; +} + +// Providers name these fields differently — Anthropic's cache pair, Codex's +// `cached_input_tokens` — so every caller can hand over whatever it was given. +export function readUsage(usage = {}) { + const number = (value) => (Number.isFinite(value) && value > 0 ? Math.round(value) : 0); + return { + input: number(usage.input_tokens ?? usage.inputTokens), + output: number(usage.output_tokens ?? usage.outputTokens), + cacheRead: number( + usage.cache_read_input_tokens ?? + usage.cacheReadInputTokens ?? + usage.cached_input_tokens ?? + usage.cachedInputTokens, + ), + cacheWrite: number(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens), + }; +} + +export function joulesFor(tokens, model) { + const { active } = profileFor(model); + const perToken = perOutputToken(active); + return ( + tokens.output * perToken + + tokens.input * perToken * SHARE.input + + tokens.cacheWrite * perToken * SHARE.cacheWrite + + tokens.cacheRead * perToken * SHARE.cacheRead + ); +} + +export function formatJoules(joules) { + if (!(joules > 0)) return "0 Wh"; + const wh = joules / 3600; + if (wh < 0.001) return `${joules.toFixed(0)} J`; + if (wh < 0.1) return `${wh.toFixed(3)} Wh`; + if (wh < 10) return `${wh.toFixed(2)} Wh`; + return `${wh.toFixed(1)} Wh`; +} + +// One everyday equivalent, picked so the number in front of it is small enough +// to picture. Watt-hours are a unit people own once something is plugged into +// them. +const APPLIANCES = [ + { watts: 10, name: "an LED bulb" }, + { watts: 30, name: "a laptop" }, + { watts: 2000, name: "an electric kettle" }, +]; + +export function everyday(joules) { + if (!(joules > 0)) return ""; + // The bulb answers almost everything a session can spend, which is the point: + // one appliance across a whole range keeps consecutive readouts comparable. + // Bigger draws are borrowed only once the bulb's own number stops being + // pictureable. + for (const { watts, name } of APPLIANCES) { + const seconds = joules / watts; + if (seconds <= 120) return `${name} for ${seconds < 10 ? seconds.toFixed(1) : seconds.toFixed(0)} s`; + const minutes = seconds / 60; + if (minutes <= 90) return `${name} for ${minutes.toFixed(0)} min`; + } + const hours = joules / 2000 / 3600; + return `an electric kettle for ${hours.toFixed(1)} h`; +} + +// A phone charge is the other intuition people have, and it is the one that +// makes a whole session legible rather than a single turn. +export function phoneCharges(joules, wattHours = 15) { + return joules / 3600 / wattHours; +} + +// The tally a session keeps. Per-model, because switching models mid-session is +// one command and the point of the whole readout is that the choice matters. +export class Energy { + constructor() { + this.joules = 0; + this.turns = 0; + this.tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + this.byModel = new Map(); + } + + get counted() { + return this.tokens.input + this.tokens.output + this.tokens.cacheRead + this.tokens.cacheWrite; + } + + // `usage` is whatever the bridge was handed; `model` is what ran it. + add(model, usage) { + const tokens = readUsage(usage); + if (!(tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite)) return 0; + const joules = joulesFor(tokens, model); + this.joules += joules; + this.turns += 1; + for (const key of Object.keys(this.tokens)) this.tokens[key] += tokens[key]; + const id = String(model || "unknown"); + const seen = this.byModel.get(id) || { joules: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }; + seen.joules += joules; + for (const key of Object.keys(seen.tokens)) seen.tokens[key] += tokens[key]; + this.byModel.set(id, seen); + return joules; + } +} + +// What this session's tokens would have cost on each hosted model, cheapest +// first and expressed as a multiple of the cheapest. This is the defensible +// half of the estimate: the ratios come from published active-parameter counts, +// so they hold even if the absolute watt-hours are off by a factor. +export function relativeModels(tokens, current = "") { + const rows = Object.keys(HOSTED).map((id) => { + const profile = profileFor(id); + return { ...profile, joules: joulesFor(tokens, id), current: id === current }; + }); + rows.sort((a, b) => a.joules - b.joules); + const floor = rows[0]?.joules || 0; + for (const row of rows) row.ratio = floor > 0 ? row.joules / floor : 1; + return rows; +} + +// The `/energy` readout, as lines. Built here rather than in the interface so +// the wording and the caveat travel with the arithmetic. +export function energyReport(energy, model = "") { + if (!energy || !energy.counted) { + return [ + "No metered turns yet — energy is counted from the usage the engine reports.", + BASIS, + ]; + } + const { tokens } = energy; + const lines = [ + `~${formatJoules(energy.joules)} this session · ${energy.turns} metered turn${energy.turns === 1 ? "" : "s"} · ${everyday(energy.joules)}`, + `${tokens.output.toLocaleString()} written · ${tokens.input.toLocaleString()} read · ${tokens.cacheRead.toLocaleString()} cached`, + ]; + const charges = phoneCharges(energy.joules); + if (charges >= 0.01) lines.push(`About ${charges < 1 ? `${(charges * 100).toFixed(0)}% of` : `${charges.toFixed(1)}×`} a phone charge.`); + + if (energy.byModel.size > 1) { + lines.push(""); + for (const [id, seen] of energy.byModel) { + lines.push(` ${(profileFor(id).label || id).padEnd(9)} ~${formatJoules(seen.joules)}`); + } + } + + lines.push(""); + lines.push("Same conversation, other models:"); + for (const row of relativeModels(tokens, model)) { + const bar = "█".repeat(Math.max(1, Math.min(24, Math.round(row.ratio * 3)))); + lines.push( + ` ${(row.label || row.id).padEnd(9)} ${bar} ${row.ratio.toFixed(1)}× · ~${formatJoules(row.joules)}` + + `${row.known ? "" : " (size undisclosed; estimated)"}${row.current ? " ← running" : ""}`, + ); + } + lines.push(""); + lines.push(BASIS); + return lines; +} diff --git a/easel/src/live.mjs b/easel/src/live.mjs index 772f4bd885..e993734fff 100644 --- a/easel/src/live.mjs +++ b/easel/src/live.mjs @@ -1,6 +1,6 @@ // live.mjs — the session's piece, and the channel that carries it to a phone. // -// Every Easel session opens on a new blank piece with a random name. +// Every Aesel session opens on a new blank piece with a random name. // The piece is a real file in the workspace, so the agent edits it like any // other file, and every save is pushed to Aesthetic Computer's `/run` endpoint // on a private code channel. Anything watching that channel — a phone that @@ -18,6 +18,7 @@ // on spaces — a tilde-separated argument arrives glued to the command name and // the channel is silently dropped, leaving the phone on an empty prompt. An // encoded space is what actually reaches `halt` as two tokens. +import { PieceRevisions, validatePieceSource } from "./revisions.mjs"; import { EventEmitter } from "node:events"; import { existsSync, mkdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs"; import { basename, dirname, extname, join, resolve } from "node:path"; @@ -194,12 +195,53 @@ export class LivePiece extends EventEmitter { return this.file; } + get history() { return new PieceRevisions(this.file); } + + async checkpoint(source = this.source()) { + const file = this.file; + await validatePieceSource(source, file); + if (file !== this.file || source !== this.source()) return null; + const revision = this.history.capture(source); + if (this.revision?.revision !== revision.revision || this.revisionFile !== file) { + this.revision = revision; + this.revisionFile = file; + this.emit("revision", revision); + } + return revision; + } + + async rollback(version) { + // Preserve a complete unobserved edit; a broken edit must still be recoverable. + const current = this.source(); + let valid = false; + try { await validatePieceSource(current, this.file); valid = true; } catch {} + if (valid) this.history.capture(current); + const revision = await this.history.restore(version); + this.revision = revision; + this.revisionFile = this.file; + this.emit("revision", revision); + return revision; + } + // Push the current source onto the code channel. async push() { + // Serialize uploads so a slow older save cannot arrive after a newer one. + if (this.pendingPush) { + await this.pendingPush.catch(() => {}); + return this.push(); + } + this.sending = true; + const pending = this.#push(); + this.pendingPush = pending; + try { return await pending; } + finally { this.pendingPush = null; this.sending = false; } + } + + async #push() { const source = this.source(); if (!source.trim()) return false; - this.sending = true; - try { + if (!await this.checkpoint(source)) return false; + { // `/run` takes no anonymous pushes: ownership of a channel is the token, // not the name. A session with no token can still watch its own piece in // a browser, it just cannot put source on anyone else's screen. @@ -215,12 +257,10 @@ export class LivePiece extends EventEmitter { body: JSON.stringify({ piece: this.slug, source, codeChannel: this.channel }), }); if (!response.ok) throw new Error(`live push failed (HTTP ${response.status})`); - } finally { - this.sending = false; } this.pushes += 1; - this.ahead = false; - this.emit("push", this.pushes); + this.ahead = source !== this.source(); + this.emit("push", this.pushes, source); return true; } diff --git a/easel/src/mouse.mjs b/easel/src/mouse.mjs new file mode 100644 index 0000000000..10567164f1 --- /dev/null +++ b/easel/src/mouse.mjs @@ -0,0 +1,41 @@ +export const MOUSE_ON = "\x1b[?1003h\x1b[?1006h"; +export const MOUSE_OFF = "\x1b[?1003l\x1b[?1006l"; + +export function mouseEvent(token) { + const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(token); + if (!match) return null; + const [, code, x, y, end] = match; + const button = Number(code); + return { x: Number(x), y: Number(y), motion: Boolean(button & 32), + wheel: button & 64 ? (button & 1 ? 1 : -1) : 0, + click: end === "M" && button === 0 }; +} + +// Keep split terminal escape sequences intact between stdin chunks. +export class InputDecoder { + pending = ""; + push(chunk) { + this.pending += chunk; + const tokens = []; + while (this.pending) { + if (this.pending.startsWith("\x1b[")) { + const match = /^\x1b\[[0-?]*[ -/]*[@-~]/.exec(this.pending); + if (!match) break; + tokens.push(match[0]); + this.pending = this.pending.slice(match[0].length); + } else if (this.pending === "\x1b") { + break; + } else { + const token = String.fromCodePoint(this.pending.codePointAt(0)); + tokens.push(token); + this.pending = this.pending.slice(token.length); + } + } + return tokens; + } + escape() { + if (this.pending !== "\x1b") return []; + this.pending = ""; + return ["\x1b"]; + } +} diff --git a/easel/src/perf-worker.mjs b/easel/src/perf-worker.mjs new file mode 100644 index 0000000000..74e9be2f07 --- /dev/null +++ b/easel/src/perf-worker.mjs @@ -0,0 +1,58 @@ +// Executed by perf.mjs in a bounded, permission-restricted child process. +import vm from "node:vm"; +import { performance } from "node:perf_hooks"; + +let input = ""; +for await (const chunk of process.stdin) input += chunk; +try { + const { source, frames, warmup, width, height, seed, timeoutMs } = JSON.parse(input); + const context = vm.createContext(Object.create(null), { + codeGeneration: { strings: false, wasm: false }, + }); + vm.runInContext(` + let randomState = ${seed} || 1; + Math.random = () => { + randomState ^= randomState << 13; + randomState ^= randomState >>> 17; + randomState ^= randomState << 5; + return (randomState >>> 0) / 4294967296; + }; + `, context, { timeout: 100 }); + const module = new vm.SourceTextModule(source, { + context, + identifier: "piece.mjs", + importModuleDynamically: () => { throw new Error("Imports are unavailable in the headless logic benchmark."); }, + }); + await module.link(() => { throw new Error("Imports are unavailable in the headless logic benchmark."); }); + await module.evaluate({ timeout: timeoutMs }); + context.__piece = module.namespace; + // All callbacks are created inside the guest realm; no host function, fs, + // process, network client, or constructor is passed through the piece API. + vm.runInContext(` + const counts = Object.create(null); + const drawing = ["wipe", "ink", "line", "circle", "box", "rect", "point", "plot", "polygon", "triangle", "write", "print", "paste"]; + const api = { screen: { width: ${width}, height: ${height} } }; + for (const name of drawing) api[name] = (..._args) => { counts[name] = (counts[name] || 0) + 1; return api; }; + Object.freeze(api.screen); + Object.freeze(api); + if (typeof __piece.paint !== "function" && typeof __piece.sim !== "function") throw new Error("This piece has no paint or sim export to benchmark."); + function call(name) { + const result = __piece[name]?.(api); + if (result && typeof result.then === "function") throw new Error("Async lifecycle functions are unavailable in the headless benchmark."); + } + call("boot"); + for (let frame = 0; frame < ${warmup}; frame++) { call("sim"); call("paint"); } + for (const name of Object.keys(counts)) counts[name] = 0; + `, context, { timeout: timeoutMs }); + const started = performance.now(); + vm.runInContext(` + for (let frame = 0; frame < ${frames}; frame++) { call("sim"); call("paint"); } + `, context, { timeout: timeoutMs }); + const elapsedMs = performance.now() - started; + const totalCalls = JSON.parse(vm.runInContext("JSON.stringify(counts)", context, { timeout: 100 })); + const drawCalls = Object.fromEntries(Object.entries(totalCalls).map(([name, count]) => [name, count / frames])); + process.stdout.write(JSON.stringify({ measurement: "headless-logic", frames, warmup, width, height, seed, elapsedMs, msPerFrame: elapsedMs / frames, drawCalls, totalCalls })); +} catch (error) { + process.stderr.write(String(error.message).slice(0, 4096)); + process.exitCode = 1; +} diff --git a/easel/src/perf.mjs b/easel/src/perf.mjs new file mode 100644 index 0000000000..d856f6d9ff --- /dev/null +++ b/easel/src/perf.mjs @@ -0,0 +1,57 @@ +// Headless logic timings with counted drawing stubs; never actual render FPS. +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { extname } from "node:path"; + +function bounded(value, fallback, min, max, name) { + const number = value === undefined ? fallback : value; + if (!Number.isSafeInteger(number) || number < min || number > max) throw new Error(`${name} must be ${min}–${max}.`); + return number; +} + +export async function benchmarkPiece({ file, frames, warmup, width, height, seed, timeoutMs, signal } = {}) { + if (!file || extname(file) !== ".mjs") throw new Error("Headless logic benchmarks currently support .mjs pieces only."); + // The child needs stable Node permissions. Never silently fall back to an + // unrestricted process on an older installed runtime. + if (Number(process.versions.node.split(".")[0]) < 22 || !process.allowedNodeEnvironmentFlags.has("--permission")) throw new Error("Headless benchmarks require Node with --permission support; use Node 24 or newer."); + const options = { + frames: bounded(frames, 600, 1, 1200, "frames"), + warmup: bounded(warmup, 60, 0, 120, "warmup"), + width: bounded(width, 800, 1, 4096, "width"), + height: bounded(height, 600, 1, 4096, "height"), + seed: bounded(seed, 1, 0, 4294967295, "seed"), + timeoutMs: bounded(timeoutMs, 3000, 100, 10000, "timeoutMs"), + }; + signal?.throwIfAborted(); + const [source, worker] = await Promise.all([ + readFile(file, "utf8"), readFile(new URL("./perf-worker.mjs", import.meta.url), "utf8"), + ]); + if (Buffer.byteLength(source) > 1_048_576) throw new Error("The piece exceeds the benchmark's 1 MB source limit."); + signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["--permission", "--no-addons", "--max-old-space-size=64", "--experimental-vm-modules", "--input-type=module", "-e", worker], { + env: { NODE_NO_WARNINGS: "1" }, + stdio: ["pipe", "pipe", "pipe"], + }); + let output = "", errors = "", failure; + const stop = (error) => { failure ||= error; child.kill("SIGKILL"); }; + const timer = setTimeout(() => stop(new Error(`Headless benchmark exceeded ${options.timeoutMs} ms.`)), options.timeoutMs); + const abort = () => stop(signal.reason || new Error("Benchmark cancelled.")); + signal?.addEventListener("abort", abort, { once: true }); + child.stdin.on("error", () => {}); + child.stdout.on("data", (chunk) => { + output += chunk; + if (output.length > 65536) stop(new Error("Benchmark output exceeded its limit.")); + }); + child.stderr.on("data", (chunk) => { if (errors.length < 8192) errors += chunk; }); + child.on("error", (error) => { clearTimeout(timer); signal?.removeEventListener("abort", abort); reject(error); }); + child.on("close", (code) => { + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + if (failure) return reject(failure); + if (code !== 0) return reject(new Error(`Headless benchmark: ${errors.trim() || `process exited ${code}`}`)); + try { resolve(JSON.parse(output)); } catch { reject(new Error("Invalid benchmark result.")); } + }); + child.stdin.end(JSON.stringify({ ...options, source })); + }); +} diff --git a/easel/src/publish.mjs b/easel/src/publish.mjs index 4fc64230ae..387471ebce 100644 --- a/easel/src/publish.mjs +++ b/easel/src/publish.mjs @@ -1,3 +1,4 @@ +import { validatePieceSource } from "./revisions.mjs"; // publish.mjs — put a piece live under the signed-in user's @handle. // // This mirrors the web prompt's `publish` command exactly: ask the site for a @@ -72,6 +73,7 @@ export async function publishPiece({ throw new Error("this file does not export a piece (boot, paint, sim, act, or default)"); } + await validatePieceSource(source, plan.path); const token = await session.token(); onStep("requesting upload grant"); const presign = await fetch(plan.grantUrl, { diff --git a/easel/src/render.mjs b/easel/src/render.mjs index d6cf541ea4..4007e92f77 100644 --- a/easel/src/render.mjs +++ b/easel/src/render.mjs @@ -1,4 +1,4 @@ -// render.mjs — one frame of the Easel interface. +// render.mjs — one frame of the Aesel interface. // // The palette is the Aesthetic Computer prompt's dark scheme (disks/prompt.mjs // `scheme.dark`): purple ground, pink prompt block, orange highlight, magenta @@ -7,6 +7,8 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { MASCOT_HEIGHT, mascotAt, mascotRow } from "./mascot.mjs"; +import { aboutMap } from "./about.mjs"; +import { formatJoules } from "./energy.mjs"; const ESCAPE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g; const CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g; @@ -281,7 +283,7 @@ export function renderBoot(elapsed = 0, columns = 80, rows = 24, useColor = true const reset = useColor ? color.reset : ""; const { lines: sprite, x } = mascotAt(elapsed); - const title = "EASEL"; + const title = "AESEL"; // He walks along a baseline under the title, indented to the same margin the // interface uses so the two frames agree about where the left edge is. const floor = Math.floor(height / 2); @@ -324,8 +326,9 @@ export function renderBoot(elapsed = 0, columns = 80, rows = 24, useColor = true .join("\n"); } -// The readout for everything happening on the far side of the QR code: how many -// people are at the piece, and what their browsers are painting. Parts fall off +// The gauge row: everything happening on the far side of the QR code — how many +// people are at the piece, and what their browsers are painting — and, last, the +// running electricity estimate for the session. Parts fall off // the right as the window narrows, worst news first — a blank frame outranks a // viewer count, because it is the one thing here that means something is wrong. // @@ -356,6 +359,11 @@ export function audienceReadout(state, room = 80, useColor = true) { parts.push({ text: `${frame.colors} colors`, tone: "muted" }); if (Number.isFinite(state?.online)) parts.push({ text: `${state.online} on AC`, tone: "muted" }); + // Last, so it is the first thing the row gives up when the window narrows: a + // running estimate is the least urgent number here. The tilde is load-bearing + // — see energy.mjs on why this is an estimate and can only be one. + if (state?.energy > 0) + parts.push({ text: `~${formatJoules(state.energy)}`, tone: "muted" }); if (parts.length === 0) return { plain: "", painted: "" }; @@ -391,9 +399,9 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { // can be covered without costing anything. Old lines are already read. const rockGutter = 0; const room = Math.max(0, width - 3 - rightWidth - rockGutter); - const title = "EASEL"; + const title = "AESEL"; let account = state.account || "not signed in"; - let piece = state.piece ? clipText(state.piece, 24) : ""; + let piece = state.piece ? `${clipText(state.piece, 24)}${state.pieceVersion ? ` v${state.pieceVersion}` : ""}` : ""; if (textWidth(`${title} ${account} ${piece}`) > room) piece = ""; if (textWidth(`${title} ${account}`) > room) account = ""; const leftPlain = clipText( @@ -402,9 +410,9 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { ); const left = leftPlain === title || !account - ? paint(useColor, "bold text", leftPlain) - : `${paint(useColor, "bold text", title)} ` + - `${paint(useColor, account.startsWith("@") ? "handle" : "muted", account)}` + + ? paint(useColor, state.hover === "about" ? "block bold" : "bold text", leftPlain) + : `${paint(useColor, state.hover === "about" ? "block bold" : "bold text", title)} ` + + `${paint(useColor, state.hover === "profile" ? "block" : account.startsWith("@") ? "handle" : "muted", account)}` + `${piece ? ` ${paint(useColor, "soft", piece)}` : ""}`; const gap = " ".repeat( Math.max(1, width - 2 - textWidth(leftPlain) - rightWidth - rockGutter), @@ -414,7 +422,7 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { // eye skips after the first second, and the count is the one number in the // interface that changes because of somebody else. const audience = audienceReadout( - { ...state.audience, frame: state.health?.frame }, + { ...state.audience, frame: state.health?.frame, energy: state.energy?.joules }, Math.max(0, width - 4 - textWidth(state.workspace || "workspace")), useColor, ); @@ -437,13 +445,17 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { // asked for breathing room it never used, which put the cliff at 24 rows and // hid the code from a 23-row window for no reason a reader could see. const qr = - useColor && state.qr && width >= state.qr.width + 24 && transcriptRows >= state.qr.height + !state.about && useColor && state.qr && width >= state.qr.width + 24 && transcriptRows >= state.qr.height ? state.qr : null; const contentWidth = qr ? width - qr.width - 2 : width - 2; - const transcript = state.entries.flatMap((entry) => entryLines(entry, contentWidth, useColor)); - const visible = transcript.slice(Math.max(0, transcript.length - transcriptRows)); - while (visible.length < transcriptRows) visible.unshift(""); + const transcript = state.about + ? aboutMap().flatMap((line) => wrapText(line, contentWidth)) + : state.entries.flatMap((entry) => entryLines(entry, contentWidth, useColor)); + const start = state.about ? Math.min(state.aboutScroll || 0, Math.max(0, transcript.length - transcriptRows)) + : Math.max(0, transcript.length - transcriptRows - (state.scrollOffset || 0)); + const visible = transcript.slice(start, start + transcriptRows); + while (visible.length < transcriptRows) state.about ? visible.push("") : visible.unshift(""); const body = visible.map((line, index) => { const row = ` ${fit(line, contentWidth)}`; @@ -486,8 +498,12 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { `${paint(useColor, "soft", pose[0])}` + `${paint(useColor, "handle", pose[1])}` + `${paint(useColor, "soft", pose[2])}`; - const helpText = state.busy - ? " ctrl-c interrupt" + const helpText = state.about ? " Esc back · ↑/↓ scroll" + : state.scrollOffset ? ` ${state.scrollOffset} lines above · End latest` + : state.hover === "about" ? " About Aesel · click" + : state.hover === "profile" ? " Open profile in browser · click" + : state.busy + ? ` ${state.progressBytes ? `${(state.progressBytes / 1024).toFixed(1)} KB received · ` : ""}ctrl-c interrupt` : " /help \u00b7 /login \u00b7 /publish \u00b7 /open \u00b7 /qr \u00b7 ctrl-c quit"; const help = width >= 23 @@ -503,3 +519,22 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { .map((line) => `${ground}${fit(line, width)}${reset}`) .join("\n"); } + +export function transcriptLineCount(state, columns = 80, rows = 24, useColor = true) { + const width = Math.max(32, columns), height = Math.max(10, rows); + const qr = useColor && state.qr && width >= state.qr.width + 24 && height - 5 >= state.qr.height ? state.qr : null; + return state.entries.reduce((count, entry) => count + entryLines(entry, qr ? width - qr.width - 2 : width - 2, false).length, 0); +} + +// Terminal mouse coordinates are one-based, like the displayed header row. +export function headerAction(state, columns, rows, x, y) { + if (columns < 32 || rows < 10 || y !== rows - 3) return ""; + const mode = state.mode === "local" ? "LOCAL" : "REMOTE"; + const rightWidth = textWidth(`${mode} · ${String(state.status || "ready").toUpperCase()}`); + const room = Math.max(0, columns - 3 - rightWidth); + if (room >= 5 && x >= 2 && x <= 6) return "about"; + const account = state.account || ""; + if (account.startsWith("@") && textWidth(`AESEL ${account}`) <= room + && x >= 9 && x < 9 + textWidth(account)) return "profile"; + return ""; +} diff --git a/easel/src/revisions.mjs b/easel/src/revisions.mjs new file mode 100644 index 0000000000..04db550d8b --- /dev/null +++ b/easel/src/revisions.mjs @@ -0,0 +1,58 @@ +// Complete piece snapshots stay on this machine; rollback appends, never erases. +import { createHash, randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { extname, join, resolve } from "node:path"; + +const digest = (source) => createHash("sha256").update(source).digest("hex"); + +// Parse JavaScript without importing it: user code must never execute in Aesel. +export async function validatePieceSource(source, file) { + if (typeof source !== "string" || !source.trim()) throw new Error("The piece is empty."); + if (extname(file) !== ".mjs") return; // Other runtimes retain their own loader validation. + await new Promise((resolveCheck, reject) => { + const child = spawn(process.execPath, ["--input-type=module", "--check"], { stdio: ["pipe", "ignore", "pipe"] }); + let detail = ""; + child.stderr.on("data", (chunk) => { if (detail.length < 4096) detail += chunk; }); + child.on("error", reject); + child.stdin.on("error", () => {}); + child.on("close", (code) => code === 0 ? resolveCheck() : reject(new Error(`Incomplete or invalid JavaScript; previous preview kept. ${detail.trim()}`))); + child.stdin.end(source); + }); +} + +export class PieceRevisions { + constructor(file, { root = process.env.EASEL_HISTORY_DIR || join(homedir(), ".local", "share", "easel", "history") } = {}) { + this.file = resolve(file); + this.directory = join(root, digest(this.file)); + } + list() { + let names; + try { names = readdirSync(this.directory); } catch (error) { if (error.code === "ENOENT") return []; throw error; } + return names.filter((name) => /^v\d+\.json$/.test(name)).map((name) => JSON.parse(readFileSync(join(this.directory, name), "utf8"))) + .sort((a, b) => a.version - b.version); + } + capture(source, { restoredFrom } = {}) { + const entries = this.list(); + const revision = digest(source); + const previous = entries.at(-1); + if (previous?.revision === revision) return previous; + const entry = { version: (previous?.version || 0) + 1, revision, updatedAt: new Date().toISOString(), source, ...(restoredFrom ? { restoredFrom } : {}) }; + mkdirSync(this.directory, { recursive: true, mode: 0o700 }); + // Exclusive final creation prevents two sessions silently overwriting a version. + writeFileSync(join(this.directory, `v${entry.version}.json`), `${JSON.stringify(entry)}\n`, { flag: "wx", mode: 0o600 }); + return entry; + } + async restore(version) { + const entry = this.list().find((item) => item.version === Number(version)); + if (!entry) throw new Error(`No saved v${version} for this piece.`); + const before = readFileSync(this.file, "utf8"); + await validatePieceSource(entry.source, this.file); + if (readFileSync(this.file, "utf8") !== before) throw new Error("The piece changed while preparing rollback. Try again when editing stops."); + const temporary = `${this.file}.${randomUUID()}.tmp`; + writeFileSync(temporary, entry.source); + renameSync(temporary, this.file); + return this.capture(entry.source, { restoredFrom: entry.version }); + } +} diff --git a/easel/src/slab-session.mjs b/easel/src/slab-session.mjs index d8423c2b5b..bf2b795965 100644 --- a/easel/src/slab-session.mjs +++ b/easel/src/slab-session.mjs @@ -110,6 +110,10 @@ export class SlabSession { }); } + revision(revision) { + this.#update({ piece_version: revision.version, piece_revision: revision.revision, piece_updated_at: revision.updatedAt }); + } + // Where the file stands against what the address is serving: // live — the channel has the current save // ahead — saved, not pushed yet diff --git a/easel/src/tools.mjs b/easel/src/tools.mjs index 71d533b666..f7738185a7 100644 --- a/easel/src/tools.mjs +++ b/easel/src/tools.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node -// tools.mjs — the native tools Easel hands the engine, as an MCP server on stdio. +// tools.mjs — the native tools Aesel hands the engine, as an MCP server on stdio. // -// Read the transcripts of the first ten Easel sessions and they open the same +// Read the transcripts of the first ten Aesel sessions and they open the same // way: the model reads the guides, then spends six to twelve shell calls — // `grep -n "function circle(" graph.mjs`, `sed -n 6590,6650p disk.mjs`, // `grep -rn "synth({" disks/*.mjs | head` — rebuilding a picture of the API @@ -30,7 +30,7 @@ import { fileURLToPath } from "node:url"; import { createInterface } from "node:readline"; const HERE = dirname(fileURLToPath(import.meta.url)); -const EASEL = join(HERE, ".."); +const AESEL = join(HERE, ".."); export const SERVER_NAME = "ac"; export const TOOL_PREFIX = `mcp__${SERVER_NAME}__`; @@ -44,7 +44,7 @@ export function disksDir(cwd) { export function loadMap() { try { - return JSON.parse(readFileSync(join(EASEL, "context", "api.json"), "utf8")); + return JSON.parse(readFileSync(join(AESEL, "context", "api.json"), "utf8")); } catch { return { entries: [] }; } @@ -352,7 +352,7 @@ export function serve({ cwd = process.cwd(), input = process.stdin, output = pro } // The MCP configuration the Claude bridge passes with --mcp-config: this file, -// run by the same node that is running Easel, pointed at the workspace. +// run by the same node that is running Aesel, pointed at the workspace. export function mcpConfig(cwd) { return { mcpServers: { diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index bd7c52c989..c05ed39b9a 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -5,17 +5,21 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import process from "node:process"; +import { StringDecoder } from "node:string_decoder"; +import { aboutMap, conversationHandoff } from "./about.mjs"; +import { InputDecoder, mouseEvent, MOUSE_ON, MOUSE_OFF } from "./mouse.mjs"; import { ACSession } from "./ac-session.mjs"; import { Audience } from "./audience.mjs"; import { AutoPublisher } from "./autopublish.mjs"; import { Diagnostics } from "./diagnostics.mjs"; import { EASEL_HEIGHT, easelFrame, easelNextFrame, easelWidth } from "./easel.mjs"; +import { Energy, energyReport } from "./energy.mjs"; import { backendFor, backendMenu, DEFAULT_BACKEND } from "./backends.mjs"; import { LivePiece } from "./live.mjs"; import { applyUpdate, checkForUpdate, currentVersion, installed } from "./updates.mjs"; import { publishPiece } from "./publish.mjs"; import { qrBlock } from "./qr.mjs"; -import { cleanText, color, easelInk, renderBoot, renderFrame } from "./render.mjs"; +import { cleanText, color, easelInk, renderBoot, renderFrame, headerAction, wrapText, transcriptLineCount } from "./render.mjs"; import { mascotNextFrameIn, mascotRowNextFrameIn } from "./mascot.mjs"; import { DEFAULT_RUNTIME, runtimeMenu } from "./runtimes.mjs"; import { SlabSession } from "./slab-session.mjs"; @@ -29,10 +33,14 @@ const flag = (name) => arguments_.includes(name); const cwd = path.resolve(option("--cwd") || process.cwd()); const resumeThreadId = option("--resume"); const initialPrompt = option("--prompt"); +const initialPiece = option("--piece"); // Which engine bridge drives the conversation, and on which model. The bridge // can be swapped mid-session with /backend, so neither is a constant. let backend = backendFor(option("--backend") || process.env.EASEL_BACKEND || DEFAULT_BACKEND); let model = option("--model") || backend.defaultModel; +let handoff = ""; +let archivedConversation = []; +let mouseEnabled = process.env.EASEL_MOUSE !== "0"; const session = new ACSession(); // Every session opens on a new blank piece with a random name. It is a real @@ -52,6 +60,10 @@ const live = new LivePiece({ } }, }); +if (initialPiece) { + const file = path.resolve(cwd, initialPiece); + if (!existsSync(file) || !live.retarget(file)) throw new Error("--piece must name an existing supported piece file"); +} const state = { workspace: cwd, mode: "remote", @@ -77,6 +89,10 @@ const state = { // What those people's browsers are actually showing — a blank frame, an // uncaught error. Null until the relay lets this session listen. health: null, + // What the session has spent in electricity, as far as the token counts the + // engine reports can say. `/energy` prints the working; energy.mjs holds the + // arithmetic and the caveat. + energy: new Energy(), qr: null, // The prompt rock in the menu bar draws this session's code at real pixel // resolution, so the transcript does not spend seventeen rows on a worse @@ -140,7 +156,7 @@ const STYLE_GUIDES = [ // The same knowledge, carried inside the install. A session opened in the // Aesthetic Computer repository reads the repo's own copies, which are newer by // definition; a session opened anywhere else — which is every session, once this -// is installed rather than cloned — reads these. Without them Easel is a general +// is installed rather than cloned — reads these. Without them Aesel is a general // editor that happens to publish to a URL, and there is no reason to install it // over the vendor CLI it is already driving. const BUNDLED_CONTEXT = [ @@ -224,13 +240,13 @@ function developerInstructions() { ] : [ "Publishing: writing a file under system/public/aesthetic.computer/disks/ or anywhere else does NOT make a piece live.", - "A piece is live only after the user runs the Easel command `/publish [slug]`, which uploads it under their @handle at https://aesthetic.computer/@handle/slug.", + "A piece is live only after the user runs the Aesel command `/publish [slug]`, which uploads it under their @handle at https://aesthetic.computer/@handle/slug.", "When you finish a piece, end with the exact /publish command for the user to run. Never tell the user to visit a route that has not been published.", ]; return [ - "You are running inside Easel, a terminal interface for Aesthetic Computer (AC) work.", + "You are running inside Aesel, a terminal interface for Aesthetic Computer (AC) work.", account, - `This session's piece is ${live.file} (${live.runtime.label}). It already exists as a blank piece that paints a flat color and nothing else. Edit that file unless the user asks for something else.`, + `This session's piece is ${live.file} (${live.runtime.label}). Its current source is the source of truth; read it before editing and preserve existing work. Edit that file unless the user asks for something else.`, "Do not write the piece's name onto the screen: the system already shows it in the corner label. If the file still carries a placeholder that writes its own name, remove it in your first edit.", ...dialect, ...styleInstructions(), @@ -252,7 +268,7 @@ function openEngine({ resume = "" } = {}) { cwd, resumeThreadId: resume, model, - developerInstructions: developerInstructions(), + developerInstructions: [developerInstructions(), handoff].filter(Boolean).join("\n\n"), // The hosted bridge has no subprocess and no file tools, so it needs the // two things a CLI would have found for itself: which file is the piece, // and a token to pay for the turn. The other bridges ignore both. @@ -271,9 +287,10 @@ function openEngine({ resume = "" } = {}) { SLAB_AGENT_TYPE: "easel", }, }); - opened.on("notification", handleNotification); - opened.on("request", handleRequest); + opened.on("notification", (...args) => { if (!closing && opened === engine) handleNotification(...args); }); + opened.on("request", (...args) => { if (!closing && opened === engine) handleRequest(...args); }); opened.on("protocolError", (error) => { + if (closing || opened !== engine) return; addEntry("error", errorText(error)); redraw(); }); @@ -289,6 +306,9 @@ function openEngine({ resume = "" } = {}) { let engine = openEngine({ resume: resumeThreadId }); let drawing = false; +let redrawTimer = null; +let lastDrawAt = 0; +let lastTranscriptLines = 0; let closing = false; // The startup easel owns the screen until it is done or dismissed. Declared // here rather than beside the splash itself because redraw() reads it, and @@ -297,6 +317,7 @@ let splashing = false; let splashTimer = null; let streamedMessageId = null; let pasteBuffer = null; +let performanceAbort = null; function addEntry(kind, text, id = `entry-${Date.now()}-${Math.random()}`) { state.entries.push({ id, kind, text: cleanText(text) }); @@ -338,8 +359,18 @@ function startDance() { function redraw() { if (closing || drawing || splashing) return; + // Token bursts coalesce into at most 30 terminal frames/second. + const remaining = 33 - (Date.now() - lastDrawAt); + if (remaining > 0) { + if (!redrawTimer) redrawTimer = setTimeout(() => { redrawTimer = null; redraw(); }, remaining); + return; + } + lastDrawAt = Date.now(); drawing = true; try { + const count = transcriptLineCount(state, process.stdout.columns || 80, process.stdout.rows || 24, process.env.NO_COLOR !== "1"); + if (state.scrollOffset) state.scrollOffset = Math.max(0, state.scrollOffset + count - lastTranscriptLines); + lastTranscriptLines = count; const frame = renderFrame(state, process.stdout.columns, process.stdout.rows, process.env.NO_COLOR !== "1"); process.stdout.write(`\x1b[H\x1b[2J${frame}`); } finally { @@ -350,6 +381,7 @@ function redraw() { async function finish(code = 0) { if (closing) return; closing = true; + performanceAbort?.abort(); session.unwatch(); const pending = autopublish.pending || autopublish.running; live.unwatch(); @@ -358,7 +390,7 @@ async function finish(code = 0) { engine.close(); process.stdin.setRawMode(false); process.stdin.pause(); - process.stdout.write("\x1b[?2004l\x1b[?25h\x1b[?1049l"); + process.stdout.write(MOUSE_OFF + "\x1b[?2004l\x1b[?25h\x1b[?1049l"); process.exitCode = code; // The last save has to land. Quitting a second after an edit would otherwise // drop it — auto-publish coalesces, and the timer it was waiting on dies with @@ -485,6 +517,7 @@ function itemSummary(item) { if (item.type === "commandExecution") return { kind: "command", text: item.command }; if (item.type === "fileChange") { const paths = (item.changes || []).map((change) => change.path).filter(Boolean); + if (item.path) paths.push(item.path); for (const file of paths) notePiece(file); return { kind: "change", text: paths.join(", ") || "workspace files" }; } @@ -519,11 +552,20 @@ function handleNotification({ method, params = {} }) { case "turn/started": state.busy = true; startDance(); - state.status = "working"; + state.status = "waiting"; + state.progressBytes = 0; engine.turnId = params.turn?.id || engine.turnId; slabSession.working(); break; + case "turn/progress": + state.status = params.phase || "working"; + state.progressBytes = params.bytes || state.progressBytes || 0; + break; + case "turn/usage": + state.energy.add(params.model || state.model || model, params.usage); + break; case "item/agentMessage/delta": + state.status = "generating"; if (!streamedMessageId || streamedMessageId !== params.itemId) { streamedMessageId = params.itemId; addEntry("assistant", "", params.itemId); @@ -534,6 +576,7 @@ function handleNotification({ method, params = {} }) { } break; case "item/started": { + if (params.item?.type === "fileChange") state.status = "writing"; const summary = itemSummary(params.item); if (summary) updateEntry(params.item.id, summary.kind, summary.text); break; @@ -562,6 +605,9 @@ function handleNotification({ method, params = {} }) { break; } case "turn/completed": { + // Codex reports what it spent on the turn that closes rather than in a + // message of its own, so the meter reads it from here when it is there. + if (params.turn?.usage) state.energy.add(params.turn.model || state.model || model, params.turn.usage); state.busy = false; state.status = params.turn?.status === "failed" ? "failed" : "ready"; engine.turnId = null; @@ -636,7 +682,7 @@ function handleRequest(request) { redraw(); return; } - engine.reject(request.id, -32601, `Easel does not support ${request.method} yet`); + engine.reject(request.id, -32601, `Aesel does not support ${request.method} yet`); } function answerApproval(character) { @@ -776,12 +822,18 @@ function commandAutopublish(argumentText) { return redraw(); } +let manualPublishInFlight = false; async function commandPublish(argumentText) { + if (manualPublishInFlight || autopublish.running || state.status === "restoring") { + addEntry("notice", "Wait for the current upload or rollback to finish before publishing."); + return redraw(); + } const [file = live.file, slug = ""] = argumentText.split(/\s+/).filter(Boolean); if (!file) { addEntry("error", "Usage: /publish [slug] — no piece has been touched yet."); return redraw(); } + manualPublishInFlight = true; const id = addEntry("publish", `Publishing ${path.basename(file)}…`); redraw(); try { @@ -799,6 +851,8 @@ async function commandPublish(argumentText) { updateEntry(id, "publish", `${result.route}${result.verified ? "" : " · uploaded, not yet readable"}`); } catch (error) { updateEntry(id, "error", `Publish failed: ${errorText(error)}`); + } finally { + manualPublishInFlight = false; } redraw(); } @@ -809,31 +863,50 @@ function engineLabel() { return `${backend.label} · ${state.model || model || backend.modelSource}`; } -// Open a thread on the current bridge, replacing whatever is running. This is -// what /new, /backend and /model all come down to: the conversation restarts, -// the piece and the QR code do not. -async function restartEngine(note) { +// Provider thread IDs cannot cross engines; carry recent conversation and +// keep the old connection available until the replacement connects. +async function restartEngine(note, nextBackend = backend, nextModel = model) { + if (nextBackend.models && !Object.hasOwn(nextBackend.models, nextModel) + && !Object.values(nextBackend.models).includes(nextModel)) { + addEntry("error", "Unknown hosted model. Use /model to see available choices."); + return redraw(); + } + const previousBackend = backend, previousModel = model, previousLabel = state.model; + const previousHandoff = handoff; + handoff = conversationHandoff([...archivedConversation, ...state.entries]); + backend = nextBackend; + model = nextModel; state.status = "starting"; + state.busy = true; redraw(); const previous = engine; - engine = openEngine(); - previous.close(); try { + engine = openEngine(); const connection = await engine.connect(); + previous.close(); slabSession.connected(engine.threadId); state.model = connection?.model || model; - state.entries = [{ kind: "notice", text: `${note} · ${engineLabel()}`, id: `thread-${Date.now()}` }]; + addEntry("notice", `${note} · ${engineLabel()} · current piece and recent conversation carried over`); state.status = "ready"; } catch (error) { + const failed = engine; + engine = previous; + if (failed !== previous) failed.close(); + backend = previousBackend; + model = previousModel; + state.model = previousLabel; + handoff = previousHandoff; addEntry("error", errorText(error)); - state.status = "failed"; + state.status = "ready"; } + state.busy = false; redraw(); + drainQueue(); } async function commandBackend(rest) { if (!rest) { - addEntry("notice", `${engineLabel()} · backends: ${backendMenu()}`); + addEntry("notice", `${engineLabel()}\n/backend ac — AC hosted, handle budget\n/backend claude — your Claude CLI sign-in\n/backend codex — your Codex CLI sign-in\n/model — models on the selected engine`); return redraw(); } if (state.busy) { @@ -848,24 +921,39 @@ async function commandBackend(rest) { addEntry("error", errorText(error)); return redraw(); } - backend = next; - model = wantedModel || next.defaultModel; - state.model = ""; - return restartEngine("Engine"); + return restartEngine("Engine", next, wantedModel || next.defaultModel); } async function commandModel(rest) { if (!rest) { - addEntry("notice", engineLabel()); + const choices = backend.models ? Object.entries(backend.models).map(([alias, id]) => `/model ${alias} — ${id}${["sonnet", "gpt"].includes(alias) ? " · premium, uses budget faster" : ""}`).join("\n") + : "/model NAME — a model supported by your signed-in CLI"; + addEntry("notice", `${engineLabel()}\n${choices}\n/backend — switch between AC hosted and your own Claude/Codex`); return redraw(); } if (state.busy) { addEntry("error", "Interrupt the current turn before switching models."); return redraw(); } - model = rest.split(/\s+/)[0]; - state.model = ""; - return restartEngine("Model"); + return restartEngine("Model", backend, rest.split(/\s+/)[0]); +} + +async function commandPerformance(rest) { + if (state.busy) { addEntry("notice", "Wait for the current turn before benchmarking."); return redraw(); } + performanceAbort = new AbortController(); + state.busy = true; + state.status = "benchmarking"; + const id = addEntry("notice", `Measuring ${state.piece} · headless logic…`); + redraw(); + try { + const { benchmarkPiece } = await import("./perf.mjs"); + const result = await benchmarkPiece({ file: live.file, frames: rest ? Number(rest) : 600, signal: performanceAbort.signal }); + const calls = Object.entries(result.drawCalls).map(([name, count]) => `${Number(count).toFixed(1)} ${name}`).join(" · "); + updateEntry(id, "notice", `Headless logic · ${result.msPerFrame.toFixed(3)} ms/frame · ${result.frames} frames at ${result.width}×${result.height}\nPer frame: ${calls}\nExcludes browser rendering, rasterization and display latency.`); + } catch (error) { updateEntry(id, "error", errorText(error)); } + finally { performanceAbort = null; state.busy = false; state.status = "ready"; } + redraw(); + drainQueue(); } // Start the next queued line, if the turn that just ended left one. Routed back @@ -893,8 +981,29 @@ async function submitInput() { const [command, ...restWords] = text.split(/\s+/); const rest = restWords.join(" "); if (command === "/quit" || command === "/exit") return finish(); + if (command === "/about") { + state.about = !state.about; + state.aboutScroll = 0; + return redraw(); + } + if (command === "/mouse") { + mouseEnabled = rest !== "off"; + process.stdout.write(mouseEnabled ? MOUSE_ON : MOUSE_OFF); + state.hover = ""; + addEntry("notice", `Mouse ${mouseEnabled ? "on · shift-drag selects in supporting terminals" : "off · terminal selection restored"}`); + return redraw(); + } + if (command === "/profile") return openProfile(); + if (command === "/performance" || command === "/perf") return commandPerformance(rest); + if (command === "/energy" || command === "/power") { + addEntry("notice", energyReport(state.energy, state.model || model).join("\n")); + return redraw(); + } + if (command === "/latest") { state.scrollOffset = 0; return redraw(); } if (command === "/clear") { + archivedConversation.push(...state.entries.filter(({ kind }) => kind === "user" || kind === "assistant")); state.entries = []; + state.scrollOffset = 0; return redraw(); } if (command === "/handle") { @@ -924,30 +1033,55 @@ async function submitInput() { } if (command === "/update") { if (!installed()) { - addEntry("notice", `Easel ${currentVersion()} — running from a checkout, so there is nothing to update. Use git.`); + addEntry("notice", `Aesel ${currentVersion()} — running from a checkout, so there is nothing to update. Use git.`); return redraw(); } - addEntry("notice", "Checking for a newer Easel…"); + addEntry("notice", "Checking for a newer Aesel…"); redraw(); try { const update = await checkForUpdate({ force: true }); if (!update) { - addEntry("notice", `Easel ${currentVersion()} is the latest.`); + addEntry("notice", `Aesel ${currentVersion()} is the latest.`); return redraw(); } - addEntry("notice", `Installing Easel ${update.version}…`); + addEntry("notice", `Installing Aesel ${update.version}…`); redraw(); const version = await applyUpdate({ manifest: update }); - addEntry("notice", `Easel ${version} installed. Restart to run it.`); + addEntry("notice", `Aesel ${version} installed. Restart to run it.`); } catch (error) { addEntry("error", `Update failed: ${errorText(error)}`); } return redraw(); } + if (command === "/versions") { + const versions = live.history.list(); + addEntry("notice", versions.length ? versions.map((entry) => `v${entry.version} · ${entry.updatedAt}${entry.restoredFrom ? ` · restored v${entry.restoredFrom}` : ""}`).join("\n") : "No saved versions yet."); + return redraw(); + } + if (command === "/rollback") { + if (state.busy || manualPublishInFlight || autopublish.running || live.sending) { + addEntry("notice", "Wait for the current turn and uploads to finish before rolling back."); + return redraw(); + } + const version = /^v?([1-9]\d*)$/.exec(rest.trim())?.[1]; + if (!version) { addEntry("notice", "Use /rollback v1 · /versions lists saved versions."); return redraw(); } + state.busy = true; + state.status = "restoring"; + autopublish.cancel(); + try { + const revision = await live.rollback(Number(version)); + addEntry("notice", `Restored v${version} as v${revision.version}.`); + await live.push(); + publishTurn(); + } catch (error) { addEntry("error", errorText(error)); } + finally { state.busy = false; state.status = "ready"; } + drainQueue(); + return redraw(); + } if (command === "/help") { addEntry( "notice", - "/login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /ask [on|off] · /piece [name] · /runtime [id] · /backend [id] · /model [name] · /handle [name] · /update · /open · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", + "/about · /profile · /mouse [on|off] · /performance [frames] · /energy · /latest · /login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /ask [on|off] · /piece [name] · /versions · /rollback vN · /runtime [id] · /backend [id] · /model [name] · /handle [name] · /update · /open · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", ); return redraw(); } @@ -960,8 +1094,8 @@ async function submitInput() { } if (command === "/publish") return commandPublish(rest); if (command === "/autopublish" || command === "/auto") return commandAutopublish(rest); - if (command === "/backend" || command === "/engine") return commandBackend(rest); - if (command === "/model") return commandModel(rest); + if (["/backend", "/engine", "/mode"].includes(command)) return commandBackend(rest); + if (command === "/model" || command === "/models") return commandModel(rest); if (command === "/piece") { if (rest) { try { @@ -1060,6 +1194,8 @@ async function submitInput() { state.status = "starting"; redraw(); try { + handoff = ""; + archivedConversation = []; engine.developerInstructions = developerInstructions(); await engine.newThread(); slabSession.connected(engine.threadId); @@ -1111,6 +1247,36 @@ function replaceInput(value) { state.cursor = Array.from(value).length; } +function openProfile() { + if (!session.handle) { + addEntry("notice", "Sign in with /login to open your profile."); + return redraw(); + } + const url = `https://aesthetic.computer/@${encodeURIComponent(session.handle)}`; + const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open"; + const child = spawn(opener, [url], { stdio: "ignore", detached: true }); + child.on("error", error => { addEntry("error", `Could not open profile: ${errorText(error)}`); redraw(); }); + child.unref(); +} + +function scrollAbout(delta) { + const height = Math.max(10, process.stdout.rows || 24); + const width = Math.max(32, process.stdout.columns || 80) - 2; + const count = aboutMap().flatMap(line => wrapText(line, width)).length; + state.aboutScroll = Math.max(0, Math.min(Math.max(0, count - (height - 5)), (state.aboutScroll || 0) + delta)); + redraw(); +} + +function scrollTranscript(delta) { + const height = Math.max(10, process.stdout.rows || 24); + const count = transcriptLineCount(state, process.stdout.columns || 80, height, process.env.NO_COLOR !== "1"); + const offset = state.scrollOffset || 0; + state.scrollOffset = Math.max(0, Math.min(Math.max(0, count - (height - 5)), + offset + (offset ? count - lastTranscriptLines : 0) + delta)); + lastTranscriptLines = count; + redraw(); +} + function insertText(value) { const characters = Array.from(state.input); const inserted = Array.from(cleanText(value.replace(/\x1b\[200~|\x1b\[201~/g, ""))); @@ -1120,6 +1286,16 @@ function insertText(value) { } function handleKey(input) { + if (state.about && ["\x1b", "\x1b[A", "\x1b[B", "\x1b[5~", "\x1b[6~"].includes(input)) { + if (input === "\x1b") { state.about = false; return redraw(); } + return scrollAbout(input === "\x1b[A" ? -1 : input === "\x1b[B" ? 1 : input === "\x1b[5~" ? -8 : 8); + } + if (input === "\x1b[5~") return scrollTranscript(Math.max(1, (process.stdout.rows || 24) - 7)); + if (input === "\x1b[6~") return scrollTranscript(-Math.max(1, (process.stdout.rows || 24) - 7)); + if (["\x1b[F", "\x1b[4~", "\x1b[1;2F"].includes(input) && !state.input) { + state.scrollOffset = 0; + return redraw(); + } // The easel is a greeting, not a gate. Any key puts it away. if (splashing) { splashing = false; @@ -1130,6 +1306,7 @@ function handleKey(input) { if (answerApproval(input)) return; if (input === "\u0003") { + if (performanceAbort) { performanceAbort.abort(new Error("Benchmark cancelled.")); return; } if (state.busy) { state.status = "interrupting"; redraw(); @@ -1172,8 +1349,13 @@ function handleKey(input) { redraw(); } +const inputDecoder = new InputDecoder(); +const utf8Decoder = new StringDecoder("utf8"); +let escapeTimer; function handleKeys(buffer) { - const tokens = buffer.toString("utf8").match(/\x1b\[[0-9;]*[~A-Za-z]|./gsu) || []; + clearTimeout(escapeTimer); + const tokens = inputDecoder.push(utf8Decoder.write(buffer)); + escapeTimer = setTimeout(() => inputDecoder.escape().forEach(handleKey), 35); for (const token of tokens) { if (token === "\x1b[200~") { pasteBuffer = ""; @@ -1184,12 +1366,23 @@ function handleKeys(buffer) { } else if (pasteBuffer !== null) { pasteBuffer += token; } else { + const mouse = mouseEvent(token); + if (mouse) { + if (!mouseEnabled || splashing) continue; + if (state.about && mouse.wheel) { scrollAbout(mouse.wheel * 3); continue; } + if (mouse.wheel) { scrollTranscript(-mouse.wheel * 3); continue; } + const action = headerAction(state, process.stdout.columns || 80, process.stdout.rows || 24, mouse.x, mouse.y); + if (state.hover !== action) { state.hover = action; redraw(); } + if (mouse.click && action === "about") { state.about = !state.about; state.aboutScroll = 0; redraw(); } + if (mouse.click && action === "profile") openProfile(); + continue; + } handleKey(token); } } } -process.stdout.write("\x1b[?1049h\x1b[?25l\x1b[?2004h"); +process.stdout.write("\x1b[?1049h\x1b[?25l\x1b[?2004h" + (mouseEnabled ? MOUSE_ON : "")); // 🎨 Stand the easel up. Each frame reads the live values rather than a // snapshot, so the address is written onto the canvas at whatever moment the @@ -1239,7 +1432,7 @@ session.watch().on("change", () => { }); // Mint this session's blank piece and the QR code that opens it on a phone. -live.create(); +if (!initialPiece) live.create(); live.watch(liveError); publishBlankOnce(); @@ -1251,7 +1444,7 @@ checkForUpdate() if (!update) return; addEntry( "notice", - `Easel ${update.version} is out — you have ${update.current}. Run /update to install it.`, + `Aesel ${update.version} is out — you have ${update.current}. Run /update to install it.`, ); redraw(); }) @@ -1262,15 +1455,21 @@ checkForUpdate() // the rock is now the published one, and a code that resolves to a 404 until // someone types is worse than a published blank. The local file is still // discarded on exit if it was never edited; the published copy stays. -live.on("push", () => { - slabSession.flow("live"); +live.on("push", (_count, source) => { + slabSession.flow(live.ahead ? "ahead" : "live"); if (live.pristine || autopublishBlocker()) return; - autopublish.note(live.source()); + autopublish.note(source); }); // A save has landed and the channel has not heard about it yet. The rock's // neighbour — the preview of the very address the rock encodes — says so, so // that an old frame never passes for the current one. live.on("dirty", () => slabSession.flow("ahead")); +live.on("revision", (revision) => { + state.pieceVersion = revision.version; + slabSession.revision(revision); + redraw(); +}); +live.checkpoint().catch(liveError); state.piece = `${live.slug}${live.runtime.extension}`; refreshQr(); audience.start(); diff --git a/easel/src/updates.mjs b/easel/src/updates.mjs index 48a1b0a09f..13e4479d4f 100644 --- a/easel/src/updates.mjs +++ b/easel/src/updates.mjs @@ -1,4 +1,4 @@ -// updates — notice that a newer Easel exists, and become it. +// updates — notice that a newer Aesel exists, and become it. // // A tool installed by a shell script has no package manager behind it, so if it // does not look after its own version nobody else will: the copy someone @@ -8,7 +8,7 @@ // Two rules shape everything here. // // It never updates a checkout. `install.json` is written into the tarball by -// bin/pack.mjs and exists nowhere else, so its absence means this Easel is a +// bin/pack.mjs and exists nowhere else, so its absence means this Aesel is a // working copy of the repository — where overwriting src/ with a release would // destroy someone's afternoon. Development is the case that must never be // guessed wrong, so it is detected by a file that only a release can have, @@ -36,7 +36,7 @@ const run = promisify(execFile); const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const SITE = process.env.EASEL_SITE || "https://aesthetic.computer"; -// Once a day. The version changes far less often than Easel opens, and a tool +// Once a day. The version changes far less often than Aesel opens, and a tool // that phones home on every launch is a tool that is slow to start on a bad // connection for no benefit. const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -100,7 +100,7 @@ export async function fetchManifest({ fetch = globalThis.fetch, site = SITE } = return manifest; } -// Is there a newer Easel? Resolves null for every reason there might not be — +// Is there a newer Aesel? Resolves null for every reason there might not be — // including "not an install" and "asked recently" — so a caller can treat any // non-null as news worth showing. export async function checkForUpdate({ @@ -128,10 +128,10 @@ export async function checkForUpdate({ // Download, verify, and swap. Returns the version now installed. // // The swap is a rename of a fully unpacked directory, which is as close to -// atomic as this gets: at no point is there a half-written Easel at the path a +// atomic as this gets: at no point is there a half-written Aesel at the path a // terminal is about to launch. export async function applyUpdate({ fetch = globalThis.fetch, site = SITE, manifest } = {}) { - if (!installed()) throw new Error("this Easel is a checkout, not an install — use git"); + if (!installed()) throw new Error("this Aesel is a checkout, not an install — use git"); const target = manifest || (await fetchManifest({ fetch, site })); const response = await fetch(`${site}${target.tarball || "/easel.tar.gz"}`, { @@ -153,7 +153,7 @@ export async function applyUpdate({ fetch = globalThis.fetch, site = SITE, manif mkdirSync(unpacked); await run("tar", ["-xzf", archive, "-C", unpacked]); if (!existsSync(join(unpacked, "bin", "easel"))) { - throw new Error("that archive does not look like Easel"); + throw new Error("that archive does not look like Aesel"); } // Keep the previous install until the new one is in place, then drop it. diff --git a/easel/src/version.mjs b/easel/src/version.mjs index d2a67d7ba2..bf526db834 100644 --- a/easel/src/version.mjs +++ b/easel/src/version.mjs @@ -5,7 +5,7 @@ // already drifted: after a self-update it went on announcing the version it was // written with, while package.json, the file the updater compares, had moved on. // The bridges announce themselves to the vendor as EASEL_VERSION, so a stale one -// there misreports which Easel is in the field. +// there misreports which Aesel is in the field. // // package.json is the single source, because it is the file the updater and the // packer both already read. diff --git a/easel/test/about.test.mjs b/easel/test/about.test.mjs new file mode 100644 index 0000000000..405babcb59 --- /dev/null +++ b/easel/test/about.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { conversationHandoff } from "../src/about.mjs"; + +test("engine handoff carries recent user/assistant context, excluding tools and UI", () => { + const handoff = conversationHandoff([ + { kind: "user", text: "keep the dots purple" }, + { kind: "command", text: "PRIVATE TOOL OUTPUT" }, + { kind: "assistant", text: "the dots are purple" }, + { kind: "change", text: "TOOL PATH" }, + { kind: "error", text: "INTERNAL ERROR" }, + { kind: "notice", text: "SIGN IN URL" }, + ]); + assert.match(handoff, /keep the dots purple/); + assert.match(handoff, /the dots are purple/); + assert.doesNotMatch(handoff, /PRIVATE|TOOL|INTERNAL|SIGN IN/); + assert.ok(handoff.indexOf('"role":"user"') < handoff.indexOf('"role":"assistant"')); +}); + +test("engine handoff bounds context and favors the latest turns", () => { + const entries = Array.from({ length: 30 }, (_, i) => ({ kind: i % 2 ? "assistant" : "user", text: `turn ${i} ${"x".repeat(50)}` })); + const handoff = conversationHandoff(entries, 300); + assert.ok(handoff.length < 400, "context budget plus heading and separators"); + assert.match(handoff, /turn 29/); + assert.match(handoff, /turn 28/); + assert.doesNotMatch(handoff, /turn 0 /); + assert.equal(conversationHandoff([{ kind: "command", text: "tool" }]), ""); +}); diff --git a/easel/test/ac-server.test.mjs b/easel/test/ac-server.test.mjs index e783005c08..103d48c343 100644 --- a/easel/test/ac-server.test.mjs +++ b/easel/test/ac-server.test.mjs @@ -136,3 +136,128 @@ test("the guides travel in the prompt, because this bridge has no file tools", a "the stable prefix comes first, or the cache breaks on every session", ); }); + +test("a completed piece checkpoint saves before the response ends", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "ac-checkpoint-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, "piece.mjs"); + await writeFile(file, "// before\n"); + let stream; + const body = new ReadableStream({ start(c) { stream = c; } }); + let call = 0; + const engine = new AcServer({ piece: { file }, token: async () => "tok", fetch: async () => call++ === 0 ? { ok: true, body } : serving(say("done"))() }); + await engine.connect(); + const saved = new Promise((resolve) => engine.on("notification", ({ method, params }) => { + if (method === "item/completed" && params.item?.type === "fileChange") resolve(); + })); + const turn = engine.startTurn("make a piece in steps"); + const source = "export function paint({wipe}) { wipe(40); }\n"; + for (const event of writes(source).slice(0, -1)) stream.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)); + await saved; + assert.equal(await readFile(file, "utf8"), source, "saved while the network response is still open"); + stream.enqueue(new TextEncoder().encode('data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}\n\n')); + stream.close(); + await turn; +}); + +test("incomplete tool source is rejected and previous working file is preserved", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "ac-invalid-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, "piece.mjs"); + await writeFile(file, "// working\n"); + const engine = new AcServer({ piece: { file }, token: async () => "tok", fetch: serving(writes("export function paint("), say("I will fix that")) }); + await engine.connect(); + await engine.startTurn("edit"); + assert.equal(await readFile(file, "utf8"), "// working\n"); + const result = engine.messages.find((m) => Array.isArray(m.content) && m.content[0]?.type === "tool_result"); + assert.equal(result.content[0].is_error, true); +}); + +test("a disconnected response reports failure instead of successful completion", async () => { + const engine = new AcServer({ token: async () => "tok", fetch: serving(say("unfinished").slice(0, 1)) }); + let completed; + engine.on("notification", ({ method, params }) => { if (method === "turn/completed") completed = params.turn; }); + await engine.connect(); + await engine.startTurn("hi"); + assert.equal(completed.status, "failed"); + assert.match(completed.error.message, /stream ended/); +}); + +test("hosted engine reads the current piece on every round, including after rollback", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "ac-context-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, "piece.mjs"); + let sent; + const engine = new AcServer({ piece: { file }, developerInstructions: "Recent conversation: keep the dots purple", token: async () => "tok", fetch: async (_url, options) => { sent = JSON.parse(options.body); return serving(say("ok"))(); } }); + await writeFile(file, "// source from another engine\n"); + await engine.connect(); + await engine.startTurn("continue"); + assert.ok(sent.system.some((block) => block.text.includes("source from another engine"))); + assert.ok(sent.system.some((block) => block.text.includes("keep the dots purple"))); + await writeFile(file, "// restored old source\n"); + await engine.startTurn("continue from rollback"); + assert.ok(sent.system.some((block) => block.text.includes("restored old source"))); + assert.ok(!sent.system.some((block) => block.text.includes("source from another engine"))); +}); + +test("interrupting a checkpoint during validation cannot write or start another round", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "ac-interrupt-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, "piece.mjs"); + await writeFile(file, "// working\n"); + let calls = 0, completed; + const serve = serving(writes("export function paint() {}"), say("done")); + const engine = new AcServer({ piece: { file }, token: async () => "tok", fetch: (...args) => { calls++; return serve(...args); } }); + engine.on("notification", ({ method, params }) => { + if (method === "turn/progress" && params.phase === "writing") engine.interrupt(); + if (method === "turn/completed") completed = params.turn; + }); + await engine.connect(); + await engine.startTurn("edit"); + assert.equal(await readFile(file, "utf8"), "// working\n"); + assert.equal(calls, 1); + assert.equal(completed.status, "interrupted"); +}); + +// A turn that called a tool paid for two responses. The meter has to see both, +// or the readout understates exactly the turns that cost the most. +test("each round reports what it spent, per round rather than per turn", async () => { + const dir = await mkdtemp(join(tmpdir(), "ac-energy-")); + const file = join(dir, "vopuzi.mjs"); + await writeFile(file, "// blank\n"); + + const metered = (events, output) => [ + { type: "message_start", message: { usage: { input_tokens: 6000, cache_read_input_tokens: 24000 } } }, + ...events, + { type: "message_delta", delta: { stop_reason: events === none ? "end_turn" : "tool_use" }, usage: { output_tokens: output } }, + ]; + const none = []; + + const engine = new AcServer({ + fetch: serving( + metered([ + { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "t1", name: "write_piece" } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify({ source: "function paint({ wipe }) { wipe(0); }" }) } }, + { type: "content_block_stop", index: 0 }, + ], 700), + metered(none, 40), + ), + token: async () => "tok", + piece: { file }, + model: "glm", + }); + + const spent = []; + engine.on("notification", ({ method, params }) => { + if (method === "turn/usage") spent.push(params); + }); + await engine.connect(); + await engine.startTurn("paint it black"); + + assert.equal(spent.length, 2, "one report per round"); + assert.equal(spent[0].model, "z-ai/glm-4.6", "reported under the id that ran, not the alias"); + assert.equal(spent[0].usage.output_tokens, 700); + assert.equal(spent[0].usage.cache_read_input_tokens, 24000, "prompt counts from message_start survive the round"); + assert.equal(spent[1].usage.output_tokens, 40); + await rm(dir, { recursive: true, force: true }); +}); diff --git a/easel/test/claude-server.test.mjs b/easel/test/claude-server.test.mjs index 17b8204486..95fc3ddc02 100644 --- a/easel/test/claude-server.test.mjs +++ b/easel/test/claude-server.test.mjs @@ -139,7 +139,7 @@ test("the launch carries the approval contract and the workspace", async (t) => assert.equal(flag("--setting-sources"), ""); assert.ok(argv.includes("--strict-mcp-config")); for (const tool of ["WebFetch", "WebSearch", "Task"]) assert.ok(argv.includes(tool)); - // Easel's own read-only tools are the one MCP server let through, pre-allowed. + // Aesel's own read-only tools are the one MCP server let through, pre-allowed. const mcp = JSON.parse(flag("--mcp-config")); assert.equal(mcp.mcpServers.ac.command, process.execPath); assert.ok(mcp.mcpServers.ac.args[0].endsWith("tools.mjs")); @@ -219,3 +219,30 @@ test("a refused turn says why, instead of stopping in silence", async (t) => { assert.deepEqual(errors, ["Usage limit reached. Try again at 6pm."], "and the reason reaches the interface"); }); + +// Energy is estimated from token counts, so the counts have to arrive — and +// under the name of the model that actually ran. The fake CLI reports Fable +// while the bridge asked for the default, which is the case a readout keyed to +// the requested model would describe wrongly. +test("a finished turn reports what it spent, keyed to the model that ran", async (t) => { + const engine = bridge(t); + const spent = []; + const completed = new Promise((resolve) => { + engine.on("notification", ({ method, params }) => { + if (method === "turn/usage") spent.push(params); + if (method === "turn/completed") resolve(params.turn.status); + }); + }); + engine.on("request", (request) => engine.respond(request.id, { decision: "accept" })); + + await engine.connect(); + await engine.startTurn("make a piece"); + assert.equal(await completed, "completed"); + + assert.equal(spent.length, 1); + assert.equal(spent[0].model, "claude-fable-5-1"); + assert.equal(spent[0].usage.outputTokens, 300); + + const { joulesFor, readUsage } = await import("../src/energy.mjs"); + assert.ok(joulesFor(readUsage(spent[0].usage), spent[0].model) > 0); +}); diff --git a/easel/test/cli.sh b/easel/test/cli.sh index d951013d94..4e96a57be9 100755 --- a/easel/test/cli.sh +++ b/easel/test/cli.sh @@ -31,7 +31,7 @@ assert_contains() { } output="$($CLI --version)" -assert_contains "$output" 'Easel 0.4.0' +assert_contains "$output" "Aesel $(node -p "require('$PROJECT_DIR/package.json').version")" output="$(EASEL_DRY_RUN=1 "$CLI" "$WORK_DIR")" assert_contains "$output" 'interface=easel' diff --git a/easel/test/context.test.mjs b/easel/test/context.test.mjs index 810f14c032..0c783057cc 100644 --- a/easel/test/context.test.mjs +++ b/easel/test/context.test.mjs @@ -5,14 +5,14 @@ import { fileURLToPath } from "node:url"; import test from "node:test"; import { BUNDLE } from "../bin/sync-context.mjs"; -const EASEL = join(dirname(fileURLToPath(import.meta.url)), ".."); +const AESEL = join(dirname(fileURLToPath(import.meta.url)), ".."); -// The bundle is the whole reason an installed Easel is worth having over the +// The bundle is the whole reason an installed Aesel is worth having over the // vendor CLI it drives. A missing file is not a cosmetic problem — it is the // tool silently becoming general-purpose. test("every bundled guide is present and carries its provenance", () => { for (const [from, to, subject] of BUNDLE) { - const path = join(EASEL, "context", to); + const path = join(AESEL, "context", to); assert.ok(existsSync(path), `context/${to} is missing — run npm run context`); const body = readFileSync(path, "utf8"); assert.ok(body.includes(from), `context/${to} should name where it came from`); @@ -23,7 +23,7 @@ test("every bundled guide is present and carries its provenance", () => { test("the bundle is small enough to travel", () => { const total = BUNDLE.reduce( - (sum, [, to]) => sum + readFileSync(join(EASEL, "context", to), "utf8").length, + (sum, [, to]) => sum + readFileSync(join(AESEL, "context", to), "utf8").length, 0, ); // Not a style rule — a tripwire. If the bundle ever approaches the size of the diff --git a/easel/test/energy.test.mjs b/easel/test/energy.test.mjs new file mode 100644 index 0000000000..cf0be7bc78 --- /dev/null +++ b/easel/test/energy.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + Energy, + energyReport, + everyday, + formatJoules, + joulesFor, + phoneCharges, + profileFor, + readUsage, + relativeModels, +} from "../src/energy.mjs"; + +test("published active-parameter counts are marked apart from guessed ones", () => { + assert.equal(profileFor("z-ai/glm-4.6").known, true); + assert.equal(profileFor("anthropic/claude-sonnet-4.6").known, false); + // An unrecognized name lands at the frontier class rather than the cheap end. + assert.ok(profileFor("some-unreleased-thing").active >= 200); + assert.equal(profileFor("claude-haiku-4-5-20251001").active, 40); +}); + +test("usage arrives under several spellings and reads the same", () => { + const anthropic = readUsage({ + input_tokens: 10, + output_tokens: 20, + cache_read_input_tokens: 30, + cache_creation_input_tokens: 40, + }); + const cli = readUsage({ + inputTokens: 10, + outputTokens: 20, + cacheReadInputTokens: 30, + cacheCreationInputTokens: 40, + }); + assert.deepEqual(anthropic, cli); + assert.deepEqual(readUsage({ input_tokens: 5, cached_input_tokens: 7 }), { + input: 5, + output: 0, + cacheRead: 7, + cacheWrite: 0, + }); + assert.deepEqual(readUsage({ input_tokens: -1, output_tokens: null }).input, 0); +}); + +test("writing a token costs more than reading one, and reading a cached one costs least", () => { + const model = "z-ai/glm-4.6"; + const out = joulesFor(readUsage({ output_tokens: 1000 }), model); + const read = joulesFor(readUsage({ input_tokens: 1000 }), model); + const cached = joulesFor(readUsage({ cache_read_input_tokens: 1000 }), model); + assert.ok(out > read && read > cached); + assert.ok(cached > 0, "a cached token is cheap, not free"); +}); + +test("a bigger model costs more for identical work", () => { + const tokens = readUsage({ input_tokens: 6000, output_tokens: 800 }); + const small = joulesFor(tokens, "z-ai/glm-4.6"); + const large = joulesFor(tokens, "openai/gpt-5.4"); + assert.ok(large > small * 2); +}); + +// The anchor the whole formula hangs on: a few hundred output tokens from a +// frontier-class model should land near the published per-prompt figures +// (Google 0.24 Wh median, Epoch ~0.3 Wh), or the estimate has drifted into +// numbers nobody has evidence for. +test("a frontier-class answer lands inside the published per-prompt range", () => { + const wh = joulesFor(readUsage({ input_tokens: 400, output_tokens: 300 }), "openai/gpt-5.4") / 3600; + assert.ok(wh > 0.05 && wh < 0.6, `${wh} Wh is outside the published range`); +}); + +test("the ledger totals per session and per model", () => { + const energy = new Energy(); + energy.add("z-ai/glm-4.6", { input_tokens: 100, output_tokens: 50 }); + energy.add("openai/gpt-5.4", { input_tokens: 100, output_tokens: 50 }); + assert.equal(energy.turns, 2); + assert.equal(energy.tokens.output, 100); + assert.equal(energy.byModel.size, 2); + assert.ok(energy.byModel.get("openai/gpt-5.4").joules > energy.byModel.get("z-ai/glm-4.6").joules); + // A turn the engine reported no counts for is not a turn the meter saw. + assert.equal(energy.add("z-ai/glm-4.6", {}), 0); + assert.equal(energy.turns, 2); +}); + +test("the relative table is cheapest-first and normalized to it", () => { + const rows = relativeModels(readUsage({ output_tokens: 500 }), "z-ai/glm-4.6"); + assert.equal(rows[0].ratio, 1); + assert.ok(rows.at(-1).ratio > 1); + assert.deepEqual([...rows].sort((a, b) => a.joules - b.joules), rows); + assert.equal(rows.find((row) => row.current).label, "glm"); +}); + +test("numbers are shown in units a person owns", () => { + assert.match(formatJoules(0), /^0 Wh$/); + assert.match(formatJoules(200), /Wh$/); + assert.match(formatJoules(36000), /^10\.0 Wh$/); + assert.match(everyday(100), /LED bulb/); + assert.match(everyday(500000), /kettle/); + assert.ok(phoneCharges(54000) > 0.9 && phoneCharges(54000) < 1.1); +}); + +test("the report says it is an estimate, with or without metered turns", () => { + assert.match(energyReport(new Energy()).join("\n"), /No metered turns yet/); + const energy = new Energy(); + energy.add("z-ai/glm-4.6", { input_tokens: 6000, output_tokens: 900, cache_read_input_tokens: 24000 }); + const report = energyReport(energy, "z-ai/glm-4.6").join("\n"); + assert.match(report, /this session/); + assert.match(report, /Same conversation, other models/); + assert.match(report, /← running/); + assert.match(report, /Estimated from active parameters/); + assert.match(report, /size undisclosed/, "guessed rows say so"); +}); + +// `/model glm` names a model by its short hosted name. A session that never +// heard the resolved id back should still price the right model. +test("hosted short names resolve to the model they select", () => { + assert.equal(profileFor("glm").active, profileFor("z-ai/glm-4.6").active); + assert.equal(profileFor("deepseek").known, true); +}); diff --git a/easel/test/fake-claude-cli.mjs b/easel/test/fake-claude-cli.mjs index 4fcb71948e..b3a8e634a1 100644 --- a/easel/test/fake-claude-cli.mjs +++ b/easel/test/fake-claude-cli.mjs @@ -119,6 +119,18 @@ createInterface({ input: process.stdin }).on("line", (line) => { ], }, }); - send({ type: "result", subtype: "success", is_error: false, terminal_reason: "completed", result: "done" }); + // The real CLI closes a turn with what it spent, both totalled and keyed by + // the model that actually ran. + send({ + type: "result", + subtype: "success", + is_error: false, + terminal_reason: "completed", + result: "done", + usage: { input_tokens: 1200, output_tokens: 300, cache_read_input_tokens: 18000, cache_creation_input_tokens: 0 }, + modelUsage: { + "claude-fable-5-1": { inputTokens: 1200, outputTokens: 300, cacheReadInputTokens: 18000, cacheCreationInputTokens: 0 }, + }, + }); } }); diff --git a/easel/test/inference-policy.test.mjs b/easel/test/inference-policy.test.mjs new file mode 100644 index 0000000000..10ffadadb3 --- /dev/null +++ b/easel/test/inference-policy.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { AC_MODELS } from "../src/ac-server.mjs"; +import { EASEL_MODELS, inferenceRequest, inferenceBudgetFailure } from "../../system/backend/easel-policy.mjs"; +const messages = [{ role: "user", content: "hello" }]; + +test("hosted defaults remain inexpensive and each advertised model is explicitly allowed", () => { + assert.equal(inferenceRequest({ messages }).model, "z-ai/glm-4.6"); + for (const model of Object.values(AC_MODELS)) { + assert.ok(Object.hasOwn(EASEL_MODELS, model)); + assert.equal(inferenceRequest({ model, messages }).model, model); + } + assert.equal(inferenceRequest({ model: "anthropic/claude-sonnet-4.6", messages }).model, "anthropic/claude-sonnet-4.6"); + assert.equal(inferenceRequest({ model: "openai/gpt-5.4", messages }).model, "openai/gpt-5.4"); +}); + +test("unsupported models and malformed requests refuse rather than silently falling back", () => { + for (const model of ["unknown", "", null, [], ["openai/gpt-5.4"], "toString", "__proto__"]) { + assert.throws(() => inferenceRequest({ model, messages }), /Unsupported model/); + } + for (const body of [null, [], "request"]) assert.throws(() => inferenceRequest(body), /request object/); + assert.throws(() => inferenceRequest({ messages: [] }), /message/); + for (const max_tokens of [-1, 0, 1.5, "100", null, Infinity]) assert.throws(() => inferenceRequest({ messages, max_tokens }), /positive integer/); + assert.equal(inferenceRequest({ messages, max_tokens: 99999 }).maxTokens, 8192); + assert.equal(inferenceRequest({ messages, max_tokens: 100 }).maxTokens, 100); +}); + +test("unknown or failed budget checks never permit paid hosted inference", () => { + const valid = { used: 0, budget: 200000, remaining: 200000, exhausted: false }; + assert.equal(inferenceBudgetFailure(valid, "test"), null); + for (const budget of [null, undefined, { ...valid, unknown: true }, {}, { ...valid, remaining: NaN }]) { + assert.equal(inferenceBudgetFailure(budget, "test").statusCode, 503); + } + assert.equal(inferenceBudgetFailure({ ...valid, remaining: 0 }, "test").statusCode, 429); + assert.equal(inferenceBudgetFailure({ ...valid, exhausted: true }, "test").statusCode, 429); +}); diff --git a/easel/test/mouse-about.test.mjs b/easel/test/mouse-about.test.mjs new file mode 100644 index 0000000000..92c81a0375 --- /dev/null +++ b/easel/test/mouse-about.test.mjs @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { InputDecoder, mouseEvent } from "../src/mouse.mjs"; +import { headerAction, renderFrame, textWidth, cleanText } from "../src/render.mjs"; + +test("mouse reports split across reads never become prompt characters", () => { + const decoder = new InputDecoder(); + assert.deepEqual(decoder.push("\x1b[<35;4"), []); + assert.deepEqual(decoder.push(";21Mhi"), ["\x1b[<35;4;21M", "h", "i"]); + assert.deepEqual(mouseEvent("\x1b[<35;4;21M"), { x: 4, y: 21, motion: true, wheel: 0, click: false }); + assert.equal(mouseEvent("\x1b[<0;4;21M").click, true); + assert.equal(mouseEvent("\x1b[<0;4;21m").click, false); + assert.equal(mouseEvent("\x1b[<65;4;21M").wheel, 1); +}); + +test("standalone escape and split arrow/paste sequences are distinct", () => { + const decoder = new InputDecoder(); + assert.deepEqual(decoder.push("\x1b"), []); + assert.deepEqual(decoder.escape(), ["\x1b"]); + assert.deepEqual(decoder.push("\x1b["), []); + assert.deepEqual(decoder.escape(), []); + assert.deepEqual(decoder.push("A\x1b[200~hello\x1b[201~"), ["\x1b[A", "\x1b[200~", "h", "e", "l", "l", "o", "\x1b[201~"]); +}); + +test("only visible header labels are clickable across terminal sizes", () => { + const state = { account: "@jeffrey", status: "ready", entries: [] }; + for (const width of [32, 40, 80, 120]) { + const header = cleanText(renderFrame(state, width, 24, false)).split("\n")[20]; + assert.equal(headerAction(state, width, 24, 3, 21), "about"); + assert.equal(headerAction(state, width, 24, 10, 21), header.includes("@jeffrey") ? "profile" : ""); + assert.equal(headerAction(state, width, 24, 3, 20), ""); + assert.equal(headerAction(state, width, 24, 8, 21), ""); + } +}); + +test("about is a scrollable map that preserves transcript and fits small windows", () => { + const state = { account: "@jeffrey", entries: [{ kind: "user", text: "keep my drawing" }], about: true }; + const top = renderFrame(state, 80, 24, false); + assert.match(top, /You → model → working piece/); + assert.doesNotMatch(top, /keep my drawing/); + assert.equal(state.entries[0].text, "keep my drawing"); + const bottom = renderFrame({ ...state, aboutScroll: 1000 }, 40, 12, false); + assert.match(bottom, /Esc returns/); + for (const width of [32, 40, 80]) { + for (const row of renderFrame(state, width, 24, false).split("\n")) assert.ok(textWidth(row) <= width); + } +}); diff --git a/easel/test/perf.test.mjs b/easel/test/perf.test.mjs new file mode 100644 index 0000000000..6fac8d142c --- /dev/null +++ b/easel/test/perf.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { benchmarkPiece } from "../src/perf.mjs"; + +async function piece(t, source) { + const root = await mkdtemp(join(tmpdir(), "easel-perf-")); + t.after(() => rm(root, { recursive: true, force: true })); + const file = join(root, "piece.mjs"); + await writeFile(file, source); + return file; +} + +test("counts measured drawing work, excluding warmup, with seeded top-level randomness", async (t) => { + const file = await piece(t, ` + const count = 1 + Math.floor(Math.random() * 100); + export function boot({ circle }) { circle(); } + export function sim({ line }) { line(); } + export function paint({ ink, circle }) { ink(); for (let i=0; i= 0); + assert.equal(first.fps, undefined, "logic timing is not rendering FPS"); +}); + +test("a looping piece times out without blocking the parent", async (t) => { + const file = await piece(t, "export function paint() { while(true) {} }"); + let ticked = false; + const tick = setTimeout(() => { ticked = true; }, 30); + await assert.rejects(benchmarkPiece({ file, timeoutMs: 150 }), /exceeded|timed out/); + clearTimeout(tick); + assert.ok(ticked); +}); + +test("filesystem imports and host globals are unavailable", async (t) => { + const imported = await piece(t, 'import fs from "node:fs"; export function paint() { fs.readFileSync("/etc/passwd"); }'); + await assert.rejects(benchmarkPiece({ file: imported }), /Imports are unavailable/); + const processPiece = await piece(t, 'export function paint() { process.env; }'); + await assert.rejects(benchmarkPiece({ file: processPiece }), /process is not defined/); + const constructorEscape = await piece(t, 'export function paint({wipe}) { wipe.constructor("return process")(); }'); + await assert.rejects(benchmarkPiece({ file: constructorEscape }), /Code generation from strings disallowed/); +}); + +test("bounded options and cancellation refuse work cleanly", async (t) => { + const file = await piece(t, "export function paint() {}"); + await assert.rejects(benchmarkPiece({ file, frames: 100000 }), /frames must be/); + const controller = new AbortController(); + controller.abort(new Error("cancelled")); + await assert.rejects(benchmarkPiece({ file, signal: controller.signal }), /cancelled/); +}); diff --git a/easel/test/render.test.mjs b/easel/test/render.test.mjs index 5735735d78..e9058a11fa 100644 --- a/easel/test/render.test.mjs +++ b/easel/test/render.test.mjs @@ -30,7 +30,7 @@ test("renders one branded interface with privacy state and prompt", () => { 20, false, ); - assert.match(frame, /EASEL/); + assert.match(frame, /AESEL/); assert.match(frame, /REMOTE · READY/); assert.match(frame, /YOU inspect this repository/); assert.match(frame, /AC I found the failing test/); @@ -54,7 +54,7 @@ test("shows the signed-in handle and the current piece in the header", () => { 12, false, ); - assert.match(frame, /EASEL @tester smiley/); + assert.match(frame, /AESEL @tester smiley/); assert.match(frame, /REMOTE · READY/); assert.match(frame, /PUB https:\/\/aesthetic\.computer\/@tester\/smiley/); assert.match(renderFrame({ workspace: "/p", mode: "remote", status: "ready", entries: [], input: "" }, 60, 12, false), /not signed in/); @@ -235,3 +235,27 @@ test("a blank frame is reported, and outranks the rest of the readout", async () const nothing = audienceReadout({ here: null, frame: null }, 80, false); assert.equal(nothing.plain, "", "and an unanswered session still claims nothing"); }); + +// The running electricity estimate shares the gauge row, and is the first thing +// that row gives up: an estimate is the least urgent number on it. +test("the energy estimate reaches the gauge row and drops first when squeezed", async () => { + const { audienceReadout } = await import("../src/render.mjs"); + const { Energy } = await import("../src/energy.mjs"); + + const energy = new Energy(); + energy.add("z-ai/glm-4.6", { input_tokens: 6200, output_tokens: 900, cache_read_input_tokens: 24000 }); + + const frame = renderFrame( + { + workspace: "/project", mode: "remote", status: "ready", + account: "@tester", piece: "kizide.mjs", input: "", entries: [], energy, + }, + 100, 24, false, + ); + assert.match(frame, /~[\d.]+ Wh/, "the number wears a tilde, because it is an estimate"); + + const full = audienceReadout({ here: 2, peak: 9, energy: 3600 }, 80, false); + assert.equal(full.plain, "2 here · 9 peak · ~1.00 Wh"); + assert.equal(audienceReadout({ here: 2, peak: 9, energy: 3600 }, 16, false).plain, "2 here · 9 peak"); + assert.equal(audienceReadout({ energy: 0 }, 80, false).plain, "", "an unmetered session claims nothing"); +}); diff --git a/easel/test/revisions.test.mjs b/easel/test/revisions.test.mjs new file mode 100644 index 0000000000..cbf399ea11 --- /dev/null +++ b/easel/test/revisions.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { PieceRevisions, validatePieceSource } from "../src/revisions.mjs"; +import { LivePiece } from "../src/live.mjs"; + +async function setup(t) { + const root = await mkdtemp(join(tmpdir(), "easel-revision-")); + t.after(() => rm(root, { recursive: true, force: true })); + const file = join(root, "piece.mjs"); + return { root, file, history: new PieceRevisions(file, { root: join(root, "history") }) }; +} + +test("revisions survive restart, deduplicate saves, and rollback appends", async (t) => { + const { root, file, history } = await setup(t); + const first = "export function paint() {}\n"; + const second = "export function paint({ wipe }) { wipe(0); }\n"; + await writeFile(file, first); + assert.equal(history.capture(first).version, 1); + assert.equal(history.capture(first).version, 1); + history.capture(second); + await writeFile(file, second); + const reopened = new PieceRevisions(file, { root: join(root, "history") }); + const restored = await reopened.restore(1); + assert.equal(restored.version, 3); + assert.equal(restored.restoredFrom, 1); + assert.equal(await readFile(file, "utf8"), first); + assert.deepEqual(reopened.list().map((v) => v.source), [first, second, first]); + await assert.rejects(reopened.restore(99), /No saved/); +}); + +test("validation parses without executing and rejects incomplete JavaScript", async () => { + await validatePieceSource('throw new Error("must not execute"); export const x = 1;', "piece.mjs"); + await assert.rejects(validatePieceSource("export function paint( {", "piece.mjs"), /invalid JavaScript/); +}); + +test("file watcher versions external edits and never pushes unfinished JavaScript", async (t) => { + const { root, history } = await setup(t); + let pushes = 0; + const live = new LivePiece({ directory: root, slug: "piece", fetch: async () => { pushes++; return new Response("ok"); } }); + Object.defineProperty(live, "history", { get: () => history }); + live.create(); + await live.checkpoint(); + t.after(() => live.unwatch()); + const errors = []; + live.watch((e) => errors.push(e)); + await writeFile(live.file, "export function paint( {"); + await new Promise((resolve) => setTimeout(resolve, 400)); + assert.equal(pushes, 0); + assert.equal(history.list().length, 1); + assert.equal(errors.length, 1); + const landed = new Promise((resolve) => live.once("push", resolve)); + await writeFile(live.file, "export function paint() {}\n"); + await landed; + assert.equal(history.list().length, 2); + await writeFile(live.file, "export function broken("); + const restored = await live.rollback(1); + assert.equal(restored.version, 3, "a broken current edit does not prevent recovery"); +}); + +test("live uploads serialize so old saves cannot overtake newer versions", async (t) => { + const { root, history } = await setup(t); + let releaseFirst; + const gate = new Promise((resolve) => { releaseFirst = resolve; }); + const seen = []; + let firstStarted; + const started = new Promise((resolve) => { firstStarted = resolve; }); + const live = new LivePiece({ directory: root, slug: "piece", fetch: async (_url, options) => { + seen.push(JSON.parse(options.body).source); + if (seen.length === 1) { firstStarted(); await gate; } + return new Response("ok"); + } }); + Object.defineProperty(live, "history", { get: () => history }); + await writeFile(live.file, "// first\n"); + const first = live.push(); + await started; + await writeFile(live.file, "// second\n"); + const second = live.push(); + assert.equal(seen.length, 1); + assert.equal(live.sending, true); + releaseFirst(); + await Promise.all([first, second]); + assert.deepEqual(seen, ["// first\n", "// second\n"]); + assert.equal(live.ahead, false); + assert.equal(live.sending, false); +}); + +test("a failed old upload does not discard a queued newer save", async (t) => { + const { root, history } = await setup(t); + let failFirst, firstStarted; + const started = new Promise((resolve) => { firstStarted = resolve; }); + const blocked = new Promise((_resolve, reject) => { failFirst = reject; }); + const seen = []; + const live = new LivePiece({ directory: root, slug: "piece", fetch: async (_url, options) => { + seen.push(JSON.parse(options.body).source); + if (seen.length === 1) { firstStarted(); await blocked; } + return new Response("ok"); + } }); + Object.defineProperty(live, "history", { get: () => history }); + await writeFile(live.file, "// old\n"); + const old = live.push(); + const failure = assert.rejects(old, /offline/); + await started; + await writeFile(live.file, "// latest\n"); + const latest = live.push(); + failFirst(new Error("offline")); + await failure; + assert.equal(await latest, true); + assert.deepEqual(seen, ["// old\n", "// latest\n"]); +}); diff --git a/easel/test/stream-transport.test.mjs b/easel/test/stream-transport.test.mjs new file mode 100644 index 0000000000..3dda471f31 --- /dev/null +++ b/easel/test/stream-transport.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import test from "node:test"; +import { relayInference } from "../../system/backend/easel-stream.mjs"; +import { sendStream } from "../../lith/stream-response.mjs"; + +const bytes = (text) => new TextEncoder().encode(text); + +test("relay forwards first chunk before EOF and cancellation stops the provider", async () => { + let source, cancelled = false, aborted = false; + const body = new ReadableStream({ start(c) { source = c; }, cancel() { cancelled = true; } }); + const reader = relayInference(body, { abort: () => { aborted = true; } }).getReader(); + source.enqueue(bytes("data: first\n\n")); + assert.equal(new TextDecoder().decode((await reader.read()).value), "data: first\n\n"); + await reader.cancel(); + assert.ok(cancelled && aborted); +}); + +test("usage survives chunk boundaries and final output-only usage updates", async () => { + let charge = 0; + const data = 'data: {"message":{"usage":{"input_tokens":100}}}\n\ndata: {"usage":{"output_tokens":8}}\n\n'; + const body = new ReadableStream({ start(c) { for (const char of data) c.enqueue(bytes(char)); c.close(); } }); + await new Response(relayInference(body, { onUsage: (n) => { charge = n; } })).text(); + await Promise.resolve(); + assert.equal(charge, 108); +}); + +test("HTTP adapter delivers data before generation ends and cancels on disconnect", async (t) => { + let provider, resolveCancel; + const cancelled = new Promise((resolve) => { resolveCancel = resolve; }); + const server = createServer((_req, res) => { + res.setHeader("Content-Type", "text/event-stream"); + const stream = new ReadableStream({ start(c) { provider = c; c.enqueue(bytes("data: token\n\n")); }, cancel() { resolveCancel(); } }); + sendStream(res, stream).catch(() => {}); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => { server.closeAllConnections(); server.close(); }); + const abort = new AbortController(); + const response = await fetch(`http://127.0.0.1:${server.address().port}`, { signal: abort.signal }); + const reader = response.body.getReader(); + assert.match(new TextDecoder().decode((await reader.read()).value), /token/); + assert.ok(provider, "provider is still open"); + abort.abort(); + await cancelled; +}); diff --git a/easel/test/tui-fixture.mjs b/easel/test/tui-fixture.mjs new file mode 100644 index 0000000000..90a4e9a66a --- /dev/null +++ b/easel/test/tui-fixture.mjs @@ -0,0 +1,32 @@ +// Imported only by the PTY integration test: no accounts, network or vendor CLIs. +import { EventEmitter } from "node:events"; +import { appendFileSync } from "node:fs"; +import { ACSession } from "../src/ac-session.mjs"; +import { BACKENDS } from "../src/backends.mjs"; +import { Audience } from "../src/audience.mjs"; +import { Diagnostics } from "../src/diagnostics.mjs"; + +ACSession.prototype.read = () => ({ access_token: "fixture", user: { handle: "tester" } }); +ACSession.prototype.token = async () => "fixture"; +ACSession.prototype.watch = function () { return this; }; +ACSession.prototype.unwatch = () => {}; +Audience.prototype.watch = () => {}; +Diagnostics.prototype.watch = async () => {}; +globalThis.fetch = async () => { throw new Error("Network disabled in PTY fixture"); }; +class FixtureEngine extends EventEmitter { + constructor(options) { super(); Object.assign(this, options); this.threadId = "fixture"; } + async connect() { + appendFileSync(process.env.EASEL_TEST_LOG, JSON.stringify({ model: this.model, context: this.developerInstructions }) + "\n"); + if (this.model === "broken") throw new Error("Fixture switch failed"); + return { model: this.model || "fixture-default" }; + } + close() { this.emit("notification", { method: "item/agentMessage/delta", params: { itemId: "stale", delta: "STALE_CALLBACK_BUG" } }); } + async startTurn(text) { + this.emit("notification", { method: "turn/started", params: { turn: { id: "turn" } } }); + this.emit("notification", { method: "item/agentMessage/delta", params: { itemId: `answer-${Date.now()}`, delta: `I remember ${text}` } }); + this.emit("notification", { method: "turn/usage", params: { model: this.model, usage: { input_tokens: 1200, output_tokens: 400, cache_read_input_tokens: 24000 } } }); + this.emit("notification", { method: "turn/completed", params: { turn: { status: "completed" } } }); + } + interrupt() {} +} +for (const backend of Object.values(BACKENDS)) backend.Engine = FixtureEngine; diff --git a/easel/test/tui-pty.py b/easel/test/tui-pty.py new file mode 100644 index 0000000000..9a3d1f3ab9 --- /dev/null +++ b/easel/test/tui-pty.py @@ -0,0 +1,104 @@ +"""Real PTY checks with offline engine fixtures; run with python3 test/tui-pty.py.""" +import fcntl +import json +import os +from pathlib import Path +import pty +import select +import shutil +import struct +import subprocess +import tempfile +import termios +import time + +ROOT = Path(__file__).resolve().parents[1] +with tempfile.TemporaryDirectory(prefix="easel-pty-") as temporary: + folder = Path(temporary) + piece = folder / "existing.mjs" + piece_source = "export function paint({wipe}) { wipe(70,50,100); }\n" + piece.write_text(piece_source) + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 100, 0, 0)) + fake_bin = folder / "bin" + fake_bin.mkdir() + opener = fake_bin / "open" + opener.write_text('#!/bin/sh\nprintf "%s" "$1" > "$EASEL_BROWSER_LOG"\n') + opener.chmod(0o755) + env = dict(os.environ, TERM="xterm-256color", NO_COLOR="1", + SLAB_HOME=str(folder / "slab"), EASEL_TEST_LOG=str(folder / "engines.jsonl"), + EASEL_HISTORY_DIR=str(folder / "history"), + EASEL_BROWSER_LOG=str(folder / "browser.txt"), PATH=str(fake_bin) + ":" + os.environ["PATH"]) + child = subprocess.Popen([shutil.which("node"), "--import", str(ROOT / "test/tui-fixture.mjs"), + str(ROOT / "src/tui.mjs"), "--cwd", str(folder), "--piece", str(piece), "--backend", "ac", "--no-autopublish"], + stdin=slave, stdout=slave, stderr=slave, env=env) + os.close(slave) + output = bytearray() + def read_for(seconds=0.15): + until = time.monotonic() + seconds + while time.monotonic() < until: + if select.select([master], [], [], max(0, until-time.monotonic()))[0]: + try: + data = os.read(master, 65536) + except OSError: + break + if not data: + break + output.extend(data) + return output.decode("utf-8", errors="replace") + def send(text): + offset = len(output) + os.write(master, text.encode()) + read_for(0.3) + return output[offset:].decode("utf-8", errors="replace") + try: + read_for(0.8) + send("\x1b") # dismiss splash + assert "make a piece" in send("/about\r") + send("\x1b") + assert "make a piece" in send("\x1b[<0;3;21M") # click EASEL + send("\x1b") + profile_result = send("\x1b[<0;10;21M") # click @tester + deadline = time.monotonic() + 2 + while not (folder / "browser.txt").exists() and time.monotonic() < deadline: + read_for(0.05) + assert (folder / "browser.txt").exists(), profile_result[-5000:] + assert (folder / "browser.txt").read_text() == "https://aesthetic.computer/@tester" + send("remember cobalt dots\r") + result = send("/backend codex\r") + assert "current piece and recent conversation carried over" in result + assert "STALE_CALLBACK_BUG" not in result + records = [json.loads(line) for line in (folder / "engines.jsonl").read_text().splitlines()] + assert "remember cobalt dots" in records[-1]["context"] + result = send("/model broken\r") + assert "Fixture switch failed" in result + assert "STALE_CALLBACK_BUG" not in result + assert "I remember still here" in send("still here\r") + # A metered turn puts the electricity estimate on the gauge row, and + # /energy prints the working: the same tokens across every model. + assert "Wh" in read_for(0.2) + energy = send("/energy\r") + assert "Same conversation, other models" in energy + assert "Estimated from active parameters" in energy + offset = len(output) + send("/performance 10\r") + read_for(1.5) + assert "Excludes browser rendering" in output[offset:].decode("utf-8", errors="replace") + for index in range(24): + send(f"line {index}\r") + result = send("\x1b[5~") + assert "lines above" in result and "AESEL" in result + result = send("\x1b[F") + assert "line 23" in result + send("/mouse off\r") + assert b"\x1b[?1003l" in output + send("/quit\r") + child.wait(timeout=5) + assert child.returncode == 0, child.returncode + assert piece.read_text() == piece_source + print("PTY passed: about/profile clicks, engine handoff/recovery, stale callbacks, energy meter, internal scroll, benchmark, existing piece preservation, mouse cleanup") + finally: + if child.poll() is None: + child.terminate() + child.wait(timeout=5) + os.close(master)