@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.
@@ -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)
@@ -31,8 +32,14 @@
31
32
  * migrations and deploys: the Worker and database are the Deploy button's.
32
33
  * homie-studio publish
33
34
  * homie-studio storage add (large media only: an R2 bucket; needs R2 turned on for the account)
34
- * homie-studio media put <file> [--as <key>]
35
- * homie-studio media list
35
+ * homie-studio media list (every song and video page, where each file is served from, what moves to R2)
36
+ * homie-studio media move [<file>...] [--dry-run] [--verify]
37
+ * (with storage: the big files of published songs and videos into R2, each
38
+ * read back and checked by SHA-256 before the site stops carrying it; the
39
+ * address stays the same and the file stays in this folder. With no file:
40
+ * everything over studio.json media.r2Over, 1 MiB unless set, or left out of
41
+ * git. Every deploy does it too. --verify re-checks what R2 holds)
42
+ * homie-studio media put <file> [--as <key>] (a loose file at /media/<key>; for a song or video, media move)
36
43
  * homie-studio status
37
44
  * homie-studio upgrade [--apply] [--diff]
38
45
  * (what this version's template adds to an existing studio: AGENTS.md
@@ -71,9 +78,14 @@
71
78
  * homie-studio agents pass <game|any> --label "<Name>" [--server <id>] [--hands self|host] [--days 7]
72
79
  * (an AI's way into a seat, always marked AI; the pass is shown once)
73
80
  * homie-studio agents passes [<game>] homie-studio agents revoke <pass id>
74
- * homie-studio agents brain <game> <server> off|script|workers-ai|owner-key
81
+ * homie-studio agents brain <game> <server> off|script|workers-ai|owner-key [--budget <n>]
75
82
  * (a narrowing change, closing a server, removing a member and the first
76
- * time AI guides may talk are ASKED for, like office kick)
83
+ * time AI guides may talk are ASKED for, like office kick; --budget is
84
+ * Workers AI neurons a day, or dollars a day for the owner's key)
85
+ * homie-studio agents brain key [--remove] (the owner's AI key, typed into a page on this computer only)
86
+ * homie-studio agents sit <game> [--server <id>] [--pass hap_…] [--label Claude]
87
+ * (an AI guide's seat from this terminal: then lines of `do <goal> {args}`,
88
+ * `say <line> {args}`, `look`, `stand`)
77
89
  *
78
90
  * homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
79
91
  * [--budget <dollars>] [--unit usd|credits] [--share]
@@ -130,14 +142,14 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, wr
130
142
  import { basename, dirname, join, relative, resolve } from 'node:path';
131
143
  import { build } from '../lib/build.mjs';
132
144
  import { check } from '../lib/check.mjs';
133
- import { ciDeploy, deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
145
+ import { ciDeploy, deploy, deployPlan, mediaMove, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
134
146
  import { setupAttach } from '../lib/setup.mjs';
135
147
  import { chromeArgs, findChrome, installChrome, noChrome } from '../lib/chrome.mjs';
136
148
  import { publish } from '../lib/directory.mjs';
137
149
  import { look } from '../lib/look.mjs';
138
150
  import { importPort, planPort } from '../lib/port.mjs';
139
151
  import { portCheck } from '../lib/port-check.mjs';
140
- import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
152
+ import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
141
153
  import { ensureMigrations, migrationWord, newStudio } from '../lib/scaffold.mjs';
142
154
  import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
143
155
  import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
@@ -145,7 +157,8 @@ import { STUDIO_VERSION } from '../lib/version.mjs';
145
157
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
146
158
  import { playersOwner, playersShow } from '../lib/players.mjs';
147
159
  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';
160
+ import { agentsBrain, agentsBrainKey, agentsPass, agentsPasses, agentsRevoke, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
161
+ import { AgentSeat } from '../lib/agent-seat.mjs';
149
162
  import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
150
163
  import { formatStatus, setupStatus } from '../lib/doctor.mjs';
151
164
  import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
@@ -157,7 +170,7 @@ import { formatHandoff, handoff } from '../lib/handoff.mjs';
157
170
 
158
171
  const argv = process.argv.slice(2);
159
172
  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'];
173
+ 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', 'dry-run', 'verify'];
161
174
  const positional = [];
162
175
  for (let i = 0; i < argv.length; i++) {
163
176
  const a = argv[i];
@@ -203,10 +216,31 @@ function print(result) {
203
216
  case 'media list':
204
217
  for (const kind of ['music', 'videos']) {
205
218
  lines.push(`${kind}/manifest.json: ${result[kind].pages.length} page(s)`);
206
- for (const e of result[kind].pages) lines.push(` /${kind}/${e.slug}/ ${e.title} (${e.files.map((f) => `${f.role} from ${f.from}`).join(', ')})`);
219
+ for (const e of result[kind].pages) lines.push(` /${kind}/${e.slug}/ ${e.title} (${e.files.map((f) => `${f.role} from ${f.from === 'r2' ? 'R2' : f.from}`).join(', ')})`);
207
220
  for (const m of result[kind].skipped) lines.push(` left out: ${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
221
+ for (const m of result[kind].notes ?? []) lines.push(` note: ${m.item} ${m.file}: ${m.why}`);
208
222
  }
223
+ if (result.moves.length) {
224
+ lines.push('', `Big media${result.r2 ? ' for R2' : ''} (over ${result.over === null ? '(off)' : lineOf(result.over)}, or left out of git):`);
225
+ for (const m of result.moves) lines.push(` ${m.path} ${m.bytes ? sizeOf(m.bytes) : ''} ${m.state === 'too-big' ? m.why : m.reason ?? ''}`);
226
+ }
227
+ if (result.storage) lines.push('', result.storage);
228
+ if (result.r2) lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media once these are in. ${result.cost}`);
229
+ break;
230
+ case 'media move': {
231
+ const verb = result.dryRun ? 'Would move' : 'Moved';
232
+ if (result.ok === false && result.needs === 'storage') { lines.push(result.why); break; }
233
+ if (result.off) { lines.push(result.why); break; }
234
+ const todo = result.rows.filter((r) => r.state === 'move');
235
+ if (result.dryRun) lines.push(todo.length ? `${verb} ${todo.length} file(s) to R2 ${result.bucket}:` : 'Nothing to move: every big file is in R2 already, or none is published.', ...todo.map((r) => ` ${r.path} ${sizeOf(r.bytes)} (${r.reason})`));
236
+ else lines.push(result.moved.length ? `${verb} ${result.moved.length} file(s) to R2 ${result.bucket}, each read back and checked by SHA-256:` : 'Nothing moved.', ...result.moved.map((m) => ` ${m.path} ${sizeOf(m.bytes)} -> served at ${m.url} (the same address)`));
237
+ for (const f of result.failed ?? []) lines.push(` NOT moved: ${f.path}: ${f.why}`);
238
+ for (const v of result.verified ?? []) lines.push(` ${v.ok ? 'verified' : 'MISMATCH'}: ${v.path}${v.why ? `: ${v.why}` : ''}`);
239
+ for (const r of result.rows.filter((x) => x.state === 'too-big' || x.state === 'unlisted' || x.state === 'missing')) lines.push(` ${r.state}: ${r.path}: ${r.why}`);
240
+ if (result.next) lines.push('', result.next);
241
+ lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media. ${result.cost}`, ...(result.warning ? [`Warning: ${result.warning}`] : []));
209
242
  break;
243
+ }
210
244
  case 'deploy':
211
245
  if (result.ci) {
212
246
  lines.push(`Live: ${result.url ?? '(Wrangler printed no address)'}${result.commit ? ` (commit ${result.commit.slice(0, 7)}${result.branch ? ` on ${result.branch}` : ''})` : ''}`, ...result.steps.map((x) => ` ${x.what}`), ...result.games.map((g) => ` ${g.id}: ${g.play}`));
@@ -214,7 +248,8 @@ function print(result) {
214
248
  }
215
249
  lines.push(`Live: ${result.url}`, ...(result.workersDev && result.workersDev !== result.url ? [` also at ${result.workersDev}`] : []),
216
250
  ...(result.local ? [` (the workers.dev address names your Cloudflare account, so it is kept in ${result.local} on this computer, never in studio.json)`] : []),
217
- ...result.games.map((g) => ` ${g.id}: ${g.play}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}, Durable Objects Table + Lobby${result.r2 ? `, R2 ${result.r2}` : ' (no storage: none needed; `homie-studio storage add` adds it for large media)'}. All on the free Workers plan${result.r2 ? ' plus R2' : ''}.`,
251
+ ...result.games.map((g) => ` ${g.id}: ${g.play}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`),
252
+ ...(result.media?.moved ?? []).map((m) => ` moved to R2 (checked by SHA-256): ${m.path}, still at ${m.url}`), ...(result.media?.failed ?? []).map((m) => ` not moved to R2: ${m.path}: ${m.why}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}, Durable Objects Table + Lobby${result.r2 ? `, R2 ${result.r2}` : ' (no storage: none needed; `homie-studio storage add` adds it for large media)'}. All on the free Workers plan${result.r2 ? ' plus R2' : ''}.`,
218
253
  result.claim ? 'The site claimed itself in the directory: list the games with the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish' : 'No directory claim yet (the site claims itself when the directory first reads it; publish does).');
219
254
  break;
220
255
  case 'deploy plan':
@@ -302,8 +337,12 @@ function print(result) {
302
337
  break;
303
338
  case 'servers level':
304
339
  case 'agents revoke':
340
+ case 'agents brain key':
305
341
  lines.push(result.message);
306
342
  break;
343
+ case 'agents sit':
344
+ lines.push(`Left ${result.room}.`);
345
+ break;
307
346
  case 'agents pass':
308
347
  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
348
  break;
@@ -399,6 +438,12 @@ function print(result) {
399
438
  if (!flags.has('diff')) lines.push(' (--diff shows how each differs from the template: "-" this studio\'s lines, "+" the template\'s)');
400
439
  }
401
440
  if (result.uncommitted && !result.applied && result.changes.length) lines.push('', `Note: ${result.uncommitted} file(s) have uncommitted changes; commit them first so the upgrade is a change of its own.`);
441
+ if (result.media) {
442
+ lines.push('', `Big media (songs and videos over ${result.media.over === null ? '(off)' : lineOf(result.media.over)}, or left out of git):`);
443
+ for (const r of result.media.inR2) lines.push(` = ${r.path} in R2`);
444
+ for (const r of result.media.big) lines.push(` > ${r.path} ${r.bytes ? sizeOf(r.bytes) : ''} ${r.state === 'too-big' ? r.why : r.reason ?? ''}`);
445
+ if (result.media.next) lines.push(` ${result.media.next}`);
446
+ }
402
447
  if (result.next?.length) lines.push('', 'Next:', ...result.next.map((n) => ` ${n}`));
403
448
  break;
404
449
  }
@@ -536,15 +581,27 @@ async function main() {
536
581
  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
582
  if (sub === 'passes') return agentsPasses(root, positional[2], { url });
538
583
  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)` };
584
+ if (sub === 'brain' && positional[2] === 'key') return agentsBrainKey(root, { remove: flags.has('remove'), log });
585
+ if (sub === 'brain') return agentsBrain(root, positional[2], positional[3], positional[4], { url, budget: flags.get('budget') });
586
+ if (sub === 'sit') return agentsSit(root, positional[2], { url, server: flags.get('server'), pass: flags.get('pass'), label: flags.get('label') });
587
+ return { ok: false, command: 'agents', why: `unknown: agents ${sub ?? ''} (pass, passes, revoke, brain, brain key, sit)` };
541
588
  }
542
589
  if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
590
+ if (cmd === 'media' && sub === 'move') return mediaMove(root, { paths: positional.slice(2), dryRun: flags.has('dry-run') || flags.has('plan'), verify: flags.has('verify'), log });
543
591
  if (cmd === 'media' && sub === 'list') {
544
592
  const studio = readStudio(root);
545
593
  const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
546
- const view = (kind) => { const r = resolveMedia(root, kind, { r2 }); return { pages: r.entries.map((e) => ({ slug: e.slug, title: e.title, kind: e.kind, files: e.files.map(({ abs, rel, ...f }) => f) })), skipped: r.skipped }; };
547
- return { ok: true, command: 'media list', r2, site: siteUrl(root, studio), music: view('music'), videos: view('videos') };
594
+ // What a deploy would serve (deploy: true), so "from r2" means the site no longer carries the file.
595
+ const view = (kind) => { const r = resolveMedia(root, kind, { r2, deploy: true }); return { pages: r.entries.map((e) => ({ slug: e.slug, title: e.title, kind: e.kind, files: e.files.map(({ abs, rel, ...f }) => f) })), skipped: r.skipped, notes: r.notes }; };
596
+ const over = r2OverOf(studio);
597
+ const plan = mediaPlan(root, { over });
598
+ const moves = plan.filter((x) => x.state === 'move' || x.state === 'too-big').map(({ abs, held, ...x }) => x);
599
+ const inR2 = plan.filter((x) => x.state === 'r2' || x.state === 'move').reduce((n, x) => n + (x.bytes ?? 0), 0);
600
+ return {
601
+ ok: true, command: 'media list', r2, site: siteUrl(root, studio), music: view('music'), videos: view('videos'), over, moves, inR2,
602
+ storage: r2 ? (moves.length ? 'the next deploy (or media move now) puts these in R2, checked by SHA-256; their addresses stay the same' : 'every big file is in R2') : (moves.length ? 'no storage: these stay on the site (files up to 25 MiB each), and a deploy from another computer or Workers Builds leaves out any file git does not keep. homie-studio storage add (a payment method on the Cloudflare account, asked first) fixes both' : null),
603
+ cost: R2_COST,
604
+ };
548
605
  }
549
606
  return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
550
607
  }
@@ -714,7 +771,9 @@ async function dev(root) {
714
771
  m.on('close', done);
715
772
  });
716
773
  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' });
774
+ const ai = devConfig(root, flags.has('remote-ai'));
775
+ if (ai.note) log(ai.note);
776
+ const child = spawn(bin, ['dev', '--local', '--ip', '127.0.0.1', '--port', port, ...ai.args], { cwd: workerDir(root), env, stdio: 'inherit' });
718
777
  mkdirSync(dirname(devFile(root)), { recursive: true });
719
778
  writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
720
779
  log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
@@ -725,6 +784,54 @@ async function dev(root) {
725
784
  return { ok: true, command: 'dev', stopped: true };
726
785
  }
727
786
 
787
+ /**
788
+ * AI guides under `dev` (0.17.0): they think scripted, for free, unless --remote-ai (real Workers AI on the signed-in
789
+ * account, billed like the live site). A config whose AI binding is not what was asked for runs from a copy in
790
+ * .wrangler/ (absolute paths, the same local state), so wrangler.jsonc itself is never changed by dev.
791
+ */
792
+ function devConfig(root, remoteAi) {
793
+ const file = join(workerDir(root), 'wrangler.jsonc');
794
+ let json;
795
+ try { json = JSON.parse(readFileSync(file, 'utf8').replace(/^\s*\/\/.*$/gm, '')); } catch { return { args: [] }; }
796
+ if (Boolean(json.ai) === remoteAi) return { args: [], note: remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : null };
797
+ const base = workerDir(root);
798
+ const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
799
+ delete json.$schema;
800
+ json.main = abs(json.main);
801
+ if (json.assets?.directory) json.assets.directory = abs(json.assets.directory);
802
+ json.d1_databases = (json.d1_databases ?? []).map((d) => ({ ...d, ...(d.migrations_dir ? { migrations_dir: abs(d.migrations_dir) } : {}) }));
803
+ if (remoteAi) json.ai = { binding: 'AI', remote: true }; else delete json.ai;
804
+ const copy = join(base, '.wrangler', 'homie-dev.wrangler.json');
805
+ mkdirSync(dirname(copy), { recursive: true });
806
+ writeFileSync(copy, `${JSON.stringify(json, null, 2)}\n`);
807
+ 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).' };
808
+ }
809
+
810
+ /** `agents sit`: an AI guide's seat from this terminal, for a demo or a test of a vocabulary. Lines on stdin act. */
811
+ async function agentsSit(root, game, { url, server, pass, label }) {
812
+ const site = url ?? siteUrl(root);
813
+ const seat = new AgentSeat({ site, game, server: server ?? null, pass: pass ?? null, label: label ?? 'Claude', root });
814
+ const r = await seat.sit();
815
+ if (!r.ok) return { ok: false, command: 'agents sit', why: r.why };
816
+ log(`Seated as ${r.name} in ${r.room} (seat ${r.seat}). Type: look | do <goal> {"arg":…} | say <line> {"arg":…} with a goal | stand`);
817
+ log(JSON.stringify({ view: r.view, asks: r.asks, choices: r.choices }, null, 1));
818
+ const { createInterface } = await import('node:readline');
819
+ const rl = createInterface({ input: process.stdin });
820
+ let goal = null;
821
+ for await (const line of rl) {
822
+ const [word, id, ...rest] = line.trim().split(/\s+/);
823
+ let args = {};
824
+ try { args = rest.length ? JSON.parse(rest.join(' ')) : {}; } catch { log('arguments are JSON, e.g. {"place":"camp"}'); continue; }
825
+ if (word === 'look') log(JSON.stringify(seat.look(), null, 1));
826
+ 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}`); }
827
+ 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}`); }
828
+ else if (word === 'stand') break;
829
+ }
830
+ rl.close();
831
+ await seat.stand();
832
+ return { ok: true, command: 'agents sit', stood: true, room: r.room };
833
+ }
834
+
728
835
  /** A big file into the studio's R2, and its key on the music/ or videos/ manifest entry that names it. */
