From 4ea99c78a5c8fc197453a81c192784256a34cb1c Mon Sep 17 00:00:00 2001 From: Alex Gorischek Date: Tue, 27 Oct 2026 15:43:14 -0700 Subject: [PATCH 1/3] feat(host): add ace demo with scripted teammates and mocked data ace demo runs a throwaway loopback host in a temporary home: a made-up acme-shop project, two seeded channels, synthetic teammates who type, talk, react and ask, and a faux demo/agent model that answers from the script. It uses no keys, models, Tailscale or GitHub account, and removes its home when it stops. Why: apps/host/src/catalog.ts#const current | ace demo must never read Tailscale, gh or the real home; its people and data are all made up Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1cbb2262-7e0b-45cd-a29e-d68510600884 --- apps/host/src/catalog.ts | 5 +- apps/host/src/cli.ts | 45 ++++++- apps/host/src/config.ts | 8 ++ apps/host/src/demo-data.ts | 262 ++++++++++++++++++++++++++++++++++++ apps/host/src/demo-model.ts | 59 ++++++++ apps/host/src/demo.ts | 8 +- apps/host/src/gateway.ts | 17 ++- apps/host/src/github.ts | 3 + apps/host/src/helper.ts | 1 + apps/host/src/keys.ts | 2 + apps/host/src/seed.ts | 23 ++-- docs/demo.md | 9 ++ 12 files changed, 423 insertions(+), 19 deletions(-) create mode 100644 apps/host/src/demo-data.ts create mode 100644 apps/host/src/demo-model.ts diff --git a/apps/host/src/catalog.ts b/apps/host/src/catalog.ts index 443a3e51..d2538018 100644 --- a/apps/host/src/catalog.ts +++ b/apps/host/src/catalog.ts @@ -73,12 +73,15 @@ const root = join(home, "channels"); * The local human, named by their Tailscale login so they are the same participant on every * host. Without Tailscale the host is single-user and the OS account name stands in. */ -const current = identity(); +const current: { login?: string; tailnet?: string } = config.demo + ? { tailnet: "demo" } + : identity(); export const user = config.user || current.login || userInfo().username; /** * Channels belong to the tailnet they were created on, and a host shows only its own tailnet's. * Like `user`, it is fixed when the process starts; without Tailscale the host has no channels. + * ace demo's throwaway host has a tailnet of its own, so it needs no Tailscale. */ export const tailnet = current.tailnet; diff --git a/apps/host/src/cli.ts b/apps/host/src/cli.ts index 5a70404d..4f4b8444 100755 --- a/apps/host/src/cli.ts +++ b/apps/host/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun -import { existsSync, realpathSync } from "node:fs"; +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { parseArgs } from "node:util"; @@ -13,6 +14,8 @@ import { Connection, request } from "./client"; import { config } from "./config"; import * as directory from "./directory"; import * as web from "./web"; +import { OWNER, SCRIPT } from "./demo-data"; +import { DEMO_MODEL } from "./demo-model"; import { serve } from "./gateway"; import type { GatewayClient, Watch } from "./gateway-client"; import { forget, store } from "./keys"; @@ -52,6 +55,8 @@ import { } from "./manage"; import { move } from "./move"; +const DEMO_PORT = 4150; + const USAGE = `ace — channels on this host ace new [--project ] [--name ] [--model ] @@ -96,6 +101,8 @@ const USAGE = `ace — channels on this host serve the app and its WebSocket gateway; --loopback stays off the tailnet and the directory; --demo runs scripted synthetic teammates (docs/demo.md) + ace demo [--port ${DEMO_PORT}] a throwaway host of made-up teammates, channels and + agent replies; uses no keys, models or Tailscale ace logs [--channel ] [--trace ] [--level warn] [--since 10m] [--grep ] [-n 200] [--follow] [--json] every process's logs, merged in time order ace open [--port ${config.port}] open this host's authenticated app @@ -412,6 +419,38 @@ function advice(conflict: HostConflict): string { return "Leave it running. Use an ace from the same version, or give this ace its own host: set ACE_HOME and ACE_PORT to unused values and run ace serve --loopback."; } +/** + * Runs `serve` again as a host of its own: catalog and keys read their home and identity when they + * load, and the demo must never reach the person's channels, keys or tailnet. Its home goes when it + * stops. + */ +async function runDemo(port: string): Promise { + const home = mkdtempSync(resolve(tmpdir(), "ace-demo-")); + const script = resolve(home, "demo.json"); + writeFileSync(script, `${JSON.stringify(SCRIPT, null, "\t")}\n`); + const [executable, ...args] = config.cli; + const child = Bun.spawn([executable, ...args, "serve", "--loopback", "--demo", script], { + // The app's command runs as the CLI only under its own name. + argv0: process.argv0, + stdio: ["inherit", "inherit", "inherit"], + env: { + ...process.env, + ACE_HOME: home, + ACE_CONFIG_HOME: resolve(home, "config"), + ACE_KEYCHAIN_SERVICE: "ace-demo", + ACE_PORT: port, + ACE_USER: OWNER, + ACE_MODEL: DEMO_MODEL, + ACE_DEMO: script, + }, + }); + // Ctrl-C reaches the host too; this waits for it to stop its workers before removing the home. + process.on("SIGINT", () => {}); + const code = await child.exited; + rmSync(home, { recursive: true, force: true }); + return code; +} + async function main() { if (flags.version) { return console.log( @@ -423,7 +462,7 @@ async function main() { if (flags.help || !command || command === "help") return console.log(HELP); // Shells start background jobs with SIGINT ignored and Bun keeps that, so `kill -INT` would not // end a backgrounded watch. A listener takes SIGINT back; serve shuts down with its own. - if (command !== "serve") process.on("SIGINT", () => process.exit(130)); + if (command !== "serve" && command !== "demo") process.on("SIGINT", () => process.exit(130)); if (command !== "serve" && command !== "logs") { openLog("cli"); log("info", "cli.command", { command, ...(ref ? { ref } : {}) }); @@ -607,6 +646,8 @@ async function main() { } case "serve": return serve(Number(flags.port || config.port), !!flags.loopback, flags.demo); + case "demo": + return process.exit(await runDemo(flags.port || String(DEMO_PORT))); case "key": { const name = rest[0]; if (!name || !/^[A-Z][A-Z0-9_]*$/.test(name)) { diff --git a/apps/host/src/config.ts b/apps/host/src/config.ts index 26415538..e2e35d09 100644 --- a/apps/host/src/config.ts +++ b/apps/host/src/config.ts @@ -28,11 +28,19 @@ export const config = { version: undefined as string | undefined, /** Set by ace serve --loopback; the host stays off the tailnet and the team directory. */ loopback: false, + /** ace demo's script, set only in its throwaway host, whose agents answer from the script. */ + demo: process.env.ACE_DEMO, worker: [ process.execPath, "--no-env-file", fileURLToPath(new URL("./worker.ts", import.meta.url)), ] as [string, ...string[]], + /** This command line, which ace demo runs again as its own host. */ + cli: [ + process.execPath, + "--no-env-file", + fileURLToPath(new URL("./cli.ts", import.meta.url)), + ] as [string, ...string[]], }; const ports: Record = { dev: 4141, canary: 4142 }; diff --git a/apps/host/src/demo-data.ts b/apps/host/src/demo-data.ts new file mode 100644 index 00000000..4f76bb4a --- /dev/null +++ b/apps/host/src/demo-data.ts @@ -0,0 +1,262 @@ +/** + * ace demo's mocked data: a small project, channels seeded with a team's conversation, and the + * script its teammates follow. Everything is made up and lives only in the demo's throwaway home. + */ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { AssistantEntry } from "@earendil-works/pi-durable"; + +import type { Seed, SeedItem } from "@ace/channel"; +import type { Reaction } from "@ace/channel/protocol"; + +import type { Script } from "./demo"; +import { DEMO_MODEL } from "./demo-model"; +import { git } from "./projects"; +import { importSeed } from "./seed"; + +/** + * The owner's login in the demo, so seeded messages can be theirs. Avatars come from GitHub, and + * GitHub's deleted-user placeholder is the one login that shows no real person's face. + */ +export const OWNER = "ghost"; + +const [provider, modelId] = DEMO_MODEL.split("/") as [string, string]; + +const FILES: Record = { + "README.md": "# acme-shop\n\nThe storefront and checkout for Acme's online shop.\n", + "src/cart.ts": `export type Line = { sku: string; price: number; quantity: number }; + +export function subtotal(lines: Line[]): number { + return lines.reduce((sum, line) => sum + line.price * line.quantity, 0); +} + +export function total(lines: Line[], discount = 0): number { + return Math.round((subtotal(lines) - discount) * 100) / 100; +} +`, + "src/receipt.ts": `import { type Line } from "./cart"; + +/** Each line's share of the discount, rounded to cents. */ +export function receipt(lines: Line[], discount = 0) { + const subtotal = lines.reduce((sum, line) => sum + line.price * line.quantity, 0); + return lines.map((line) => { + const amount = line.price * line.quantity; + return { ...line, amount: Math.round((amount - (discount * amount) / subtotal) * 100) / 100 }; + }); +} +`, + "src/refund.ts": `export function refund(paid: number, fraction: number): number { + return Math.round(paid * fraction * 100) / 100; +} +`, +}; + +function said( + author: string, + at: string, + text: string, + extra: { invoke?: boolean; reactions?: Reaction[] } = {}, +): SeedItem { + return { author, at: `2026-01-15T${at}:00Z`, text, ...extra }; +} + +function agent(at: string, text: string): SeedItem { + const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + return { + agent: { + kind: AssistantEntry.kind, + model: [{ + role: "assistant", + content: [{ type: "text", text }], + api: provider, + provider, + model: modelId, + usage: { ...usage, totalTokens: 0, cost: { ...usage, total: 0 } }, + stopReason: "stop", + timestamp: Date.parse(`2026-01-15T${at}:00Z`), + }], + }, + }; +} + +const SEEDS: Seed[] = [ + { + format: "ace-seed", + version: 1, + name: "checkout-totals", + summary: + "Fixing checkout totals that disagree with receipts by a cent when an order has a discount and several lines. Moving amounts to integer cents.", + chats: [{ + model: { provider, modelId }, + items: [ + said( + "mona", + "15:02", + "Support forwarded three receipts where the total is a cent off. All of them have a discount code and more than one line.", + ), + said( + OWNER, + "15:04", + "I can reproduce it with SPRING10 on a cart with three different items: we charge a cent less than the receipt's lines add up to.", + ), + said( + "octocat", + "15:05", + "the receipt rounds each line and total() rounds once, so they disagree whenever the discount splits unevenly", + { reactions: [{ from: "mona", emoji: "👍" }] }, + ), + said(OWNER, "15:06", "Where exactly do the receipt and total() round differently?", { + invoke: true, + }), + agent( + "15:07", + "They round at different points.\n\n- `total()` in `src/cart.ts` subtracts the discount from the raw subtotal and rounds once.\n- `receipt()` in `src/receipt.ts` spreads the discount across lines and rounds each line, so fractions of a cent that `total()` drops once can round up on several lines.\n\nThe smallest fix is to keep amounts in integer cents and spread the discount in whole cents, giving the remainder to the last line. Then the receipt's lines always add up to the total.", + ), + said( + "mona", + "15:09", + "integer cents gets my vote, that should also fix the refund rounding I saw last week", + { reactions: [{ from: "octocat", emoji: "🎉" }, { from: OWNER, emoji: "👍" }] }, + ), + said( + "hubot", + "15:11", + "I'll turn the three receipts from support into test cases once there's a lane", + ), + ], + }], + }, + { + format: "ace-seed", + version: 1, + name: "release-notes", + summary: "Drafting the notes for the 2.4 release, leading with saved carts.", + chats: [{ + model: { provider, modelId }, + items: [ + said( + "hubot", + "14:30", + "Draft for 2.4 is up in docs/releases/2.4.md, mostly checkout and search changes.", + ), + said("octocat", "14:32", "can we lead with saved carts? it's the one people asked for", { + reactions: [{ from: "hubot", emoji: "👍" }], + }), + said( + OWNER, + "14:35", + "Turn the merged pull requests since 2.3 into three short highlights for the top of the notes.", + { invoke: true }, + ), + agent( + "14:36", + "Three highlights for the top of 2.4:\n\n- **Saved carts.** Signed-in shoppers find their cart where they left it, on any device.\n- **Faster search.** Results appear as you type, and small typos still find the right product.\n- **Clearer checkout.** Each line shows its discount, and the total matches the receipt to the cent.", + ), + said("hubot", "14:38", "Perfect, pasting those in.", { + reactions: [{ from: "mona", emoji: "❤️" }], + }), + ], + }], + }, +]; + +const TOTALS = "checkout-totals"; +const NOTES = "release-notes"; + +export const SCRIPT: Script = { + people: { + mona: { login: "mona", github: "mona" }, + octocat: { login: "octocat", github: "octocat" }, + hubot: { login: "hubot", github: "hubot" }, + }, + steps: [ + { at: 0, who: "mona", open: true, channel: TOTALS }, + { at: 1, who: "octocat", open: true, channel: TOTALS }, + { at: 2, who: "hubot", open: true, channel: NOTES }, + { at: 3, who: "mona", type: 5, channel: TOTALS }, + { + at: 8, + who: "mona", + say: "pushed the integer cents change to my lane, can someone look before I open the PR?", + channel: TOTALS, + }, + { at: 10, who: "octocat", react: "👀", channel: TOTALS }, + { at: 11, who: "octocat", type: 5, channel: TOTALS }, + { + at: 16, + who: "octocat", + ask: "Does the cents change still handle a discount larger than the subtotal?", + thinking: + "Read total() and receipt() for a discount above the subtotal, then check what else still rounds in dollars.", + reply: + "Yes. `total()` now clamps at zero, so a discount larger than the subtotal leaves a total of 0 cents instead of a negative amount, and `receipt()` gives the remainder to the last line so the lines still add up.\n\nOne gap: `refund()` in `src/refund.ts` still works in dollars, so a partial refund of a discounted order can be a cent off. Converting it the same way would close that.", + channel: TOTALS, + }, + { at: 20, who: "hubot", type: 4, channel: NOTES }, + { + at: 24, + who: "hubot", + say: "moved saved carts to the top, the notes are ready for a look", + channel: NOTES, + }, + { at: 30, who: "mona", type: 4, channel: TOTALS }, + { + at: 34, + who: "mona", + say: "good catch on refund(), I'll convert it in the same lane", + channel: TOTALS, + }, + { at: 36, who: "hubot", open: true, channel: TOTALS }, + { at: 37, who: "hubot", react: "🎉", channel: TOTALS }, + { at: 39, who: "hubot", type: 4, channel: TOTALS }, + { + at: 43, + who: "hubot", + say: "I'll point the three support receipts at that lane as tests", + channel: TOTALS, + }, + { at: 45, who: "octocat", react: "👍", channel: TOTALS }, + { at: 50, who: "mona", away: true }, + { at: 54, who: "octocat", close: true, channel: TOTALS }, + { at: 55, who: "octocat", open: true, channel: NOTES }, + { at: 56, who: "octocat", type: 3, channel: NOTES }, + { at: 59, who: "octocat", say: "notes look good to me", channel: NOTES }, + { at: 60, who: "hubot", react: "🚀", channel: NOTES }, + { at: 66, who: "hubot", leave: true }, + { at: 70, who: "mona", leave: true }, + { at: 72, who: "octocat", leave: true }, + ], + loop: 15, +}; + +/** A Git project with the seeded channels in `home`, whose times end now. */ +export async function seed(home: string): Promise { + const dir = join(home, "acme-shop"); + for (const [path, text] of Object.entries(FILES)) { + mkdirSync(dirname(join(dir, path)), { recursive: true }); + writeFileSync(join(dir, path), text); + } + // The person's own Git config may sign commits or run hooks, which a demo must not need. + const commit = ["-c", "user.name=Ace demo", "-c", "user.email=demo@ace.invalid"]; + for ( + const args of [ + ["init", "-q", "-b", "main"], + ["add", "."], + [ + ...commit, + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "--no-verify", + "-m", + "Start acme-shop", + ], + ] + ) { + const result = await git(dir, args); + if (result.status !== 0) throw new Error(`Could not set up the demo project: ${result.stderr}`); + } + for (const value of SEEDS) await importSeed(value, { dir, now: true, shared: true }); +} diff --git a/apps/host/src/demo-model.ts b/apps/host/src/demo-model.ts new file mode 100644 index 00000000..fd71f622 --- /dev/null +++ b/apps/host/src/demo-model.ts @@ -0,0 +1,59 @@ +/** + * ace demo's only model: an agent answers a scripted ask with the step's reply, streamed like a + * model's, so the demo reads no keys and reaches no provider. + */ +import { readFileSync } from "node:fs"; + +import { + createModels, + fauxAssistantMessage, + fauxProvider, + type FauxResponseFactory, + fauxText, + fauxThinking, + type Message, + type MutableModels, +} from "@earendil-works/pi-ai"; + +import type { Script } from "./demo"; + +export const DEMO_MODEL = "demo/agent"; + +const FALLBACK = + "This is ace demo, so no model runs here: the teammates and the agent's replies come from its script. In a real channel I'd work on this in a lane of the project and answer here."; + +/** More than the runs a demo has at once; the provider takes one queued reply per call. */ +const QUEUED = 8; + +function asked(messages: Message[]): string { + const message = messages.findLast((value) => value.role === "user"); + if (!message) return ""; + if (typeof message.content === "string") return message.content; + return message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join(""); +} + +export function models(file: string): MutableModels { + const script = JSON.parse(readFileSync(file, "utf8")) as Script; + const asks = script.steps.flatMap((step) => "ask" in step && step.reply ? [step] : []); + const [provider, id] = DEMO_MODEL.split("/") as [string, string]; + const faux = fauxProvider({ + api: provider, + provider, + models: [{ id, name: "Demo agent" }], + tokensPerSecond: 30, + }); + const reply: FauxResponseFactory = (context) => { + faux.appendResponses([reply]); + const text = asked(context.messages); + const step = asks.find((value) => text.includes(value.ask)); + if (!step?.reply) return fauxAssistantMessage(FALLBACK); + return fauxAssistantMessage([ + ...step.thinking ? [fauxThinking(step.thinking)] : [], + fauxText(step.reply), + ]); + }; + faux.setResponses(Array.from({ length: QUEUED }, () => reply)); + const found = createModels(); + found.setProvider(faux.provider); + return found; +} diff --git a/apps/host/src/demo.ts b/apps/host/src/demo.ts index dcfef4e7..5d3bffb2 100644 --- a/apps/host/src/demo.ts +++ b/apps/host/src/demo.ts @@ -36,7 +36,8 @@ type Step = /** Shows as typing in the chat for this many seconds. */ | ({ type: number } & Target) | ({ say: string } & Target) - | ({ ask: string } & Target) + /** Under ace demo, the agent streams `reply`, after `thinking` when given. */ + | ({ ask: string; reply?: string; thinking?: string } & Target) /** Reacts to the chat's newest message or reply. */ | ({ react: string } & Target) ); @@ -227,6 +228,11 @@ export function read(file: string): Script { if (!UNTARGETED.has(actions[0]!) && typeof step.channel !== "string") { throw fail(`${where} needs "channel"`); } + for (const key of ["reply", "thinking"]) { + if (key in step && (actions[0] !== "ask" || typeof step[key] !== "string")) { + throw fail(`${where} may have "${key}" only as text on an ask`); + } + } } if (value.loop !== undefined && typeof value.loop !== "number") throw fail(`"loop" is seconds`); return value as Script; diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index b1d2a2d2..fb792963 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -12,6 +12,7 @@ import { Connection } from "./client"; import * as compute from "./compute"; import { config } from "./config"; import * as demo from "./demo"; +import { seed } from "./demo-data"; import { diagnostics } from "./diagnostics"; import * as asks from "./asks"; import * as directory from "./directory"; @@ -1228,6 +1229,7 @@ export async function serve(port: number, loopback = false, script?: string): Pr openLog("host"); const scripted = script === undefined ? undefined : demo.read(script); await claim(port); + if (config.demo) await seed(catalog.home); await resume(); const server = await listen(port); await Bun.write(SERVED(), String(server.port)); @@ -1270,12 +1272,15 @@ export async function serve(port: number, loopback = false, script?: string): Pr process.on("SIGTERM", () => void stop()); process.on("SIGINT", () => void stop()); // The owner's login, the channels' tailnet, and every tailnet listener are read once at startup. - cleanup.push(tailscale(catalog.tailnet, (tailnet) => { - log("info", "host.tailnet.moved", { from: catalog.tailnet, to: tailnet }); - // launchd starts Ace Helper again after a failed exit, which picks up the new tailnet. - if (config.helper) void stop(75); - else console.log(`Tailscale is on ${tailnet} now. Restart ace serve to use it.`); - })); + // ace demo's tailnet is its own, which Tailscale never reports. + if (!config.demo) { + cleanup.push(tailscale(catalog.tailnet, (tailnet) => { + log("info", "host.tailnet.moved", { from: catalog.tailnet, to: tailnet }); + // launchd starts Ace Helper again after a failed exit, which picks up the new tailnet. + if (config.helper) void stop(75); + else console.log(`Tailscale is on ${tailnet} now. Restart ace serve to use it.`); + })); + } console.log(`Ace on http://${server.hostname}:${server.port}`); // Ace Helper's console is its log file, which must not hold the token. if (!config.helper) { diff --git a/apps/host/src/github.ts b/apps/host/src/github.ts index cbd0de3c..95f7d541 100644 --- a/apps/host/src/github.ts +++ b/apps/host/src/github.ts @@ -9,6 +9,7 @@ import type { GithubPull, } from "@ace/channel/protocol"; +import { config } from "./config"; import { githubEnv } from "./keys"; import { githubRepo, type HostRequest, NOT_GITHUB } from "./protocol"; @@ -161,6 +162,8 @@ export function environment(): NodeJS.ProcessEnv { } async function gh(args: string[]): Promise { + // The demo's people and projects are made up; gh would show the real account beside them. + if (config.demo) throw new Error("ace demo doesn't use GitHub."); const path = Bun.which("gh", { PATH: process.env.PATH }); if (!path) throw new Error("Install GitHub CLI on this host, then sign in with gh auth login."); const child = Bun.spawn([path, ...args], { diff --git a/apps/host/src/helper.ts b/apps/host/src/helper.ts index a9e454bf..0152a9f0 100644 --- a/apps/host/src/helper.ts +++ b/apps/host/src/helper.ts @@ -24,6 +24,7 @@ config.version = ACE_VERSION; config.app = join(dirname(process.execPath), "..", "Resources", "app", "web"); config.services = join(dirname(process.execPath), "..", "Resources", "services"); config.worker = [process.execPath, "--worker"]; +config.cli = [process.execPath]; // The app links its command as `ace` for stable and `ace-` otherwise, as its identifier ends. config.command = cli ? basename(process.argv0) diff --git a/apps/host/src/keys.ts b/apps/host/src/keys.ts index 6d3c6cd2..e81af52c 100644 --- a/apps/host/src/keys.ts +++ b/apps/host/src/keys.ts @@ -10,6 +10,7 @@ import type { MutableModels } from "@earendil-works/pi-ai"; import { builtinModels } from "@earendil-works/pi-ai/providers/all"; import { config } from "./config"; +import * as demo from "./demo-model"; import type { CredentialStatus } from "./protocol"; const sealed = new Map(); @@ -199,6 +200,7 @@ export async function latest(name: string, fresh?: boolean): Promise Promise): MutableModels { + if (config.demo) return demo.models(config.demo); return builtinModels({ authContext: { env: lookup, diff --git a/apps/host/src/seed.ts b/apps/host/src/seed.ts index d69c6f2a..2971a442 100644 --- a/apps/host/src/seed.ts +++ b/apps/host/src/seed.ts @@ -68,14 +68,17 @@ export async function exportSeed(record: catalog.Listing, file: string): Promise } /** - * A seed as a new local channel in `dir`, or the current directory. `now` moves the seed's times - * so its newest message is now. The channel is listed only once its store holds the whole seed. + * A seed, or the seed file at `from`, as a new local channel in `dir`. `now` moves the seed's times + * so its newest message is now, and `shared` turns agent access on. The channel is listed only once + * its store holds the whole seed. */ export async function importSeed( - file: string, - options: { dir: string; name?: string; now?: boolean }, + from: string | Seed, + options: { dir: string; name?: string; now?: boolean; shared?: boolean }, ): Promise { - const seed = JSON.parse(readFileSync(resolve(file), "utf8")) as Seed; + const seed = typeof from === "string" + ? JSON.parse(readFileSync(resolve(from), "utf8")) as Seed + : from; validateSeed(seed); const record = { ...catalog.draft(await project(options.dir), seed.chats[0]!.model, options.name || seed.name), @@ -83,10 +86,12 @@ export async function importSeed( }; const { base } = catalog.paths(record.id); try { - await offline( - { ...record, named: true }, - (channel) => channel.import(seed, options.now ? shiftTo(seed, Date.now()) : 0), - ); + await offline({ ...record, named: true }, async (channel) => { + await channel.import(seed, options.now ? shiftTo(seed, Date.now()) : 0); + if (options.shared) { + await channel.handle({ op: "share", author: record.owner, shared: true }, () => {}); + } + }); return catalog.save({ ...record, named: true }); } catch (error) { rmSync(base, { recursive: true, force: true }); diff --git a/docs/demo.md b/docs/demo.md index d32b4faa..2362cfd2 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -1,5 +1,11 @@ # Demo mode +`ace demo` shows Ace with nothing of yours: a made-up `acme-shop` project, two seeded channels, and +the teammates mona, octocat and hubot, who type, talk, react and ask the channel agent, whose +answers come from the script. It runs its own loopback host on port 4150 (`--port` picks another) +in a temporary home that it removes when it stops, and uses no keys, models, Tailscale or GitHub +account. Open the sign-in link it prints. From a checkout, build the app first with `bun app build`. + `ace serve --demo ` runs scripted synthetic teammates on this host, for recordings. They are present in projects and channels, and they post, ask agents and react through ordinary channel requests, which each channel admits by its own rules: an ask needs the channel's agent @@ -40,3 +46,6 @@ Seed the channels first with `ace import` (see [channel data](data.md#seeds)). them typing for that many seconds), `say`, `ask`, and `react`, which reacts to the chat's newest message or reply. Acting opens the channel first. `chat` targets a chat other than the root. +- Under `ace demo`, an `ask` may carry the agent's `reply` and optional `thinking`. The agent answers + any message containing an ask's text with that reply, and anything else with a note that this is a + demo. From 545e588ad4ca8b5cab13dbdd5173927804c2b65d Mon Sep 17 00:00:00 2001 From: Alex Gorischek Date: Tue, 27 Oct 2026 15:53:05 -0700 Subject: [PATCH 2/3] feat(host): make ace demo a beats-demo project with delay and synthwave channels Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1cbb2262-7e0b-45cd-a29e-d68510600884 --- apps/host/src/demo-data.ts | 184 ++++++++++++++++++++++--------------- docs/demo.md | 10 +- 2 files changed, 113 insertions(+), 81 deletions(-) diff --git a/apps/host/src/demo-data.ts b/apps/host/src/demo-data.ts index 4f76bb4a..df507c30 100644 --- a/apps/host/src/demo-data.ts +++ b/apps/host/src/demo-data.ts @@ -23,31 +23,61 @@ export const OWNER = "ghost"; const [provider, modelId] = DEMO_MODEL.split("/") as [string, string]; +const PROJECT = "beats-demo"; + const FILES: Record = { - "README.md": "# acme-shop\n\nThe storefront and checkout for Acme's online shop.\n", - "src/cart.ts": `export type Line = { sku: string; price: number; quantity: number }; + "README.md": + "# beats-demo\n\nA browser drum machine: a 16-step sequencer on the Web Audio API.\n", + "src/engine.ts": `export const STEPS = 16; + +export type Track = { name: string; sample: AudioBuffer; steps: boolean[] }; -export function subtotal(lines: Line[]): number { - return lines.reduce((sum, line) => sum + line.price * line.quantity, 0); +/** Schedules a step ahead of the audio clock, so timing never waits on the UI thread. */ +export function schedule( + context: AudioContext, + tracks: Track[], + step: number, + at: number, + out: AudioNode, +): void { + for (const track of tracks) { + if (!track.steps[step]) continue; + const source = context.createBufferSource(); + source.buffer = track.sample; + source.connect(out); + source.start(at); + } } -export function total(lines: Line[], discount = 0): number { - return Math.round((subtotal(lines) - discount) * 100) / 100; +/** A sixteenth note at this tempo, in seconds. */ +export function sixteenth(bpm: number): number { + return 60 / bpm / 4; } `, - "src/receipt.ts": `import { type Line } from "./cart"; - -/** Each line's share of the discount, rounded to cents. */ -export function receipt(lines: Line[], discount = 0) { - const subtotal = lines.reduce((sum, line) => sum + line.price * line.quantity, 0); - return lines.map((line) => { - const amount = line.price * line.quantity; - return { ...line, amount: Math.round((amount - (discount * amount) / subtotal) * 100) / 100 }; - }); + "src/effects.ts": + `/** The master chain: everything the tracks play goes through here to the speakers. */ +export function master(context: AudioContext): GainNode { + const input = context.createGain(); + const compressor = context.createDynamicsCompressor(); + input.connect(compressor).connect(context.destination); + return input; } `, - "src/refund.ts": `export function refund(paid: number, fraction: number): number { - return Math.round(paid * fraction * 100) / 100; + "src/theme.css": `:root { + --background: #f5f5f4; + --pad: #d6d3d1; + --pad-on: #2563eb; + --text: #1c1917; + font-family: system-ui, sans-serif; +} + +.pad { + background: var(--pad); + border-radius: 4px; +} + +.pad[data-on] { + background: var(--pad-on); } `, }; @@ -80,49 +110,54 @@ function agent(at: string, text: string): SeedItem { }; } +const DELAY = "delay-effect"; +const THEME = "synthwave-theme"; + const SEEDS: Seed[] = [ { format: "ace-seed", version: 1, - name: "checkout-totals", - summary: - "Fixing checkout totals that disagree with receipts by a cent when an order has a discount and several lines. Moving amounts to integer cents.", + name: DELAY, + summary: "Adding a tempo-synced delay to the master chain, with feedback and mix controls.", chats: [{ model: { provider, modelId }, items: [ said( "mona", "15:02", - "Support forwarded three receipts where the total is a cent off. All of them have a discount code and more than one line.", + "Can we get a delay on the master? Something like a dotted eighth that follows the tempo.", ), said( OWNER, "15:04", - "I can reproduce it with SPRING10 on a cart with three different items: we charge a cent less than the receipt's lines add up to.", + "yes, and it should follow the BPM slider live, not only when you hit play", ), said( "octocat", "15:05", - "the receipt rounds each line and total() rounds once, so they disagree whenever the discount splits unevenly", - { reactions: [{ from: "mona", emoji: "👍" }] }, + "keep feedback under 1 or it runs away and blows out everyone's speakers 😅", + { reactions: [{ from: "mona", emoji: "😂" }] }, + ), + said( + OWNER, + "15:06", + "Add a tempo-synced delay to the master chain with feedback and mix controls.", + { invoke: true }, ), - said(OWNER, "15:06", "Where exactly do the receipt and total() round differently?", { - invoke: true, - }), agent( "15:07", - "They round at different points.\n\n- `total()` in `src/cart.ts` subtracts the discount from the raw subtotal and rounds once.\n- `receipt()` in `src/receipt.ts` spreads the discount across lines and rounds each line, so fractions of a cent that `total()` drops once can round up on several lines.\n\nThe smallest fix is to keep amounts in integer cents and spread the discount in whole cents, giving the remainder to the last line. Then the receipt's lines always add up to the total.", + "Here's the plan for `src/effects.ts`:\n\n- A `DelayNode` whose `delayTime` is a dotted eighth at the current tempo, `0.75 * 60 / bpm`.\n- A feedback `GainNode` from the delay back into itself, capped at 0.9 so repeats always die out.\n- Dry and wet gains for the mix, both feeding the compressor.\n\n`setTempo(bpm)` ramps `delayTime` with `setTargetAtTime`, so dragging the BPM slider doesn't click.", ), said( "mona", "15:09", - "integer cents gets my vote, that should also fix the refund rounding I saw last week", + "ramping the delay time is a nice touch, it'll smear like tape when you drag the tempo", { reactions: [{ from: "octocat", emoji: "🎉" }, { from: OWNER, emoji: "👍" }] }, ), said( "hubot", "15:11", - "I'll turn the three receipts from support into test cases once there's a lane", + "I'll put a mix knob next to the BPM slider once there's a lane", ), ], }], @@ -130,30 +165,30 @@ const SEEDS: Seed[] = [ { format: "ace-seed", version: 1, - name: "release-notes", - summary: "Drafting the notes for the 2.4 release, leading with saved carts.", + name: THEME, + summary: "Restyling the sequencer in a synthwave theme: neon pads on a dark grid.", chats: [{ model: { provider, modelId }, items: [ said( "hubot", "14:30", - "Draft for 2.4 is up in docs/releases/2.4.md, mostly checkout and search changes.", + "Synthwave mockup is up: dark purple, neon pink and cyan, a grid floor fading behind the pads.", ), - said("octocat", "14:32", "can we lead with saved carts? it's the one people asked for", { - reactions: [{ from: "hubot", emoji: "👍" }], - }), said( - OWNER, - "14:35", - "Turn the merged pull requests since 2.3 into three short highlights for the top of the notes.", - { invoke: true }, + "octocat", + "14:32", + "love it. can the playing step glow? pads lighting up as the playhead passes", + { reactions: [{ from: "hubot", emoji: "👍" }] }, ), + said(OWNER, "14:35", "Turn the synthwave mockup into theme tokens for src/theme.css.", { + invoke: true, + }), agent( "14:36", - "Three highlights for the top of 2.4:\n\n- **Saved carts.** Signed-in shoppers find their cart where they left it, on any device.\n- **Faster search.** Results appear as you type, and small typos still find the right product.\n- **Clearer checkout.** Each line shows its discount, and the total matches the receipt to the cent.", + "Synthwave tokens for `src/theme.css`:\n\n- `--background: #1a1033`, with a magenta-to-cyan grid fading toward the horizon\n- `--pad: #2d1b4e` and `--pad-on: #ff2bd6`, neon pink\n- `--playhead: #22d3ee`, cyan, for the step that's playing\n- `--glow: 0 0 12px var(--pad-on)` on active pads\n- `--text: #f5d0fe` in a wide monospace\n\nThe token names stay the same as the light theme's, so the sequencer's markup doesn't change.", ), - said("hubot", "14:38", "Perfect, pasting those in.", { + said("hubot", "14:38", "Perfect, wiring those in.", { reactions: [{ from: "mona", emoji: "❤️" }], }), ], @@ -161,9 +196,6 @@ const SEEDS: Seed[] = [ }, ]; -const TOTALS = "checkout-totals"; -const NOTES = "release-notes"; - export const SCRIPT: Script = { people: { mona: { login: "mona", github: "mona" }, @@ -171,58 +203,58 @@ export const SCRIPT: Script = { hubot: { login: "hubot", github: "hubot" }, }, steps: [ - { at: 0, who: "mona", open: true, channel: TOTALS }, - { at: 1, who: "octocat", open: true, channel: TOTALS }, - { at: 2, who: "hubot", open: true, channel: NOTES }, - { at: 3, who: "mona", type: 5, channel: TOTALS }, + { at: 0, who: "mona", open: true, channel: DELAY }, + { at: 1, who: "octocat", open: true, channel: DELAY }, + { at: 2, who: "hubot", open: true, channel: THEME }, + { at: 3, who: "mona", type: 5, channel: DELAY }, { at: 8, who: "mona", - say: "pushed the integer cents change to my lane, can someone look before I open the PR?", - channel: TOTALS, + say: "pushed the delay to my lane, try it on the 808 pattern at 120", + channel: DELAY, }, - { at: 10, who: "octocat", react: "👀", channel: TOTALS }, - { at: 11, who: "octocat", type: 5, channel: TOTALS }, + { at: 10, who: "octocat", react: "🔥", channel: DELAY }, + { at: 11, who: "octocat", type: 5, channel: DELAY }, { at: 16, who: "octocat", - ask: "Does the cents change still handle a discount larger than the subtotal?", + ask: "Does the delay stay in time if someone changes the tempo mid-pattern?", thinking: - "Read total() and receipt() for a discount above the subtotal, then check what else still rounds in dollars.", + "Check how setTempo updates the delay time, and what happens to repeats already in the feedback loop.", reply: - "Yes. `total()` now clamps at zero, so a discount larger than the subtotal leaves a total of 0 cents instead of a negative amount, and `receipt()` gives the remainder to the last line so the lines still add up.\n\nOne gap: `refund()` in `src/refund.ts` still works in dollars, so a partial refund of a discounted order can be a cent off. Converting it the same way would close that.", - channel: TOTALS, + "Yes. `setTempo()` ramps `delayTime` to the new dotted eighth over about 50 ms, so new repeats land on the new grid right away. Repeats already in the feedback loop keep their old spacing until they fade, which takes under a second at the default feedback of 0.4.\n\nFor a hard cut instead, `setTempo()` could dip the wet gain to zero during the ramp.", + channel: DELAY, }, - { at: 20, who: "hubot", type: 4, channel: NOTES }, + { at: 20, who: "hubot", type: 4, channel: THEME }, { at: 24, who: "hubot", - say: "moved saved carts to the top, the notes are ready for a look", - channel: NOTES, + say: "playhead glow is in, the pads pulse cyan as it passes", + channel: THEME, }, - { at: 30, who: "mona", type: 4, channel: TOTALS }, + { at: 30, who: "mona", type: 4, channel: DELAY }, { at: 34, who: "mona", - say: "good catch on refund(), I'll convert it in the same lane", - channel: TOTALS, + say: "honestly the smear when you drag the tempo is my favorite part, let's keep it", + channel: DELAY, }, - { at: 36, who: "hubot", open: true, channel: TOTALS }, - { at: 37, who: "hubot", react: "🎉", channel: TOTALS }, - { at: 39, who: "hubot", type: 4, channel: TOTALS }, + { at: 36, who: "hubot", open: true, channel: DELAY }, + { at: 37, who: "hubot", react: "🎉", channel: DELAY }, + { at: 39, who: "hubot", type: 4, channel: DELAY }, { at: 43, who: "hubot", - say: "I'll point the three support receipts at that lane as tests", - channel: TOTALS, + say: "delay plus the synthwave theme together is very 1986, I'm into it", + channel: DELAY, }, - { at: 45, who: "octocat", react: "👍", channel: TOTALS }, + { at: 45, who: "octocat", react: "😎", channel: DELAY }, { at: 50, who: "mona", away: true }, - { at: 54, who: "octocat", close: true, channel: TOTALS }, - { at: 55, who: "octocat", open: true, channel: NOTES }, - { at: 56, who: "octocat", type: 3, channel: NOTES }, - { at: 59, who: "octocat", say: "notes look good to me", channel: NOTES }, - { at: 60, who: "hubot", react: "🚀", channel: NOTES }, + { at: 54, who: "octocat", close: true, channel: DELAY }, + { at: 55, who: "octocat", open: true, channel: THEME }, + { at: 56, who: "octocat", type: 3, channel: THEME }, + { at: 59, who: "octocat", say: "the neon pink on active pads is perfect", channel: THEME }, + { at: 60, who: "hubot", react: "🚀", channel: THEME }, { at: 66, who: "hubot", leave: true }, { at: 70, who: "mona", leave: true }, { at: 72, who: "octocat", leave: true }, @@ -232,7 +264,7 @@ export const SCRIPT: Script = { /** A Git project with the seeded channels in `home`, whose times end now. */ export async function seed(home: string): Promise { - const dir = join(home, "acme-shop"); + const dir = join(home, PROJECT); for (const [path, text] of Object.entries(FILES)) { mkdirSync(dirname(join(dir, path)), { recursive: true }); writeFileSync(join(dir, path), text); @@ -251,7 +283,7 @@ export async function seed(home: string): Promise { "-q", "--no-verify", "-m", - "Start acme-shop", + `Start ${PROJECT}`, ], ] ) { diff --git a/docs/demo.md b/docs/demo.md index 2362cfd2..e80e2279 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -1,10 +1,10 @@ # Demo mode -`ace demo` shows Ace with nothing of yours: a made-up `acme-shop` project, two seeded channels, and -the teammates mona, octocat and hubot, who type, talk, react and ask the channel agent, whose -answers come from the script. It runs its own loopback host on port 4150 (`--port` picks another) -in a temporary home that it removes when it stops, and uses no keys, models, Tailscale or GitHub -account. Open the sign-in link it prints. From a checkout, build the app first with `bun app build`. +`ace demo` shows Ace with nothing of yours: a made-up `beats-demo` drum machine project, its +`delay-effect` and `synthwave-theme` channels, and the teammates mona, octocat and hubot, who type, +talk, react and ask the channel agent, whose answers come from the script. It runs its own +loopback host on port 4150 (`--port` picks another) in a temporary home that it removes when it +stops, and uses no keys, models, Tailscale or GitHub account. Open the sign-in link it prints. From a checkout, build the app first with `bun app build`. `ace serve --demo ` runs scripted synthetic teammates on this host, for recordings. They are present in projects and channels, and they post, ask agents and react through ordinary From 0f4093b0382f4b0af14efbf0965c93de310a5eaf Mon Sep 17 00:00:00 2001 From: Alex Gorischek Date: Tue, 27 Oct 2026 15:55:48 -0700 Subject: [PATCH 3/3] feat(host): cast idan and davidslater in the demo Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1cbb2262-7e0b-45cd-a29e-d68510600884 --- apps/host/src/demo-data.ts | 79 ++++++++++++++++++-------------------- docs/demo.md | 2 +- 2 files changed, 39 insertions(+), 42 deletions(-) diff --git a/apps/host/src/demo-data.ts b/apps/host/src/demo-data.ts index df507c30..9ff4eb87 100644 --- a/apps/host/src/demo-data.ts +++ b/apps/host/src/demo-data.ts @@ -123,7 +123,7 @@ const SEEDS: Seed[] = [ model: { provider, modelId }, items: [ said( - "mona", + "idan", "15:02", "Can we get a delay on the master? Something like a dotted eighth that follows the tempo.", ), @@ -133,10 +133,10 @@ const SEEDS: Seed[] = [ "yes, and it should follow the BPM slider live, not only when you hit play", ), said( - "octocat", + "davidslater", "15:05", "keep feedback under 1 or it runs away and blows out everyone's speakers 😅", - { reactions: [{ from: "mona", emoji: "😂" }] }, + { reactions: [{ from: "idan", emoji: "😂" }] }, ), said( OWNER, @@ -149,13 +149,13 @@ const SEEDS: Seed[] = [ "Here's the plan for `src/effects.ts`:\n\n- A `DelayNode` whose `delayTime` is a dotted eighth at the current tempo, `0.75 * 60 / bpm`.\n- A feedback `GainNode` from the delay back into itself, capped at 0.9 so repeats always die out.\n- Dry and wet gains for the mix, both feeding the compressor.\n\n`setTempo(bpm)` ramps `delayTime` with `setTargetAtTime`, so dragging the BPM slider doesn't click.", ), said( - "mona", + "idan", "15:09", "ramping the delay time is a nice touch, it'll smear like tape when you drag the tempo", - { reactions: [{ from: "octocat", emoji: "🎉" }, { from: OWNER, emoji: "👍" }] }, + { reactions: [{ from: "davidslater", emoji: "🎉" }, { from: OWNER, emoji: "👍" }] }, ), said( - "hubot", + "davidslater", "15:11", "I'll put a mix knob next to the BPM slider once there's a lane", ), @@ -171,15 +171,15 @@ const SEEDS: Seed[] = [ model: { provider, modelId }, items: [ said( - "hubot", + "davidslater", "14:30", "Synthwave mockup is up: dark purple, neon pink and cyan, a grid floor fading behind the pads.", ), said( - "octocat", + "idan", "14:32", "love it. can the playing step glow? pads lighting up as the playhead passes", - { reactions: [{ from: "hubot", emoji: "👍" }] }, + { reactions: [{ from: "davidslater", emoji: "👍" }] }, ), said(OWNER, "14:35", "Turn the synthwave mockup into theme tokens for src/theme.css.", { invoke: true, @@ -188,8 +188,8 @@ const SEEDS: Seed[] = [ "14:36", "Synthwave tokens for `src/theme.css`:\n\n- `--background: #1a1033`, with a magenta-to-cyan grid fading toward the horizon\n- `--pad: #2d1b4e` and `--pad-on: #ff2bd6`, neon pink\n- `--playhead: #22d3ee`, cyan, for the step that's playing\n- `--glow: 0 0 12px var(--pad-on)` on active pads\n- `--text: #f5d0fe` in a wide monospace\n\nThe token names stay the same as the light theme's, so the sequencer's markup doesn't change.", ), - said("hubot", "14:38", "Perfect, wiring those in.", { - reactions: [{ from: "mona", emoji: "❤️" }], + said("davidslater", "14:38", "Perfect, wiring those in.", { + reactions: [{ from: "idan", emoji: "❤️" }], }), ], }], @@ -198,26 +198,24 @@ const SEEDS: Seed[] = [ export const SCRIPT: Script = { people: { - mona: { login: "mona", github: "mona" }, - octocat: { login: "octocat", github: "octocat" }, - hubot: { login: "hubot", github: "hubot" }, + idan: { login: "idan", github: "idan" }, + davidslater: { login: "davidslater", github: "davidslater" }, }, steps: [ - { at: 0, who: "mona", open: true, channel: DELAY }, - { at: 1, who: "octocat", open: true, channel: DELAY }, - { at: 2, who: "hubot", open: true, channel: THEME }, - { at: 3, who: "mona", type: 5, channel: DELAY }, + { at: 0, who: "idan", open: true, channel: DELAY }, + { at: 1, who: "davidslater", open: true, channel: DELAY }, + { at: 3, who: "idan", type: 5, channel: DELAY }, { at: 8, - who: "mona", + who: "idan", say: "pushed the delay to my lane, try it on the 808 pattern at 120", channel: DELAY, }, - { at: 10, who: "octocat", react: "🔥", channel: DELAY }, - { at: 11, who: "octocat", type: 5, channel: DELAY }, + { at: 10, who: "davidslater", react: "🔥", channel: DELAY }, + { at: 11, who: "davidslater", type: 5, channel: DELAY }, { at: 16, - who: "octocat", + who: "davidslater", ask: "Does the delay stay in time if someone changes the tempo mid-pattern?", thinking: "Check how setTempo updates the delay time, and what happens to repeats already in the feedback loop.", @@ -225,39 +223,38 @@ export const SCRIPT: Script = { "Yes. `setTempo()` ramps `delayTime` to the new dotted eighth over about 50 ms, so new repeats land on the new grid right away. Repeats already in the feedback loop keep their old spacing until they fade, which takes under a second at the default feedback of 0.4.\n\nFor a hard cut instead, `setTempo()` could dip the wet gain to zero during the ramp.", channel: DELAY, }, - { at: 20, who: "hubot", type: 4, channel: THEME }, + { at: 22, who: "davidslater", open: true, channel: THEME }, + { at: 23, who: "davidslater", type: 4, channel: THEME }, { - at: 24, - who: "hubot", + at: 27, + who: "davidslater", say: "playhead glow is in, the pads pulse cyan as it passes", channel: THEME, }, - { at: 30, who: "mona", type: 4, channel: DELAY }, + { at: 30, who: "idan", type: 4, channel: DELAY }, { at: 34, - who: "mona", + who: "idan", say: "honestly the smear when you drag the tempo is my favorite part, let's keep it", channel: DELAY, }, - { at: 36, who: "hubot", open: true, channel: DELAY }, - { at: 37, who: "hubot", react: "🎉", channel: DELAY }, - { at: 39, who: "hubot", type: 4, channel: DELAY }, + { at: 36, who: "davidslater", open: true, channel: DELAY }, + { at: 37, who: "davidslater", react: "🎉", channel: DELAY }, + { at: 39, who: "davidslater", type: 4, channel: DELAY }, { at: 43, - who: "hubot", + who: "davidslater", say: "delay plus the synthwave theme together is very 1986, I'm into it", channel: DELAY, }, - { at: 45, who: "octocat", react: "😎", channel: DELAY }, - { at: 50, who: "mona", away: true }, - { at: 54, who: "octocat", close: true, channel: DELAY }, - { at: 55, who: "octocat", open: true, channel: THEME }, - { at: 56, who: "octocat", type: 3, channel: THEME }, - { at: 59, who: "octocat", say: "the neon pink on active pads is perfect", channel: THEME }, - { at: 60, who: "hubot", react: "🚀", channel: THEME }, - { at: 66, who: "hubot", leave: true }, - { at: 70, who: "mona", leave: true }, - { at: 72, who: "octocat", leave: true }, + { at: 45, who: "idan", react: "😎", channel: DELAY }, + { at: 50, who: "idan", open: true, channel: THEME }, + { at: 51, who: "idan", type: 3, channel: THEME }, + { at: 54, who: "idan", say: "the neon pink on active pads is perfect", channel: THEME }, + { at: 56, who: "davidslater", react: "🚀", channel: THEME }, + { at: 62, who: "idan", away: true }, + { at: 66, who: "davidslater", leave: true }, + { at: 70, who: "idan", leave: true }, ], loop: 15, }; diff --git a/docs/demo.md b/docs/demo.md index e80e2279..db2055fb 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -1,7 +1,7 @@ # Demo mode `ace demo` shows Ace with nothing of yours: a made-up `beats-demo` drum machine project, its -`delay-effect` and `synthwave-theme` channels, and the teammates mona, octocat and hubot, who type, +`delay-effect` and `synthwave-theme` channels, and the teammates idan and davidslater, who type, talk, react and ask the channel agent, whose answers come from the script. It runs its own loopback host on port 4150 (`--port` picks another) in a temporary home that it removes when it stops, and uses no keys, models, Tailscale or GitHub account. Open the sign-in link it prints. From a checkout, build the app first with `bun app build`.