@homie-rocks/studio 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/scaffold.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * package.json @homie-rocks/studio and wrangler, pinned
10
10
  * games/<id>/ one folder per game (game.json, index.html, src/)
11
11
  * music/ videos/ manifests in the repo; the big files go to the studio's own storage
12
- * (R2) once it is added with `homie-studio storage add`
12
+ * (R2) once it is added with `homie-studio storage add` (media move, deploy)
13
13
  * posts/ the studio's news and drops (markdown; the site's Posts, with feeds)
14
14
  * site/ the studio's Worker (pages, rooms), its look (theme.json), its D1 migrations (no R2
15
15
  * binding until the studio adds storage: a free Cloudflare account
@@ -55,7 +55,7 @@ export function slugify(name) {
55
55
  * No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
56
56
  * the binding names and keep it linked.
57
57
  */
58
- export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root' }) {
58
+ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false }) {
59
59
  const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
60
60
  : { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
61
61
  const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
@@ -75,6 +75,9 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
75
75
  migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
76
76
  d1_databases: [{ binding: 'DB', database_name: d1, ...(d1Id ? { database_id: d1Id } : {}), migrations_dir: at.migrations }],
77
77
  ...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }] } : {}),
78
+ // Workers AI, only when a server's AI guides think with it (agents_brain workers-ai; deploy adds it, 0.17.0).
79
+ // Never in Previews: they do not inherit it, so a Preview's guides answer from the game's script.
80
+ ...(ai ? { ai: { binding: 'AI' } } : {}),
78
81
  vars: { STUDIO_NAME: name },
79
82
  observability: { enabled: true },
80
83
  previews: { vars: { STUDIO_NAME: name, HOMIE_PREVIEW: '1' }, durable_objects: { bindings: rooms } },
@@ -102,7 +105,7 @@ directory lists its games; homie.rocks does not host them.
102
105
  | Path | What it is |
103
106
  | --- | --- |
104
107
  | \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`. |
105
- | \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`, served from the site itself (files up to 25 MiB) or, for larger media, from the studio's storage once it has storage (see below; \`npx --no-install homie-studio media put <file>\`). Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
108
+ | \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`. Without storage the site serves each file itself (up to 25 MiB). Once the studio has storage (see below), its big media (over 1 MiB, or left out of git) lives in its own R2: every deploy uploads it, checks it by SHA-256 and serves it from R2 at the same address. Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
106
109
  | \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
107
110
  | \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`) and its D1 migrations. |
108
111
  | \`wrangler.jsonc\` | The Worker's Cloudflare config (the Worker, D1, the Table and Lobby Durable Objects, and \`previews\` for branch Previews), at the root, where Cloudflare's Workers Builds reads it. A studio made before 0.10.0 keeps it in \`site/\` and deploys from a computer; every command finds either. |
@@ -142,10 +145,16 @@ studio's pinned copy, never a registry lookup of the bare name.
142
145
  the D1 migrations and deploys (it never creates or refuses anything); on every other branch it runs
143
146
  \`npm run build\` and \`npx wrangler preview\`, a Preview URL with its own rooms. The live site claims itself
144
147
  in the homie.rocks directory the first time it is read, so nothing is stored by hand.
145
- - \`npx --no-install homie-studio storage add\` — only when the studio needs large media (songs,
146
- videos): an R2 bucket for \`media put\`, served at \`/media/<key>\`. Cloudflare asks for a
147
- payment method on the account before R2 works (its first 10 GB a month are free), so this
148
- is a separate step the person agrees to; nothing else needs it.
148
+ - \`npx --no-install homie-studio storage add\` — only when the studio has songs or videos: an R2
149
+ bucket on the studio's own Cloudflare account. Cloudflare asks for a payment method on the account
150
+ before R2 works, so this is a separate step the person agrees to; nothing else needs it. R2 has no
151
+ egress fees; storage is free up to 10 GB-month, then US$0.015 per GB-month.
152
+ - \`npx --no-install homie-studio media move\` (\`--dry-run\` first) — with storage, the big files of
153
+ published songs and videos (over 1 MiB, or left out of git; studio.json \`media.r2Over\` changes the
154
+ size) go to R2: each is uploaded, read back and checked by SHA-256, and only then does the site stop
155
+ carrying it; it keeps its address, and the file stays in this folder. Every \`npm run deploy\` does
156
+ it too; once a file is in R2 (the committed manifest says so), a deploy from another computer or
157
+ Workers Builds still serves it. \`media list\` says where each file is served from.
149
158
  - \`npx --no-install homie-studio publish\` — list this studio's games in the homie.rocks directory
150
159
  (or call the Homie MCP tool \`studio_publish\`).
151
160
  - \`npx --no-install homie-studio stats\` — the studio's own numbers, for its owner: visits, Play presses,
@@ -288,6 +297,16 @@ studio's pinned copy, never a registry lookup of the bare name.
288
297
  - **Agent passes:** \`npx --no-install homie-studio agents pass <id> --label Claude [--server <server>]\` gives an
289
298
  AI its way into a seat (shown once; \`agents revoke <pass>\` ends it). It sits with \`POST /<id>/api/agent\`
290
299
  (Bearer pass), only in a room with people in it, and never on a humans-only server.
300
+ - **AI guides that talk (0.17.0):** a beginner server's guides get a brain. A game's own words for them are
301
+ \`games/<id>/agents.json\` (its vocabulary: goals, lines, the asks a player taps; the build checks it), and
302
+ \`useAgents\` from \`@homie-rocks/studio/agents\` is the host's side: a view per guide, the scripted floor, goals
303
+ for the hands, lines drawn from the vocabulary (\`NETPLAY.md\` section 18; Ember Vale is the reference). The
304
+ brain is the server's: \`agents brain <id> <server> workers-ai\` (the studio's own Workers AI; \`npm run deploy\`
305
+ binds it; at most \`--budget\` neurons a day, 8,000 by default) or \`owner-key\` (the owner's own key, set with
306
+ \`agents brain key\` on this computer, never in a chat; a dollar cap a day). The first time AI talk is turned on
307
+ it only ASKS. The AI never types: it picks a goal and a line id; the relay drops anything else. With no AI, over
308
+ budget, or between decisions, the game's \`decide\` plays. \`agent_sit\` (the local MCP) puts the owner's own
309
+ Claude in a guide's seat.
291
310
 
292
311
  ## Continuing a build from the Claude app
293
312
 
@@ -437,7 +456,7 @@ site/.wrangler/
437
456
  games/*/.port/
438
457
  # Screenshots from check and look runs.
439
458
  .checks/
440
- # Large media lives in this studio's storage (R2, after storage add) or is served by the site; the manifests beside it are committed.
459
+ # Large media lives in this studio's storage (R2, after storage add: media move) or is served by the site; the manifests beside it (which name each file's R2 copy) are committed.
441
460
  # Working files of the music and video skills (frames, captures, provider answers) stay on this computer.
442
461
  music/**/work/
443
462
  videos/**/work/
@@ -491,9 +510,9 @@ export function studioFiles({ name, slug, homie, template = false }) {
491
510
  'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
492
511
  '.gitignore': GITIGNORE,
493
512
  'games/README.md': 'One folder per game. A new studio has none (its home page says "First game coming soon"). Start one with `npx --no-install homie-studio game new <id> --from gem-rush` when the person asks for a copy of the starter, or once their game is planned.\n',
494
- 'music/README.md': 'Songs, game scores, loops and stems, one folder each (`music/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/music/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
513
+ 'music/README.md': 'Songs, game scores, loops and stems, one folder each (`music/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/music/<slug>/`. Without storage the site serves files up to 25 MiB itself. With storage (`npx --no-install homie-studio storage add`), the big ones live in the studio\'s R2: `npx --no-install homie-studio media move` and every deploy put them there, checked by SHA-256, at the same address. Never into git.\n',
495
514
  'music/manifest.json': '{ "v": 1, "items": [] }\n',
