@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/README.md +27 -15
- package/agents/GUIDES.md +99 -0
- package/agents/agents.ts +395 -0
- package/bin/homie-studio.mjs +123 -16
- package/lib/agent-seat.mjs +185 -0
- package/lib/build.mjs +32 -5
- package/lib/cloudflare.mjs +163 -9
- package/lib/mcp-tools.mjs +56 -0
- package/lib/media.mjs +185 -16
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +29 -10
- package/lib/servers.mjs +66 -4
- package/lib/template-history.json +56 -0
- package/lib/upgrade.mjs +27 -0
- package/media/MEDIA.md +68 -17
- package/netplay/NETPLAY.md +157 -6
- package/netplay/netplay.ts +10 -4
- package/package.json +8 -2
- package/site/SITE.md +6 -0
- 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/worker/agents.mjs +359 -4
- package/worker/brain.mjs +417 -0
- package/worker/index.mjs +195 -21
- package/worker/office-page.mjs +13 -0
- package/worker/office.mjs +62 -8
- package/worker/room.mjs +115 -6
- package/worker/stats.mjs +4 -2
- package/worker/version.mjs +1 -1
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
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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>/`.
|
|
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>/`.
|
|
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
|
|
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
|
|
79
|
-
file of a
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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.
|
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
|
+
|