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..9ff4eb87 --- /dev/null +++ b/apps/host/src/demo-data.ts @@ -0,0 +1,291 @@ +/** + * 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 PROJECT = "beats-demo"; + +const FILES: Record = { + "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[] }; + +/** 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); + } +} + +/** A sixteenth note at this tempo, in seconds. */ +export function sixteenth(bpm: number): number { + return 60 / bpm / 4; +} +`, + "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/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); +} +`, +}; + +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 DELAY = "delay-effect"; +const THEME = "synthwave-theme"; + +const SEEDS: Seed[] = [ + { + format: "ace-seed", + version: 1, + name: DELAY, + summary: "Adding a tempo-synced delay to the master chain, with feedback and mix controls.", + chats: [{ + model: { provider, modelId }, + items: [ + said( + "idan", + "15:02", + "Can we get a delay on the master? Something like a dotted eighth that follows the tempo.", + ), + said( + OWNER, + "15:04", + "yes, and it should follow the BPM slider live, not only when you hit play", + ), + said( + "davidslater", + "15:05", + "keep feedback under 1 or it runs away and blows out everyone's speakers 😅", + { reactions: [{ from: "idan", emoji: "😂" }] }, + ), + said( + OWNER, + "15:06", + "Add a tempo-synced delay to the master chain with feedback and mix controls.", + { invoke: true }, + ), + agent( + "15:07", + "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( + "idan", + "15:09", + "ramping the delay time is a nice touch, it'll smear like tape when you drag the tempo", + { reactions: [{ from: "davidslater", emoji: "🎉" }, { from: OWNER, emoji: "👍" }] }, + ), + said( + "davidslater", + "15:11", + "I'll put a mix knob next to the BPM slider once there's a lane", + ), + ], + }], + }, + { + format: "ace-seed", + version: 1, + name: THEME, + summary: "Restyling the sequencer in a synthwave theme: neon pads on a dark grid.", + chats: [{ + model: { provider, modelId }, + items: [ + said( + "davidslater", + "14:30", + "Synthwave mockup is up: dark purple, neon pink and cyan, a grid floor fading behind the pads.", + ), + said( + "idan", + "14:32", + "love it. can the playing step glow? pads lighting up as the playhead passes", + { reactions: [{ from: "davidslater", emoji: "👍" }] }, + ), + said(OWNER, "14:35", "Turn the synthwave mockup into theme tokens for src/theme.css.", { + invoke: true, + }), + agent( + "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("davidslater", "14:38", "Perfect, wiring those in.", { + reactions: [{ from: "idan", emoji: "❤️" }], + }), + ], + }], + }, +]; + +export const SCRIPT: Script = { + people: { + idan: { login: "idan", github: "idan" }, + davidslater: { login: "davidslater", github: "davidslater" }, + }, + steps: [ + { 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: "idan", + say: "pushed the delay to my lane, try it on the 808 pattern at 120", + channel: DELAY, + }, + { at: 10, who: "davidslater", react: "🔥", channel: DELAY }, + { at: 11, who: "davidslater", type: 5, channel: DELAY }, + { + at: 16, + 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.", + reply: + "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: 22, who: "davidslater", open: true, channel: THEME }, + { at: 23, who: "davidslater", type: 4, channel: THEME }, + { + at: 27, + who: "davidslater", + say: "playhead glow is in, the pads pulse cyan as it passes", + channel: THEME, + }, + { at: 30, who: "idan", type: 4, channel: DELAY }, + { + at: 34, + who: "idan", + say: "honestly the smear when you drag the tempo is my favorite part, let's keep it", + 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: "davidslater", + say: "delay plus the synthwave theme together is very 1986, I'm into it", + channel: DELAY, + }, + { 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, +}; + +/** A Git project with the seeded channels in `home`, whose times end now. */ +export async function seed(home: string): Promise { + 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); + } + // 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 ${PROJECT}`, + ], + ] + ) { + 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..db2055fb 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 `beats-demo` drum machine project, its +`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`. + `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.