729
836
  function mediaPut(root, file, as) {
730
837
  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)
@@ -13,7 +15,11 @@
13
15
  * site/dist/_studio/codex/<id>/ each game's Game Codex (games/<id>/CODEX.md, lib/codex.mjs): served only to
14
16
  * the studio's owner (worker/stats-page.mjs), never listed or indexed
15
17
  * site/dist/<file> site/public, as it is (fonts, a logo, hero footage)
16
- * site/dist/music/..., site/dist/videos/... media files the site serves itself (R2 keys are served from R2)
18
+ * site/dist/music/..., site/dist/videos/... media files the site serves itself. A file the studio's R2 has (media
19
+ * move: the same bytes, by SHA-256) is left out of a deploy's build and
20
+ * served from R2 at the same address; a local build still copies it when it
21
+ * fits, for `dev` (whose R2 is empty). Workers Builds (WORKERS_CI=1) builds
22
+ * as a deploy.
17
23
  *
18
24
  * game.json "build" picks how a game becomes files (a ported game keeps its own shape):
19
25
  * (absent) / { "mode": "bundle" } src/main.ts (or "entry") bundled by esbuild — new games and ES-module ports
@@ -35,6 +41,7 @@ import { SEAT_MAX } from '../worker/seats.mjs';
35
41
  import { STUDIO_VERSION } from './version.mjs';
