@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.
@@ -17,7 +17,8 @@
17
17
  * homie-studio game remix <source.json url> --id <new id>
18
18
  * homie-studio games
19
19
  * homie-studio build [<id>]
20
- * homie-studio dev [--port 8787] (--stop: stop exactly this studio's dev server, nothing else)
20
+ * homie-studio dev [--port 8787] [--remote-ai] (--stop: stop exactly this studio's dev server, nothing else;
21
+ * AI guides think scripted here unless --remote-ai: real Workers AI, billed)
21
22
  * homie-studio check <id> [--url <site>] [--shots <dir>]
22
23
  * homie-studio chrome [install] [--fresh] (which Chrome the checks use; on Linux, `install` fetches Chrome for Testing;
23
24
  * --fresh fetches it even when the machine has a Chrome)
@@ -71,9 +72,14 @@
71
72
  * homie-studio agents pass <game|any> --label "<Name>" [--server <id>] [--hands self|host] [--days 7]
72
73
  * (an AI's way into a seat, always marked AI; the pass is shown once)
73
74
  * homie-studio agents passes [<game>] homie-studio agents revoke <pass id>
74
- * homie-studio agents brain <game> <server> off|script|workers-ai|owner-key
75
+ * homie-studio agents brain <game> <server> off|script|workers-ai|owner-key [--budget <n>]
75
76
  * (a narrowing change, closing a server, removing a member and the first
76
- * time AI guides may talk are ASKED for, like office kick)
77
+ * time AI guides may talk are ASKED for, like office kick; --budget is
78
+ * Workers AI neurons a day, or dollars a day for the owner's key)
79
+ * homie-studio agents brain key [--remove] (the owner's AI key, typed into a page on this computer only)
80
+ * homie-studio agents sit <game> [--server <id>] [--pass hap_…] [--label Claude]
81
+ * (an AI guide's seat from this terminal: then lines of `do <goal> {args}`,
82
+ * `say <line> {args}`, `look`, `stand`)
77
83
  *
78
84
  * homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
79
85
  * [--budget <dollars>] [--unit usd|credits] [--share]
@@ -145,7 +151,8 @@ import { STUDIO_VERSION } from '../lib/version.mjs';
145
151
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
146
152
  import { playersOwner, playersShow } from '../lib/players.mjs';
147
153
  import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeRevoke, officeShow } from '../lib/office.mjs';
148
- import { agentsBrain, agentsPass, agentsPasses, agentsRevoke, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
154
+ import { agentsBrain, agentsBrainKey, agentsPass, agentsPasses, agentsRevoke, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
155
+ import { AgentSeat } from '../lib/agent-seat.mjs';
149
156
  import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
150
157
  import { formatStatus, setupStatus } from '../lib/doctor.mjs';
151
158
  import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
@@ -157,7 +164,7 @@ import { formatHandoff, handoff } from '../lib/handoff.mjs';
157
164
 
158
165
  const argv = process.argv.slice(2);
159
166
  const flags = new Map();
160
- const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids'];
167
+ const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai'];
161
168
  const positional = [];
162
169
  for (let i = 0; i < argv.length; i++) {
163
170
  const a = argv[i];
@@ -302,8 +309,12 @@ function print(result) {
302
309
  break;
303
310
  case 'servers level':
304
311
  case 'agents revoke':
312
+ case 'agents brain key':
305
313
  lines.push(result.message);
306
314
  break;
315
+ case 'agents sit':
316
+ lines.push(`Left ${result.room}.`);
317
+ break;
307
318
  case 'agents pass':
308
319
  lines.push(`${result.pass.name} (pass ${result.pass.id}, ${result.pass.role}, hands ${result.pass.hands}${result.pass.server ? `, ${result.pass.server} only` : ''}):`, ` ${result.secret}`, result.use);
309
320
  break;
@@ -536,8 +547,10 @@ async function main() {
536
547
  if (sub === 'pass') return agentsPass(root, positional[2], { url, label: flags.get('label'), server: flags.get('server'), hands: flags.get('hands'), role: flags.get('role'), days: flags.get('days') });
537
548
  if (sub === 'passes') return agentsPasses(root, positional[2], { url });
538
549
  if (sub === 'revoke') return agentsRevoke(root, positional[2], { url });
539
- if (sub === 'brain') return agentsBrain(root, positional[2], positional[3], positional[4], { url });
540
- return { ok: false, command: 'agents', why: `unknown: agents ${sub ?? ''} (pass, passes, revoke, brain)` };
550
+ if (sub === 'brain' && positional[2] === 'key') return agentsBrainKey(root, { remove: flags.has('remove'), log });
551
+ if (sub === 'brain') return agentsBrain(root, positional[2], positional[3], positional[4], { url, budget: flags.get('budget') });
552
+ if (sub === 'sit') return agentsSit(root, positional[2], { url, server: flags.get('server'), pass: flags.get('pass'), label: flags.get('label') });
553
+ return { ok: false, command: 'agents', why: `unknown: agents ${sub ?? ''} (pass, passes, revoke, brain, brain key, sit)` };
541
554
  }
542
555
  if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
543
556
  if (cmd === 'media' && sub === 'list') {
@@ -714,7 +727,9 @@ async function dev(root) {
714
727
  m.on('close', done);
715
728
  });
716
729
  log(`Local site: http://127.0.0.1:${port}/ (each game: http://127.0.0.1:${port}/<id>/play — open it in two browsers)`);
717
- const child = spawn(bin, ['dev', '--local', '--ip', '127.0.0.1', '--port', port], { cwd: workerDir(root), env, stdio: 'inherit' });
730
+ const ai = devConfig(root, flags.has('remote-ai'));
731
+ if (ai.note) log(ai.note);
732
+ const child = spawn(bin, ['dev', '--local', '--ip', '127.0.0.1', '--port', port, ...ai.args], { cwd: workerDir(root), env, stdio: 'inherit' });
718
733
  mkdirSync(dirname(devFile(root)), { recursive: true });
719
734
  writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
720
735
  log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
@@ -725,6 +740,54 @@ async function dev(root) {
725
740
  return { ok: true, command: 'dev', stopped: true };
726
741
  }
727
742
 
743
+ /**
744
+ * AI guides under `dev` (0.17.0): they think scripted, for free, unless --remote-ai (real Workers AI on the signed-in
745
+ * account, billed like the live site). A config whose AI binding is not what was asked for runs from a copy in
746
+ * .wrangler/ (absolute paths, the same local state), so wrangler.jsonc itself is never changed by dev.
747
+ */
748
+ function devConfig(root, remoteAi) {
749
+ const file = join(workerDir(root), 'wrangler.jsonc');
750
+ let json;
751
+ try { json = JSON.parse(readFileSync(file, 'utf8').replace(/^\s*\/\/.*$/gm, '')); } catch { return { args: [] }; }
752
+ if (Boolean(json.ai) === remoteAi) return { args: [], note: remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : null };
753
+ const base = workerDir(root);
754
+ const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
755
+ delete json.$schema;
756
+ json.main = abs(json.main);
757
+ if (json.assets?.directory) json.assets.directory = abs(json.assets.directory);
758
+ json.d1_databases = (json.d1_databases ?? []).map((d) => ({ ...d, ...(d.migrations_dir ? { migrations_dir: abs(d.migrations_dir) } : {}) }));
759
+ if (remoteAi) json.ai = { binding: 'AI', remote: true }; else delete json.ai;
760
+ const copy = join(base, '.wrangler', 'homie-dev.wrangler.json');
761
+ mkdirSync(dirname(copy), { recursive: true });
762
+ writeFileSync(copy, `${JSON.stringify(json, null, 2)}\n`);
763
+ return { args: ['--config', copy, '--persist-to', join(base, '.wrangler', 'state')], note: remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : 'AI guides think scripted here (no Workers AI under dev; --remote-ai for the real one).' };
764
+ }
765
+
766
+ /** `agents sit`: an AI guide's seat from this terminal, for a demo or a test of a vocabulary. Lines on stdin act. */
767
+ async function agentsSit(root, game, { url, server, pass, label }) {
768
+ const site = url ?? siteUrl(root);
769
+ const seat = new AgentSeat({ site, game, server: server ?? null, pass: pass ?? null, label: label ?? 'Claude', root });
770
+ const r = await seat.sit();
771
+ if (!r.ok) return { ok: false, command: 'agents sit', why: r.why };
772
+ log(`Seated as ${r.name} in ${r.room} (seat ${r.seat}). Type: look | do <goal> {"arg":…} | say <line> {"arg":…} with a goal | stand`);
773
+ log(JSON.stringify({ view: r.view, asks: r.asks, choices: r.choices }, null, 1));
774
+ const { createInterface } = await import('node:readline');
775
+ const rl = createInterface({ input: process.stdin });
776
+ let goal = null;
777
+ for await (const line of rl) {
778
+ const [word, id, ...rest] = line.trim().split(/\s+/);
779
+ let args = {};
780
+ try { args = rest.length ? JSON.parse(rest.join(' ')) : {}; } catch { log('arguments are JSON, e.g. {"place":"camp"}'); continue; }
781
+ if (word === 'look') log(JSON.stringify(seat.look(), null, 1));
782
+ else if (word === 'do') { goal = { goal: id, args }; const d = await seat.do(goal); log(d.ok ? `done: ${JSON.stringify(d.sent)}` : `refused: ${d.why}`); }
783
+ else if (word === 'say') { const d = await seat.do({ ...(goal ?? { goal: Object.keys(seat.vocab.goals)[0], args: {} }), say: id, sayArgs: args }); log(d.ok ? `said: ${d.text}` : `refused: ${d.why}`); }
784
+ else if (word === 'stand') break;
785
+ }
786
+ rl.close();
787
+ await seat.stand();
788
+ return { ok: true, command: 'agents sit', stood: true, room: r.room };
789
+ }
790
+
728
791
  /** A big file into the studio's R2, and its key on the music/ or videos/ manifest entry that names it. */
729
792
  function mediaPut(root, file, as) {
730
793
  if (!file || !existsSync(file) || !statSync(file).isFile()) return { ok: false, command: 'media put', why: 'usage: homie-studio media put <file> [--as <key>]' };
@@ -0,0 +1,185 @@
1
+ /**
2
+ * AN AI IN A SEAT, FROM THIS COMPUTER (@homie-rocks/studio 0.17.0, NETPLAY.md section 18): the local MCP's agent_sit,
3
+ * agent_look, agent_do and agent_stand, and `homie-studio agents sit`. The person's own AI (Claude in the desktop app,
4
+ * Claude Code, any MCP client) takes a guide's seat with hands `host`: the host's game moves the body every frame at the
5
+ * party's dial, while this holds the socket between the AI's turns (about 30 s a decision), keeps the latest view the
6
+ * host showed it and the asks made of it, and sends its goals and lines: only the game's own vocabulary (agents.json),
7
+ * which the relay checks again. It always plays as "<label> · AI".
8
+ *
9
+ * The pass: one the owner gave (`hap_…`), or, from a studio folder, a one-day guide pass minted with the owner's office
10
+ * key and revoked when the AI stands. The secret stays in this process; it is never printed or returned.
11
+ */
12
+ import { parseDecision, renderLine, vocabularyOf } from '../worker/brain.mjs';
13
+ import { withKey } from './office.mjs';
14
+
15
+ const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
16
+ const SERVER = /^[a-z0-9][a-z0-9-]{1,19}$/;
17
+ const PASS = /^hap_[a-f0-9]{10}_[A-Za-z0-9_-]{40}$/;
18
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
19
+
20
+ export class AgentSeat {
21
+ /**
22
+ * @param {object} o
23
+ * @param {string} o.site the studio's site (https://…, or http://127.0.0.1:8787 for `homie-studio dev`)
24
+ * @param {string} o.game
25
+ * @param {string} [o.server] a server id (else the pass's, else public)
26
+ * @param {string} [o.pass] an agent pass (hap_…); else one is minted from `root` with the office key
27
+ * @param {string} [o.root] the studio folder (for a pass of its own)
28
+ */
29
+ constructor({ site, game, server = null, pass = null, label = 'Claude', root = null, WebSocketImpl = globalThis.WebSocket, fetchImpl = globalThis.fetch }) {
30
+ this.site = String(site ?? '').replace(/\/+$/, '');
31
+ this.game = game;
32
+ this.server = server;
33
+ this.pass = pass;
34
+ this.label = label;
35
+ this.root = root;
36
+ this.WS = WebSocketImpl;
37
+ this.fetch = fetchImpl;
38
+ this.minted = null;
39
+ this.ws = null;
40
+ this.seat = null;
41
+ this.name = null;
42
+ this.room = null;
43
+ this.policy = null;
44
+ this.view = null;
45
+ this.viewAt = 0;
46
+ this.asks = [];
47
+ this.party = [];
48
+ this.lastDo = null;
49
+ this.doAt = 0;
50
+ this.sayAt = 0;
51
+ this.closed = null;
52
+ this.errors = [];
53
+ this.vocab = null;
54
+ this.ping = null;
55
+ }
56
+
57
+ async sit() {
58
+ if (!GAME.test(String(this.game ?? ''))) return { ok: false, why: 'game is the game\'s id' };
59
+ if (this.server !== null && !SERVER.test(String(this.server)) && this.server !== 'public') return { ok: false, why: 'server is a server id' };
60
+ if (!this.site) return { ok: false, why: 'no site: run the studio here (preview_run / homie-studio dev) or deploy it, or give url' };
61
+ if (!this.WS) return { ok: false, why: 'this Node has no WebSocket (Node 22 or newer)' };
62
+ // The vocabulary: what this AI may choose and say, from the built game.
63
+ try {
64
+ const res = await this.fetch(`${this.site}/games/${this.game}/agents.json`, { signal: AbortSignal.timeout(10_000) });
65
+ if (res.ok) this.vocab = vocabularyOf(await res.json()).vocab;
66
+ } catch { this.vocab = null; }
67
+ if (!this.vocab) return { ok: false, why: `${this.game} has no agents.json (the guides' vocabulary): an AI with no game client has nothing to choose from` };
68
+ if (!this.pass) {
69
+ if (!this.root) return { ok: false, why: 'give pass (an agent pass, hap_…), or run this from the studio folder' };
70
+ const r = await withKey(this.root, this.site, (call) => call('/_studio/api/agents/pass', { action: 'create', game: this.game, label: this.label, role: 'guide', hands: 'host', days: 1, server: this.server && this.server !== 'public' ? this.server : null }));
71
+ if (!r.ok || !PASS.test(String(r.secret ?? ''))) return { ok: false, why: `could not make a pass: ${r.message ?? r.why ?? 'the office did not answer'}` };
72
+ this.pass = r.secret;
73
+ this.minted = r.pass?.id ?? null;
74
+ }
75
+ let sat;
76
+ try {
77
+ const res = await this.fetch(`${this.site}/${this.game}/api/agent`, {
78
+ method: 'POST', headers: { authorization: `Bearer ${this.pass}`, 'content-type': 'application/json', 'user-agent': 'homie-studio-agent' },
79
+ body: JSON.stringify(this.server ? { server: this.server } : {}), signal: AbortSignal.timeout(15_000),
80
+ });
81
+ sat = await res.json().catch(() => ({ ok: false, message: `the site answered ${res.status}` }));
82
+ } catch (error) { sat = { ok: false, message: error.message }; }
83
+ if (!sat.ok) { await this.revoke(); return { ok: false, why: sat.message ?? sat.error ?? 'the seat was refused', error: sat.error ?? null }; }
84
+ if (sat.hands !== 'host') { await this.revoke(); return { ok: false, why: 'this pass is for an AI that runs the game itself (hands self); agent_sit needs hands host' }; }
85
+ this.room = sat.room;
86
+ this.server = sat.server;
87
+ const ws = new this.WS(sat.ws);
88
+ this.ws = ws;
89
+ const welcome = await new Promise((done) => {
90
+ const timer = setTimeout(() => done({ t: 'timeout' }), 12_000);
91
+ ws.onopen = () => ws.send(JSON.stringify({ ...sat.hello, device: 'desk' }));
92
+ ws.onmessage = (e) => {
93
+ let m; try { m = JSON.parse(String(e.data)); } catch { return; }
94
+ if (m.t === 'welcome' || (m.t === 'error' && !this.seat)) { clearTimeout(timer); done(m); }
95
+ this.onFrame(m);
96
+ };
97
+ ws.onclose = (e) => { this.closed = this.closed ?? (e?.reason || 'closed'); clearTimeout(timer); done({ t: 'closed' }); };
98
+ ws.onerror = () => {};
99
+ });
100
+ if (welcome.t !== 'welcome') { this.close(); await this.revoke(); return { ok: false, why: welcome.message ?? `no seat (${welcome.code ?? welcome.t})`, error: welcome.code ?? welcome.t }; }
101
+ this.ping = setInterval(() => { try { ws.send(JSON.stringify({ t: 'ping', c: Date.now() })); } catch { /* closed */ } }, 2000);
102
+ // The host shows a guide the game every 2 s: wait for the first look (up to 6 s).
103
+ for (let i = 0; i < 30 && !this.view && !this.closed; i += 1) await wait(200);
104
+ return { ok: true, seat: this.seat, name: this.name, room: this.room, server: this.server, ...this.look() };
105
+ }
106
+
107
+ onFrame(m) {
108
+ switch (m.t) {
109
+ case 'welcome': this.seat = m.seat; this.name = m.name; this.policy = m.policy ?? null; return;
110
+ case 'policy': this.policy = m.policy ?? this.policy; return;
111
+ case 'error': this.errors = [...this.errors, { code: m.code, message: m.message, at: Date.now() }].slice(-6); if (['agents-alone', 'agents-off', 'kicked', 'room-closed', 'agent-yield'].includes(m.code)) this.closed = m.code; return;
112
+ case 'ev': {
113
+ const k = String(m.k ?? '');
114
+ const d = m.d && typeof m.d === 'object' ? m.d : {};
115
+ if (k === 'agent:view') { this.view = d; this.viewAt = Date.now(); return; }
116
+ const ask = /^ask:([a-z][a-z0-9_]{0,31})$/.exec(k)?.[1];
117
+ if (ask && d.seat === this.seat) { this.asks = [...this.asks, { k: ask, args: d.args ?? {}, from: m.from, at: Date.now() }].slice(-6); return; }
118
+ const line = /^say:([a-z][a-z0-9_]{0,31})$/.exec(k)?.[1];
119
+ if (line && m.from !== this.seat) this.party = [...this.party, { seat: m.from, line, args: d.args ?? {}, at: Date.now() }].slice(-6);
120
+ return;
121
+ }
122
+ default: return;
123
+ }
124
+ }
125
+
126
+ /** What the AI needs to decide: the view, the asks made of it, the party's lines, its choices (ids only). */
127
+ look() {
128
+ const now = Date.now();
129
+ const v = this.vocab;
130
+ const describe = (entries) => Object.fromEntries(Object.entries(entries ?? {}).map(([id, e]) => [id, { ...(e.about ? { about: e.about } : { text: e.text }), args: Object.fromEntries(Object.entries(e.args ?? {}).map(([k, s]) => [k, s.kind === 'player' ? 'player (a seat number)' : s.kind === 'list' ? s.values : `view.${s.key}`])) }]));
131
+ return {
132
+ seat: this.seat, name: this.name, room: this.room, closed: this.closed,
133
+ talking: Boolean(this.policy && ['workers-ai', 'owner-key'].includes(this.policy.brain) && this.policy.speech !== 'off'),
134
+ view: this.view, viewAgeMs: this.viewAt ? now - this.viewAt : null,
135
+ asks: this.asks.filter((a) => now - a.at < 60_000).map((a) => ({ ask: a.k, args: a.args, seat: a.from, text: v ? renderLine(v, a.k, a.args, { kind: 'asks', nameOf: (s) => `seat ${s}` }) : null, secondsAgo: Math.round((now - a.at) / 1000) })),
136
+ party: this.party.filter((p) => now - p.at < 60_000).map((p) => ({ seat: p.seat, line: p.line, args: p.args })),
137
+ lastDo: this.lastDo,
138
+ choices: v ? { goals: describe(v.goals), lines: describe(v.lines) } : null,
139
+ errors: this.errors.slice(-3),
140
+ };
141
+ }
142
+
143
+ /** One decision: a goal (and at most one line) of the vocabulary, checked here first and again by the relay. */
144
+ async do({ goal, args = {}, say = null, sayArgs = {} } = {}) {
145
+ if (!this.ws || this.closed) return { ok: false, why: this.closed ? `the seat closed (${this.closed}); agent_sit again` : 'not seated: agent_sit first' };
146
+ const p = parseDecision({ goal, args, say: say || null, sayArgs: say ? sayArgs : {} }, this.vocab, { view: this.view });
147
+ if (!p.ok) return { ok: false, why: `${p.why}. Choose a goal and line from look().choices, with values the view offers.` };
148
+ const now = Date.now();
149
+ if (now - this.doAt < 3000) await wait(3000 - (now - this.doAt));
150
+ if (!this.talking() && p.decision.say) return { ok: false, why: 'this server\'s AI may not talk (its owner has not turned AI talk on); choose a goal with say null' };
151
+ if (p.decision.say && Date.now() - this.sayAt < 8000) return { ok: false, why: 'a guide says at most one line every 8 seconds; send the goal alone, or wait' };
152
+ this.ws.send(JSON.stringify({ t: 'ev', k: 'agent:do', d: { goal: p.decision.goal, args: p.decision.args } }));
153
+ this.doAt = Date.now();
154
+ if (p.decision.say) { this.ws.send(JSON.stringify({ t: 'ev', k: `say:${p.decision.say}`, d: { args: p.decision.sayArgs } })); this.sayAt = Date.now(); }
155
+ this.lastDo = { goal: p.decision.goal, args: p.decision.args, say: p.decision.say, at: new Date().toISOString() };
156
+ this.asks = [];
157
+ await wait(300);
158
+ const refused = this.errors.find((e) => e.at >= this.doAt - 50);
159
+ return refused ? { ok: false, why: `the room said ${refused.code}: ${refused.message}` } : { ok: true, sent: this.lastDo, text: p.decision.say ? renderLine(this.vocab, p.decision.say, p.decision.sayArgs, { me: this.name, nameOf: (s) => `seat ${s}`, view: this.view }) : null };
160
+ }
161
+
162
+ talking() { return Boolean(this.policy && ['workers-ai', 'owner-key'].includes(this.policy.brain) && this.policy.speech !== 'off'); }
163
+
164
+ close() {
165
+ if (this.ping) clearInterval(this.ping);
166
+ this.ping = null;
167
+ if (this.ws) { try { this.ws.send(JSON.stringify({ t: 'bye' })); } catch { /* closed */ } try { this.ws.close(1000, 'bye'); } catch { /* closed */ } }
168
+ this.ws = null;
169
+ this.closed = this.closed ?? 'stood';
170
+ }
171
+
172
+ /** A pass this seat minted ends with it. */
173
+ async revoke() {
174
+ if (!this.minted || !this.root) return;
175
+ const id = this.minted;
176
+ this.minted = null;
177
+ await withKey(this.root, this.site, (call) => call('/_studio/api/agents/pass', { action: 'revoke', id })).catch(() => {});
178
+ }
179
+
180
+ async stand() {
181
+ this.close();
182
+ await this.revoke();
183
+ return { ok: true, stood: true, room: this.room };
184
+ }
185
+ }
package/lib/build.mjs CHANGED
@@ -5,6 +5,8 @@
5
5
  * site/dist/games/<id>/index.html the game's page (the Worker adds HOMIE_NET)
6
6
  * site/dist/games/<id>/assets/main.js its bundle (esbuild; @homie-rocks/studio/netplay inlined)
7
7
  * site/dist/games/<id>/... everything in games/<id>/public/
8
+ * site/dist/games/<id>/agents.json the AI guides' vocabulary (games/<id>/agents.json, checked; NETPLAY.md
9
+ * section 18): the only goals and lines an AI in its rooms has
8
10
  * site/dist/games.json { studio, games[], songs[], videos[], posts[], site } from studio.json,
9
11
  * game.json files, the music/ and videos/ manifests (media/MEDIA.md),
10
12
  * posts/*.md and the studio's site/ folder (site/SITE.md)
@@ -35,6 +37,7 @@ import { SEAT_MAX } from '../worker/seats.mjs';
35
37
  import { STUDIO_VERSION } from './version.mjs';
36
38
  import { licenseOf, remixRow } from '../worker/license.mjs';
37
39
  import { SERVER_LIMITS, serverOf } from '../worker/servers.mjs';
40
+ import { vocabularyOf } from '../worker/brain.mjs';
38
41
 
39
42
  const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
40
43
  /** Never copied into a static game's served folder. */
@@ -152,6 +155,22 @@ export function netplayRevOf(out) {
152
155
  return rev;
153
156
  }
154
157
 
158
+ /**
159
+ * games/<id>/agents.json, checked (worker/brain.mjs vocabularyOf: argument types, text over 120 characters, a goal
160
+ * against a player, an ask naming a goal or line that is not there) and served beside the game, where the room's Table
161
+ * reads it. A game with none has guides that play but never talk. Returns whether it has one.
162
+ */
163
+ export function vocabFor(g, out) {
164
+ const file = join(g.dir, 'agents.json');
165
+ if (!existsSync(file)) { rmSync(join(out, 'agents.json'), { force: true }); return false; }
166
+ let raw;
167
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { throw new Error(`games/${g.id}/agents.json is not JSON: ${error.message}`); }
168
+ const v = vocabularyOf(raw);
169
+ if (!v.ok) throw new Error(`games/${g.id}/agents.json: ${v.problems.slice(0, 6).join('; ')}`);
170
+ writeFileSync(join(out, 'agents.json'), `${JSON.stringify(raw)}\n`);
171
+ return true;
172
+ }
173
+
155
174
  /** game.json "servers": the seeds a game ships with (worker/servers.mjs; a D1 row of the same id wins). */
156
175
  function serverSeeds(g, log) {
157
176
  if (!Array.isArray(g.servers)) return [];
@@ -236,6 +255,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
236
255
  }
237
256
  if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
238
257
  if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
258
+ // The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
259
+ vocabFor(g, out);
239
260
  // The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
240
261
  if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id, { studio: studio.name ?? null, game: g.name ?? g.id, license: g.license }))}\n`);
241
262
  const main = join(out, 'assets', 'main.js');
@@ -277,6 +298,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
277
298
  // predates servers), and game.json "agents": { "vote": "game" | false } (the game draws its own vote card, or none).
278
299
  ...(seeds.length ? { servers: seeds } : {}),
279
300
  netplayRev: netplayRevOf(join(dist, 'games', g.id)),
301
+ // 0.17.0: the game has a vocabulary for its AI guides (agents.json), so they can talk once the owner says so.
302
+ ...(existsSync(join(dist, 'games', g.id, 'agents.json')) ? { vocab: true } : {}),
280
303
  ...(g.agents && typeof g.agents === 'object' && (g.agents.vote === 'game' || g.agents.vote === false) ? { agents: { vote: g.agents.vote } } : {}),
281
304
  // Its source licence (worker/license.mjs), and, for a remix, what it is a remix of (shown on its landing).
282
305
  license: licenseOf(g.license),
@@ -197,6 +197,11 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
197
197
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
198
198
  if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
199
199
  step('D1 migrations applied');
200
+ // Workers AI (0.17.0): bound only when a server's AI guides think with it.
201
+ if (needsWorkersAi(root, w, cf.d1)) {
202
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true }));
203
+ step('Workers AI bound (AI): a server\'s AI guides think with it, within the day\'s budget (free allocation: 10,000 neurons a day)');
204
+ }
200
205
 
201
206
  const started = Date.now();
202
207
  const dep = w(['deploy', ...repoVar(root)]);
@@ -239,6 +244,21 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
239
244
  };
240
245
  }
241
246
 
247
+ /**
248
+ * Whether any server of this studio's games thinks with Workers AI (a D1 row with brain 'workers-ai', or a game.json
249
+ * seed): deploy binds AI only then (DESIGN section 6). A database without the servers table yet says no.
250
+ */
251
+ export function needsWorkersAi(root, w, db) {
252
+ try {
253
+ const cat = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8'));
254
+ if ((cat.games ?? []).some((g) => (g.servers ?? []).some((s) => s.brain === 'workers-ai'))) return true;
255
+ } catch { /* not built */ }
256
+ const r = w(['d1', 'execute', db, '--remote', '--json', '--command', "SELECT COUNT(*) AS n FROM servers WHERE brain = 'workers-ai' AND state = 'open'"]);
257
+ if (r.code !== 0) return false;
258
+ const parsed = parseJson(r.stdout ?? r.out);
259
+ return Number((Array.isArray(parsed) ? parsed[0] : parsed)?.results?.[0]?.n) > 0;
260
+ }
261
+
242
262
  /*
243
263
  * THE STUDIO'S REPOSITORY, FOR ITS WORKER (lib/repo.mjs). The Claude app's hand-off opens Claude Code on the studio's
244
264
  * own GitHub repository, and only the studio knows which it is: the deploy hands it to the Worker as a variable
@@ -299,6 +319,14 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
299
319
  const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
300
320
  if (migrate.code !== 0 && !first) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
301
321
  if (!first) step('D1 migrations applied');
322
+ // Workers AI (0.17.0): bound only when a server's AI guides think with it. The checkout is thrown away after.
323
+ if (!first && needsWorkersAi(root, w, names.binding)) {
324
+ try {
325
+ const json = JSON.parse(readFileSync(configPath(root), 'utf8').replace(/^\s*\/\/.*$/gm, ''));
326
+ if (!json.ai) { json.ai = { binding: 'AI' }; writeFileSync(configPath(root), `${JSON.stringify(json, null, 2)}\n`); }
327
+ step('Workers AI bound (AI) for the AI guides');
328
+ } catch { step('could not add the Workers AI binding to wrangler.jsonc: the guides answer from the game\'s script'); }
329
+ }
302
330
  const started = Date.now();
303
331
  const dep = w(['deploy', ...repoVar(root)]);
304
332
  if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
package/lib/mcp-tools.mjs CHANGED
@@ -11,6 +11,10 @@
11
11
  *
12
12
  * Every tool works in ONE studio folder: one the person named, else the one in use, else the only one there is. The
13
13
  * file tools never leave it (lib/files.mjs).
14
+ *
15
+ * AN AI IN A SEAT (0.17.0, lib/agent-seat.mjs): agent_sit, agent_look, agent_do and agent_stand put the person's own
16
+ * AI in a game's guide seat, marked " · AI". This process holds the socket between turns; the game's own bot code
17
+ * moves the body while the AI thinks; the AI chooses only the game's own goals and lines (agents.json).
14
18
  */
15
19
  import { spawnSync } from 'node:child_process';
16
20
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs';
@@ -26,6 +30,7 @@ import { newStudio, slugify } from './scaffold.mjs';
26
30
  import { repoFromUrl, studioRepo } from './repo.mjs';
27
31
  import { GAME_ID, findStudio, listGames, readLocal, readStudio, siteUrl, workerDir } from './studio.mjs';
28
32
  import { STUDIO_VERSION } from './version.mjs';
33
+ import { AgentSeat } from './agent-seat.mjs';
29
34
 
30
35
  export const UI = Object.freeze({
31
36
  setup: 'ui://homie-studio/setup',
@@ -960,6 +965,57 @@ export function toolDefs(ctx, avail = {}) {
960
965
  return r.job.code === 0 ? ok(text, { kind: 'run', args, result: r.result }) : fail(`homie-studio ${args.join(' ')}: ${whyOf(r.job)}`, { kind: 'run', args, result: r.result });
961
966
  },
962
967
  },
968
+ {
969
+ name: 'agent_sit', title: 'Sit in a game as an AI guide',
970
+ description: 'Take a guide\'s seat in a live room of one of the studio\'s games as an AI ("Claude · AI": always marked AI, never on a humans-only server, only in a room with people in it). The game\'s own bot code moves the body every frame; you choose its goal and, when the server lets its AI talk, one of the game\'s own lines (agents.json; never free text). Returns your seat, what you see (view), the asks players made of you and your choices. Then agent_look and agent_do every 20 to 40 seconds, and agent_stand when done. Uses the site running here (preview_run) or the live site; with no pass, makes a one-day guide pass with the owner\'s office key and revokes it when you stand.',
971
+ inputSchema: { type: 'object', properties: { game: str('The game\'s id'), server: str('Optional: a server id (default: the pass\'s, else Quick play)'), pass: str('Optional: an agent pass (hap_…) the owner gave; never shown back'), label: str('Optional: the name you play under (default "Claude"; " · AI" is added)'), url: str('Optional: the site (default: this computer\'s preview, else the live site)'), ...STUDIO_ARG }, required: ['game'] },
972
+ annotations: { title: 'Sit in a game as an AI guide', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
973
+ run: async (a) => {
974
+ const root = ctx.root(a.studio);
975
+ if (ctx.seat && !ctx.seat.closed) await ctx.seat.stand();
976
+ const site = a.url ? String(a.url) : devOf(root)?.url ?? siteUrl(root);
977
+ if (!site) return fail('the studio is not running here and has no live site: preview_run first, or studio_deploy');
978
+ const seat = new AgentSeat({ site, game: String(a.game ?? ''), server: a.server ?? null, pass: a.pass ?? null, label: a.label ? String(a.label).slice(0, 16) : 'Claude', root });
979
+ const r = await seat.sit();
980
+ if (!r.ok) return fail(`No seat: ${r.why}`, { kind: 'agent', ok: false, error: r.error ?? null });
981
+ ctx.seat = seat;
982
+ return ok(`Seated as ${r.name} in ${r.room} (seat ${r.seat}). ${r.talking ? 'This server lets its AI talk: lines from look.choices.lines, at most one every 8 s.' : 'This server\'s AI does not talk: choose goals only (say null).'} Look again with agent_look; act with agent_do { goal, args, say?, sayArgs? }.\n${JSON.stringify({ view: r.view, asks: r.asks, choices: r.choices }, null, 1).slice(0, 6000)}`, { kind: 'agent', ...r });
983
+ },
984
+ },
985
+ {
986
+ name: 'agent_look', title: 'Look as the AI guide',
987
+ description: 'What your AI guide sees now: the view the game showed you (game state, seats, never names), the asks players made of you (answer these first), the party\'s lines, your last goal, and your choices.',
988
+ inputSchema: { type: 'object', properties: {} },
989
+ annotations: { title: 'Look as the AI guide', ...RO },
990
+ run: async () => {
991
+ if (!ctx.seat) return fail('not seated: agent_sit first');
992
+ const l = ctx.seat.look();
993
+ return ok(`${l.closed ? `The seat closed (${l.closed}); agent_sit again.\n` : ''}${JSON.stringify(l, null, 1).slice(0, 8000)}`, { kind: 'agent', ...l });
994
+ },
995
+ },
996
+ {
997
+ name: 'agent_do', title: 'Act as the AI guide',
998
+ description: 'Choose your guide\'s goal (one of look.choices.goals, with argument values the view offers; a player argument is a seat number) and, if the server lets its AI talk, at most one line (look.choices.lines; never free text). The game\'s bot code carries it out at frame rate until you choose again. At most one goal every 3 s and one line every 8 s.',
999
+ inputSchema: { type: 'object', properties: { goal: str('A goal id'), args: { type: 'object', description: 'The goal\'s arguments' }, say: str('Optional: a line id'), sayArgs: { type: 'object', description: 'The line\'s arguments' } }, required: ['goal'] },
1000
+ annotations: { title: 'Act as the AI guide', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1001
+ run: async (a) => {
1002
+ if (!ctx.seat) return fail('not seated: agent_sit first');
1003
+ const r = await ctx.seat.do({ goal: a.goal, args: a.args ?? {}, say: a.say ?? null, sayArgs: a.sayArgs ?? {} });
1004
+ return r.ok ? ok(`Done: ${r.sent.goal} ${JSON.stringify(r.sent.args)}${r.text ? `, saying "${r.text}"` : ''}.`, { kind: 'agent', ...r }) : fail(r.why, { kind: 'agent', ok: false });
1005
+ },
1006
+ },
1007
+ {
1008
+ name: 'agent_stand', title: 'Leave the seat',
1009
+ description: 'Your AI guide leaves the room (its seat becomes a guide the game\'s own script plays); a pass made for this sitting is revoked.',
1010
+ inputSchema: { type: 'object', properties: {} },
1011
+ annotations: { title: 'Leave the seat', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1012
+ run: async () => {
1013
+ if (!ctx.seat) return ok('Not seated.');
1014
+ const r = await ctx.seat.stand();
1015
+ ctx.seat = null;
1016
+ return ok(`Left ${r.room}.`, { kind: 'agent', ...r });
1017
+ },
1018
+ },
963
1019
  {
964
1020
  name: 'studio_job', title: 'A job, so far',
965
1021
  description: 'Where a job a tool handed back is (an npm install, a check, a deploy, a render): running or done, its last lines and its result. Waits up to 30 s for it to finish.',
package/lib/office.mjs CHANGED
@@ -59,7 +59,7 @@ function signinTo(root, url, to) {
59
59
 
60
60
  export const askedFor = (root, url, r, command) => {
61
61
  if (!r.ok) return { ok: false, command, why: r.message ?? r.why ?? `the studio said ${r.error ?? r.status}` };
62
- if (r.needs !== 'owner' || !r.ask) return { ok: true, command, done: true, result: r, message: r.what ? `Done: ${r.what}` : 'Done.' };
62
+ if (r.needs !== 'owner' || !r.ask) return { ok: true, command, done: true, result: r, message: `${r.what ? `Done: ${r.what}` : 'Done.'}${r.note ? ` ${r.note}` : ''}` };
63
63
  const link = signinTo(root, url, `/_studio/confirm/${r.ask.id}`);
64
64
  return {
65
65
  ok: true, command, asked: true, ask: r.ask, what: r.ask.what,
package/lib/scaffold.mjs CHANGED
@@ -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 } },
@@ -288,6 +291,16 @@ studio's pinned copy, never a registry lookup of the bare name.
288
291
  - **Agent passes:** \`npx --no-install homie-studio agents pass <id> --label Claude [--server <server>]\` gives an
289
292
  AI its way into a seat (shown once; \`agents revoke <pass>\` ends it). It sits with \`POST /<id>/api/agent\`
290
293
  (Bearer pass), only in a room with people in it, and never on a humans-only server.
294
+ - **AI guides that talk (0.17.0):** a beginner server's guides get a brain. A game's own words for them are
295
+ \`games/<id>/agents.json\` (its vocabulary: goals, lines, the asks a player taps; the build checks it), and
296
+ \`useAgents\` from \`@homie-rocks/studio/agents\` is the host's side: a view per guide, the scripted floor, goals
297
+ for the hands, lines drawn from the vocabulary (\`NETPLAY.md\` section 18; Ember Vale is the reference). The
298
+ brain is the server's: \`agents brain <id> <server> workers-ai\` (the studio's own Workers AI; \`npm run deploy\`
299
+ binds it; at most \`--budget\` neurons a day, 8,000 by default) or \`owner-key\` (the owner's own key, set with
300
+ \`agents brain key\` on this computer, never in a chat; a dollar cap a day). The first time AI talk is turned on
301
+ it only ASKS. The AI never types: it picks a goal and a line id; the relay drops anything else. With no AI, over
302
+ budget, or between decisions, the game's \`decide\` plays. \`agent_sit\` (the local MCP) puts the owner's own
303
+ Claude in a guide's seat.
291
304
 
292
305
  ## Continuing a build from the Claude app
293
306