@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/README.md +11 -4
- package/agents/GUIDES.md +99 -0
- package/agents/agents.ts +395 -0
- package/bin/homie-studio.mjs +71 -8
- package/lib/agent-seat.mjs +185 -0
- package/lib/build.mjs +23 -0
- package/lib/cloudflare.mjs +28 -0
- package/lib/mcp-tools.mjs +56 -0
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +14 -1
- package/lib/servers.mjs +66 -4
- package/lib/template-history.json +56 -0
- package/netplay/NETPLAY.md +157 -6
- package/netplay/netplay.ts +10 -4
- package/package.json +7 -1
- package/site/SITE.md +16 -4
- package/starters/ember-vale/agents.json +34 -0
- package/starters/ember-vale/index.html +8 -0
- package/starters/ember-vale/src/main.ts +252 -5
- package/starters/gem-rush/src/main.ts +4 -1
- package/worker/agents.mjs +359 -4
- package/worker/brain.mjs +417 -0
- package/worker/index.mjs +115 -7
- package/worker/office-page.mjs +273 -26
- package/worker/office.mjs +62 -8
- package/worker/pages.mjs +28 -10
- package/worker/room.mjs +115 -6
- package/worker/stats.mjs +4 -2
- package/worker/version.mjs +1 -1
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
|
|
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
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
|
-
# Homie netplay contract, v1 (revision
|
|
1
|
+
# Homie netplay contract, v1 (revision 7)
|
|
2
2
|
|
|
3
|
-
Status: **v1, revision
|
|
4
|
-
`v: 1`. Everything revisions 2 to
|
|
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 (
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
+
|
package/netplay/netplay.ts
CHANGED
|
@@ -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 =
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
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>
|