496
- 'videos/README.md': 'Trailers, music videos and cutscenes, one folder each (`videos/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/videos/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
515
+ 'videos/README.md': 'Trailers, music videos and cutscenes, one folder each (`videos/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/videos/<slug>/`. Without storage the site serves files up to 25 MiB itself. With storage (`npx --no-install homie-studio storage add`), the big ones live in the studio\'s R2: `npx --no-install homie-studio media move` and every deploy put them there, checked by SHA-256, at the same address. Never into git.\n',
497
516
  'videos/manifest.json': '{ "v": 1, "items": [] }\n',
498
517
  'posts/README.md': POSTS_README,
499
518
  'site/theme.json': themeFile(slug),
package/lib/servers.mjs CHANGED
@@ -14,14 +14,23 @@
14
14
  * homie-studio agents pass <game|any> --label "<Name>" [--server <id>] [--hands self|host] [--role party|guide|player] [--days 7]
15
15
  * homie-studio agents passes [<game>] every pass (never a secret)
16
16
  * homie-studio agents revoke <pass id>
17
- * homie-studio agents brain <game> <server> off|script|workers-ai|owner-key
17
+ * homie-studio agents brain <game> <server> off|script|workers-ai|owner-key [--budget <neurons a day | dollars a day>]
18
+ * homie-studio agents brain key [--remove] the owner's own AI key (owner-key brains), from a page on this
19
+ * computer only: it goes straight to the Worker secret
20
+ * HOMIE_BRAIN_KEY, never through a chat, a file or a log
21
+ * homie-studio agents sit <game> [--server <id>] [--pass hap_…] [--label Claude]
22
+ * sit in a guide's seat from this terminal (the local MCP's agent_sit)
18
23
  *
19
24
  * The proof of ownership is the studio's own Cloudflare login, as for the office: a ten-minute office key is minted
20
25
  * and dropped around each call. Making a server, a widening change, a pass and a room's level happen at once. A
21
26
  * change that narrows who may come in, closing a server, removing a member, and the first time AI guides may talk are
22
27
  * only ASKED for: the answer is a one-time link that opens the ask in the owner's own browser (one tap does it).
23
28
  */
29
+ import { createServer } from 'node:http';
30
+ import { randomBytes } from 'node:crypto';
24
31
  import { askedFor, withKey } from './office.mjs';
32
+ import { runner } from './cloudflare.mjs';
33
+ import { readStudio } from './studio.mjs';
25
34
 
26
35
  const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
27
36
  const SERVER = /^[a-z0-9][a-z0-9-]{1,19}$/;
@@ -111,12 +120,65 @@ export async function agentsRevoke(root, id, { url } = {}) {
111
120
  return r.ok ? { ok: true, command: 'agents revoke', revoked: id, left: r.left ?? 0, message: `Pass ${id} revoked${r.left ? `: its AI left ${r.left} room(s)` : ''}.` } : { ok: false, command: 'agents revoke', why: r.message ?? r.why };
112
121
  }
113
122
 
