@homie-rocks/studio 0.16.1 → 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 +28 -0
- package/netplay/NETPLAY.md +157 -6
- package/netplay/netplay.ts +10 -4
- package/package.json +7 -1
- 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 +115 -7
- 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/bin/homie-studio.mjs
CHANGED
|
@@ -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]
|
|
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
|
|
540
|
-
|
|
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
|
|
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),
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -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
|
|