@homie-rocks/studio 0.16.0 → 0.17.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/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 = [];
@@ -462,6 +462,62 @@
462
462
  "## Beta": "9d02638207a26e73"
463
463
  }
464
464
  }
465
+ },
466
+ "0.16.1": {
467
+ "files": {
468
+ "CLAUDE.md": "ddf2b2a0376202f6",
469
+ "README.md": "684b3cca8001673c",
470
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
471
+ "games/README.md": "5ad32fe80c61f117",
472
+ "music/README.md": "b8a828de9311bd28",
473
+ "videos/README.md": "70614388cbb203c7",
474
+ "posts/README.md": "37841aae476bd540",
475
+ "site/README.md": "ca131cd0472464fb",
476
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
477
+ },
478
+ "sections": {
479
+ "AGENTS.md": {
480
+ "(intro)": "214aa46eb3a895d5",
481
+ "## Layout": "f14d641159480e6c",
482
+ "## Commands (all through the pinned CLI in node_modules)": "e579cd6ce3ce342a",
483
+ "## The site": "ab922bb6bde23293",
484
+ "## Making games": "a8a893eeada33bf5",
485
+ "## The Game Codex and progress": "0738ef719cef3a4c",
486
+ "## Running live games (the back office)": "f0566f07cff04113",
487
+ "## Servers and AI seats": "961ac5e8ccc2a548",
488
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
489
+ "## Rules": "969318425c99f5a5",
490
+ "## Beta": "9d02638207a26e73"
491
+ }
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
+ }
465
521
  }
466
522
  }
467
523
  }
@@ -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
+
@@ -39,6 +39,11 @@
39
39
  * an AI's seat is marked (`peer.agent`, `net.isAgent(seat)`, every name
40
40
  * ends in " · AI"); `Roster` keeps a hybrid server's AI seats; `net.hushed`
41
41
  * is the player's "Quiet AI".
42
+ * - Agent hands and brains (revision 7, NETPLAY.md section 18): an AI with no
43
+ * game client of its own sees the game through `agent:view` events the host
44
+ * sends its seat and moves through `agent:do` goals; it speaks only the
45
+ * game's `agents.json` lines (`say:<lineId>`). `@homie-rocks/studio/agents`
46
+ * (`useAgents`) is the host's side of it; this file only carries the frames.
42
47
  *
43
48
  * WHAT IT DOES NOT DO: rendering, physics, input devices, bots. `Roster` below
44
49
  * is the bot-yield bookkeeping a host needs; the bots themselves are the game's.
@@ -47,9 +52,9 @@
47
52
 
48
53
  export const NETPLAY_VERSION = 1;
49
54
  /** The contract revision this helper speaks (NETPLAY.md): its hello says so (`rev`), and so does every build of it. */
50
- export const NETPLAY_REVISION = 6;
55
+ export const NETPLAY_REVISION = 7;
51
56
  /** In every bundle that includes the helper: `homie-studio build` reads it to tell the office which revision a build speaks. */
52
- export const NETPLAY_MARK = 'homie-netplay-rev:6';
57
+ export const NETPLAY_MARK = 'homie-netplay-rev:7';
53
58
 
54
59
  export type Role = 'host' | 'replica' | 'screen';
55
60
  export type Device = 'phone' | 'desk' | 'tv';
@@ -1235,8 +1240,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1235
1240
  case 'ev': {
1236
1241
  const k = String(m['k'] ?? '');
1237
1242
  const from = typeof m['from'] === 'number' ? m['from'] : null;
1238
- // Quiet AI (section 17): this browser does not hear an AI's speech.
1239
- if (hushed && SPEECH_KIND.test(k) && from !== null && api.isAgent(from)) return;
1243
+ // Quiet AI (section 17): this browser does not hear an AI's speech, its own or relayed by the host (section 18:
1244
+ // a host relays an AI's line with `d.ai`).
1245
+ if (hushed && SPEECH_KIND.test(k) && ((from !== null && api.isAgent(from)) || (m['d'] && typeof m['d'] === 'object' && (m['d'] as { ai?: unknown }).ai === true))) return;
1240
1246
  emit('event', { k, d: m['d'], from, ...(typeof m['id'] === 'string' ? { id: m['id'] } : {}) });
1241
1247
  return;
1242
1248
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -30,6 +30,10 @@
30
30
  "types": "./saves/saves.ts",
31
31
  "default": "./saves/saves.ts"
32
32
  },
33
+ "./agents": {
34
+ "types": "./agents/agents.ts",
35
+ "default": "./agents/agents.ts"
36
+ },
33
37
  "./worker": "./worker/index.mjs",
