From b7414be12244b0a7e236852278fa7ef4c754928c Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 13 Sep 2026 20:18:13 -0400 Subject: [PATCH] easel: native tools for the piece-making surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine Easel sessions, read back: Bash was 70–85% of every one, and the calls before the first edit were the same hunt each time — grep graph.mjs for `function circle(`, sed a window of disk.mjs, grep disks/ for a `synth({`, page notepat.mjs in 80-line slices. A minute or two per session on a surface that does not change. So the surface is computed once. bin/build-api-map.mjs reads the paint API off disk.mjs and graph.mjs into context/api.json (87 entries: runtime signatures, docs, real call sites), and src/tools.mjs serves it as a dependency-free MCP server over stdio — ac_api, ac_examples, ac_outline, ac_symbol — passed to the Claude bridge with --mcp-config and pre-allowed. The guides are inlined into the first turn instead of named, and the in-repo list now includes the piece authoring guide. The map travels with the context bundle: `npm run context` rebuilds it, `npm test` fails when it is stale. Co-Authored-By: Claude Fable 5.1 --- easel/README.md | 13 + easel/bin/build-api-map.mjs | 287 ++++++++++ easel/bin/sync-context.mjs | 13 +- easel/context/api.json | 905 ++++++++++++++++++++++++++++++ easel/src/claude-server.mjs | 14 + easel/src/tools.mjs | 370 ++++++++++++ easel/src/tui.mjs | 34 +- easel/test/claude-server.test.mjs | 6 + easel/test/tools.test.mjs | 133 +++++ 9 files changed, 1769 insertions(+), 6 deletions(-) create mode 100644 easel/bin/build-api-map.mjs create mode 100644 easel/context/api.json create mode 100644 easel/src/tools.mjs create mode 100644 easel/test/tools.test.mjs diff --git a/easel/README.md b/easel/README.md index 674b56dc7d..fa18e100fc 100644 --- a/easel/README.md +++ b/easel/README.md @@ -72,6 +72,19 @@ 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 +`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 +`context/api.json`), `ac_examples` (pieces that call a symbol), `ac_outline` +(a piece's top-level symbols with line spans) and `ac_symbol` (one symbol's +source). They exist because the first ten sessions each spent six to twelve +shell calls — `grep function circle( graph.mjs`, `sed -n 6590,6650p disk.mjs`, +`grep -rn "synth({" disks/` — rebuilding the same picture before the first +edit. The guides are inlined into the first turn for the same reason. All four +tools are read-only and pre-allowed; `npm run context` rebuilds the map and +`npm test` fails when it is stale. + The two are not equivalent on containment. Codex runs commands inside an operating-system sandbox with the network off; Claude Code has no such sandbox, so on that bridge the approval prompt is the whole boundary. The difference is diff --git a/easel/bin/build-api-map.mjs b/easel/bin/build-api-map.mjs new file mode 100644 index 0000000000..c1970cdd2a --- /dev/null +++ b/easel/bin/build-api-map.mjs @@ -0,0 +1,287 @@ +#!/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 +// `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 +// edit. The answers do not change between sessions; only the model's memory +// does. So the answers are computed once, here, and shipped as +// `easel/context/api.json` for `ac_api` to serve in a single call. +// +// What it records, per API name a piece can call: +// - where it lives on `$api` (`circle`, `sound.synth`, `ui.Button`) +// - the runtime signature, read from the defining `function` line +// - the comment immediately above the definition, when there is one +// - up to three one-line uses from real pieces under disks/, as file:line +// +// Regex over source, not a parser: the runtime is one 600 KB file with a +// house style regular enough that `^ name: graph.name,` is a grammar. Where +// the pattern misses, the entry is simply absent — a hole in the map, never a +// wrong signature. +// +// node bin/build-api-map.mjs # write context/api.json +// node bin/build-api-map.mjs --check # exit 1 if it is stale +import { readFileSync, readdirSync, writeFileSync, existsSync } from "node:fs"; +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 AC = join(REPO, "system", "public", "aesthetic.computer"); +const OUT = join(EASEL, "context", "api.json"); + +const read = (file) => readFileSync(join(AC, file), "utf8").split("\n"); + +// The comment block sitting directly above `line` (0-based), joined, trimmed. +function commentAbove(lines, line, max = 6) { + const out = []; + for (let i = line - 1; i >= 0 && out.length < max; i--) { + const text = lines[i].trim(); + if (!text.startsWith("//")) break; + out.unshift(text.replace(/^\/\/\s?/, "")); + } + return out.join(" ").replace(/\s+/g, " ").trim(); +} + +// `function name(a, b = 1, ...rest) {` → "name(a, b = 1, ...rest)". A signature +// split across lines (oval, synth) is joined until its parenthesis closes. +function signatureAt(lines, line, name) { + let text = ""; + for (let i = line; i < Math.min(lines.length, line + 24); i++) { + text += lines[i].replace(/\/\/.*$/, "").trim() + " "; + if (/\)\s*(=>\s*)?\{?\s*$/.test(text) || text.includes(") {")) break; + } + const match = text.match(/\(([\s\S]*)\)\s*(?:=>\s*)?\{?\s*$/); + if (!match) return `${name}(…)`; + let params = match[1].replace(/\s+/g, " ").trim(); + // A destructured options object reads as its keys. + params = params.replace(/\{\s*([^}]*)\}\s*=\s*\{\}/, (_, keys) => `{ ${keys.trim()} }`); + return `${name}(${params})`; +} + +// Find `function name(` / `name = function(` / `name(...) {` / `name: function` +// anywhere in `lines`, preferring a top-level `function`. +function definitionOf(lines, name) { + const patterns = [ + new RegExp(`^(?:export\\s+)?(?:async\\s+)?function\\s+${name}\\s*\\(`), + new RegExp(`^\\s*(?:const|let)\\s+${name}\\s*=\\s*(?:async\\s*)?(?:function\\s*\\(|\\()`), + new RegExp(`^\\s*[$\\w.]*\\.?${name}\\s*=\\s*(?:async\\s+)?function\\s*(?:\\w+\\s*)?\\(`), + new RegExp(`^\\s*${name}\\s*:\\s*(?:async\\s+)?function\\s*(?:\\w+\\s*)?\\(`), + new RegExp(`^\\s*(?:async\\s+)?${name}\\s*\\([^)]*\\)\\s*\\{\\s*$`), + new RegExp(`^\\s*${name}\\s*:\\s*(?:async\\s*)?\\(`), + ]; + for (const pattern of patterns) { + const at = lines.findIndex((text) => pattern.test(text)); + if (at >= 0) return at; + } + return -1; +} + +const disk = read("lib/disk.mjs"); +const graph = read("lib/graph.mjs"); + +// The object the paint API is built from. `circle: graph.circle,` is the shape +// of most of it; the rest are inline functions wrapping a graph call. +function paintApi() { + const start = disk.findIndex((text) => /^const \$paintApiUnwrapped = \{$/.test(text)); + if (start < 0) throw new Error("disk.mjs: $paintApiUnwrapped not found"); + let end = start + 1; + while (end < disk.length && !/^\};?$/.test(disk[end])) end++; + const entries = []; + for (let i = start + 1; i < end; i++) { + const text = disk[i]; + let match = text.match(/^ (\w+): graph\.(\w+),?\s*(\/\/\s*(.*))?$/); + if (match) { + const [, name, target, , note] = match; + const at = definitionOf(graph, target); + entries.push({ + name, + path: name, + signature: at >= 0 ? signatureAt(graph, at, name) : `${name}(…)`, + doc: note?.trim() || (at >= 0 ? commentAbove(graph, at) : "") || commentAbove(disk, i), + source: at >= 0 ? `lib/graph.mjs:${at + 1}` : `lib/disk.mjs:${i + 1}`, + }); + continue; + } + match = text.match(/^ (\w+)(?::\s*(?:async\s+)?function\s*\w*\s*\(|\s*\()/); + if (match) { + const name = match[1]; + // An inline wrapper usually forwards to graph.; take the + // graph signature when it exists, since that is what the arguments are. + const at = definitionOf(graph, name); + entries.push({ + name, + path: name, + signature: at >= 0 ? signatureAt(graph, at, name) : signatureAt(disk, i, name), + doc: commentAbove(disk, i) || (at >= 0 ? commentAbove(graph, at) : ""), + source: at >= 0 ? `lib/graph.mjs:${at + 1}` : `lib/disk.mjs:${i + 1}`, + }); + } + } + return entries; +} + +// Things a piece reaches through a namespace. Named by hand: this is the short +// list the sessions actually hunted for, and each one is checked against the +// source so the signature is the runtime's, not a memory of it. +const NAMESPACED = [ + ["sound.synth", disk, "synth", "Play a synthesized tone. `tone` is Hz or a note name like \"c4\"; returns a voice with .kill() and .update()."], + ["sound.play", disk, "play", "Play a loaded sample or sfx by id."], + ["ui.Button", null, "Button", "A rectangular button: new ui.Button(x, y, w, h) or ({x,y,w,h}); btn.paint(callback) inside paint, btn.act(e, { push, down, up, cancel }) inside act."], + ["ui.TextButton", null, "TextButton", "A labelled button sized to its text."], + ["hud.label", disk, "label", "Take over the system's corner label — the only sanctioned way to draw in the top-left."], + ["write", disk, "write", "Draw text with the current ink: write(text, { x, y, size, center: \"x\" }) or write(text, x, y). Chain from ink(): ink(\"white\").write(...)."], + ["num.randInt", null, "randInt", "Random integer in [0, n]."], + ["num.randIntRange", null, "randIntRange", "Random integer in [low, high]."], + ["num.lerp", null, "lerp", "Linear interpolation a→b by t."], + ["num.clamp", null, "clamp", "Clamp a value between min and max."], + ["num.dist", null, "dist", "Distance between two points."], + ["num.map", null, "map", "Map a value from one range to another."], + ["num.radians", null, "radians", "Degrees to radians."], + ["geo.Box", null, "Box", "An axis-aligned rectangle with .contains(point) and .crop()."], + ["geo.Circle", null, "Circle", "A circle with .contains(point)."], +]; + +function namespaced() { + const libs = { + ui: read("lib/ui.mjs"), + num: read("lib/num.mjs"), + geo: read("lib/geo.mjs"), + }; + const out = []; + for (const [path, where, name, doc] of NAMESPACED) { + const [ns] = path.split("."); + const lines = where || libs[ns]; + if (!lines) continue; + let at = definitionOf(lines, name); + let signature = `${path}(…)`; + let source = ""; + if (at >= 0) { + signature = signatureAt(lines, at, path); + source = `lib/${where ? "disk" : ns}.mjs:${at + 1}`; + } else { + // A class: the signature is its constructor's. + const cls = lines.findIndex((text) => new RegExp(`^(?:export\\s+)?class\\s+${name}\\b`).test(text)); + if (cls < 0) continue; + const ctor = lines.slice(cls).findIndex((text) => /^\s*constructor\s*\(/.test(text)); + signature = ctor >= 0 ? signatureAt(lines, cls + ctor, `new ${path}`) : `new ${path}(…)`; + source = `lib/${ns}.mjs:${cls + 1}`; + at = cls; + } + out.push({ name: path.split(".").pop(), path, signature, doc: doc || commentAbove(lines, at), source }); + } + return out; +} + +// Up to `limit` short lines from pieces that call `name(`. Long lines and the +// definition of a same-named helper inside a piece are skipped; what is wanted +// is a call site a model can copy the shape of. +function examplesFor(entries, limit = 3) { + const disksDir = join(AC, "disks"); + const files = readdirSync(disksDir).filter((f) => f.endsWith(".mjs")).sort(); + const sources = files.map((f) => [f, readFileSync(join(disksDir, f), "utf8").split("\n")]); + for (const entry of entries) { + const leaf = entry.path.split(".").pop(); + const needle = entry.path.includes(".") + ? new RegExp(`\\b${entry.path.replace(".", "\\.")}\\s*\\(|new ${entry.path.replace(".", "\\.")}\\s*\\(`) + : new RegExp(`(? 140) continue; + found.push(`disks/${file}:${i + 1} ${text.trim()}`); + } + if (found.length >= limit) break; + } + entry.examples = found; + } +} + +// Definitions that read `arguments` show up as `name()`. The real shapes, by +// hand, for the handful a piece cannot do without — checked against +// graph.mjs's own header comments, which this generator otherwise trusts. +const OVERRIDES = { + 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)." }, + ink2: { signature: "ink2(...color)", doc: "Secondary color, used by gradient-aware primitives." }, + wipe: { signature: "wipe(...color)", doc: "Fill the whole screen with a color; the usual first line of paint()." }, + line: { signature: "line(x1, y1, x2, y2) | line({x, y}, {x, y}) | line(x1, y1, x2, y2, thickness)", doc: "Draw a 1px line between two points in the current ink." }, + box: { signature: "box(x, y, size) | box(x, y, w, h) | box(x, y, w, h, mode) | box({x, y, w, h}, mode)", doc: "Rectangle. `mode` is \"fill\" (default), \"outline\", \"inline\", or \"fill*center\" / \"outline*center\" to draw from the center." }, + shape: { signature: "shape(x1, y1, x2, y2, ...) | shape([[x, y], [x, y], ...], filled = true)", doc: "Rasterize a filled or outlined polygon from point pairs." }, + tri: { signature: "tri(x1, y1, x2, y2, x3, y3, mode = \"fill\")", doc: "Triangle from three points; mode \"fill\" or \"outline\"." }, + clear: { signature: "clear()", doc: "Clear the buffer to transparent (unlike wipe, which paints a color)." }, + page: { signature: "page(buffer)", doc: "Point subsequent drawing at another painting buffer; page(screen) comes back." }, + draw: { signature: "draw(drawing, x, y, scale = 1, angle = 0, thickness = 1)", doc: "Draw a stored vector drawing (from `drawing`/store) at a position." }, + unpan: { signature: "unpan()", doc: "Undo pan(x, y)." }, + mask: { signature: "mask({ x, y, width, height })", doc: "Clip drawing to a rectangle until unmask()." }, + unmask: { signature: "unmask()", doc: "Lift the clip set by mask()." }, + flip: { signature: "flip(horizontal = false, vertical = false)", doc: "Mirror the screen." }, + sort: { signature: "sort()", doc: "Pixel-sort the screen — a glitch effect." }, + invert: { signature: "invert()", doc: "Invert every pixel's color." }, + "num.dist": { signature: "num.dist(x1, y1, x2, y2)", doc: "Distance between two points." }, + "ui.Button": { signature: "new ui.Button(x, y, w, h) | new ui.Button({ x, y, w, h })", doc: "A button. In paint: btn.paint((b) => { ink(b.down ? \"yellow\" : \"gray\").box(b.box) }). In act: btn.act(e, { push: () => {}, down: () => {}, up: () => {}, cancel: () => {} }). Pass pens() as the 3rd arg to act for multitouch. Rebuild buttons in `reframed`." }, + "geo.Box": { signature: "new geo.Box(x, y, w, h)", doc: "An axis-aligned rectangle with .x .y .w .h and .contains({x, y})." }, + 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 })." }, + "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." }, +}; + +// Not functions, so nothing to read a signature from — but the sessions hunted +// for these as often as for any primitive. +const STATIC = [ + { name: "screen", path: "screen", signature: "screen.width, screen.height, screen.pixels, screen.center", doc: "The canvas. Read width/height in paint(); never write screen.pixels directly (writes can silently drop) — draw into your own painting buffer and paste() it.", source: "lib/disk.mjs" }, + { name: "pen", path: "pen", signature: "pen.x, pen.y, pen.drawing, pen.delta", doc: "The single primary pointer; null when there is none. Read in paint()/sim().", source: "lib/disk.mjs" }, + { name: "pens", path: "pens", signature: "pens() → [{ x, y, id, drawing }]", doc: "Every active pointer, for multitouch. Pass to btn.act(e, callbacks, pens()).", source: "lib/disk.mjs" }, + { name: "event", path: "act(e)", signature: "e.is(\"touch\") | e.is(\"draw\") | e.is(\"lift\") | e.is(\"keyboard:down:space\") | e.is(\"reframed\") ; e.x, e.y, e.delta, e.key", doc: "Events arrive in act({ event: e, ... }). Pointer: touch → draw → lift. Keys: keyboard:down:, keyboard:up:.", source: "lib/disk.mjs" }, + { name: "sim", path: "sim", signature: "function sim({ ... }) — runs 120 times per second", doc: "Physics and timers go here, not in paint(); paint() runs at display rate and only when something needs painting.", source: "lib/disk.mjs" }, + { name: "needsPaint", path: "needsPaint", signature: "needsPaint()", doc: "Ask for another paint() when the piece is static and something changed.", source: "lib/disk.mjs" }, + { name: "painting", path: "painting", signature: "painting(w, h, (api) => { ... }) → buffer", doc: "Make an offscreen buffer by drawing into it; show it later with paste(buffer, x, y).", source: "lib/disk.mjs" }, + { name: "help.choose", path: "help.choose", signature: "help.choose(...items)", doc: "Pick one item at random.", source: "lib/help.mjs" }, + { name: "help.repeat", path: "help.repeat", signature: "help.repeat(n, (i) => { ... })", doc: "Call a function n times.", source: "lib/help.mjs" }, +]; + +export function build() { + const entries = [...paintApi(), ...namespaced()]; + for (const entry of entries) { + const fix = OVERRIDES[entry.path]; + if (!fix) continue; + if (fix.signature) entry.signature = fix.signature; + if (fix.doc) entry.doc = fix.doc; + } + // graph.mjs's TODO notes are not documentation. + for (const entry of entries) if (/^TODO/i.test(entry.doc)) entry.doc = ""; + examplesFor(entries); + entries.push(...STATIC.map((entry) => ({ ...entry, examples: [] }))); + const body = JSON.stringify( + { + about: + "The Aesthetic Computer piece API, read from the runtime. `path` is where it sits on the $api object a piece destructures in paint/act/sim. Signatures are the runtime's own.", + built_from: "system/public/aesthetic.computer/lib/{disk,graph,ui,num,geo}.mjs", + entries, + }, + null, + 1, + ); + return body + "\n"; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const check = process.argv.includes("--check"); + const body = build(); + const current = existsSync(OUT) ? readFileSync(OUT, "utf8") : ""; + if (current === body) { + console.log("context/api.json is current."); + } else if (check) { + console.error("stale: context/api.json no longer matches the runtime — run `npm run context`."); + process.exit(1); + } else { + writeFileSync(OUT, body); + const count = JSON.parse(body).entries.length; + console.log(`wrote context/api.json (${count} entries, ${(body.length / 1024).toFixed(1)} KB)`); + } +} diff --git a/easel/bin/sync-context.mjs b/easel/bin/sync-context.mjs index 5dda56baa5..e54725f215 100755 --- a/easel/bin/sync-context.mjs +++ b/easel/bin/sync-context.mjs @@ -20,6 +20,7 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; 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, ".."); @@ -41,10 +42,20 @@ const header = (from, subject) => export function build() { mkdirSync(OUT, { recursive: true }); - return BUNDLE.map(([from, to, subject]) => { + const guides = BUNDLE.map(([from, to, subject]) => { const body = header(from, subject) + readFileSync(join(REPO, from), "utf8"); 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. + guides.push({ + path: join(OUT, "api.json"), + body: buildApiMap(), + from: "system/public/aesthetic.computer/lib/{disk,graph,ui,num,geo}.mjs", + to: "api.json", + subject: "the piece API map", + }); + return guides; } const check = process.argv.includes("--check"); diff --git a/easel/context/api.json b/easel/context/api.json new file mode 100644 index 0000000000..ed1c0d8bce --- /dev/null +++ b/easel/context/api.json @@ -0,0 +1,905 @@ +{ + "about": "The Aesthetic Computer piece API, read from the runtime. `path` is where it sits on the $api object a piece destructures in paint/act/sim. Signatures are the runtime's own.", + "built_from": "system/public/aesthetic.computer/lib/{disk,graph,ui,num,geo}.mjs", + "entries": [ + { + "name": "blend", + "path": "blend", + "signature": "blend(mode = \"blend\")", + "doc": "Shortcuts l: graph.line, i: ink, Defaults", + "source": "lib/graph.mjs:2851", + "examples": [] + }, + { + "name": "setEraseTarget", + "path": "setEraseTarget", + "signature": "setEraseTarget(target, targetWidth)", + "doc": "", + "source": "lib/graph.mjs:2859", + "examples": [] + }, + { + "name": "page", + "path": "page", + "signature": "page(buffer)", + "doc": "Point subsequent drawing at another painting buffer; page(screen) comes back.", + "source": "lib/disk.mjs:6391", + "examples": [ + "disks/bits.mjs:33 page(sys.painting)", + "disks/breathe.mjs:29 page(screen);", + "disks/cal.mjs:34 ← / → — page (month pages months; week pages weeks; day pages days)" + ] + }, + { + "name": "edit", + "path": "edit", + "signature": "edit(changer)", + "doc": "Edit pixels by pasing a callback.", + "source": "lib/graph.mjs:752", + "examples": [] + }, + { + "name": "ink", + "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", + "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 });", + "disks/$.mjs:204 ink([160, 160, 160]).write(compressedText, { x: loopX, y, size: scale });" + ] + }, + { + "name": "ink2", + "path": "ink2", + "signature": "ink2(...color)", + "doc": "Secondary color, used by gradient-aware primitives.", + "source": "lib/disk.mjs:6425", + "examples": [] + }, + { + "name": "wipe", + "path": "wipe", + "signature": "wipe(...color)", + "doc": "Fill the whole screen with a color; the usual first line of paint().", + "source": "lib/disk.mjs:6431", + "examples": [ + "disks/$.mjs:67 wipe(0);", + "disks/$.mjs:324 wipe(0);", + "disks/$.mjs:705 wipe(\"black\").ink(\"cyan\").write(\"$\", { center: \"xy\", size: 4 });" + ] + }, + { + "name": "backgroundFill", + "path": "backgroundFill", + "signature": "backgroundFill(color)", + "doc": "Set background fill color for reframe operations (especially for KidLisp pieces)", + "source": "lib/disk.mjs:6483", + "examples": [] + }, + { + "name": "clear", + "path": "clear", + "signature": "clear()", + "doc": "Clear the buffer to transparent (unlike wipe, which paints a color).", + "source": "lib/graph.mjs:1407", + "examples": [ + "disks/doodle.mjs:53 clear(); // Always clear if the line is changing.", + "disks/doodle.mjs:61 clear();", + "disks/oldwand.mjs:996 clear(color, segments, segmentMarkers, totalLength, remote = false) {" + ] + }, + { + "name": "copy", + "path": "copy", + "signature": "copy(destX, destY, srcX, srcY, src, alpha = 1.0)", + "doc": "", + "source": "lib/graph.mjs:1835", + "examples": [ + "disks/oldpull.mjs:121 copy(x, y, selection.x + x, selection.y + y, sketch);" + ] + }, + { + "name": "paste", + "path": "paste", + "signature": "paste(from, destX = 0, destY = 0, scale = 1, blit = false)", + "doc": "", + "source": "lib/graph.mjs:2212", + "examples": [ + "disks/25.4.13.19.24.mjs:52 paste(drawing, x, y, scale);", + "disks/angel.mjs:65 paste(painting, xposition, screen.height - painting.height);", + "disks/arena.mjs:3262 paste(buffer, centerX, centerY);" + ] + }, + { + "name": "stamp", + "path": "stamp", + "signature": "stamp(from, x, y, scale, angle)", + "doc": "Similar to paste, but always draws from the center of x, y. Has partial support for {center, bottom}. 24.02.15.12.19", + "source": "lib/graph.mjs:2797", + "examples": [ + "disks/flap.mjs:50 if (store[`flap~${num}`]) stamp(store[`flap~${num}`], screen.width / 2, screen.height / 2);", + "disks/graphics.mjs:28 stamp(", + "disks/kokazo.mjs:243 stamp(ink, color, x, y, jx, jy, r);" + ] + }, + { + "name": "pixel", + "path": "pixel", + "signature": "pixel(x, y, painting = { width, height, pixels })", + "doc": "Return a pixel from the main buffer or from a specified buffer.", + "source": "lib/graph.mjs:757", + "examples": [ + "disks/blur.mjs:36 const srcColor = pixel(...xy, system.painting);", + "disks/blur.mjs:50 const sampleCol = pixel(...sampleXY, system.painting);", + "disks/colplay.mjs:100 const color = pixel(x, y, system.painting);" + ] + }, + { + "name": "plot", + "path": "plot", + "signature": "plot(x, y)", + "doc": "Where a pixel is a region in which we draw from the upper left corner. (2D)", + "source": "lib/graph.mjs:1639", + "examples": [ + "disks/bootpics.mjs:184 plot(ox + x, oy + y);", + "disks/doodle.mjs:78 plot(x, y);", + "disks/jas.mjs:557 plot(ox + fx * scale, oy + fy * scale);" + ] + }, + { + "name": "flood", + "path": "flood", + "signature": "flood(x, y, fillColor = c)", + "doc": "Fill pixels with a color using a flood fill technique.", + "source": "lib/graph.mjs:778", + "examples": [ + "disks/colplay.mjs:116 flood(x, y, [255, 255, 255, 127]);", + "disks/fill.mjs:30 flood(pen.x, pen.y, floodColor);" + ] + }, + { + "name": "compositeLayers", + "path": "compositeLayers", + "signature": "compositeLayers(layers)", + "doc": "GPU-accelerated multi-layer compositing", + "source": "lib/graph.mjs:6087", + "examples": [] + }, + { + "name": "batchedEffects", + "path": "batchedEffects", + "signature": "batchedEffects(options = {})", + "doc": "GPU-accelerated batched effects (zoom+scroll+contrast+brightness in one pass)", + "source": "lib/graph.mjs:6182", + "examples": [] + }, + { + "name": "point", + "path": "point", + "signature": "point(...args)", + "doc": "Plots a single pixel within the panned coordinate space. Basically a wrapper over plot, which should ultimately be renamed to set? Accepts x, y or {x, y}", + "source": "lib/graph.mjs:1725", + "examples": [ + "disks/i.mjs:102 point(...g.point).line(...g.line[0], ...g.line[1]);", + "disks/nail.mjs:166 point(p) {", + "disks/pline.mjs:137 const p = point(e);" + ] + }, + { + "name": "line", + "path": "line", + "signature": "line(x1, y1, x2, y2) | line({x, y}, {x, y}) | line(x1, y1, x2, y2, thickness)", + "doc": "Draw a 1px line between two points in the current ink.", + "source": "lib/graph.mjs:3205", + "examples": [ + "disks/ableton.mjs:124 line(x, y + v, x + 1, y + v);", + "disks/ableton.mjs:527 const blurbY = labelY + 12; // label line (10 tall) + 2 gap", + "disks/ableton.mjs:528 const buttonsY = blurbY + 14; // blurb line (10 tall) + 4 gap" + ] + }, + { + "name": "lineAngle", + "path": "lineAngle", + "signature": "lineAngle(x1, y1, dist, degrees)", + "doc": "Draws a line from a point at a distance... with an angle in degrees.", + "source": "lib/graph.mjs:3407", + "examples": [ + "disks/ucla-5.mjs:32 8. - [] Learning `lineAngle(x1, y1, dist, degrees)`", + "disks/ucla-6-turtle.mjs:14 8. - [] Learning `lineAngle(x1, y1, dist, degrees)`" + ] + }, + { + "name": "pline", + "path": "pline", + "signature": "pline(coords, thickness, shader)", + "doc": "", + "source": "lib/graph.mjs:3596", + "examples": [] + }, + { + "name": "setBufferAlpha", + "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", + "examples": [ + "disks/line.mjs:215 setBufferAlpha(nopaint.buffer, strokeAlpha);" + ] + }, + { + "name": "pppline", + "path": "pppline", + "signature": "pppline(points, shader)", + "doc": "Takes an array of pixel coords `{x, y}` and filters out L shapes. Note: It checks the previous, current, and next pixel and requires a minimum set of 3 before it removes anything. Draws a regular `line` if only two pixels are provided. Transcribed from: https://rickyhan.com/jekyll/update/2018/11/22/pixel-art-algorithm-pixel-perfect.html", + "source": "lib/graph.mjs:3346", + "examples": [] + }, + { + "name": "oval", + "path": "oval", + "signature": "oval(x0, y0, radiusX, radiusY, filled = false, thickness = 1, precision,)", + "doc": "", + "source": "lib/graph.mjs:3542", + "examples": [ + "disks/flower-eater.mjs:255 oval(x, y + 2, rx, max(1, floor(rx * 0.3)), true);", + "disks/lmn-flower.mjs:32 oval(screen.width / 2, screen.height / 2, 20, 10, true )", + "disks/lmn-petal.mjs:29 oval(screen.width / 2, screen.height / 2, 80, 17, true);" + ] + }, + { + "name": "circle", + "path": "circle", + "signature": "circle(x0, y0, radius, filled = false, thickness, precision)", + "doc": "", + "source": "lib/graph.mjs:3477", + "examples": [ + "disks/butterflies.mjs:535 circle(voice.pointerX, voice.pointerY, 11);", + "disks/butterflies.mjs:537 circle(voice.pointerX, voice.pointerY, 7);", + "disks/butterflies.mjs:540 circle(reader.x, reader.y, 8);" + ] + }, + { + "name": "pie", + "path": "pie", + "signature": "pie(x0, y0, radius, startAngle, endAngle, precision = 3)", + "doc": "Draw a filled pie slice / wedge (for pie charts, progress indicators, etc.) startAngle and endAngle are in radians, 0 = right, PI/2 = down, etc.", + "source": "lib/graph.mjs:3521", + "examples": [] + }, + { + "name": "tri", + "path": "tri", + "signature": "tri(x1, y1, x2, y2, x3, y3, mode = \"fill\")", + "doc": "Triangle from three points; mode \"fill\" or \"outline\".", + "source": "lib/graph.mjs:4566", + "examples": [ + "disks/blank.mjs:480 tri(proj[a][0], proj[a][1], proj[b][0], proj[b][1], proj[c][0], proj[c][1]);", + "disks/blank.mjs:481 tri(proj[a][0], proj[a][1], proj[c][0], proj[c][1], proj[d][0], proj[d][1]);", + "disks/blank.mjs:496 tri(x0, y0, x1, y1, x2, y2);" + ] + }, + { + "name": "poly", + "path": "poly", + "signature": "poly(coords)", + "doc": "Draws a series of 1px lines without overlapping / overdrawing points. TODO: Add closed mode? Example: ink(handPalette.w).poly([...w, w[0]]);", + "source": "lib/graph.mjs:3571", + "examples": [ + "disks/toss.mjs:496 poly(points);", + "disks/toss.mjs:514 poly(points);", + "disks/visualizer.mjs:1614 poly(waveformPoints);" + ] + }, + { + "name": "box", + "path": "box", + "signature": "box(x, y, size) | box(x, y, w, h) | box(x, y, w, h, mode) | box({x, y, w, h}, mode)", + "doc": "Rectangle. `mode` is \"fill\" (default), \"outline\", \"inline\", or \"fill*center\" / \"outline*center\" to draw from the center.", + "source": "lib/graph.mjs:3815", + "examples": [ + "disks/1v1.mjs:1490 box(x * squareSize, y * squareSize, squareSize, squareSize);", + "disks/1v1.mjs:1516 box(x * squareSize, y * squareSize, squareSize, squareSize);", + "disks/a-star.mjs:819 box(x * CELL_WIDTH, y * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT);" + ] + }, + { + "name": "shape", + "path": "shape", + "signature": "shape(x1, y1, x2, y2, ...) | shape([[x, y], [x, y], ...], filled = true)", + "doc": "Rasterize a filled or outlined polygon from point pairs.", + "source": "lib/graph.mjs:4132", + "examples": [ + "disks/flower-eater.mjs:314 shape([[girlX - 7, headY + 16], [girlX + 6, headY + 16],", + "disks/oldwipppps.mjs:369 shape([", + "disks/oldwipppps.mjs:377 shape([" + ] + }, + { + "name": "grid", + "path": "grid", + "signature": "grid({ box: { x, y, w: cols, h: rows }, transform: { scale, angle, width: twidth, height: theight, anchor }, centers = [], }, buffer,)", + "doc": "", + "source": "lib/graph.mjs:4690", + "examples": [ + "disks/plot.mjs:190 grid(" + ] + }, + { + "name": "draw", + "path": "draw", + "signature": "draw(drawing, x, y, scale = 1, angle = 0, thickness = 1)", + "doc": "Draw a stored vector drawing (from `drawing`/store) at a position.", + "source": "lib/graph.mjs:5053", + "examples": [ + "disks/icon.mjs:6 - [] Use angle: `draw(drawing, x, y, scale = 1, angle = 0)`", + "disks/wgr.mjs:540 draw({ x, y, pressure }) {" + ] + }, + { + "name": "setShowClippedWireframes", + "path": "setShowClippedWireframes", + "signature": "setShowClippedWireframes(enabled)", + "doc": "Function to toggle wireframe rendering", + "source": "lib/graph.mjs:8174", + "examples": [ + "disks/1v1.mjs:1464 setShowClippedWireframes(showWireframes);", + "disks/1v1.mjs:2023 setShowClippedWireframes(showWireframes);", + "disks/fps.mjs:326 setShowClippedWireframes(showWireframes);" + ] + }, + { + "name": "clearWireframeBuffer", + "path": "clearWireframeBuffer", + "signature": "clearWireframeBuffer()", + "doc": "Function to clear wireframe buffer (called at start of frame)", + "source": "lib/graph.mjs:8179", + "examples": [ + "disks/1v1.mjs:1469 clearWireframeBuffer();", + "disks/fps.mjs:331 clearWireframeBuffer();" + ] + }, + { + "name": "drawBufferedWireframes", + "path": "drawBufferedWireframes", + "signature": "drawBufferedWireframes()", + "doc": "Function to draw all buffered wireframes (called at end of frame)", + "source": "lib/graph.mjs:8357", + "examples": [ + "disks/1v1.mjs:1603 drawBufferedWireframes();", + "disks/fps.mjs:402 drawBufferedWireframes();" + ] + }, + { + "name": "getRenderStats", + "path": "getRenderStats", + "signature": "getRenderStats()", + "doc": "Function to get current render stats", + "source": "lib/graph.mjs:8197", + "examples": [] + }, + { + "name": "printLine", + "path": "printLine", + "signature": "printLine(text, font, startX, startY, blockWidth = 6, scale = 1, xOffset = 0, thickness = 1, rotation = 0, fontMetadata = null, fallbackFont = null,)", + "doc": "", + "source": "lib/graph.mjs:5316", + "examples": [] + }, + { + "name": "pan", + "path": "pan", + "signature": "pan(x, y)", + "doc": "", + "source": "lib/graph.mjs:1795", + "examples": [ + "disks/baktok.mjs:133 pan(noshake ? 0 : choose(-1, 0, 1), noshake ? 0 : choose(-1, 0, 1));", + "disks/baktok.mjs:136 pan(noshake ? 0 : choose(-1, 0, 1), noshake ? 0 : choose(-1, 0, 1));", + "disks/clock.mjs:3757 const pan = 0; // Centered pan (could be enhanced later)" + ] + }, + { + "name": "unpan", + "path": "unpan", + "signature": "unpan()", + "doc": "Undo pan(x, y).", + "source": "lib/graph.mjs:1805", + "examples": [ + "disks/baktok.mjs:135 unpan();", + "disks/baktok.mjs:138 unpan();", + "disks/lmn-petal.mjs:32 unpan();" + ] + }, + { + "name": "savepan", + "path": "savepan", + "signature": "savepan()", + "doc": "Save the local transform.", + "source": "lib/graph.mjs:1813", + "examples": [ + "disks/field.mjs:58 savepan();" + ] + }, + { + "name": "loadpan", + "path": "loadpan", + "signature": "loadpan()", + "doc": "Restore it.", + "source": "lib/graph.mjs:1818", + "examples": [ + "disks/field.mjs:69 loadpan();" + ] + }, + { + "name": "mask", + "path": "mask", + "signature": "mask({ x, y, width, height })", + "doc": "Clip drawing to a rectangle until unmask().", + "source": "lib/graph.mjs:1826", + "examples": [ + "disks/chat.mjs:1278 mask({", + "disks/clocks.mjs:152 mask({", + "disks/commits.mjs:326 mask({ x: 0, y: topMargin, width: w, height: chatHeight });" + ] + }, + { + "name": "unmask", + "path": "unmask", + "signature": "unmask()", + "doc": "Lift the clip set by mask().", + "source": "lib/graph.mjs:1831", + "examples": [ + "disks/chat.mjs:2154 unmask();", + "disks/clocks.mjs:205 unmask(); // End masking", + "disks/commits.mjs:485 unmask();" + ] + }, + { + "name": "steal", + "path": "steal", + "signature": "steal(x, y, width, height)", + "doc": "", + "source": "lib/graph.mjs:7733", + "examples": [] + }, + { + "name": "scroll", + "path": "scroll", + "signature": "scroll(dx = 0, dy = 0)", + "doc": "Scroll the entire pixel buffer by x and/or y pixels with wrapping", + "source": "lib/graph.mjs:5599", + "examples": [ + "disks/gulmo.mjs:36 let scroll = 0; // continuous forward scroll (fractional rows)", + "disks/mibo.mjs:54 let scrollPhase = 0; // accumulated depth-scroll (fly-forward), advances in onSim", + "disks/mugs.mjs:35 let scroll = 0; // Negative scroll (like colors.mjs)" + ] + }, + { + "name": "flip", + "path": "flip", + "signature": "flip(horizontal = false, vertical = false)", + "doc": "Mirror the screen.", + "source": "lib/graph.mjs:5725", + "examples": [ + "disks/textfence.mjs:229 const completed = flip();", + "disks/textfence.mjs:243 if (flip()) {" + ] + }, + { + "name": "spin", + "path": "spin", + "signature": "spin(steps = 0, anchorX = null, anchorY = null)", + "doc": "Each ring rotates by exactly 'steps' pixels, preserving all data", + "source": "lib/graph.mjs:6274", + "examples": [] + }, + { + "name": "sort", + "path": "sort", + "signature": "sort()", + "doc": "Pixel-sort the screen — a glitch effect.", + "source": "lib/graph.mjs:7623", + "examples": [] + }, + { + "name": "zoom", + "path": "zoom", + "signature": "zoom(level = 1, anchorX = 0.5, anchorY = 0.5)", + "doc": "Zoom the entire pixel buffer with 1.0 as neutral (no change) level < 1.0 zooms out, level > 1.0 zooms in, level = 1.0 does nothing anchorX, anchorY: 0.0 = top/left, 0.5 = center, 1.0 = bottom/right Uses bilinear sampling with hard-edge thresholding for smooth scaling with crisp output", + "source": "lib/graph.mjs:6706", + "examples": [ + "disks/wgr.mjs:482 zoom(amt) {" + ] + }, + { + "name": "suck", + "path": "suck", + "signature": "suck(strength = 1, centerX, centerY)", + "doc": "Radial displacement transformation with pixel-perfect nearest neighbor sampling Creates discrete, lossless pixel movement without blur or center holes", + "source": "lib/graph.mjs:7121", + "examples": [] + }, + { + "name": "blur", + "path": "blur", + "signature": "blur(strength = 1, quality = \"medium\")", + "doc": "Efficient Gaussian blur using separable filtering with linear sampling optimization Creates smooth blur effect by applying horizontal then vertical Gaussian convolution", + "source": "lib/graph.mjs:7273", + "examples": [ + "disks/notepat.mjs:8583 blur(0.5);" + ] + }, + { + "name": "sharpen", + "path": "sharpen", + "signature": "sharpen(strength = 1)", + "doc": "Apply sharpening filter to enhance edges and details strength: 0 = no sharpening, 1 = normal sharpening, >1 = aggressive sharpening", + "source": "lib/graph.mjs:7514", + "examples": [ + "disks/notepat.mjs:5872 sharpen(sharpenAmount);" + ] + }, + { + "name": "invert", + "path": "invert", + "signature": "invert()", + "doc": "Invert every pixel's color.", + "source": "lib/graph.mjs:2094", + "examples": [] + }, + { + "name": "contrast", + "path": "contrast", + "signature": "contrast(level = 1.0)", + "doc": "Adjust the contrast of the pixel buffer level: 1.0 = no change, >1.0 = more contrast, <1.0 = less contrast", + "source": "lib/graph.mjs:1939", + "examples": [ + "disks/oldwipppps.mjs:55 filter: contrast(1.2) brightness(1.1) saturate(1.1)" + ] + }, + { + "name": "shear", + "path": "shear", + "signature": "shear(shearX = 0, shearY = 0)", + "doc": "KidPix-style shear function shearX: horizontal shear factor (positive = right lean, negative = left lean) shearY: vertical shear factor (positive = down lean, negative = up lean)", + "source": "lib/graph.mjs:7748", + "examples": [] + }, + { + "name": "resetScrollState", + "path": "resetScrollState", + "signature": "resetScrollState()", + "doc": "Reset scroll accumulators - called when pieces change", + "source": "lib/graph.mjs:5582", + "examples": [] + }, + { + "name": "noise16", + "path": "noise16", + "signature": "noise16()", + "doc": "", + "source": "lib/graph.mjs:5446", + "examples": [ + "disks/graphics.mjs:29 painting(6, 6, ({ noise16 }) => noise16()),", + "disks/ordfish.mjs:213 ready <= GO ? noise16(0) : wipe();", + "disks/wgr.mjs:185 noise16();" + ] + }, + { + "name": "noise16DIGITPAIN", + "path": "noise16DIGITPAIN", + "signature": "noise16DIGITPAIN()", + "doc": "", + "source": "lib/graph.mjs:5471", + "examples": [ + "disks/hell_-world.mjs:944 noise16DIGITPAIN();", + "disks/images.mjs:20 imgToExport = painting(256, 256, ({noise16DIGITPAIN}) => noise16DIGITPAIN());", + "disks/noise.mjs:34 noise16DIGITPAIN();" + ] + }, + { + "name": "noise16Aesthetic", + "path": "noise16Aesthetic", + "signature": "noise16Aesthetic()", + "doc": "", + "source": "lib/graph.mjs:5496", + "examples": [ + "disks/login-pattern.mjs:15 noise16Aesthetic().ink(0, 100).box(0, 0, width, height);" + ] + }, + { + "name": "noise16Sotce", + "path": "noise16Sotce", + "signature": "noise16Sotce()", + "doc": "", + "source": "lib/graph.mjs:5521", + "examples": [] + }, + { + "name": "noiseTinted", + "path": "noiseTinted", + "signature": "noiseTinted(tint, amount, saturation)", + "doc": "", + "source": "lib/graph.mjs:5546", + "examples": [ + "disks/aframe.mjs:83 noiseTinted([0, 0, 0], 0.9, 0.1);", + "disks/decode.mjs:57 noiseTinted([189, 164, 166], 0.8, 0.6);", + "disks/noise.mjs:23 noiseTinted(hud.currentStatusColor(), 0.15, 0.1);" + ] + }, + { + "name": "pasteWithAlpha", + "path": "pasteWithAlpha", + "signature": "pasteWithAlpha(source, x, y, alpha)", + "doc": "🎨 Alpha-blended paste for crossfade compositing", + "source": "lib/disk.mjs:6669", + "examples": [ + "disks/merry-fade.mjs:85 if (outAlpha > 0) pasteWithAlpha(outgoing, 0, 0, outAlpha);" + ] + }, + { + "name": "kidlisp", + "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", + "examples": [ + "disks/$.mjs:369 kidlisp(", + "disks/cross-tab-test.mjs:76 kidlisp(", + "disks/kidlisp-in-js.mjs:64 kidlisp(0, 0, hw, h, \"(wipe green) (ink blue) (box 20 20 60 60)\");" + ] + }, + { + "name": "synth", + "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", + "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 });", + "disks/1but.mjs:67 sound.synth({ type: \"triangle\", tone: 1319, attack: 0.06, decay: 0.1, duration: 0.1, volume: 0.2 });" + ] + }, + { + "name": "play", + "path": "sound.play", + "signature": "sound.play(sfx, options, callbacks)", + "doc": "Play a loaded sample or sfx by id.", + "source": "lib/disk.mjs:13039", + "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 });", + "disks/booted-by.mjs:211 sound.play(startupSfx); // Play startup sound..." + ] + }, + { + "name": "Button", + "path": "ui.Button", + "signature": "new ui.Button(x, y, w, h) | new ui.Button({ x, y, w, h })", + "doc": "A button. In paint: btn.paint((b) => { ink(b.down ? \"yellow\" : \"gray\").box(b.box) }). In act: btn.act(e, { push: () => {}, down: () => {}, up: () => {}, cancel: () => {} }). Pass pens() as the 3rd arg to act for multitouch. Rebuild buttons in `reframed`.", + "source": "lib/ui.mjs:264", + "examples": [ + "disks/arena.mjs:3554 up: { btn: new ui.Button(moveX + btnSize + gap, moveY, btnSize, btnSize), key: \"forward\", label: \"↑\", isArrow: true },", + "disks/arena.mjs:3556 left: { btn: new ui.Button(moveX, moveY + btnSize + gap, btnSize, btnSize), key: \"left\", label: \"←\", isArrow: true },", + "disks/arena.mjs:3559 view: { btn: new ui.Button(actionX, actionY, btnSizeWide, btnSize), key: \"view\", label: \"VIEW\", color: [150, 110, 200], isView: true }," + ] + }, + { + "name": "TextButton", + "path": "ui.TextButton", + "signature": "new ui.TextButton(text = \"Button\", pos = { x: 0, y: 0 }, typeface = TYPEFACE_UI, gap = null)", + "doc": "A labelled button sized to its text.", + "source": "lib/ui.mjs:875", + "examples": [ + "disks/ads.mjs:35 chatBtn = new ui.TextButton(\"CHAT\", { center: \"x\", y: Math.floor(screen.height * 0.55), screen });", + "disks/amail.mjs:100 inboxBtn = new ui.TextButton(\"inbox\", { screen });", + "disks/amail.mjs:101 sentBtn = new ui.TextButton(\"sent\", { screen });" + ] + }, + { + "name": "label", + "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", + "examples": [ + "disks/$.mjs:642 hud.label(`Previewing ${entry.codeText}`, \"cyan\");", + "disks/amail.mjs:82 hud.label(\"amail\");", + "disks/audio.mjs:81 hud.label(\"audio\");" + ] + }, + { + "name": "write", + "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", + "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);", + "disks/arena.mjs:3094 write(msg1, { x: Math.floor(screen.width / 2 - msg1.length * 2), y: bannerY }, undefined, undefined, false, \"MatrixChunky8\");" + ] + }, + { + "name": "randInt", + "path": "num.randInt", + "signature": "num.randInt(n)", + "doc": "Random integer in [0, n].", + "source": "lib/num.mjs:225", + "examples": [ + "disks/bgm.mjs:26 if (params.length === 0) params[0] = num.randInt(trackCount - 1);", + "disks/crayon.mjs:86 let numDots = num.randInt(minNumDots, maxNumDots);", + "disks/dafu.mjs:101 const speed = num.randInt(20) / 10 + 0.4;" + ] + }, + { + "name": "randIntRange", + "path": "num.randIntRange", + "signature": "num.randIntRange(low, high)", + "doc": "Random integer in [low, high].", + "source": "lib/num.mjs:248", + "examples": [ + "disks/ant.mjs:56 num.randIntRange(margin, gridW - margin),", + "disks/ant.mjs:57 num.randIntRange(margin, gridH - margin),", + "disks/ant.mjs:249 angle: num.randIntRange(0, 360) * (Math.PI / 180)," + ] + }, + { + "name": "lerp", + "path": "num.lerp", + "signature": "num.lerp(a, b, amount)", + "doc": "Linear interpolation a→b by t.", + "source": "lib/num.mjs:342", + "examples": [ + "disks/blur.mjs:62 avgCol[i] = num.lerp(srcColor[i], avgCol[i], lerpAmt);", + "disks/bubble.mjs:116 radius = num.lerp(radius, currentParams.radius, 0.15);", + "disks/bubble.mjs:125 parameterDisplay.fadeAlpha = num.lerp(parameterDisplay.fadeAlpha, 0, 0.02);" + ] + }, + { + "name": "clamp", + "path": "num.clamp", + "signature": "num.clamp(value, low, high)", + "doc": "Clamp a value between min and max.", + "source": "lib/num.mjs:328", + "examples": [ + "disks/bubble.mjs:80 const normalizedDistance = num.clamp(", + "disks/bubble.mjs:104 const volume = num.clamp(normalizedEdgeDistance + 0.3, 0.3, 1.0);", + "disks/bubble.mjs:291 const normalizedDistance = num.clamp(" + ] + }, + { + "name": "dist", + "path": "num.dist", + "signature": "num.dist(x1, y1, x2, y2)", + "doc": "Distance between two points.", + "source": "lib/num.mjs:280", + "examples": [ + "disks/bubble.mjs:79 let distanceFromCenter = num.dist(pointer.x, pointer.y, centerX, centerY);", + "disks/bubble.mjs:290 let distanceFromCenter = num.dist(e.x, e.y, centerX, centerY);", + "disks/bubble.mjs:356 let distanceFromCenter = num.dist(e.x, e.y, centerX, centerY);" + ] + }, + { + "name": "map", + "path": "num.map", + "signature": "num.map(num, inMin, inMax, outMin, outMax)", + "doc": "Map a value from one range to another.", + "source": "lib/num.mjs:348", + "examples": [ + "disks/bubble.mjs:87 const pan = num.map(pointer.x, 0, screen.width, -1, 1);", + "disks/bubble.mjs:90 const rise = num.map(pointer.y, 0, screen.height, 4.0, 0.2);", + "disks/bubble.mjs:93 const bubbleRadius = num.map(normalizedDistance, 0, 1, 3, 25);" + ] + }, + { + "name": "radians", + "path": "num.radians", + "signature": "num.radians(deg = 0)", + "doc": "Degrees to radians.", + "source": "lib/num.mjs:317", + "examples": [ + "disks/moods.mjs:595 const osc = (sin(num.radians(bounceCount % 360)) + 1) / 2;", + "disks/staka.mjs:105 const radians = $.num.radians(ball.angle);", + "disks/staka.mjs:141 const radians = $.num.radians(ball.angle);" + ] + }, + { + "name": "Box", + "path": "geo.Box", + "signature": "new geo.Box(x, y, w, h)", + "doc": "An axis-aligned rectangle with .x .y .w .h and .contains({x, y}).", + "source": "lib/geo.mjs:41", + "examples": [ + "disks/hell_-world.mjs:873 prevBtn.box = new geo.Box(", + "disks/hell_-world.mjs:901 nextBtn.box = new geo.Box(", + "disks/notepat.mjs:8806 buttons[label].box = new geo.Box(...geometry);" + ] + }, + { + "name": "Circle", + "path": "geo.Circle", + "signature": "new geo.Circle(x, y, radius = 8)", + "doc": "A circle with .contains(point).", + "source": "lib/geo.mjs:8", + "examples": [ + "disks/balls.mjs:17 ball.circle = new geo.Circle(screen.width / 2, screen.height / 2, 8);", + "disks/staka.mjs:53 ball.circle = new geo.Circle(screen.width / 2, screen.height / 2 - 100, radius);" + ] + }, + { + "name": "screen", + "path": "screen", + "signature": "screen.width, screen.height, screen.pixels, screen.center", + "doc": "The canvas. Read width/height in paint(); never write screen.pixels directly (writes can silently drop) — draw into your own painting buffer and paste() it.", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "pen", + "path": "pen", + "signature": "pen.x, pen.y, pen.drawing, pen.delta", + "doc": "The single primary pointer; null when there is none. Read in paint()/sim().", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "pens", + "path": "pens", + "signature": "pens() → [{ x, y, id, drawing }]", + "doc": "Every active pointer, for multitouch. Pass to btn.act(e, callbacks, pens()).", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "event", + "path": "act(e)", + "signature": "e.is(\"touch\") | e.is(\"draw\") | e.is(\"lift\") | e.is(\"keyboard:down:space\") | e.is(\"reframed\") ; e.x, e.y, e.delta, e.key", + "doc": "Events arrive in act({ event: e, ... }). Pointer: touch → draw → lift. Keys: keyboard:down:, keyboard:up:.", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "sim", + "path": "sim", + "signature": "function sim({ ... }) — runs 120 times per second", + "doc": "Physics and timers go here, not in paint(); paint() runs at display rate and only when something needs painting.", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "needsPaint", + "path": "needsPaint", + "signature": "needsPaint()", + "doc": "Ask for another paint() when the piece is static and something changed.", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "painting", + "path": "painting", + "signature": "painting(w, h, (api) => { ... }) → buffer", + "doc": "Make an offscreen buffer by drawing into it; show it later with paste(buffer, x, y).", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "help.choose", + "path": "help.choose", + "signature": "help.choose(...items)", + "doc": "Pick one item at random.", + "source": "lib/help.mjs", + "examples": [] + }, + { + "name": "help.repeat", + "path": "help.repeat", + "signature": "help.repeat(n, (i) => { ... })", + "doc": "Call a function n times.", + "source": "lib/help.mjs", + "examples": [] + } + ] +} diff --git a/easel/src/claude-server.mjs b/easel/src/claude-server.mjs index c30db50849..20d376a9c2 100644 --- a/easel/src/claude-server.mjs +++ b/easel/src/claude-server.mjs @@ -30,6 +30,7 @@ // prompt — but the prompt, not the kernel, is the boundary. See // docs/local-contract.md. import { spawn } from "node:child_process"; +import { mcpConfig, SERVER_NAME } from "./tools.mjs"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { createInterface } from "node:readline"; @@ -77,9 +78,12 @@ export class ClaudeServer extends EventEmitter { environment = {}, developerInstructions = "", model = DEFAULT_CLAUDE_MODEL, + // Easel's native tools (ac_api, ac_examples, ac_outline, ac_symbol). + tools = true, }) { super(); this.cwd = cwd; + this.tools = tools; this.command = command; this.args = args; this.environment = environment; @@ -241,6 +245,16 @@ export class ClaudeServer extends EventEmitter { "--add-dir", this.cwd, ]; + // Easel'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 + // "what does circle take?" would cost the round trip the tool exists to + // save. + if (this.tools) { + args.push("--mcp-config", JSON.stringify(mcpConfig(this.cwd))); + args.push("--allowedTools", `mcp__${SERVER_NAME}`); + } if (this.developerInstructions) { args.push("--append-system-prompt", this.developerInstructions); } diff --git a/easel/src/tools.mjs b/easel/src/tools.mjs new file mode 100644 index 0000000000..71d533b666 --- /dev/null +++ b/easel/src/tools.mjs @@ -0,0 +1,370 @@ +#!/usr/bin/env node +// tools.mjs — the native tools Easel hands the engine, as an MCP server on stdio. +// +// Read the transcripts of the first ten Easel 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 +// that the previous session had already built and thrown away. A minute or two +// per session before the first edit, on a surface that does not change. +// +// So the picture is built once (`bin/build-api-map.mjs` → `context/api.json`) +// and served here, alongside the two things a large piece needs that `sed -n` +// gives badly: an outline of its symbols, and one symbol's source by name. +// Four tools, all read-only, all answered from local files: +// +// ac_api what does `circle` / `sound.synth` / `ui.Button` take? +// ac_examples show me pieces that call it +// ac_outline what is in notepat.mjs, and where? +// ac_symbol give me `setupButtons` from notepat.mjs +// +// This is an MCP server without a dependency: the protocol is JSON-RPC over +// newline-delimited stdio, and a server that only lists and calls tools needs +// four methods. Claude Code is pointed at it with `--mcp-config`, which is the +// one hole `--strict-mcp-config` leaves open on purpose. +// +// node src/tools.mjs --cwd /path/to/workspace +import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; +import { dirname, join, resolve, relative, isAbsolute } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createInterface } from "node:readline"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EASEL = join(HERE, ".."); + +export const SERVER_NAME = "ac"; +export const TOOL_PREFIX = `mcp__${SERVER_NAME}__`; + +// Where the pieces are: the repo's disks folder when the workspace is the +// Aesthetic Computer repository, the workspace itself anywhere else. +export function disksDir(cwd) { + const inRepo = join(cwd, "system", "public", "aesthetic.computer", "disks"); + return existsSync(inRepo) ? inRepo : cwd; +} + +export function loadMap() { + try { + return JSON.parse(readFileSync(join(EASEL, "context", "api.json"), "utf8")); + } catch { + return { entries: [] }; + } +} + +// ---------------------------------------------------------------- ac_api ---- + +function scoreEntry(entry, terms) { + const path = entry.path.toLowerCase(); + const name = entry.name.toLowerCase(); + const hay = `${path} ${entry.signature} ${entry.doc}`.toLowerCase(); + let score = 0; + for (const term of terms) { + if (name === term || path === term) score += 100; + else if (name.startsWith(term) || path.endsWith(`.${term}`)) score += 40; + else if (path.includes(term)) score += 20; + else if (hay.includes(term)) score += 5; + } + return score; +} + +export function apiLookup(map, query, { limit = 6 } = {}) { + const terms = String(query || "") + .toLowerCase() + .split(/[^a-z0-9_.$]+/) + .filter(Boolean); + if (!terms.length) { + return map.entries.map((entry) => `${entry.path} — ${entry.signature}`).join("\n"); + } + const ranked = map.entries + .map((entry) => [scoreEntry(entry, terms), entry]) + .filter(([score]) => score > 0) + .sort((a, b) => b[0] - a[0]) + .slice(0, limit) + .map(([, entry]) => entry); + if (!ranked.length) return `Nothing in the API map matches "${query}". Call ac_api with no query for the full list.`; + return ranked.map(describe).join("\n\n"); +} + +function describe(entry) { + const lines = [`${entry.path}`, ` ${entry.signature}`]; + if (entry.doc) lines.push(` ${entry.doc}`); + if (entry.source) lines.push(` source: ${entry.source}`); + for (const example of entry.examples || []) lines.push(` e.g. ${example}`); + return lines.join("\n"); +} + +// ----------------------------------------------------------- ac_examples ---- + +export function examples(cwd, symbol, { limit = 12 } = {}) { + const dir = disksDir(cwd); + const leaf = String(symbol || "").trim(); + if (!leaf) return "Name a symbol, e.g. synth or ui.Button."; + const escaped = leaf.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const needle = new RegExp(leaf.includes(".") ? `\\b${escaped}\\b` : `(? /\.(mjs|lisp)$/.test(f)) + .sort(); + const found = []; + for (const file of files) { + let text; + try { + text = readFileSync(join(dir, file), "utf8"); + } catch { + continue; + } + const lines = text.split("\n"); + let perFile = 0; + for (let i = 0; i < lines.length && found.length < limit && perFile < 3; i++) { + if (!needle.test(lines[i])) continue; + if (/^\s*\/\//.test(lines[i])) continue; + found.push(`${file}:${i + 1} ${lines[i].trim().slice(0, 160)}`); + perFile++; + } + if (found.length >= limit) break; + } + if (!found.length) return `No piece in ${relative(cwd, dir) || "."} calls ${leaf}.`; + return found.join("\n"); +} + +// ------------------------------------------------- ac_outline / ac_symbol ---- + +// Resolve a piece name or path to a file, never outside the workspace. +export function resolvePiece(cwd, file) { + const raw = String(file || "").trim(); + if (!raw) throw new Error("name a file, e.g. notepat.mjs"); + const candidates = []; + if (isAbsolute(raw)) candidates.push(raw); + else { + candidates.push(resolve(cwd, raw)); + const dir = disksDir(cwd); + candidates.push(resolve(dir, raw)); + if (!/\.\w+$/.test(raw)) { + candidates.push(resolve(dir, `${raw}.mjs`), resolve(dir, `${raw}.lisp`)); + } + } + for (const path of candidates) { + const inside = !relative(cwd, path).startsWith(".."); + if (inside && existsSync(path) && statSync(path).isFile()) return path; + } + throw new Error(`no such piece: ${raw}`); +} + +const SYMBOL_LINE = [ + // export function paint({ ... }) { + [/^(?:export\s+)?(?:async\s+)?function\s*\*?\s*([\w$]+)\s*\(/, "function"], + // const foo = (a, b) => { / const foo = function + [/^(?:export\s+)?(?:const|let|var)\s+([\w$]+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*=>/, "function"], + [/^(?:export\s+)?(?:const|let|var)\s+([\w$]+)\s*=\s*(?:async\s+)?function\b/, "function"], + [/^(?:export\s+)?class\s+([\w$]+)/, "class"], + // top-level data: const buttons = { / let x = 0 + [/^(?:export\s+)?(?:const|let|var)\s+([\w$]+)\s*=/, "value"], + [/^export\s*\{([^}]*)\}/, "exports"], + [/^import\b.*from\s+["']([^"']+)["']/, "import"], +]; + +// The top-level shape of a JavaScript piece: every symbol declared at column +// zero, with the line where it starts and where the next one begins. Column +// zero is the whole heuristic — pieces are written flat, one function after +// another, and nesting inside a symbol is exactly what the outline is meant to +// skip over. +export function outline(source) { + const lines = source.split("\n"); + const items = []; + for (let i = 0; i < lines.length; i++) { + const text = lines[i]; + if (!text || /^\s/.test(text)) continue; + for (const [pattern, kind] of SYMBOL_LINE) { + const match = text.match(pattern); + if (!match) continue; + items.push({ name: match[1].trim(), kind, line: i + 1 }); + break; + } + } + for (let i = 0; i < items.length; i++) { + const next = items[i + 1]; + let end = next ? next.line - 1 : lines.length; + // Trim trailing blank lines and comments off the span so a symbol's source + // ends where its brace does, not where the next one's header comment begins. + while (end > items[i].line && /^\s*(\/\/.*)?$/.test(lines[end - 1])) end--; + items[i].end = end; + } + return { lines: lines.length, items }; +} + +export function outlineText(cwd, file) { + const path = resolvePiece(cwd, file); + const source = readFileSync(path, "utf8"); + if (path.endsWith(".lisp")) { + const heads = source + .split("\n") + .map((text, i) => [text, i + 1]) + .filter(([text]) => /^\(/.test(text)) + .map(([text, line]) => `${String(line).padStart(5)} ${text.slice(0, 80)}`); + return [`${relative(cwd, path)} — ${source.split("\n").length} lines, ${heads.length} top-level forms`, ...heads].join("\n"); + } + const { lines, items } = outline(source); + const rows = items + .filter((item) => item.kind !== "import") + .map((item) => `${String(item.line).padStart(5)}-${String(item.end).padEnd(5)} ${item.kind.padEnd(8)} ${item.name}`); + const imports = items.filter((item) => item.kind === "import").map((item) => item.name); + return [ + `${relative(cwd, path)} — ${lines} lines, ${rows.length} top-level symbols${imports.length ? `, imports: ${imports.join(", ")}` : ""}`, + "lines kind name", + ...rows, + ].join("\n"); +} + +export function symbolText(cwd, file, name, { maxLines = 220 } = {}) { + const path = resolvePiece(cwd, file); + const source = readFileSync(path, "utf8"); + const wanted = String(name || "").trim(); + const { items } = outline(source); + const item = items.find((entry) => entry.name === wanted) || items.find((entry) => entry.name.startsWith(wanted)); + if (!item) { + const near = items.filter((entry) => entry.name.toLowerCase().includes(wanted.toLowerCase())).map((entry) => entry.name); + return `No top-level symbol "${wanted}" in ${relative(cwd, path)}.${near.length ? ` Close: ${near.join(", ")}.` : " Call ac_outline to see what is there."}`; + } + const lines = source.split("\n"); + const span = lines.slice(item.line - 1, item.end); + const clipped = span.length > maxLines; + const shown = clipped ? span.slice(0, maxLines) : span; + const numbered = shown.map((text, i) => `${String(item.line + i).padStart(5)} ${text}`); + const head = `${relative(cwd, path)}:${item.line}-${item.end} ${item.kind} ${item.name}`; + const tail = clipped ? `… ${span.length - maxLines} more lines; read ${relative(cwd, path)} from line ${item.line + maxLines} for the rest.` : ""; + return [head, ...numbered, tail].filter(Boolean).join("\n"); +} + +// ------------------------------------------------------------- the server ---- + +export const TOOLS = [ + { + name: "ac_api", + description: + "Look up the Aesthetic Computer piece API: what a drawing primitive, sound call, ui class or event takes, with its runtime signature and real call sites from existing pieces. Use this before grepping graph.mjs or disk.mjs. No query lists every name.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "A name or words: circle, synth, button, text, multitouch." }, + }, + }, + }, + { + name: "ac_examples", + description: + "Lines from existing pieces that call a symbol (e.g. synth, pline, ui.Button, hud.label), as file:line. Use instead of grep -rn over disks/.", + inputSchema: { + type: "object", + properties: { + symbol: { type: "string", description: "The function or dotted name to find call sites for." }, + limit: { type: "integer", description: "Max lines (default 12)." }, + }, + required: ["symbol"], + }, + }, + { + name: "ac_outline", + description: + "The top-level symbols of a piece with their line spans — functions, classes, values, exports. Use before reading a large piece so you can fetch one symbol with ac_symbol instead of paging through it.", + inputSchema: { + type: "object", + properties: { + file: { type: "string", description: "A piece name (notepat), file (notepat.mjs) or path." }, + }, + required: ["file"], + }, + }, + { + name: "ac_symbol", + description: "The full source of one top-level symbol from a piece, numbered by line. Pairs with ac_outline.", + inputSchema: { + type: "object", + properties: { + file: { type: "string", description: "A piece name, file or path." }, + name: { type: "string", description: "The symbol to fetch, as ac_outline listed it." }, + }, + required: ["file", "name"], + }, + }, +]; + +export function callTool(name, args, { cwd, map }) { + switch (name) { + case "ac_api": + return apiLookup(map, args?.query); + case "ac_examples": + return examples(cwd, args?.symbol, { limit: Number(args?.limit) || 12 }); + case "ac_outline": + return outlineText(cwd, args?.file); + case "ac_symbol": + return symbolText(cwd, args?.file, args?.name); + default: + throw new Error(`unknown tool: ${name}`); + } +} + +// One JSON-RPC message in, at most one out. Notifications get nothing back. +export function handle(message, context) { + const { id, method, params } = message; + const reply = (result) => (id === undefined ? null : { jsonrpc: "2.0", id, result }); + const fail = (code, text) => (id === undefined ? null : { jsonrpc: "2.0", id, error: { code, message: text } }); + switch (method) { + case "initialize": + return reply({ + protocolVersion: params?.protocolVersion || "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: `easel-${SERVER_NAME}`, version: "1" }, + }); + case "notifications/initialized": + case "notifications/cancelled": + return null; + case "ping": + return reply({}); + case "tools/list": + return reply({ tools: TOOLS }); + case "tools/call": { + try { + const text = callTool(params?.name, params?.arguments || {}, context); + return reply({ content: [{ type: "text", text }] }); + } catch (error) { + return reply({ content: [{ type: "text", text: String(error?.message || error) }], isError: true }); + } + } + default: + return fail(-32601, `method not found: ${method}`); + } +} + +export function serve({ cwd = process.cwd(), input = process.stdin, output = process.stdout } = {}) { + const context = { cwd: resolve(cwd), map: loadMap() }; + const lines = createInterface({ input, crlfDelay: Infinity }); + lines.on("line", (line) => { + if (!line.trim()) return; + let message; + try { + message = JSON.parse(line); + } catch { + output.write(`${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } })}\n`); + return; + } + const response = handle(message, context); + if (response) output.write(`${JSON.stringify(response)}\n`); + }); + return lines; +} + +// 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. +export function mcpConfig(cwd) { + return { + mcpServers: { + [SERVER_NAME]: { + command: process.execPath, + args: [fileURLToPath(import.meta.url), "--cwd", cwd], + }, + }, + }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const at = process.argv.indexOf("--cwd"); + serve({ cwd: at >= 0 ? process.argv[at + 1] : process.cwd() }); +} diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index e4593c8f79..bd7c52c989 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import process from "node:process"; @@ -132,6 +132,7 @@ function autopublishRoute() { // the repository that holds them. Naming a path that isn't there teaches the // model to ignore the whole instruction. const STYLE_GUIDES = [ + ["system/public/aesthetic.computer/disks/CLAUDE.md", "the piece authoring guide"], ["SCREEN.md", "how a piece draws on the AC canvas"], ["HAND.md", "how the code reads"], ]; @@ -161,11 +162,22 @@ function styleInstructions() { ([file]) => existsSync(file), ); if (source.length === 0) return []; - const named = source - .map(([file, subject]) => `${file} (${subject})`) - .join(" and "); + // Inlined rather than named. Every session so far opened by reading these + // three files — three tool calls and ten seconds before the first thought + // about the piece — and the bytes cost the same either way. Here they arrive + // with the first turn and are cached for every turn after it. + const inlined = source + .map(([file, subject]) => { + try { + return `## ${subject} (${path.relative(cwd, file) || file})\n\n${readFileSync(file, "utf8").trim()}`; + } catch { + return ""; + } + }) + .filter(Boolean); const lines = [ - `Style: the Aesthetic Computer guides are ${named} — read them before writing a piece, and follow them over your own defaults.`, + "Style: the Aesthetic Computer guides follow. They are the house rules for a piece and win over your own defaults. Do not re-read them from disk; they are already here.", + ...inlined, ]; // The one rule that gets broken on a first draft, inlined because a model // that skips the read still has to know it. Lua pieces draw through @@ -178,6 +190,17 @@ function styleInstructions() { return lines; } +// The native tools, named so the model reaches for them instead of the shell. +// The pattern being replaced is specific: grep graph.mjs for a signature, sed a +// window of disk.mjs, grep disks/ for a call site, page a 9,000-line piece in +// 80-line slices. Each of those is one call here. +function toolInstructions() { + if (!backend.Engine || backend.id !== "claude") return []; + return [ + "Tools: you have ac_api (the piece API — signatures, docs and real call sites for circle, line, box, write, sound.synth, ui.Button, pens, events…), ac_examples (pieces that call a symbol), ac_outline (a piece's top-level symbols with line spans) and ac_symbol (one symbol's source). Use them instead of grep/sed/head over lib/ and disks/: ask ac_api before opening graph.mjs or disk.mjs, and outline a large piece before reading any of it. Start writing the piece as soon as the request is clear — the guides above are already the context.", + ]; +} + function developerInstructions() { const account = session.handle ? `The user is signed in to Aesthetic Computer as @${session.handle}.` @@ -212,6 +235,7 @@ function developerInstructions() { ...dialect, ...styleInstructions(), "Every save of that file is pushed live to a phone that scanned the interface's QR code, so small frequent edits are better than one big rewrite.", + ...toolInstructions(), ...publishing, "Dev servers: do not stop a dev server you were asked to start; say that it is still running.", ].join("\n"); diff --git a/easel/test/claude-server.test.mjs b/easel/test/claude-server.test.mjs index 4da73ba838..17b8204486 100644 --- a/easel/test/claude-server.test.mjs +++ b/easel/test/claude-server.test.mjs @@ -139,6 +139,12 @@ 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. + 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")); + assert.deepEqual(mcp.mcpServers.ac.args.slice(1), ["--cwd", directory]); + assert.equal(flag("--allowedTools"), "mcp__ac"); assert.equal(flag("--add-dir"), directory); assert.equal(flag("--append-system-prompt"), "piece rules"); assert.ok(argv.includes("--session-id")); diff --git a/easel/test/tools.test.mjs b/easel/test/tools.test.mjs new file mode 100644 index 0000000000..e91c7f1992 --- /dev/null +++ b/easel/test/tools.test.mjs @@ -0,0 +1,133 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { apiLookup, loadMap, outline, symbolText, outlineText, examples, handle, mcpConfig, TOOLS } from "../src/tools.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(here, "..", ".."); +const server = path.join(here, "..", "src", "tools.mjs"); + +const PIECE = `// smiley, 2026 +import { thing } from "./lib/thing.mjs"; + +const RADIUS = 20; + +// Draw the face. +function paint({ wipe, ink, circle, screen }) { + wipe("blue"); + ink("yellow").circle(screen.width / 2, screen.height / 2, RADIUS, true); +} + +function act({ event: e }) { + if (e.is("touch")) grow(); +} + +const grow = () => { + // nothing yet +}; + +export { paint, act }; +`; + +function workspace() { + const root = mkdtempSync(path.join(tmpdir(), "easel-tools-")); + mkdirSync(path.join(root, "disks")); + writeFileSync(path.join(root, "smiley.mjs"), PIECE); + writeFileSync(path.join(root, "other.mjs"), `function paint({ circle }) { circle(1, 2, 3); }\nexport { paint };\n`); + return { root, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +test("the API map is built and answers the questions sessions actually asked", () => { + const map = loadMap(); + assert.ok(map.entries.length > 60, `map has ${map.entries.length} entries`); + const circle = apiLookup(map, "circle"); + assert.match(circle, /^circle\n\s+circle\(x0, y0, radius, filled/m); + assert.match(circle, /e\.g\. disks\//); + const synth = apiLookup(map, "synth"); + assert.match(synth, /sound\.synth\n\s+sound\.synth\(\{ tone = 440/); + const button = apiLookup(map, "button multitouch"); + assert.match(button, /ui\.Button/); + assert.match(apiLookup(map, "zzzznotathing"), /Nothing in the API map/); + // No query lists everything, one per line. + assert.ok(apiLookup(map, "").split("\n").length === map.entries.length); +}); + +test("outline reads a flat piece as symbols with spans", () => { + const { items, lines } = outline(PIECE); + assert.equal(lines, PIECE.split("\n").length); + const names = items.map((item) => `${item.kind}:${item.name}`); + assert.deepEqual(names, [ + "import:./lib/thing.mjs", + "value:RADIUS", + "function:paint", + "function:act", + "function:grow", + "exports:paint, act", + ]); + const paint = items.find((item) => item.name === "paint"); + assert.equal(paint.line, 7); + // The span ends at paint's closing brace, not at act's opening line. + assert.equal(paint.end, 10); +}); + +test("symbol and outline resolve a piece by bare name inside the workspace", () => { + const { root, cleanup } = workspace(); + try { + const text = symbolText(root, "smiley", "paint"); + assert.match(text, /^smiley\.mjs:7-10 {2}function paint/); + assert.match(text, /circle\(screen\.width/); + assert.match(symbolText(root, "smiley.mjs", "nope"), /No top-level symbol "nope"/); + assert.match(outlineText(root, "smiley"), /5 top-level symbols/); + assert.throws(() => outlineText(root, "../../etc/passwd"), /no such piece/); + const hits = examples(root, "circle"); + assert.match(hits, /smiley\.mjs:9/); + assert.match(hits, /other\.mjs:1/); + } finally { + cleanup(); + } +}); + +test("outline of a real large piece is a page, not a file", () => { + const text = outlineText(repo, "notepat"); + const rows = text.split("\n"); + assert.match(rows[0], /notepat\.mjs — \d+ lines, \d+ top-level symbols/); + assert.ok(rows.length > 20 && rows.length < 400, `${rows.length} rows`); +}); + +test("the JSON-RPC surface: initialize, list, call, unknown", () => { + const context = { cwd: repo, map: loadMap() }; + const init = handle({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18" } }, context); + assert.equal(init.result.protocolVersion, "2025-06-18"); + assert.deepEqual(init.result.capabilities, { tools: {} }); + assert.equal(handle({ jsonrpc: "2.0", method: "notifications/initialized" }, context), null); + const list = handle({ jsonrpc: "2.0", id: 2, method: "tools/list" }, context); + assert.deepEqual(list.result.tools.map((tool) => tool.name), ["ac_api", "ac_examples", "ac_outline", "ac_symbol"]); + assert.equal(list.result.tools, TOOLS); + const call = handle({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "ac_api", arguments: { query: "wipe" } } }, context); + assert.match(call.result.content[0].text, /^wipe\n/); + const bad = handle({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "ac_symbol", arguments: { file: "missing", name: "x" } } }, context); + assert.equal(bad.result.isError, true); + const unknown = handle({ jsonrpc: "2.0", id: 5, method: "resources/list" }, context); + assert.equal(unknown.error.code, -32601); +}); + +test("the server runs on stdio and the config points the CLI at it", async () => { + const config = mcpConfig(repo); + assert.equal(config.mcpServers.ac.command, process.execPath); + assert.deepEqual(config.mcpServers.ac.args.slice(1), ["--cwd", repo]); + const child = spawn(config.mcpServers.ac.command, config.mcpServers.ac.args, { stdio: ["pipe", "pipe", "inherit"] }); + const out = []; + child.stdout.on("data", (chunk) => out.push(chunk)); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} })}\n`); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "ac_outline", arguments: { file: "notepat" } } })}\n`); + child.stdin.end(); + await new Promise((done) => child.on("close", done)); + const replies = Buffer.concat(out).toString().trim().split("\n").map((line) => JSON.parse(line)); + assert.equal(replies.length, 2); + assert.equal(replies[0].result.serverInfo.name, "easel-ac"); + assert.match(replies[1].result.content[0].text, /notepat\.mjs — \d+ lines/); +}); -- 2.51.2