@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/worker/office.mjs
CHANGED
|
@@ -45,8 +45,9 @@
|
|
|
45
45
|
*/
|
|
46
46
|
import { SEAT_MAX, seatsOf } from './seats.mjs';
|
|
47
47
|
import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
|
|
48
|
-
import { aiName, fillSpot, passById, passCreate, passList, passRevoke } from './agents.mjs';
|
|
49
|
-
import {
|
|
48
|
+
import { BRAIN_BUDGET, aiName, fillSpot, passById, passCreate, passList, passRevoke } from './agents.mjs';
|
|
49
|
+
import { DEFAULT_MODEL, OWNER_MODEL } from './brain.mjs';
|
|
50
|
+
import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession, today } from './stats.mjs';
|
|
50
51
|
import { esc, layout, notFoundPage } from './site.mjs';
|
|
51
52
|
import { confirmPage, lockedPage, officePage } from './office-page.mjs';
|
|
52
53
|
import { licenseOf } from './license.mjs';
|
|
@@ -455,9 +456,35 @@ async function roomRow(env, meta, room, max) {
|
|
|
455
456
|
host: f.host ?? null, announce: f.announce ?? null,
|
|
456
457
|
slots: slots.slice(0, 64).map((s) => ({ slot: s.slot, seat: s.seat ?? null, name: oneLine(s.name, 40), bot: Boolean(s.bot), ...(s.agent ? { agent: { role: s.agent.role ?? 'party', seat: s.agent.seat ?? null } } : {}) })),
|
|
457
458
|
clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
|
|
459
|
+
// The house guides' brains (0.17.0): which brain, why not when it is not, and the last decisions (seats and ids).
|
|
460
|
+
...(f.brains && typeof f.brains === 'object' ? { brains: {
|
|
461
|
+
brain: String(f.brains.brain ?? ''), why: f.brains.why ? oneLine(f.brains.why, 120) : null, calls: Number(f.brains.calls) || 0,
|
|
462
|
+
guides: (Array.isArray(f.brains.guides) ? f.brains.guides : []).slice(0, 8).map((g) => ({ name: oneLine(g.name, 40), seat: g.seat ?? null, provider: g.provider ?? null })),
|
|
463
|
+
decisions: (Array.isArray(f.brains.decisions) ? f.brains.decisions : []).slice(-50),
|
|
464
|
+
} } : {}),
|
|
458
465
|
};
|
|
459
466
|
}
|
|
460
467
|
|
|
468
|
+
/** The AI brains' day for the whole studio: the budget (meta brain_budget) and what today used (stats_daily). */
|
|
469
|
+
export async function brainDay(env) {
|
|
470
|
+
const budget = { neurons: BRAIN_BUDGET.neurons, usd: BRAIN_BUDGET.usd };
|
|
471
|
+
const used = { neurons: 0, usd: 0, calls: 0 };
|
|
472
|
+
try {
|
|
473
|
+
const b = JSON.parse((await env.DB.prepare("SELECT value FROM meta WHERE key = 'brain_budget'").first())?.value ?? 'null');
|
|
474
|
+
if (b && Number.isFinite(Number(b.neurons))) budget.neurons = Number(b.neurons);
|
|
475
|
+
if (b && Number.isFinite(Number(b.usd))) budget.usd = Number(b.usd);
|
|
476
|
+
} catch { /* the defaults */ }
|
|
477
|
+
try {
|
|
478
|
+
const { results } = await env.DB.prepare("SELECT metric, SUM(n) AS n FROM stats_daily WHERE day = ?1 AND metric IN ('brain-calls', 'brain-neurons', 'brain-microdollars') GROUP BY metric").bind(today()).all();
|
|
479
|
+
for (const r of results ?? []) {
|
|
480
|
+
if (r.metric === 'brain-calls') used.calls = Number(r.n) || 0;
|
|
481
|
+
if (r.metric === 'brain-neurons') used.neurons = Number(r.n) || 0;
|
|
482
|
+
if (r.metric === 'brain-microdollars') used.usd = Math.round(Number(r.n) || 0) / 1e6;
|
|
483
|
+
}
|
|
484
|
+
} catch { /* nothing counted yet */ }
|
|
485
|
+
return { budget, used, ai: Boolean(env.AI), key: Boolean(env.HOMIE_BRAIN_KEY), model: env.HOMIE_BRAIN_MODEL || DEFAULT_MODEL, ownerModel: OWNER_MODEL };
|
|
486
|
+
}
|
|
487
|
+
|
|
461
488
|
async function invitesOf(env, game, origin) {
|
|
462
489
|
try {
|
|
463
490
|
const { results } = await env.DB.prepare('SELECT id, code, label, uses, max_uses, expires_at, revoked, created_at, server FROM office_invites WHERE game = ?1 ORDER BY created_at DESC LIMIT 100').bind(game).all();
|
|
@@ -500,7 +527,9 @@ export async function officeView(env, cat, origin) {
|
|
|
500
527
|
id: g.id, name: g.name, launch, launchFrom: row?.launch ? 'office' : g.launch ? 'game.json' : 'default',
|
|
501
528
|
// Servers (0.16.0): each with its policy and what is on it now; its build reads the dial only from netplay rev 6.
|
|
502
529
|
servers: servers.map((sv) => ({ ...serverView(sv, { origin, game: g.id }), live: byServer[sv.id] ?? { rooms: 0, players: 0, ai: 0 }, members: members[sv.id] ?? 0 })),
|
|
503
|
-
build: { netplayRev: Number.isInteger(g.netplayRev) ? g.netplayRev : null, predates: !(Number(g.netplayRev) >= 6), caps: g.caps ?? null },
|
|
530
|
+
build: { netplayRev: Number.isInteger(g.netplayRev) ? g.netplayRev : null, predates: !(Number(g.netplayRev) >= 6), caps: g.caps ?? null, guides: Number(g.netplayRev) >= 7 },
|
|
531
|
+
// 0.17.0: the game's AI guides have words of their own (agents.json): without it they play but never talk.
|
|
532
|
+
vocab: g.vocab === true,
|
|
504
533
|
passes: (await passList(env, { game: g.id })).filter((x) => x.live || Date.now() - x.createdAt < 7 * 86_400_000),
|
|
505
534
|
remix: remixOf(g, settings), remixBuilt: g.landing?.source !== false, license: licenseOf(g.license).kind,
|
|
506
535
|
seats: seatsOf(g), maxPlayers: max, maxSet: Number(row?.max_players) >= 1 ? Number(row.max_players) : null,
|
|
@@ -515,6 +544,7 @@ export async function officeView(env, cat, origin) {
|
|
|
515
544
|
// The fill-a-spot service (an AI seated in an empty spot for a price) is a money decision: not offered yet.
|
|
516
545
|
fillSpot: { available: Boolean(fillSpot()), note: 'Let a fill-a-spot service seat AI (coming later)' },
|
|
517
546
|
agentsTalk: await talkConsented(env),
|
|
547
|
+
brain: await brainDay(env),
|
|
518
548
|
};
|
|
519
549
|
}
|
|
520
550
|
async function memberCountsOf(env, game) {
|
|
@@ -624,7 +654,10 @@ function checkAction(cat, op, body) {
|
|
|
624
654
|
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
625
655
|
if (typeof body.server !== 'string' || !(body.server === 'public' || /^[a-z0-9][a-z0-9-]{1,19}$/.test(body.server))) return bad('server is the server\'s id');
|
|
626
656
|
if (!['off', 'script', 'workers-ai', 'owner-key'].includes(body.mode)) return bad('mode is off, script, workers-ai or owner-key');
|
|
627
|
-
|
|
657
|
+
// The day's budget for the studio's brains: Workers AI neurons (workers-ai) or dollars of the owner's key (owner-key).
|
|
658
|
+
const budget = body.budget === undefined || body.budget === null ? undefined : Number(body.budget);
|
|
659
|
+
if (budget !== undefined && (!['workers-ai', 'owner-key'].includes(body.mode) || !(budget >= 0) || budget > (body.mode === 'owner-key' ? 100 : 1e7))) return bad('budget goes with workers-ai (neurons a day; the free allocation is 10,000 an account) or owner-key (dollars a day, at most 100)');
|
|
660
|
+
return { ok: true, action: { op, game: meta.id, server: body.server, mode: body.mode, ...(budget !== undefined ? { budget } : {}) } };
|
|
628
661
|
}
|
|
629
662
|
default:
|
|
630
663
|
return bad('unknown control');
|
|
@@ -670,7 +703,7 @@ export function describe(cat, a) {
|
|
|
670
703
|
case 'pass': return a.action === 'revoke' ? `Revoke the agent pass ${a.id}: that AI leaves every room and cannot sit again.` : `Issue an agent pass for an AI called "${a.label} · AI".`;
|
|
671
704
|
case 'room-level': return `Set the AI in ${where} to ${levelName(a.level)} (level ${a.level}).`;
|
|
672
705
|
case 'agents-brain': return ['workers-ai', 'owner-key'].includes(a.mode)
|
|
673
|
-
? `Let the AI guides on ${gname}'s server ${a.server} talk: they speak only the lines the game's own agents.json gives them (never free text), at most one line every 8 seconds, never about a person, and a player can quiet them. Their brain runs on ${a.mode === 'workers-ai' ?
|
|
706
|
+
? `Let the AI guides on ${gname}'s server ${a.server} talk: they speak only the lines the game's own agents.json gives them (never free text), at most one line every 8 seconds, never about a person, and a player can quiet them. Their brain runs on ${a.mode === 'workers-ai' ? `this studio's own Workers AI (free allowance${a.budget !== undefined ? `; at most ${Math.round(a.budget).toLocaleString('en-US')} neurons a day` : ''})` : `your own AI provider key (claude-haiku-4-5, your money${a.budget !== undefined ? `, at most $${Number(a.budget).toFixed(2)} a day` : ', capped daily'})`}.`
|
|
674
707
|
: `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
|
|
675
708
|
default: return 'A control.';
|
|
676
709
|
}
|
|
@@ -686,7 +719,12 @@ export async function needsAsk(env, cat, a) {
|
|
|
686
719
|
const before = meta ? (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server) : null;
|
|
687
720
|
return narrows(before, a.fields);
|
|
688
721
|
}
|
|
689
|
-
if (a.op === 'agents-brain')
|
|
722
|
+
if (a.op === 'agents-brain') {
|
|
723
|
+
if (['workers-ai', 'owner-key'].includes(a.mode) && !(await talkConsented(env))) return true;
|
|
724
|
+
// Raising the cap on the owner's own money is the owner's to confirm; Workers AI's free allocation is not money.
|
|
725
|
+
if (a.mode === 'owner-key' && a.budget !== undefined) return a.budget > (await brainDay(env)).budget.usd;
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
690
728
|
return false;
|
|
691
729
|
}
|
|
692
730
|
|
|
@@ -765,7 +803,7 @@ export async function perform(env, cat, a) {
|
|
|
765
803
|
try { await writeServer(env, meta.id, sv); } catch (error) { return { ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
|
|
766
804
|
const view = serverView(sv, { origin: a.origin ?? '', game: meta.id });
|
|
767
805
|
const notes = [];
|
|
768
|
-
if (sv.policy === 'beginner' && sv.guides) notes.push(`${sv.guides} AI guide seat${sv.guides === 1 ? '' : 's'} in every room, marked AI: the game's
|
|
806
|
+
if (sv.policy === 'beginner' && sv.guides) notes.push(`${sv.guides} AI guide seat${sv.guides === 1 ? '' : 's'} in every room, marked AI: they play from the game's script, silent. To let them talk (only the game's own agents.json lines), turn on a brain: agents brain ${meta.id} ${sv.id} workers-ai (the first time asks the owner).${meta.vocab ? '' : ` ${meta.name} has no agents.json yet: write one first.`}`);
|
|
769
807
|
if (sv.policy === 'hybrid') notes.push(`${sv.aiSeats} seat${sv.aiSeats === 1 ? '' : 's'} in every room are AI companions, marked AI; the party votes their level.`);
|
|
770
808
|
if (sv.policy === 'humans-only') notes.push('No AI can join; the game\'s practice bots are off unless the owner turns them on (bots: fill).');
|
|
771
809
|
if (!(Number(meta.netplayRev) >= 6)) notes.push(`${meta.name}'s build predates servers (netplay revision ${meta.netplayRev ?? '5 or older'}): reserved AI seats stay empty and its bots do not read the dial until it is rebuilt with @homie-rocks/studio 0.16.`);
|
|
@@ -839,8 +877,24 @@ export async function perform(env, cat, a) {
|
|
|
839
877
|
await writeServer(env, meta.id, sv);
|
|
840
878
|
if (['workers-ai', 'owner-key'].includes(a.mode)) await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('agents_talk_ok', ?1)").bind(String(Date.now())).run();
|
|
841
879
|
} catch (error) { return { ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
|
|
880
|
+
if (a.budget !== undefined) {
|
|
881
|
+
const day = await brainDay(env);
|
|
882
|
+
const next = { neurons: day.budget.neurons, usd: day.budget.usd, ...(a.mode === 'workers-ai' ? { neurons: Math.round(a.budget) } : { usd: Math.round(a.budget * 100) / 100 }) };
|
|
883
|
+
await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('brain_budget', ?1)").bind(JSON.stringify(next)).run().catch(() => {});
|
|
884
|
+
}
|
|
842
885
|
await pushPolicy(env, meta, settings, sv);
|
|
843
|
-
|
|
886
|
+
const day = await brainDay(env);
|
|
887
|
+
const notes = [];
|
|
888
|
+
if (!meta.vocab && ['workers-ai', 'owner-key'].includes(a.mode)) notes.push(`${meta.name} has no agents.json yet: its guides play but have no words. Write one (the game skill's "Write the guide vocabulary"), build and deploy.`);
|
|
889
|
+
if (a.mode === 'workers-ai') notes.push(!env.AI ? 'This Worker has no Workers AI binding yet: run `npm run deploy` once more (it binds Workers AI when a server uses it). Until then the guides answer from the game\'s script.'
|
|
890
|
+
: !(day.budget.neurons > 0) ? 'The day\'s Workers AI budget is 0: the guides answer from the game\'s script (set --budget to let them think).'
|
|
891
|
+
: `The guides think with Workers AI (${day.model}) from their next decision: at most ${day.budget.neurons.toLocaleString('en-US')} neurons a day for the whole studio (${Math.round(day.used.neurons).toLocaleString('en-US')} used today), then the game's script until 00:00 UTC.`);
|
|
892
|
+
if (a.mode === 'owner-key') notes.push(!env.HOMIE_BRAIN_KEY ? 'Set your key on your own computer: `npx --no-install homie-studio agents brain key` opens a page there (the key goes straight to the Worker, never through a chat). Until then the guides answer from the game\'s script.'
|
|
893
|
+
: !(day.budget.usd > 0) ? 'The day\'s dollar cap is 0: the guides answer from the game\'s script.'
|
|
894
|
+
: `The guides think with your key (claude-haiku-4-5) from their next decision, at most $${day.budget.usd.toFixed(2)} a day ($${day.used.usd.toFixed(2)} used today), then the game's script until 00:00 UTC.`);
|
|
895
|
+
if (a.mode === 'script') notes.push('The guides play from the game\'s script, silent.');
|
|
896
|
+
if (a.mode === 'off') notes.push('The guides are the game\'s plain bots.');
|
|
897
|
+
return { ok: true, op: a.op, game: meta.id, server: sv.id, brain: sv.brain, budget: day.budget, note: `Saved. ${notes.join(' ')}`.trim() };
|
|
844
898
|
}
|
|
845
899
|
default: return { ok: false, error: 'op' };
|
|
846
900
|
}
|
package/worker/room.mjs
CHANGED
|
@@ -28,13 +28,20 @@
|
|
|
28
28
|
* reserves for AI, names every agent "<label> · AI", rewrites the host's roster
|
|
29
29
|
* and results so they cannot hide an AI, filters speech, runs the party's vote on
|
|
30
30
|
* the skill dial, and closes agents that are left alone with no person seated.
|
|
31
|
+
*
|
|
32
|
+
* AGENT HANDS AND BRAINS (revision 7, NETPLAY.md section 18): an AI with no game client of its own (hands `host`)
|
|
33
|
+
* sees the game through `agent:view` (the host's, to its seat, at most one every 2 s, under 2 KB) and moves through
|
|
34
|
+
* `agent:do` (a goal of the game's agents.json, at most one every 3 s). It says only `say:<lineId>` lines of that
|
|
35
|
+
* vocabulary, with arguments that fit, and only on a server whose AI may talk: the relay drops everything else an AI
|
|
36
|
+
* says (`stats.agentDrops`). The Table's own house agents (worker/agents.mjs) are peers like any other, on a loopback.
|
|
31
37
|
* =============================================================================
|
|
32
38
|
*/
|
|
33
39
|
import { aiName, skillPreset, stripAi } from './agents.mjs';
|
|
40
|
+
import { checkArgs, talks, vocabularyOf } from './brain.mjs';
|
|
34
41
|
|
|
35
42
|
export const NET_VERSION = 1;
|
|
36
43
|
/** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
|
|
37
|
-
export const NET_REVISION =
|
|
44
|
+
export const NET_REVISION = 7;
|
|
38
45
|
const POLICY_KINDS = ['open', 'humans-only', 'hybrid', 'beginner'];
|
|
39
46
|
/**
|
|
40
47
|
* A room's policy before the Worker says anything (and the public server's with nothing set): today's behaviour.
|
|
@@ -48,6 +55,11 @@ export const VOTE = Object.freeze({ ms: 15_000, cooldownMs: 120_000, autoMs: 30
|
|
|
48
55
|
/** Agents left with no person seated for this long are closed (`agents-alone`): an AI never keeps a room alive. */
|
|
49
56
|
export const AGENTS_ALONE_MS = 60_000;
|
|
50
57
|
const CAPS = ['skill', 'agents'];
|
|
58
|
+
/** Revision 7: what a game's host shows an AI (`agent:view`) and how often an AI may act (`agent:do`). */
|
|
59
|
+
export const AGENT_FRAMES = Object.freeze({ viewMs: 2000, viewBytes: 2048, doMs: 3000 });
|
|
60
|
+
const SAY = /^say:([a-z][a-z0-9_]{0,31})$/;
|
|
61
|
+
const d0 = (m) => (m.d && typeof m.d === 'object' && !Array.isArray(m.d) ? m.d : {});
|
|
62
|
+
const ASK = /^ask:([a-z][a-z0-9_]{0,31})$/;
|
|
51
63
|
|
|
52
64
|
/** A policy as the relay keeps it: every field checked (the Worker composed it, but a bad one must not break a room). */
|
|
53
65
|
export function normalizePolicy(p) {
|
|
@@ -209,6 +221,8 @@ export class NetRoom {
|
|
|
209
221
|
this.lastHumanAt = 0;
|
|
210
222
|
/** A policy that no longer lets AI in, waiting for the round to finish: when the agents leave. */
|
|
211
223
|
this.agentsOut = null;
|
|
224
|
+
/** Revision 7: the game's agents.json (the Table reads it once), the only words an AI here may say. */
|
|
225
|
+
this.vocab = null;
|
|
212
226
|
this.code = code;
|
|
213
227
|
this.seatCap = maxPlayers;
|
|
214
228
|
this.maxPlayers = maxPlayers;
|
|
@@ -277,6 +291,8 @@ export class NetRoom {
|
|
|
277
291
|
watch: conn.watch === true, policy: WATCH_POLICIES.includes(conn.watchPolicy) ? conn.watchPolicy : 'follow', follow: null, followWhy: null,
|
|
278
292
|
// An agent (section 17): only the Worker's word, from a verified pass, makes one; its hello cannot.
|
|
279
293
|
agentWord: conn.agent && typeof conn.agent === 'object' ? conn.agent : null, agent: null, caps: new Set(), speechAt: [],
|
|
294
|
+
// Revision 7: the latest view the host showed this AI (what its arguments are checked against), and its pace.
|
|
295
|
+
lastView: null, viewAt: 0, doAt: 0,
|
|
280
296
|
};
|
|
281
297
|
this.clients.set(c.id, c);
|
|
282
298
|
return {
|
|
@@ -432,22 +448,35 @@ export class NetRoom {
|
|
|
432
448
|
// The server's speech (section 17): `lines` drops every free-text chat (quick lines and emotes pass), `off`
|
|
433
449
|
// drops all speech, whoever sends it (a host relaying a player's chat too).
|
|
434
450
|
if (SPEECH.test(kind) && (this.policy.speech === 'off' || (this.policy.speech === 'lines' && /^chat/i.test(kind)))) { this.stats.speechDrops += 1; return; }
|
|
451
|
+
// What an AI may send (section 18): its own goals and its game's lines, checked here whatever its host says.
|
|
452
|
+
if (c.agent && !this.agentEvOk(c, kind, m, text.length, now)) { this.stats.agentDrops += 1; return; }
|
|
435
453
|
// An agent says at most one line every 4 s and 8 a minute.
|
|
436
454
|
if (c.agent && SPEECH.test(kind)) {
|
|
437
455
|
while (c.speechAt.length && c.speechAt[0] <= now - 60_000) c.speechAt.shift();
|
|
438
456
|
if (c.speechAt.length >= AGENT_SPEECH.perMinute || (c.speechAt.length && now - c.speechAt[c.speechAt.length - 1] < AGENT_SPEECH.gapMs)) { this.stats.agentDrops += 1; return; }
|
|
439
457
|
c.speechAt.push(now);
|
|
440
458
|
}
|
|
459
|
+
// A line the host relays for an AI (`d.ai`): still only a line of the vocabulary, with fitting arguments.
|
|
460
|
+
if (isHost && d0(m).ai === true && !this.aiLineOk(kind, d0(m))) { this.stats.agentDrops += 1; return; }
|
|
461
|
+
// The host shows one AI the game (`agent:view`): to that AI's seat only, small, at most one every 2 s.
|
|
462
|
+
if (kind === 'agent:view') {
|
|
463
|
+
const o = isHost && Number.isInteger(m.to) ? this.live().find((x) => x.seat === m.to && x.agent) : null;
|
|
464
|
+
if (!o || text.length > AGENT_FRAMES.viewBytes || now - o.viewAt < AGENT_FRAMES.viewMs - 250) { this.stats.agentDrops += 1; return; }
|
|
465
|
+
o.viewAt = now;
|
|
466
|
+
o.lastView = m.d && typeof m.d === 'object' && !Array.isArray(m.d) ? m.d : null;
|
|
467
|
+
}
|
|
441
468
|
this.stats.evs += 1;
|
|
442
469
|
const out = { t: 'ev', from: c.seat, k: kind, d: m.d ?? null };
|
|
443
470
|
if (isHost) {
|
|
444
471
|
if (typeof m.to === 'string') { const o = this.clients.get(m.to); if (o && o.helloed && o !== c) this.send(o, out); }
|
|
445
472
|
else if (Number.isInteger(m.to)) { for (const o of this.others(c)) if (o.seat === m.to) this.send(o, out); }
|
|
446
|
-
// An agent with no game client hears only what is addressed to its seat (the lite feed).
|
|
447
|
-
else for (const o of this.others(c)) { if (!this.lite(o)) this.send(o, out); }
|
|
473
|
+
// An agent with no game client hears only what is addressed to its seat (the lite feed), and the party's lines.
|
|
474
|
+
else for (const o of this.others(c)) { if (!this.lite(o) || this.liteHears(kind)) this.send(o, out); }
|
|
448
475
|
} else {
|
|
449
476
|
const h = this.host();
|
|
450
477
|
if (h) this.send(h, { ...out, id: c.id });
|
|
478
|
+
// The lite feed (section 18): an AI with no game client hears the party's lines, and an ask made of it.
|
|
479
|
+
if (!c.agent) this.copyToLite(c, kind, out);
|
|
451
480
|
}
|
|
452
481
|
return;
|
|
453
482
|
}
|
|
@@ -607,8 +636,18 @@ export class NetRoom {
|
|
|
607
636
|
this.reapSeats(now);
|
|
608
637
|
let full = false;
|
|
609
638
|
if (c.want === 'play' && !this.seatClient(c, typeof m.token === 'string' ? m.token : '')) {
|
|
610
|
-
|
|
611
|
-
|
|
639
|
+
// The studio's own house guide (a loopback) makes way for an AI with a pass (the owner's Claude, say).
|
|
640
|
+
const house = c.agent && !c.conn.loopback ? this.live().find((o) => o.agent && o.conn.loopback && o.seat !== null) : null;
|
|
641
|
+
if (house) {
|
|
642
|
+
const seat = house.seat;
|
|
643
|
+
this.error(house, 'agent-yield', 'an AI with a pass took this seat');
|
|
644
|
+
this.kick(house, 'agent-yield', 4002);
|
|
645
|
+
if (this.seats.get(seat)?.token === house.token) { this.seats.delete(seat); this.seatsDirty = true; }
|
|
646
|
+
}
|
|
647
|
+
if (!house || !this.seatClient(c, '')) {
|
|
648
|
+
if (c.agent) return refuse('agents-off', 'no AI seat is free in this room');
|
|
649
|
+
full = true; c.waiting = true;
|
|
650
|
+
}
|
|
612
651
|
}
|
|
613
652
|
if (c.seat === null) c.name = c.typed || (c.want === 'screen' && !c.watch ? 'Screen' : 'Watcher');
|
|
614
653
|
if (c.seat !== null && !c.agent) this.lastHumanAt = now;
|
|
@@ -760,6 +799,73 @@ export class NetRoom {
|
|
|
760
799
|
}
|
|
761
800
|
}
|
|
762
801
|
|
|
802
|
+
/* ------------------------------------------------------------ agent hands and brains (section 18) */
|
|
803
|
+
|
|
804
|
+
/** The game's agents.json (the Table reads it once from the build): the only goals and lines an AI here has. */
|
|
805
|
+
setVocabulary(raw) {
|
|
806
|
+
this.vocab = raw ? vocabularyOf(raw).vocab : null;
|
|
807
|
+
return Boolean(this.vocab);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** The seats people hold now (a "player" argument names one of them). */
|
|
811
|
+
peopleSeats() { return this.seatedHumans().map((c) => c.seat); }
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Whether this AI may send this event: `agent:do` (a goal of the vocabulary, arguments that fit the view the host
|
|
815
|
+
* last showed it, at most one every 3 s), `say:<lineId>` (a line of the vocabulary, on a server whose AI may talk),
|
|
816
|
+
* never `agent:view`, `chat:` or `emote:` (an AI never types), and nothing else from an AI with no game client.
|
|
817
|
+
*/
|
|
818
|
+
agentEvOk(c, kind, m, bytes, now) {
|
|
819
|
+
if (kind === 'agent:view') return false;
|
|
820
|
+
const ctx = { view: c.lastView, players: this.peopleSeats() };
|
|
821
|
+
const d = m.d && typeof m.d === 'object' && !Array.isArray(m.d) ? m.d : {};
|
|
822
|
+
if (kind === 'agent:do') {
|
|
823
|
+
if (c.id === this.hostId || !this.vocab || bytes > 1024 || now - c.doAt < AGENT_FRAMES.doMs) return false;
|
|
824
|
+
const goal = this.vocab.goals[d.goal];
|
|
825
|
+
if (typeof d.goal !== 'string' || !goal || checkArgs(goal.args, d.args, ctx)) return false;
|
|
826
|
+
c.doAt = now;
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
if (SPEECH.test(kind)) {
|
|
830
|
+
const id = SAY.exec(kind)?.[1];
|
|
831
|
+
const line = id && this.vocab ? this.vocab.lines[id] : null;
|
|
832
|
+
return Boolean(line && talks(this.policy) && bytes <= 1024 && !checkArgs(line.args, d.args, ctx));
|
|
833
|
+
}
|
|
834
|
+
return !this.lite(c);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* A line a host relays for an AI (`say:<lineId>`, `d.ai`): a line of the vocabulary on a server whose AI may talk,
|
|
839
|
+
* arguments that fit (a view argument: the speaking AI's latest view, or for a guide no AI holds, an id at most).
|
|
840
|
+
* A host cannot put words of its own in an AI's mouth on other screens.
|
|
841
|
+
*/
|
|
842
|
+
aiLineOk(kind, d) {
|
|
843
|
+
const id = SAY.exec(kind)?.[1];
|
|
844
|
+
const line = id && this.vocab ? this.vocab.lines[id] : null;
|
|
845
|
+
if (!line || !talks(this.policy)) return false;
|
|
846
|
+
const speaker = Number.isInteger(d.seat) ? this.live().find((o) => o.seat === d.seat && o.agent) : null;
|
|
847
|
+
if (Number.isInteger(d.seat) && !speaker) return false;
|
|
848
|
+
const args = d.args && typeof d.args === 'object' ? d.args : {};
|
|
849
|
+
const view = speaker ? speaker.lastView : Object.fromEntries(Object.entries(line.args).filter(([, sp]) => sp.kind === 'view').map(([k, sp]) => [sp.key, /^[a-z0-9][a-z0-9_-]{0,39}$/.test(String(args[k] ?? '')) ? [args[k]] : []]));
|
|
850
|
+
return !checkArgs(line.args, args, { view, players: this.peopleSeats() });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** What an AI with no game client hears of a host's broadcast: the party's lines (free chat only on a "game" server). */
|
|
854
|
+
liteHears(kind) { return /^(?:say|emote):/i.test(kind) || (/^chat/i.test(kind) && this.policy.speech === 'game'); }
|
|
855
|
+
|
|
856
|
+
/** A person's line, copied to every AI with no game client; a person's ask (`ask:<id>`, d.seat), to that AI alone. */
|
|
857
|
+
copyToLite(c, kind, out) {
|
|
858
|
+
const ask = ASK.exec(kind)?.[1];
|
|
859
|
+
if (ask) {
|
|
860
|
+
const seat = out.d && Number.isInteger(out.d.seat) ? out.d.seat : null;
|
|
861
|
+
if (seat === null || (this.vocab && !this.vocab.asks[ask])) return;
|
|
862
|
+
for (const o of this.live()) if (o !== c && this.lite(o) && o.seat === seat) this.send(o, out);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (!SPEECH.test(kind) || !this.liteHears(kind)) return;
|
|
866
|
+
for (const o of this.live()) if (o !== c && this.lite(o) && o.id !== this.hostId) this.send(o, out);
|
|
867
|
+
}
|
|
868
|
+
|
|
763
869
|
/* ------------------------------------------------------------ watchers (section 16) */
|
|
764
870
|
|
|
765
871
|
/**
|
|
@@ -871,7 +977,8 @@ export class NetRoom {
|
|
|
871
977
|
// becomes a bot instead of a frozen body.
|
|
872
978
|
for (const c of [...this.clients.values()]) {
|
|
873
979
|
if (!c.helloed) { if (now - c.joinedAt > 5000) { this.clients.delete(c.id); try { c.conn.close(4000, 'no-hello'); } catch { /* gone */ } } continue; }
|
|
874
|
-
|
|
980
|
+
// A loopback (the Table's own house agent) cannot be half-open: it is never closed for silence.
|
|
981
|
+
if (now - c.lastSeen > this.idleMs && !c.conn.loopback) this.kick(c, 'silent', 4000);
|
|
875
982
|
}
|
|
876
983
|
// A host that stopped sending snapshots while others are present.
|
|
877
984
|
const h = this.host();
|
|
@@ -1473,6 +1580,8 @@ export class NetRoom {
|
|
|
1473
1580
|
policy: this.policyOut(),
|
|
1474
1581
|
...(this.vote ? { vote: this.voteView() } : {}),
|
|
1475
1582
|
caps: [...this.caps],
|
|
1583
|
+
// Revision 7: whether this room's game has a vocabulary its AI may speak (agents.json).
|
|
1584
|
+
vocab: Boolean(this.vocab),
|
|
1476
1585
|
memory: {
|
|
1477
1586
|
snapBytes: this.lastSnapText ? this.lastSnapText.length : 0,
|
|
1478
1587
|
ckptBytes: this.lastCkpt ? JSON.stringify(this.lastCkpt).length : 0,
|
package/worker/stats.mjs
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
* peak the most people playing one game at once that day (all its rooms), and peak-room in one room
|
|
17
17
|
* song a song's player started; video: a video's player started
|
|
18
18
|
* agent-minutes minutes an AI sat in a seat (an agent pass, NETPLAY.md section 17; 0.16.0), counted as it leaves
|
|
19
|
-
* brain-calls
|
|
19
|
+
* brain-calls AI guides' brain calls (0.17.0), by provider (workers-ai, owner-key): the house guides' alarms
|
|
20
|
+
* brain-neurons, brain-microdollars what those calls used: Workers AI neurons, and the owner's own key in
|
|
21
|
+
* millionths of a dollar. The day's budget (meta `brain_budget`) is read against these, every game.
|
|
20
22
|
* A round's rows count agents as AI, never as people: `humans` is people only.
|
|
21
23
|
* Each visit, play and screen row carries WHERE FROM: '' (typed, bookmarked, or this site's own link), the
|
|
22
24
|
* referring site's host (homie.rocks, another studio, anywhere on the web), or `via:<tag>` from a ?via= link.
|
|
@@ -57,7 +59,7 @@ CREATE TABLE IF NOT EXISTS stats_keys (
|
|
|
57
59
|
) WITHOUT ROWID;
|
|
58
60
|
`;
|
|
59
61
|
|
|
60
|
-
export const METRICS = Object.freeze(['visit', 'play', 'screen', 'watch', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video', 'agent-minutes', 'brain-calls', 'brain-neurons']);
|
|
62
|
+
export const METRICS = Object.freeze(['visit', 'play', 'screen', 'watch', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video', 'agent-minutes', 'brain-calls', 'brain-neurons', 'brain-microdollars']);
|
|
61
63
|
const MAX_METRICS = new Set(['peak', 'peak-room']);
|
|
62
64
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
63
65
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.17.0';
|