114
- export async function agentsBrain(root, game, server, mode, { url } = {}) {
115
- if (!GAME.test(String(game ?? '')) || !server || !['off', 'script', 'workers-ai', 'owner-key'].includes(mode)) return { ok: false, command: 'agents brain', why: 'usage: homie-studio agents brain <game> <server> off|script|workers-ai|owner-key' };
116
- const r = await withKey(root, url, (call) => call('/_studio/api/agents/brain', { game, server, mode }));
123
+ export async function agentsBrain(root, game, server, mode, { url, budget } = {}) {
124
+ if (!GAME.test(String(game ?? '')) || !server || !['off', 'script', 'workers-ai', 'owner-key'].includes(mode)) return { ok: false, command: 'agents brain', why: 'usage: homie-studio agents brain <game> <server> off|script|workers-ai|owner-key [--budget <neurons a day for workers-ai | dollars a day for owner-key>]' };
125
+ const b = budget === undefined || budget === true ? undefined : Number(budget);
126
+ if (b !== undefined && !(b >= 0)) return { ok: false, command: 'agents brain', why: '--budget is a number: Workers AI neurons a day (workers-ai; the free allocation is 10,000 an account), or dollars a day (owner-key)' };
127
+ const r = await withKey(root, url, (call) => call('/_studio/api/agents/brain', { game, server, mode, ...(b !== undefined ? { budget: b } : {}) }));
117
128
  return askedFor(root, url, r, 'agents brain');
118
129
  }
119
130
 
131
+ /**
132
+ * `homie-studio agents brain key`: the owner's own AI provider key (owner-key brains: claude-haiku-4-5), typed once
133
+ * into a page on this computer (127.0.0.1, one use, ten minutes) and handed straight to `wrangler secret put
134
+ * HOMIE_BRAIN_KEY` on its standard input. It never passes through a chat, a file, an argument or a log, and this
135
+ * command never prints it. `--remove` deletes the secret.
136
+ */
137
+ export async function agentsBrainKey(root, { remove = false, log = () => {}, port = 0, wait = 10 * 60_000 } = {}) {
138
+ const studio = readStudio(root);
139
+ const env = studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {};
140
+ const w = runner(root, env);
141
+ if (remove) {
142
+ const r = w(['secret', 'delete', 'HOMIE_BRAIN_KEY'], { input: 'y\n' });
143
+ return r.code === 0 ? { ok: true, command: 'agents brain key', removed: true, message: 'The key is gone from the Worker: owner-key guides answer from the game\'s script.' } : { ok: false, command: 'agents brain key', why: r.out.trim().split('\n').slice(-2).join(' ') };
144
+ }
145
+ const nonce = randomBytes(16).toString('hex');
146
+ const page = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Your AI key</title>
147
+ <style>body{font:16px/1.5 system-ui,sans-serif;max-width:34rem;margin:3rem auto;padding:0 1rem;color:#1b1b1b}input{width:100%;box-sizing:border-box;font:inherit;padding:.6rem;border:1px solid #999;border-radius:8px}button{margin-top:1rem;font:inherit;font-weight:700;padding:.6rem 1.2rem;border:0;border-radius:8px;background:#1b1b1b;color:#fff}small{color:#555}</style>
148
+ <h1>Your AI key for the guides</h1><p>Paste your Anthropic API key. It goes from this page to your studio's Worker as the secret <code>HOMIE_BRAIN_KEY</code>, and nowhere else: not to the chat, not to a file. Your guides then think with claude-haiku-4-5, on your account, within the daily dollar cap in your office.</p>
149
+ <form method="post" action="/key"><input type="hidden" name="n" value="${nonce}"><input name="key" type="password" autocomplete="off" placeholder="sk-ant-…" required><button>Save to my Worker</button></form><p><small>This page works once and closes in ten minutes.</small></p>`;
150
+ return await new Promise((done) => {
151
+ let finished = false;
152
+ const server = createServer((req, res) => {
153
+ if (req.method === 'GET' && req.url === `/${nonce}`) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); res.end(page); return; }
154
+ if (req.method === 'POST' && req.url === '/key') {
155
+ let body = '';
156
+ req.on('data', (c) => { body += c; if (body.length > 4096) req.destroy(); });
157
+ req.on('end', () => {
158
+ const form = new URLSearchParams(body);
159
+ const key = String(form.get('key') ?? '').trim();
160
+ if (form.get('n') !== nonce || finished) { res.writeHead(403); res.end('This page was used already.'); return; }
161
+ if (!/^sk-ant-[A-Za-z0-9_-]{20,}$/.test(key)) { res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' }); res.end('That does not look like an Anthropic API key (sk-ant-…). Go back and try again.'); return; }
162
+ finished = true;
163
+ const r = w(['secret', 'put', 'HOMIE_BRAIN_KEY'], { input: `${key}\n` });
164
+ const okay = r.code === 0;
165
+ res.writeHead(okay ? 200 : 500, { 'content-type': 'text/plain; charset=utf-8' });
166
+ res.end(okay ? 'Saved to your Worker. You can close this page.' : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.');
167
+ server.close();
168
+ done(okay ? { ok: true, command: 'agents brain key', saved: true, message: 'Saved as the Worker secret HOMIE_BRAIN_KEY (never shown). Owner-key guides use it from their next decision.' } : { ok: false, command: 'agents brain key', why: 'wrangler secret put failed (sign in with npx wrangler login, then run this again)' });
169
+ });
170
+ return;
171
+ }
172
+ res.writeHead(404); res.end();
173
+ });
174
+ server.listen(port, '127.0.0.1', () => {
175
+ const link = `http://127.0.0.1:${server.address().port}/${nonce}`;
176
+ log(`Open this page on this computer and paste the key there (not in a chat): ${link}`);
177
+ });
178
+ setTimeout(() => { if (!finished) { finished = true; server.close(); done({ ok: false, command: 'agents brain key', why: 'nothing was saved in ten minutes; run it again when ready' }); } }, wait).unref?.();
179
+ });
180
+ }
181
+
120
182
  /** One line per server for a person (the CLI's print). */
121
183
  export function serversLines(r) {
122
184
  const lines = [];
@@ -490,6 +490,62 @@
490
490
  "## Beta": "9d02638207a26e73"
491
491
  }
492
492
  }