34
38
  "./package.json": "./package.json"
35
39
  },
@@ -41,6 +45,7 @@
41
45
  "worker/",
42
46
  "mcp/",
43
47
  "saves/",
48
+ "agents/",
44
49
  "starters/",
45
50
  "media/",
46
51
  "site/",
@@ -52,6 +57,7 @@
52
57
  "node": ">=22"
53
58
  },
54
59
  "dependencies": {
60
+ "@anthropic-ai/sdk": "0.131.0",
55
61
  "esbuild": "0.28.1",
56
62
  "puppeteer-core": "25.8.0"
57
63
  },
package/site/SITE.md CHANGED
@@ -157,6 +157,8 @@ where the game has room, per device:
157
157
  - It shows the room's name ("Room 7") for a few seconds, then shrinks to a round icon until it is touched;
158
158
  `"label": false` keeps it the icon always (the code is in its sheet), for a corner with little room.
159
159
  - A button at the bottom left sends the "finding a room" chip to the bottom right.
160
+ - On a server, the server's pill sits beside the button, on its inner side in the same band, never under it: on a
161
+ phone held upright it is one more dot; elsewhere its name shows for a few seconds, then it is a dot too.
160
162
 
161
163
  Look at the play page on a computer and a phone (both ways up) once a round is on: the button must not sit on a
162
164
  score, a timer or a bar. A room code the relay cannot use (1 to 32 letters, digits, `-` or `_`) is refused
@@ -219,17 +221,27 @@ its **Quick play** server (`public`), so every old link keeps working.
219
221
  the same door, except that watching a beginner room needs no new account.
220
222
  - **When the owner hides Quick play** (`homie-studio servers close <id> public`), Play shows the servers to pick
221
223
  from, and a named `?room=` is off (except for the owner).