36
42
  import { licenseOf, remixRow } from '../worker/license.mjs';
37
43
  import { SERVER_LIMITS, serverOf } from '../worker/servers.mjs';
44
+ import { vocabularyOf } from '../worker/brain.mjs';
38
45
 
39
46
  const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
40
47
  /** Never copied into a static game's served folder. */
@@ -152,6 +159,22 @@ export function netplayRevOf(out) {
152
159
  return rev;
153
160
  }
154
161
 
162
+ /**
163
+ * games/<id>/agents.json, checked (worker/brain.mjs vocabularyOf: argument types, text over 120 characters, a goal
164
+ * against a player, an ask naming a goal or line that is not there) and served beside the game, where the room's Table
165
+ * reads it. A game with none has guides that play but never talk. Returns whether it has one.
166
+ */
167
+ export function vocabFor(g, out) {
168
+ const file = join(g.dir, 'agents.json');
169
+ if (!existsSync(file)) { rmSync(join(out, 'agents.json'), { force: true }); return false; }
170
+ let raw;
171
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { throw new Error(`games/${g.id}/agents.json is not JSON: ${error.message}`); }
172
+ const v = vocabularyOf(raw);
173
+ if (!v.ok) throw new Error(`games/${g.id}/agents.json: ${v.problems.slice(0, 6).join('; ')}`);
174
+ writeFileSync(join(out, 'agents.json'), `${JSON.stringify(raw)}\n`);
175
+ return true;
176
+ }
177
+
155
178
  /** game.json "servers": the seeds a game ships with (worker/servers.mjs; a D1 row of the same id wins). */