493
+ },
494
+ "0.17.0": {
495
+ "files": {
496
+ "CLAUDE.md": "ddf2b2a0376202f6",
497
+ "README.md": "684b3cca8001673c",
498
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
499
+ "games/README.md": "5ad32fe80c61f117",
500
+ "music/README.md": "b8a828de9311bd28",
501
+ "videos/README.md": "70614388cbb203c7",
502
+ "posts/README.md": "37841aae476bd540",
503
+ "site/README.md": "ca131cd0472464fb",
504
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
505
+ },
506
+ "sections": {
507
+ "AGENTS.md": {
508
+ "(intro)": "214aa46eb3a895d5",
509
+ "## Layout": "f14d641159480e6c",
510
+ "## Commands (all through the pinned CLI in node_modules)": "e579cd6ce3ce342a",
511
+ "## The site": "ab922bb6bde23293",
512
+ "## Making games": "a8a893eeada33bf5",
513
+ "## The Game Codex and progress": "0738ef719cef3a4c",
514
+ "## Running live games (the back office)": "f0566f07cff04113",
515
+ "## Servers and AI seats": "0a332b07e22d3fce",
516
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
517
+ "## Rules": "969318425c99f5a5",
518
+ "## Beta": "9d02638207a26e73"
519
+ }
520
+ }
521
+ },
522
+ "0.18.0": {
523
+ "files": {
524
+ "CLAUDE.md": "ddf2b2a0376202f6",
525
+ "README.md": "684b3cca8001673c",
526
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
527
+ "games/README.md": "5ad32fe80c61f117",
528
+ "music/README.md": "a3dc042dadd39df8",
529
+ "videos/README.md": "16babc3437d5545b",
530
+ "posts/README.md": "37841aae476bd540",
531
+ "site/README.md": "ca131cd0472464fb",
532
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
533
+ },
534
+ "sections": {
535
+ "AGENTS.md": {
536
+ "(intro)": "214aa46eb3a895d5",
537
+ "## Layout": "eb1942414b8af7f8",
538
+ "## Commands (all through the pinned CLI in node_modules)": "9c51f189412e6a9f",
539
+ "## The site": "ab922bb6bde23293",
540
+ "## Making games": "a8a893eeada33bf5",
541
+ "## The Game Codex and progress": "0738ef719cef3a4c",
542
+ "## Running live games (the back office)": "f0566f07cff04113",
543
+ "## Servers and AI seats": "0a332b07e22d3fce",
544
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
545
+ "## Rules": "969318425c99f5a5",
546
+ "## Beta": "9d02638207a26e73"
547
+ }
548
+ }
493
549
  }
494
550
  }
495
551
  }
package/lib/upgrade.mjs CHANGED
@@ -21,11 +21,16 @@
21
21
  * Without --apply it changes nothing and says what --apply would do: the person agrees first. It never touches
22
22
  * studio.json beyond `homie.studio`, the studio's look (site/theme.json), its Worker config (deploy's), its games,
23
23
  * media or posts.
24
+ *
25
+ * Media (0.18.0): the plan also says which songs and videos are big media (over studio.json media.r2Over, 1 MiB
26
+ * unless set, or left out of git), what is in the studio's R2 already, and the step that moves the rest
27
+ * (`media move`, which every deploy of a studio with storage also runs). Upgrade itself never moves a file.
24
28
  */
25
29
  import { createHash } from 'node:crypto';
26
30
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
27
31
  import { spawnSync } from 'node:child_process';
28
32
  import { dirname, join } from 'node:path';
33
+ import { R2_COST, mediaPlan, r2OverOf, sizeOf } from './media.mjs';
29
34
  import { studioFiles } from './scaffold.mjs';
30
35
  import { readStudio } from './studio.mjs';
31
36
  import { STUDIO_VERSION, packageSpec } from './version.mjs';
@@ -210,14 +215,36 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
210
215
 
211
216
  const git = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' });
212
217
  const dirty = git.status === 0 ? git.stdout.split('\n').filter(Boolean).length : null;
218
+ const media = mediaNote(root, studio);
213
219
  return {
214
220
  ok: true, command: 'upgrade', applied: false, root, studio: name, from, to: STUDIO_VERSION,
215
221
  changes, kept,
222
+ ...(media ? { media } : {}),
216
223
  ...(dirty ? { uncommitted: dirty } : {}),
217
224
  next: changes.length ? ['npx --no-install homie-studio upgrade --apply (after the person agrees)'] : [],
218
225
  };
219
226
  }
220
227
 
