@homie-rocks/studio 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 = 6;
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
- if (c.agent) return refuse('agents-off', 'no AI seat is free in this room');
611
- full = true; c.waiting = true;
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
- if (now - c.lastSeen > this.idleMs) this.kick(c, 'silent', 4000);
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, brain-neurons reserved for AI guides' brains (a later version): calls made and Workers AI used
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}$/;
@@ -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.16.1';
2
+ export const STUDIO_VERSION_TAG = '0.18.0';