156
179
  function serverSeeds(g, log) {
157
180
  if (!Array.isArray(g.servers)) return [];
@@ -182,7 +205,7 @@ export function seatsFor(g, net = netplayOf(g)) {
182
205
  return { min: Math.min(max, minNamed ?? 1), max, asked };
183
206
  }
184
207
 
185
- export async function build(root, { only = null, log = () => {} } = {}) {
208
+ export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1' } = {}) {
186
209
  const require = createRequire(join(root, 'package.json'));
187
210
  let esbuild;
188
211
  try { esbuild = require('esbuild'); } catch { esbuild = await import('esbuild'); }
@@ -236,6 +259,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
236
259
  }
237
260
  if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
238
261
  if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
262
+ // The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
263
+ vocabFor(g, out);
239
264
  // The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
240
265
  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
266
  const main = join(out, 'assets', 'main.js');
@@ -249,9 +274,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
249
274
  // Songs and videos (music/ and videos/ manifests): rebuilt with every full build; a one-game build keeps them.
250
275
  const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
251
276
  let media = null;
252
- if (!only) media = buildMedia(root, dist, { r2, log });
277
+ if (!only) media = buildMedia(root, dist, { r2, deploy, log });
253
278
  else {
254
- try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [] }; } catch { media = { songs: [], videos: [], skipped: [] }; }
279
+ try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [], notes: [] }; } catch { media = { songs: [], videos: [], skipped: [], notes: [] }; }
255
280
  }