222
- - **The play shell** on a server: a pill beside the room button ("First Light · Beginner"; tap: the policy's line,
223
- "AI level: Steady" with **Change** when the game's bots read the dial, and **Quiet AI**, which hides AI chat on
224
- this screen only); the chip says "4 playing · 2 AI"; every AI row and result carries an AI pill.
224
+ - **The play shell** on a server: a pill beside the room button, in the same band at the room button's place
225
+ (`screen.share`), never under it ("First Light · Beginner", then a dot after a few seconds; on a phone held
226
+ upright, the dot from the start; tap: the server's name, the policy's line, "AI level: Steady" with **Change**
227
+ when the game's bots read the dial, and **Quiet AI**, which hides AI chat on this screen only). A server room's
228
+ button says "Room 2", as Quick play's does. The chip says "First Light · 4 playing · 2 AI"; every AI row and result
229
+ carries an AI pill. A game's HUD keeps that band clear (Gem Rush starts its scores under it).
225
230
  - **The vote card** ("How strong should the AI be?", five levels, "the middle vote wins") opens by itself at a
226
231
  party's first round with AI seats, from the pill's Change, or from the game; a lone player's tap decides at once,
227
- every seated person's vote closes it early, else 15 s. Keys 1-5 on a computer; a bottom sheet on a phone. The
232
+ every seated person's vote closes it early, else 15 s. Keys 1-5 on a computer; a bottom sheet on a phone. The card
233
+ is opaque: nothing of the game shows through it. The
228
234
  result is a toast ("The party set the AI to Steady (3 votes)."). A game that draws its own card says game.json
229
235
  `"agents": { "vote": "game" }` (`false`: none).
230
236
  - `/<id>/api/servers` is the servers with their live counts (cacheable 15 s). `/api/rooms` rows say their
231
237
  `server`, `policy` and `ai`.
232
238
 
239
+ **AI guides that talk** (0.17.0, `netplay/NETPLAY.md` section 18): a guide's line is the game's own text
240
+ (its `agents.json`), drawn by the game (Ember Vale: a bubble over the guide); **Quiet AI** hides it on that
241
+ screen. Asking a guide is buttons the game draws, never a text box. The page itself adds nothing for guides.
242
+ The office shows each server's brain, today's Workers AI neurons or dollars against the day's budget, and each
243
+ room's last guide decisions (seats and ids only).
244
+
233
245
  **An AI's seat** (an agent pass from `homie-studio agents pass`): `POST /<id>/api/agent` with `Authorization:
234
246
  Bearer <pass>` and `{ "server": "<id>" }` answers a room with people in it (never an empty one), a ticket, its
235
247
  socket and (an AI that runs the game itself) the frame to load. A humans-only server answers 403 "This server is
@@ -0,0 +1,34 @@
1
+ {
2
+ "v": 1,
3
+ "persona": "A patient guide for new heroes in Ember Vale. Short, kind, never sarcastic. You help a hero find their feet: you stay close, you point the way, and you take on the hard fights with them.",
4
+ "names": ["Wren", "Ash", "Moss"],
5
+ "labels": {
6
+ "king-slime": "King Slime",
7
+ "slime-hunt": "a slime hunt",
8
+ "big-slime": "the big slime",
9
+ "camp": "camp",
10
+ "king": "the King's hill",
11
+ "east-woods": "the east woods"
12
+ },
13
+ "goals": {
14
+ "follow": { "about": "stay with a player", "args": { "seat": "player" } },
15
+ "quest": { "about": "do a quest with the party", "args": { "quest": "view.quests" } },
16
+ "lead": { "about": "lead the party to a place", "args": { "place": ["camp", "king", "east-woods"] } },
17
+ "guard": { "about": "hold here and protect the party" },
18
+ "back": { "about": "pull back to safety at camp" }
19
+ },
20
+ "lines": {
21
+ "hello": { "text": "Hi {player}! I'm {me}, an AI guide. Tap me if you want help.", "args": { "player": "player" } },
22
+ "follow_me": { "text": "Follow me to {place}!", "args": { "place": ["camp", "king", "east-woods"] } },
23
+ "quest_help": { "text": "Let's take on {quest} together.", "args": { "quest": "view.quests" } },
24
+ "careful": { "text": "Careful, {thing} ahead!", "args": { "thing": ["slimes", "the King Slime"] } },
25
+ "nice": { "text": "Nice strike, {player}!", "args": { "player": "player" } },
26
+ "wait": { "text": "I'll wait here for you." },
27
+ "bye": { "text": "Okay! I'll give you some space." }
28
+ },
29
+ "asks": {
30
+ "ask_help": { "text": "Help me with {quest}", "args": { "quest": "view.quests" }, "goal": "quest", "say": "quest_help" },
31
+ "lead_me": { "text": "Take me to {place}", "args": { "place": ["camp", "king", "east-woods"] }, "goal": "lead", "say": "follow_me" },
32
+ "no_thanks": { "text": "No thanks", "goal": "back", "say": "bye", "leave": true }
33
+ }
34
+ }
@@ -21,12 +21,20 @@
21
21
  .panel li { margin: 4px 0; color: #e7d9b4; }
22
22
  .panel li small { color: #a39a80; }
23
23
  .panel .close { float: right; background: none; border: 0; color: #c9c0a6; font-size: 20px; cursor: pointer; }
24
+ /* Asking a guide: buttons (agents.json "asks"), never a text box. Bottom centre, clear of the stick and STRIKE. */
25
+ .asks { left: 50%; transform: translateX(-50%); bottom: calc(14px + env(safe-area-inset-bottom)); max-width: min(560px, calc(100vw - 200px)); box-sizing: border-box; padding: 8px 10px; border-radius: 14px; background: rgba(10,20,18,.84); border: 1px solid rgba(127,216,200,.45); text-align: center; }
26
+ .asks .who { font: 700 12.5px/1.2 inherit; font-family: inherit; color: #7fd8c8; margin: 0 0 6px; }
27
+ .asks .list { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
28
+ .asks button { min-height: 34px; padding: 0 12px; border-radius: 999px; border: 1px solid rgba(127,216,200,.5); background: rgba(127,216,200,.12); color: #e9fff9; font: 600 13px/1 inherit; font-family: inherit; cursor: pointer; }
29
+ .asks button.sent { background: #7fd8c8; color: #0b1512; }
30
+ @media (max-width: 540px) { .asks { max-width: calc(100vw - 168px); bottom: calc(10px + env(safe-area-inset-bottom)); } .asks button { min-height: 40px; padding: 0 10px; } }
24
31
  [hidden] { display: none !important; }
25
32
  </style>
26
33
  </head>
27
34
  <body>
28
35
  <canvas id="c"></canvas>
29
36
  <div class="ui pill"><button type="button" id="who"></button><button type="button" id="hall-btn">Hall of the Fallen</button></div>
37
+ <div class="ui asks" id="asks" hidden aria-live="polite"><p class="who" id="asks-who"></p><div class="list" id="asks-list"></div></div>
30
38
  <div class="ui panel" id="make" hidden>
31
39
  <h2>A new hero</h2>
32
40
  <p>Your hero keeps every level and coin, on every device you play on.</p>