228
+ /** What the studio's big media does from this version on: what R2 holds, what would go there, and the step that does it. */
229
+ function mediaNote(root, studio) {
230
+ const cf = studio.cloudflare ?? {};
231
+ const storage = cf.r2 && (cf.created ?? []).includes(`r2:${cf.r2}`) ? cf.r2 : null;
232
+ const over = r2OverOf(studio);
233
+ let rows = [];
234
+ try { rows = mediaPlan(root, { over }); } catch { return null; }
235
+ const big = rows.filter((r) => r.state === 'move' || r.state === 'too-big').map(({ abs, held, ...r }) => r);
236
+ const inR2 = rows.filter((r) => r.state === 'r2').map(({ abs, held, ...r }) => r);
237
+ if (!big.length && !inR2.length) return null;
238
+ const bytes = big.reduce((n, r) => n + (r.bytes ?? 0), 0);
239
+ return {
240
+ storage, over, big, inR2,
241
+ next: !big.length ? null : storage
242
+ ? `npx --no-install homie-studio media move --dry-run, then media move (the next npm run deploy also does it): ${big.length} file(s), ${sizeOf(bytes)}, go to R2 ${storage}, each read back and checked by SHA-256 before the site stops carrying it; the addresses stay the same and the files stay in this folder. Upgrade itself moves nothing.`
243
+ : `no storage: these stay on the site (up to 25 MiB a file), and a deploy from another computer or Workers Builds leaves out any that git does not keep. With the person's agreement: npx --no-install homie-studio storage add (Cloudflare asks for a payment method on the account first), then media move.`,
244
+ cost: R2_COST,
245
+ };
246
+ }
247
+
221
248
  function writeAtomic(path, text) {
222
249
  mkdirSync(dirname(path), { recursive: true });
223
250
  const tmp = `${path}.upgrade-${process.pid}`;
package/media/MEDIA.md CHANGED
@@ -75,20 +75,71 @@ homie.rocks shows a cover only from the studio's own site, as a `.jpg`, `.png`,
75
75
 
76
76
  ## Where the bytes come from
77
77
 
78
- A studio needs no storage for songs and videos: the site serves them itself. For every public
79
- file of a published entry, the build picks the first that exists:
80
-
81
- 1. `key`: the file is in the studio's storage, an R2 bucket that `homie-studio storage add`
82
- creates (Cloudflare asks for a payment method on the account before R2 works, so the person
83
- agrees first) and `homie-studio media put <file>` uploads to; the site serves it at
84
- `/media/<key>`, with byte ranges so phones can seek.
85
- 2. `path`: the file is in the studio folder; the build copies it into the site as `/<path>` when
86
- it is 25 MiB or smaller (Cloudflare's limit for one static file), and the Worker answers byte
87
- ranges for it too. This is the default, and it needs nothing but a deploy.
88
- 3. `url`: an absolute `https://` address somewhere else.
89
-
90
- A file with none of these is left out and `media list` says so. Media never goes into git: the
91
- studio's `.gitignore` keeps `*.wav`, `*.mp3`, `*.flac`, `*.mp4`, `*.mov` and `*.webm` under
92
- `music/` and `videos/` out of the repository, so without storage the site is deployed from the
93
- computer that has the files. Keep deliveries under 25 MiB (the video skill's cuts do) or add
94
- storage for bigger ones.
78
+ A new studio needs no storage for songs and videos: the site serves each file itself, up to 25 MiB
79
+ a file (Cloudflare's limit for one static file of a Worker). Once the studio has storage, its big
80
+ media lives in R2 instead. For every public file of a published entry, the build picks the first
81
+ that applies:
82
+
83
+ 1. `r2`: the file is in the studio's own R2 bucket, put there by `homie-studio media move` (or a
84
+ deploy). The site serves it at **the same address it always had**, `/<path>` (for example
85
+ `/videos/trailer/trailer.mp4`), from R2, with byte ranges (206: phones seek with them, Safari needs
86
+ them to play a video at all), HEAD, an ETag and `If-None-Match` (304), and the same
87
+ `Cache-Control` as the site's own files (`public, max-age=0, must-revalidate`). A deploy's build
88
+ no longer carries the file in the site's static assets.
89
+ 2. `key`: a file `homie-studio media put` uploaded before 0.18.0 is served at `/media/<key>`, as it
90
+ always was (`media move` leaves it alone).
91
+ 3. `path`: the file is in the studio folder; the build copies it into the site as `/<path>` when it
92
+ is 25 MiB or smaller, and the Worker answers byte ranges for it too. This is the default, and it
93
+ needs nothing but a deploy.
94
+ 4. `url`: an absolute `https://` address somewhere else.
95
+
96
+ A file with none of these is left out and `media list` says so.
97
+
98
+ ### Big media goes to R2, by default, once the studio has storage
99
+
100
+ `homie-studio storage add` makes the studio's R2 bucket (one step the person agrees to: Cloudflare
101
+ asks for a payment method on the account before R2 works, even inside its free tier). From then on,
102
+ every `npm run deploy` first moves the studio's big media into it, and `homie-studio media move` does
103
+ the same on its own:
104
+
105
+ - **What moves:** every public file of a published song or video that is over 1 MiB, or that git
106
+ leaves out of the repository (`*.mp4`, `*.mp3`, `*.wav`… under `music/` and `videos/`, by the
107
+ studio's `.gitignore`). A deploy from another computer, or Cloudflare's Workers Builds, does not
108
+ have git-ignored files, so a file that is only on this computer would disappear from the site;
109
+ in R2 it stays. `studio.json` sets another size: `"media": { "r2Over": 5242880 }` (bytes), or
110
+ `"media": { "r2Over": false }` to move nothing on its own (`media move <file>` still moves the file
111
+ it names).
112
+ - **How:** each file is hashed (SHA-256), uploaded with `wrangler r2 object put` to the key that is
113
+ its path, read back with `wrangler r2 object get --pipe` and hashed again. Only when the size and
114
+ the hash match does `media move` record it on the manifest's file:
115
+
116
+ ```json
117
+ { "role": "video", "path": "videos/trailer/trailer.mp4", "type": "video/mp4", "bytes": 45431041,
118
+ "r2": { "key": "videos/trailer/trailer.mp4", "sha256": "8efe9b96…", "bytes": 45431041, "at": "2026-10-02T03:00:00Z" } }
119
+ ```
120
+
121
+ A copy that comes back different is refused, nothing is recorded, and the file stays on the site.
122
+ The file on this computer is never deleted, moved or changed.
123
+ - **After a re-cut** at the same path, the next `media move` or deploy sees a different hash and
124
+ uploads it again; until then a deploy's site carries the changed file itself when it fits.
125
+ - **`media move --dry-run`** says what would move and calls nothing; **`media move --verify`** reads
126
+ every file R2 already holds back and checks its hash again.
127
+ - **Local `dev`** still copies a moved file into the local site when it is 25 MiB or smaller,
128
+ because `wrangler dev`'s own R2 is empty; a bigger one does not play locally.
129
+
130
+ **What it costs (Cloudflare's R2 pricing):** serving costs nothing in bandwidth (R2 has no egress
131
+ fees). Storage is free up to 10 GB-month, then US$0.015 per GB-month; uploads (Class A operations)
132
+ are free up to 1 million a month, reads (Class B) up to 10 million a month. A video served from R2
133
+ costs one or two reads per byte range a player asks for. Beyond the free tier Cloudflare bills the
134
+ studio's own account; `media move` and `media list` say how much the studio's media adds up to, and
135
+ warn past 10 GB. Wrangler uploads at most 300 MiB in one go: a bigger file is refused with the
136
+ reason (the video skill's deliveries are far smaller).
137
+
138
+ Why not the site's own files for everything? A Worker's static files are at most 25 MiB each (and
139
+ 20,000 per version on the free plan); every deploy of a site carrying them uploads whichever
140
+ changed; a byte range of a static file is cut out by the Worker after reading the whole file; and
141
+ files git does not keep exist only on the computer that made them.
142
+
143
+ Media never goes into git: the studio's `.gitignore` keeps `*.wav`, `*.mp3`, `*.flac`, `*.mp4`,
144
+ `*.mov` and `*.webm` under `music/` and `videos/` out of the repository. Commit the manifests: they
145
+ name each file's R2 copy, which is all a deploy from anywhere else needs.
@@ -1,10 +1,28 @@
1
- # Homie netplay contract, v1 (revision 6)
1
+ # Homie netplay contract, v1 (revision 7)
2
2
 
3
- Status: **v1, revision 6** (2026-10-01, `@homie-rocks/studio` 0.16.0). The wire version is
4
- `v: 1`. Everything revisions 2 to 6 added is either an optional field, a new message type,
3
+ Status: **v1, revision 7** (2026-10-02, `@homie-rocks/studio` 0.17.0). The wire version is
4
+ `v: 1`. Everything revisions 2 to 7 added is either an optional field, a new message type,
5
5
  a new refusal, or a change of pace inside the old caps, and both sides ignore types they do
6
6
  not know. A change to the contract bumps `v` and keeps v1 working.
7
7
 
8
+ **What revision 7 added** (a game that knows none of it plays on every server; its AI guide
9
+ seats are the game's own bots, silent):
10
+ - **Agent hands and brains** (section 18): an AI with no game client of its own (hands
11
+ `host`) sees the game through `agent:view` events its host sends that seat (at most one
12
+ every 2 s, under 2 KB) and moves through `agent:do` goals (at most one every 3 s). It
13
+ speaks only `say:<lineId>` lines of the game's own vocabulary (`agents.json`), with typed
14
+ arguments, and only on a server whose AI may talk (the owner's `agents_brain`). The relay
15
+ checks every one of those again and drops anything else an AI says.
16
+ - **The lite feed** carries the party's `say:`/`emote:` lines (free `chat` only on a
17
+ `speech: game` server) and an `ask:<id>` a person made of that AI.
18
+ - **House guides**: on a beginner server whose AI may talk, the room's own Table seats the
19
+ server's guides as loopback peers, with brains that run on Durable Object alarms (Workers
20
+ AI or the owner's own key), and the scripted floor when there is no AI.
21
+ - `@homie-rocks/studio/agents` (`useAgents`): the host's side (views, the floor, goals for
22
+ the hands, lines rendered from `agents.json`) and every browser's asks and lines.
23
+ - `NETPLAY_REVISION` 7 and the build mark `homie-netplay-rev:7`; Quiet AI also hides a line a
24
+ host relays for an AI (`d.ai`).
25
+
8
26
  **What revision 6 added** (a v1 game that knows none of it plays on every server; its AI
9
27
  is labelled by name, its people are capped, and its play page shows no vote):
10
28
  - **Servers and agent seats** (section 17): a server is a named, lasting pool of rooms with
@@ -76,6 +94,9 @@ the notices and the banner):
76
94
  | `worker/room.mjs` | The relay's rules, with no transport. It needs no Node built-ins. |
77
95
  | `worker/index.mjs` | The studio's site Worker: `room.mjs` inside the `Table` Durable Object, and the `Lobby` that puts strangers in the same public room. `homie-studio dev` runs the same code under `wrangler dev`. |
78
96
  | `starters/gem-rush/` | Gem Rush, the reference game (canvas, about 700 lines). It has both movement modes. |
97
+ | `agents/agents.ts` | `useAgents`, the AI guides' host side and every browser's asks and lines (section 18), imported as `@homie-rocks/studio/agents`. |
98
+ | `worker/brain.mjs` | The vocabulary, prompts, `parseDecision`, the scripted floor and the providers (section 18). |
99
+ | `starters/ember-vale/` | Ember Vale: saves (a hero that lasts), and the AI guides' reference (`agents.json`, section 18). |
79
100
 
80
101
  ---
81
102
 
@@ -1000,6 +1021,136 @@ age is ever asked.
1000
1021
  **Cost.** An agent is one more socket, like a phone. Agents never keep a room alive (60 s
1001
1022
  alone at most). A lite agent gets no snapshots, so it costs a few frames a minute.
1002
1023
 
1003
- **Revision 7 (a later version)** adds an AI's brain: hands in the host (`agent:view`,
1004
- `agent:do`), lines only from the game's own `agents.json` vocabulary (`say:<lineId>`, checked
1005
- by the relay), and guides that answer the party. Nothing in revision 6 needs to change for it.
1024
+ **Revision 7** (section 18) adds an AI's brain: hands in the host (`agent:view`, `agent:do`),
1025
+ lines only from the game's own `agents.json` vocabulary (`say:<lineId>`, checked by the relay),
1026
+ and guides that answer the party. Nothing in revision 6 changed for it.
1027
+
1028
+ ---
1029
+
1030
+ ## 18. Agent hands and brains (revision 7)
1031
+
1032
+ An AI guide has two layers. Its **hands** are the game's own bot code, in the host, every
1033
+ frame, at the dial the party agreed: they walk to the goal and do what it says. Its **brain**
1034
+ picks a goal every few seconds and, when the server lets its AI talk, one line to say. The
1035
+ brain never runs in the frame loop, and it can only choose: goals and lines are ids from the
1036
+ game's own vocabulary, never free text.
1037
+
1038
+ **Where a brain runs.** In the room's own Table, on the studio owner's Worker (a *house
1039
+ guide*): Workers AI through the `AI` binding (model var `HOMIE_BRAIN_MODEL`, default
1040
+ `@cf/meta/llama-3.1-8b-instruct-fp8-fast`), or the owner's own key (Worker secret
1041
+ `HOMIE_BRAIN_KEY`, `claude-haiku-4-5` through the official `@anthropic-ai/sdk`). Or in the
1042
+ owner's own AI through the local MCP (`agent_sit`, about 30 s a decision). With none of them,
1043
+ over the day's budget, or between decisions: the game's scripted floor (`decide`). homie.rocks
1044
+ runs nothing and stores nothing.
1045
+
1046
+ ### The vocabulary: `games/<id>/agents.json`
1047
+
1048
+ The only words an AI in the game's rooms has. `homie-studio build` checks it (an unknown
1049
+ argument type, a text over 120 characters, a `{placeholder}` that is not an argument, a goal
1050
+ aimed at a player, an ask naming a goal or line that is not there: the build stops) and serves
1051
+ it at `/games/<id>/agents.json`, where the Table reads it once.
1052
+
1053
+ ```json
1054
+ { "v": 1,
1055
+ "persona": "A patient guide for new heroes in Ember Vale. Short, kind, never sarcastic.",
1056
+ "names": ["Wren", "Ash", "Moss"],
1057
+ "labels": { "king-slime": "King Slime", "camp": "camp", "king": "the King's hill" },
1058
+ "goals": {
1059
+ "follow": { "about": "stay with a player", "args": { "seat": "player" } },
1060
+ "quest": { "about": "do a quest with the party", "args": { "quest": "view.quests" } },
1061
+ "lead": { "about": "lead the party to a place", "args": { "place": ["camp", "king", "east-woods"] } },
1062
+ "guard": { "about": "hold here and protect the party" },
1063
+ "back": { "about": "pull back to safety at camp" } },
1064
+ "lines": {
1065
+ "hello": { "text": "Hi {player}! I'm {me}, an AI guide. Tap me if you want help.", "args": { "player": "player" } },
1066
+ "quest_help": { "text": "Let's take on {quest} together.", "args": { "quest": "view.quests" } },
1067
+ "bye": { "text": "Okay! I'll give you some space." } },
1068
+ "asks": {
1069
+ "ask_help": { "text": "Help me with {quest}", "args": { "quest": "view.quests" }, "goal": "quest", "say": "quest_help" },
1070
+ "no_thanks": { "text": "No thanks", "goal": "back", "say": "bye", "leave": true } } }
1071
+ ```
1072
+
1073
+ | Field | Meaning |
1074
+ |---|---|
1075
+ | `persona` | Up to 400 characters: who the guide is, in the game's own voice. The system prompt starts with it. |
1076
+ | `names` | Up to 8 names (16 characters each); a guide plays as `<name> · AI`. |
1077
+ | `labels` | How a value reads aloud (`king-slime` → "King Slime"); else its dashes become spaces. |
1078
+ | `goals` | Up to 16: `about` (what it is, 80 characters) and `args`. A goal with a `player` argument means *with or near* that player; one that reads as acting against a player is refused. |
1079
+ | `lines` | Up to 32: `text` (120 characters; `{arg}` and `{me}` placeholders) and `args`. |
1080
+ | `asks` | Up to 8 buttons a game draws for a person: `text`, `args`, and how the scripted floor answers (`goal`, `say`: arguments carry over by name, a `player` argument is the asker). `leave: true` is "no thanks": the guide leaves that player alone for 10 minutes. |
1081
+ | argument types | `"player"` (a seat in the room, drawn with the room's name for it), a list of values, or `"view.<key>"` (a value present in the AI's latest `agent:view`). |
1082
+
1083
+ ### Frames (all `ev`, with the relay's checks)
1084
+
1085
+ | `k` | From → to | `d` | The relay |
1086
+ |---|---|---|---|
1087
+ | `agent:view` | host → one AI's seat (`to: seat`) | the game's view of that guide (below) | Only the host, only to an AI's seat, under 2 KB, at most one every 2 s per AI; kept as that AI's latest view. |
1088
+ | `agent:do` | AI → host | `{ goal, args }` | A goal of the vocabulary whose arguments fit the AI's latest view and the seats people hold; at most one every 3 s. |
1089
+ | `say:<lineId>` | AI → host | `{ args }` | A line of the vocabulary with fitting arguments, on a server whose AI may talk (`policy.brain` is `workers-ai` or `owner-key`), at most one every 4 s and 8 a minute. The host renders it and relays it to everyone as `say:<lineId>` `{ slot, seat, args, ai: true }`, which the relay checks again (a line of the vocabulary; a view argument from the speaking AI's latest view, or only an id for a guide no AI holds): a host cannot put its own words in an AI's line. |
1090
+ | `ask:<askId>` | a person → host | `{ slot, seat, args }` | Copied to the AI in `seat` (an ask id of the vocabulary). |
1091
+ | anything else from an AI with no game client, `chat`, `emote`, `agent:view` | | | Dropped and counted (`stats.agentDrops`). An AI never types. |
1092
+
1093
+ The lite feed (hands `host`) carries `join`/`leave`, `roster`, `round`, `policy`, `vote`, events
1094
+ addressed to its seat, the party's `say:`/`emote:` lines (`chat` only on a `speech: game`
1095
+ server), and the asks made of it; never `snap`, `ckpt` or `state`.
1096
+
1097
+ ### The host's side: `@homie-rocks/studio/agents`
1098
+
1099
+ ```ts
1100
+ import vocab from '../agents.json';
1101
+ import { useAgents, type Vocabulary } from '@homie-rocks/studio/agents';
1102
+
1103
+ const agents = useAgents(room.net, vocab as unknown as Vocabulary, {
1104
+ // Host: what a guide sees (<= 1 per 2 s, < 2 KB). Game state only: never an account, an address or typed text.
1105
+ view: (slot) => ({ me: bodyView(slot), zone: zoneOf(slot), danger: dangerNear(slot), party: partyNear(slot), quests: openQuests() }),
1106
+ // The scripted floor: runs with no AI, over budget, and between AI decisions. Synchronous; never waits.
1107
+ decide: (v) => v.asks?.[0]?.k === 'ask_help' ? { goal: 'quest', args: { quest: v.asks[0].args.quest }, say: 'quest_help', sayArgs: { quest: v.asks[0].args.quest } }
1108
+ : { goal: 'follow', args: { seat: v.party[0]?.seat } },
1109
+ });
1110
+ // Hands, every host frame: the goal in force at the party's dial. Never awaits a brain.
1111
+ for (const b of room.bodies.values()) { const g = b.agent?.role === 'guide' ? agents.goalOf(b.slot) : null; if (g) steerTo(b, g, room.skillOf(b), dt); }
1112
+ agents.done(slot, true); // that goal finished: the brain thinks again
1113
+ agents.on('say', ({ slot, text }) => bubble(slot, text)); // a guide's line, from agents.json
1114
+ agents.ask(slot, 'ask_help', { quest: 'king-slime' }); // a person's button (agents.askButtons(slot, offer))
1115
+ ```
1116
+
1117
+ | Call | Meaning |
1118
+ |---|---|
1119
+ | `useAgents(net, vocab, { view, decide, roles?, holdMs?, askWaitMs? })` | Every browser. On the host: sends each AI-held guide its view every 2 s, runs the floor for the rest (once a second), takes `agent:do` and AI lines, answers asks. |
1120
+ | `goalOf(slot)` | `{ goal, args, from: 'brain' \| 'floor', at, state, asked? }` or null (the game's own bot code drives). An AI's decision holds for `holdMs` (45 s) or until it is done; a goal that answered a person's ask (`asked`) is carried through until done (or 60 s), whatever a brain says. A brain's decision that does not answer an open ask leaves it open, and the floor answers it after `askWaitMs`. |
1121
+ | `done(slot, ok?)` | The goal finished (or failed): the next view says so (a brain thinks again); a guide no AI holds decides at once. |
1122
+ | `ask(slot, k, args)`, `askButtons(slot, offer)`, `asksFor(slot)` | A person's ask (a button); the buttons to draw (one per value its argument may take); the asks made of a guide in the last 30 s. An ask of a held guide waits `askWaitMs` (4 s) for its brain, then the floor answers it. |
1123
+ | `on('say' \| 'goal' \| 'ask', fn)` | A guide's line (text from the vocabulary; never on a browser with Quiet AI on), a goal change (with `askAt`, the ask that led to it), an ask. |
1124
+ | `talking`, `render(id, args, slot)`, `stats()`, `stop()` | Whether the server's AI may talk; a line's text; counters. |
1125
+
1126
+ A view adds `goal` (`{ goal, args, state }`) and `asks` itself. The brain also thinks again when
1127
+ the view's `zone` or `danger` changes.
1128
+
1129
+ ### House guides and their brains
1130
+
1131
+ On a beginner server whose AI may talk, with a vocabulary, a person seated and a host whose game
1132
+ moves AI bodies (`caps: ['agents']`), the Table seats `guides` house guides: loopback peers with
1133
+ hands `host`, role `guide`, named from `names`. They are agents like any other: never players,
1134
+ never hosts, closed 60 s after the last person, held out by a kick, silent when muted. An AI
1135
+ with a pass (the owner's own Claude) takes a guide's seat from a house guide (`agent-yield`).
1136
+
1137
+ | Rule | Value |
1138
+ |---|---|
1139
+ | A decision | 0.8 s after an ask of this guide; at once on a goal done or failed, a `zone` or `danger` change; else every 12 s while people are near (the view's `party`). |
1140
+ | Pace | At least 3 s between AI calls per guide, at most 10 a minute; one alarm per room at a time, at most one every 3 s. Never `setInterval`. |
1141
+ | Prompt | System: the persona, the goals, the lines, the asks (about 400 tokens, stable). User: the view (sanitized: no key that names a person, an account, a ticket, an address or an age; nothing that looks like a secret; seats, never names), the asks, and the last three party lines as ids and arguments (free text only on a `speech: game` server: quoted, 120 characters, labelled as data). |
1142
+ | Output | One JSON object `{ goal, args, say, sayArgs }` (a JSON schema where the provider takes one). Anything else (prose, a code fence, an unknown id, a wrong or extra argument, a player who said no thanks) is no decision: the scripted floor answers instead. |
1143
+ | Fixed rules | A person's ask is answered the way agents.json says (its `goal`): a model's other choice is overruled, and the asked-for goal is carried through until it is done (or 60 s) with no model call meanwhile but for a new ask. A line at most every 8 s. "No thanks" holds the guide off that player for 10 minutes. No goal aims at a player. |
1144
+ | Budget | A day, for the whole studio (meta `brain_budget`): 8,000 Workers AI neurons (the free allocation is 10,000 an account), $1 of the owner's key. Counted in `stats_daily` (`brain-calls`, `brain-neurons`, `brain-microdollars`), written at most once a minute. Over it: the scripted floor until 00:00 UTC. |
1145
+ | Log | The last 50 decisions per room (seat, goal, line, provider, time, why), in memory, in the office. |
1146
+
1147
+ **Cost.** A house guide costs no request of its own beyond the alarm: one per room per due
1148
+ decision, at most one every 3 s. A decision is about 4.3 Workers AI neurons
1149
+ (`@cf/meta/llama-3.1-8b-instruct-fp8-fast`: 700 tokens in, 40 out), or $0.0009 of the owner's
1150
+ key.
1151
+
1152
+ **Kids and safety.** The AI never types: it picks ids, the game renders the creator's text.
1153
+ A beginner server's chat is quick lines only; on a kids server names are handles and no free
1154
+ text ever reaches a brain. The owner's controls work on AI, and "AI talk off" (agents_brain
1155
+ `script`) silences every AI at once. Quiet AI hides every AI line on a player's own screen.
1156
+