256
281
  // The site around the games (site/SITE.md): the look, each game's landing, posts, and what site/ overrides.
257
282
  const theme = readTheme(root, { log });
@@ -277,6 +302,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
277
302
  // predates servers), and game.json "agents": { "vote": "game" | false } (the game draws its own vote card, or none).
278
303
  ...(seeds.length ? { servers: seeds } : {}),
279
304
  netplayRev: netplayRevOf(join(dist, 'games', g.id)),
305
+ // 0.17.0: the game has a vocabulary for its AI guides (agents.json), so they can talk once the owner says so.
306
+ ...(existsSync(join(dist, 'games', g.id, 'agents.json')) ? { vocab: true } : {}),
280
307
  ...(g.agents && typeof g.agents === 'object' && (g.agents.vote === 'game' || g.agents.vote === false) ? { agents: { vote: g.agents.vote } } : {}),
281
308
  // Its source licence (worker/license.mjs), and, for a remix, what it is a remix of (shown on its landing).
282
309
  license: licenseOf(g.license),
@@ -322,7 +349,7 @@ export async function build(root, { only = null, log = () => {} } = {}) {
322
349
  writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
323
350
  return {
324
351
  ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
325
- songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped,
352
+ songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped, mediaNotes: media.notes ?? [],
326
353
  posts: posts.map((p) => p.slug), postsSkipped, codexes, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
327
354
  landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length), source: g.landing.source })),
328
355
  };