@homie-rocks/studio 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/worker/agents.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * AGENT SEATS (@homie-rocks/studio 0.16.0, NETPLAY.md section 17): an AI that sits in a room's seat the way a phone
3
- * does, always marked AI, and the skill dial every game's bots can read.
2
+ * AGENT SEATS (@homie-rocks/studio 0.16.0, NETPLAY.md section 17; brains 0.17.0, section 18): an AI that sits in
3
+ * a room's seat the way a phone does, always marked AI, and the skill dial every game's bots can read.
4
4
  *
5
5
  * SKILLS / skillPreset the dial: five levels, one shape everywhere ({ level, name, reactionMs, aimNoise,
6
6
  * aggression, positioning }); netplay/netplay.ts has the same table (a test keeps them equal)
@@ -11,8 +11,12 @@
11
11
  * ticket, the socket and (hands `self`) the game's frame to load
12
12
  * fillSpot the fill-a-spot hook: nothing issues service passes yet (a money decision), so it is null
13
13
  *
14
- * Nothing here imports the rest of the Worker, so the relay (room.mjs) can use the dial and the names as they are.
14
+ * HouseAgents (phase 2) a beginner server's AI guides, run by the room's own Table on alarms
15
+ *
16
+ * Nothing here imports the rest of the Worker (only brain.mjs, which is pure), so the relay (room.mjs) can use the
17
+ * dial and the names as they are.
15
18
  */
19
+ import { GUIDE_RULES, costOf, decisionSchema, ownerKey, parseDecision, promptFor, scripted, talks, workersAi } from './brain.mjs';
16
20
 
17
21
  /** The skill dial (DESIGN D8; NETPLAY.md section 17). Level 3, Fair, is what the port kit's bots always were. */
18
22
  export const SKILLS = Object.freeze([
@@ -226,7 +230,7 @@ export async function sitRoute(request, env, url, deps) {
226
230
  const ws = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}/${deps.meta.id}/__net?room=${encodeURIComponent(room)}&t=${encodeURIComponent(ticket)}`;
227
231
  return json({
228
232
  ok: true, game: deps.meta.id, server: server.id, room, ticket, name: aiName(pass.label), role: pass.role, hands: pass.hands, policy: { kind: policy.kind, level: policy.level, levelMax: policy.levelMax },
229
- ws, ...(pass.hands === 'self' ? { frame: `${url.origin}/${deps.meta.id}/__game/?room=${encodeURIComponent(room)}&t=${encodeURIComponent(ticket)}` } : { hello: { t: 'hello', v: 1, rev: 6, want: 'play', canHost: false, agent: { hands: 'host', role: pass.role } } }),
233
+ ws, ...(pass.hands === 'self' ? { frame: `${url.origin}/${deps.meta.id}/__game/?room=${encodeURIComponent(room)}&t=${encodeURIComponent(ticket)}` } : { hello: { t: 'hello', v: 1, rev: 7, want: 'play', canHost: false, agent: { hands: 'host', role: pass.role } } }),
230
234
  expiresAt: Date.now() + AGENT_TICKET_MS,
231
235
  });
232
236
  }
@@ -236,3 +240,354 @@ export async function sitRoute(request, env, url, deps) {
236
240
  * decision, so nothing issues `service` passes and this hook answers null. A later version decides who, what and how.
237
241
  */
238
242
  export function fillSpot() { return null; }
243
+
244
+ /* ------------------------------------------------------------------ house agents (phase 2, 0.17.0) */
245
+
246
+ /**
247
+ * HOUSE AGENTS (NETPLAY.md section 18): a beginner server's AI guides, run by the room's own Table on the studio
248
+ * owner's Worker. Each is a peer on a loopback `conn` handed to `room.attach()`, so it speaks the same protocol as any
249
+ * AI: it hears `agent:view` from the host and the party's lines, and sends `agent:do` and `say:<lineId>`; the relay
250
+ * checks it like any other. Its brain runs on Durable Object ALARMS (never an interval): one alarm per room when a
251
+ * decision is due, at most one every 3 s, only while a person is seated and the server's AI may talk.
252
+ *
253
+ * cadence a decision on an ask made of this guide (0.8 s after it, to gather a second tap), on a goal finished or
254
+ * failed, on a zone or danger change, else every 12 s while people are near; at least 3 s between AI
255
+ * calls per guide and at most 10 a minute. The game's hands keep playing at frame rate throughout.
256
+ * brain the server's: workers-ai (env.AI, model HOMIE_BRAIN_MODEL) or owner-key (the Worker secret
257
+ * HOMIE_BRAIN_KEY). With no binding or key, over the day's budget, or on any error or answer that is not
258
+ * a decision: the scripted floor (brain.mjs scripted), which answers asks the way agents.json says.
259
+ * rules fixed, outside the model: a person's ask is answered as agents.json says (a model that chose otherwise is
260
+ * overruled), and that goal is carried through until it is done (or 60 s): no model call replaces it, and
261
+ * none is made meanwhile but for a new ask; a line at most every 8 s; after "no thanks" (an ask with `leave`) the guide
262
+ * leaves that player alone for 10 minutes; a guide never acts against a player (the vocabulary refuses
263
+ * such a goal); the room's owner can mute or kick a guide like anyone (a kicked guide's seat stays an AI
264
+ * seat the game's bots move, with no brain).
265
+ * log the last 50 decisions (seats and ids only, never a name or an account), in memory, for the office.
266
+ */
267
+
268
+ export const BRAIN_CADENCE = Object.freeze({ askDebounceMs: 800, gapMs: 3000, perMinute: 10, everyMs: 12_000, alarmGapMs: 3000, batchMs: 1500, timeoutMs: 8000 });
269
+ /** The day's default budget: 8,000 Workers AI neurons (the free allocation is 10,000 an account), $1 of the owner's key. */
270
+ export const BRAIN_BUDGET = Object.freeze({ neurons: 8000, usd: 1 });
271
+ export const HOUSE_PASS = 'house-';
272
+ const LOG_MAX = 50;
273
+
274
+ export class HouseAgents {
275
+ /**
276
+ * @param {object} o
277
+ * @param {import('./room.mjs').NetRoom} o.room
278
+ * @param {object} [o.env] the Worker's env (AI, HOMIE_BRAIN_KEY, HOMIE_BRAIN_MODEL)
279
+ * @param {(at: number) => void} [o.setAlarm] the Durable Object's storage.setAlarm
280
+ * @param {{ left: (kind: 'neurons' | 'micros') => number, spend: (c: { neurons: number, micros: number, provider: string }) => void }} [o.budget]
281
+ * @param {object} [o.providers] { 'workers-ai': (env, args) => …, 'owner-key': (env, args) => … } (tests stand in for them)
282
+ */
283
+ constructor({ room, env = {}, now = Date.now, setAlarm = () => {}, budget = null, providers = {}, log = () => {} }) {
284
+ this.room = room;
285
+ this.env = env;
286
+ this.now = now;
287
+ this.setAlarm = setAlarm;
288
+ this.budget = budget ?? { left: () => Infinity, spend: () => {} };
289
+ this.providers = { 'workers-ai': workersAi, 'owner-key': ownerKey, ...providers };
290
+ this.log = log;
291
+ this.agents = [];
292
+ this.decisions = [];
293
+ this.alarmAt = null;
294
+ this.lastAlarmAt = -Infinity;
295
+ this.backoffUntil = 0;
296
+ /** pass id → until: a guide the owner kicked stays out until its hold ends. */
297
+ this.held = new Map();
298
+ this.why = null;
299
+ this.calls = 0;
300
+ this.spent = { neurons: 0, micros: 0 };
301
+ }
302
+
303
+ /** How many house guides this room should have now: the server's guides, less any a pass-holding AI took. */
304
+ wanted() {
305
+ const r = this.room;
306
+ const p = r.policy;
307
+ const no = (why) => { this.wantWhy = why; return 0; };
308
+ if (!talks(p)) return no('talk-off');
309
+ if (!r.vocab) return no('no-vocabulary');
310
+ if (!(p.guides > 0)) return no('no-guides');
311
+ if (r.closedUntil > this.now()) return no('closed');
312
+ if (!r.seatedHumans().length) return no('nobody-seated');
313
+ const host = r.host();
314
+ if (!(host ? host.caps : r.caps).has('agents')) return no('host-moves-no-ai');
315
+ const taken = r.live().filter((c) => c.agent && !c.conn.loopback && c.agent.role === 'guide').length;
316
+ this.wantWhy = taken ? 'pass-guides' : null;
317
+ return Math.max(0, p.guides - taken);
318
+ }
319
+
320
+ live() { return this.agents.filter((a) => !a.closed); }
321
+
322
+ /** Seat or stand guides so the room has what it should (called on the Table's 1 s beat and on a policy change). */
323
+ sync() {
324
+ const want = this.wanted();
325
+ const live = this.live();
326
+ for (const a of live.slice(want).reverse()) this.stand(a, this.wantWhy ?? 'not-needed');
327
+ if (live.length >= want || this.now() < this.backoffUntil) return;
328
+ for (let i = live.length; i < want; i += 1) if (!this.sit()) break;
329
+ }
330
+
331
+ /** One guide sits: a loopback peer whose hello says it is an AI with no game client (hands `host`). */
332
+ sit() {
333
+ const vocab = this.room.vocab;
334
+ const used = new Set(this.live().map((a) => a.n));
335
+ let n = 0;
336
+ while (used.has(n)) n += 1;
337
+ const pass = `${HOUSE_PASS}${n + 1}`;
338
+ if ((this.held.get(pass) ?? 0) > this.now()) return false;
339
+ const names = vocab?.names?.length ? vocab.names : ['Guide'];
340
+ const label = names.length > n ? names[n] : `${names[n % names.length]} ${Math.floor(n / names.length) + 1}`;
341
+ const a = {
342
+ n, pass, name: aiName(label), seat: null, closed: false, view: null, viewAt: 0, zone: undefined, danger: undefined, goalState: null,
343
+ asks: [], answered: new Map(), carry: null, party: [], avoid: new Map(), calls: [], lastCallAt: -Infinity, dueAt: Infinity, dueWhy: null, sayAt: -Infinity, inFlight: false, provider: null,
344
+ };
345
+ a.conn = {
346
+ loopback: true, ip: null, browser: null, via: null, player: null, qa: false, watch: false,
347
+ agent: { pass, role: 'guide', hands: 'host', by: 'studio', name: label },
348
+ send: (text) => this.onFrame(a, text),
349
+ close: (code, why) => this.onClosed(a, why),
350
+ buffered: () => 0,
351
+ };
352
+ this.agents.push(a);
353
+ a.h = this.room.attach(a.conn);
354
+ a.h.onMessage(JSON.stringify({ t: 'hello', v: 1, rev: 7, device: 'desk', want: 'play', canHost: false, agent: { hands: 'host', role: 'guide' } }));
355
+ return !a.closed;
356
+ }
357
+
358
+ stand(a, why = 'bye') {
359
+ if (a.closed) return;
360
+ a.closed = true;
361
+ try { a.h.onMessage(JSON.stringify({ t: 'bye' })); } catch { /* gone */ }
362
+ this.log({ ev: 'house-stand', room: this.room.code, pass: a.pass, why });
363
+ }
364
+
365
+ /** Every guide stands (AI talk turned off, the room forgotten). */
366
+ stop(why = 'stop') { for (const a of this.live()) this.stand(a, why); }
367
+
368
+ onClosed(a, why) {
369
+ a.closed = true;
370
+ a.seat = null;
371
+ this.agents = this.agents.filter((x) => x !== a);
372
+ if (why === 'agents-alone') this.backoffUntil = this.now() + 30_000;
373
+ }
374
+
375
+ onFrame(a, text) {
376
+ let m;
377
+ try { m = JSON.parse(text); } catch { return; }
378
+ const now = this.now();
379
+ switch (m.t) {
380
+ case 'welcome': a.seat = m.seat; if (typeof m.name === 'string') a.name = m.name; return;
381
+ case 'error':
382
+ if (m.code === 'kicked') this.held.set(a.pass, Number(m.until) || now + 10 * 60_000);
383
+ else if (['agents-off', 'agents-unsupported', 'agent-pass', 'room-closed', 'room-full', 'too-many'].includes(m.code)) this.backoffUntil = now + 60_000;
384
+ else if (m.code === 'agents-alone') this.backoffUntil = now + 30_000;
385
+ return;
386
+ case 'policy':
387
+ if (!talks(m.policy)) this.stop('talk-off');
388
+ return;
389
+ case 'ev': return this.onEvent(a, m, now);
390
+ default: return;
391
+ }
392
+ }
393
+
394
+ onEvent(a, m, now) {
395
+ const k = String(m.k ?? '');
396
+ const d = m.d && typeof m.d === 'object' ? m.d : {};
397
+ if (k === 'agent:view') {
398
+ const before = a.view;
399
+ a.view = d;
400
+ a.viewAt = now;
401
+ // What makes a guide think again at once: its goal ended, the zone or the danger changed.
402
+ const state = d.goal && typeof d.goal === 'object' ? d.goal.state ?? null : null;
403
+ if ((state === 'done' || state === 'failed') && a.goalState !== state) this.due(a, now, `goal ${state}`);
404
+ a.goalState = state;
405
+ if (before && (JSON.stringify(d.zone ?? null) !== JSON.stringify(a.zone ?? null) || JSON.stringify(d.danger ?? null) !== JSON.stringify(a.danger ?? null))) this.due(a, now, d.danger ? 'danger' : 'zone');
406
+ a.zone = d.zone ?? null;
407
+ a.danger = d.danger ?? null;
408
+ // Asks the game lists in the view (the host's own player's asks reach a guide this way).
409
+ for (const x of Array.isArray(d.asks) ? d.asks.slice(0, 4) : []) if (x && typeof x.k === 'string' && Number.isInteger(x.from)) this.ask(a, { k: x.k, args: x.args ?? {}, from: x.from, at: Number(x.at) || now }, now);
410
+ if (a.dueAt === Infinity && this.near(a)) this.due(a, Math.max(now, (a.lastCallAt === -Infinity ? now : a.lastCallAt + BRAIN_CADENCE.everyMs)), 'every 12 s');
411
+ return;
412
+ }
413
+ const ask = /^ask:([a-z][a-z0-9_]{0,31})$/.exec(k)?.[1];
414
+ if (ask && d.seat === a.seat && Number.isInteger(m.from)) { this.ask(a, { k: ask, args: d.args ?? {}, from: m.from, at: now }, now); return; }
415
+ // The party's lines: the brain sees the last three, as ids and arguments (free text only on a "game" server).
416
+ const line = /^say:([a-z][a-z0-9_]{0,31})$/.exec(k)?.[1];
417
+ if (line && m.from !== a.seat && !(d.ai && d.slot !== undefined && d.seat === a.seat)) a.party = [...a.party, { seat: m.from, line, args: d.args ?? {} }].slice(-3);
418
+ else if (/^chat/i.test(k) && typeof (d.text ?? m.d) === 'string') a.party = [...a.party, { seat: m.from, chat: String(d.text ?? m.d).slice(0, 120) }].slice(-3);
419
+ }
420
+
421
+ /** An ask made of this guide: remembered for 30 s, decided on 0.8 s later. "No thanks" holds the player off 10 minutes. */
422
+ ask(a, x, now) {
423
+ const vocab = this.room.vocab;
424
+ if (!vocab?.asks?.[x.k]) return;
425
+ // One ask is decided once: the same ask (the host's view lists it for 30 s, and a person's copy came first) is not
426
+ // a new reason to think, nor a new call to a model.
427
+ const key = `${x.k}|${x.from}|${JSON.stringify(x.args ?? {})}`;
428
+ for (const [k, until] of a.answered) if (until <= now) a.answered.delete(k);
429
+ if (a.answered.has(key) || a.asks.some((y) => `${y.k}|${y.from}|${JSON.stringify(y.args ?? {})}` === key)) return;
430
+ if (vocab.asks[x.k].leave) a.avoid.set(x.from, now + GUIDE_RULES.leaveMs);
431
+ a.asks = [...a.asks, x].slice(-4);
432
+ this.due(a, now + BRAIN_CADENCE.askDebounceMs, `ask ${x.k}`);
433
+ }
434
+
435
+ /** People near this guide (the view's `party`, else anyone seated). */
436
+ near(a) {
437
+ const p = a.view?.party;
438
+ return Array.isArray(p) ? p.length > 0 : this.room.seatedHumans().length > 0;
439
+ }
440
+
441
+ due(a, at, why) {
442
+ if (at < a.dueAt) { a.dueAt = at; a.dueWhy = why; }
443
+ this.schedule();
444
+ }
445
+
446
+ /** When this guide may next call its brain: 3 s after its last call, and within 10 calls a minute. */
447
+ readyAt(a, now) {
448
+ const recent = a.calls.filter((t) => t > now - 60_000);
449
+ const cap = recent.length >= BRAIN_CADENCE.perMinute ? recent[recent.length - BRAIN_CADENCE.perMinute] + 60_000 : -Infinity;
450
+ return Math.max(a.lastCallAt + BRAIN_CADENCE.gapMs, cap);
451
+ }
452
+
453
+ /** One alarm for the room: the earliest due guide, never sooner than 3 s after the last alarm. */
454
+ schedule() {
455
+ const now = this.now();
456
+ let next = Infinity;
457
+ for (const a of this.live()) if (a.seat !== null && a.dueAt !== Infinity && !a.inFlight) next = Math.min(next, Math.max(a.dueAt, this.readyAt(a, now)));
458
+ if (next === Infinity) return null;
459
+ const at = Math.max(next, this.lastAlarmAt + BRAIN_CADENCE.alarmGapMs, now + 20);
460
+ if (this.alarmAt !== null && this.alarmAt <= at && this.alarmAt > now) return this.alarmAt;
461
+ this.alarmAt = at;
462
+ try { this.setAlarm(at); } catch { /* the next beat schedules again */ }
463
+ return at;
464
+ }
465
+
466
+ /** The Durable Object's alarm: every guide due by now (or within 1.5 s) decides, then the next alarm is set. */
467
+ async onAlarm() {
468
+ const now = this.now();
469
+ this.lastAlarmAt = now;
470
+ this.alarmAt = null;
471
+ const due = this.live().filter((a) => a.seat !== null && !a.inFlight && a.dueAt <= now + BRAIN_CADENCE.batchMs && this.readyAt(a, now) <= now + 100);
472
+ await Promise.all(due.map((a) => this.decide(a).catch((error) => { a.inFlight = false; this.log({ ev: 'house-decide-failed', room: this.room.code, pass: a.pass, error: String(error?.message ?? error).slice(0, 200) }); })));
473
+ this.schedule();
474
+ return due.length;
475
+ }
476
+
477
+ /** The brain this server asked for, if it can run now: its binding or key is there, and today's budget is not spent. */
478
+ providerFor(mode) {
479
+ if (mode === 'workers-ai') {
480
+ if (!this.env?.AI) return { why: 'no Workers AI binding (deploy once more to add it)' };
481
+ if (this.budget.left('neurons') <= 0) return { why: 'today\'s Workers AI budget is spent (scripted until 00:00 UTC)' };
482
+ return { run: (args) => this.providers['workers-ai'](this.env, args) };
483
+ }
484
+ if (mode === 'owner-key') {
485
+ if (!this.env?.HOMIE_BRAIN_KEY) return { why: 'no key yet (homie-studio agents brain key)' };
486
+ if (this.budget.left('micros') <= 0) return { why: 'today\'s dollar cap is reached (scripted until 00:00 UTC)' };
487
+ return { run: (args) => this.providers['owner-key'](this.env, args) };
488
+ }
489
+ return { why: 'scripted' };
490
+ }
491
+
492
+ async decide(a) {
493
+ const vocab = this.room.vocab;
494
+ if (!vocab || a.closed) return null;
495
+ a.inFlight = true;
496
+ const now = this.now();
497
+ const dueWhy = a.dueWhy;
498
+ a.dueAt = Infinity;
499
+ a.dueWhy = null;
500
+ for (const [seat, until] of a.avoid) if (until <= now) a.avoid.delete(seat);
501
+ const asks = a.asks.filter((x) => now - x.at < GUIDE_RULES.askMs);
502
+ const quiet = now - a.sayAt < GUIDE_RULES.quietMs;
503
+ // Carrying out what a person asked (until it is done, or 60 s): nothing to decide but a new ask; no model call.
504
+ const goalNow = a.view?.goal && typeof a.view.goal === 'object' ? a.view.goal : null;
505
+ if (!asks.length && a.carry && now < a.carry.until && (!goalNow || (goalNow.goal === a.carry.goal && goalNow.state === 'active'))) {
506
+ a.inFlight = false;
507
+ if (this.near(a) && !a.closed) this.due(a, now + BRAIN_CADENCE.everyMs, 'every 12 s');
508
+ return null;
509
+ }
510
+ if (a.carry && (now >= a.carry.until || (goalNow && (goalNow.goal !== a.carry.goal || goalNow.state !== 'active')))) a.carry = null;
511
+ const players = this.room.peopleSeats();
512
+ const avoid = [...a.avoid.keys()];
513
+ const mode = this.room.policy.brain;
514
+ const p = this.providerFor(mode);
515
+ this.why = p.why ?? null;
516
+ let decision = null;
517
+ let provider = 'script';
518
+ let why = p.why ?? null;
519
+ let ms = 0;
520
+ let cost = { neurons: 0, micros: 0 };
521
+ // Every decision keeps the 3 s pace (the relay takes one goal every 3 s); AI calls also count to 10 a minute.
522
+ a.lastCallAt = now;
523
+ if (p.run) {
524
+ const prompt = promptFor(vocab, { view: a.view, me: { seat: a.seat }, goal: a.view?.goal ?? null, asks, party: a.party, speech: this.room.policy.speech, quiet });
525
+ a.calls = [...a.calls.filter((t) => t > now - 60_000), now];
526
+ try {
527
+ const r = await p.run({ ...prompt, schema: decisionSchema(vocab), ms: BRAIN_CADENCE.timeoutMs });
528
+ ms = this.now() - now;
529
+ cost = costOf({ provider: mode, model: r.model, usage: r.usage, system: prompt.system, user: prompt.user, text: typeof r.out === 'string' ? r.out : JSON.stringify(r.out ?? '') });
530
+ this.calls += 1;
531
+ this.spent.neurons += cost.neurons;
532
+ this.spent.micros += cost.micros;
533
+ this.budget.spend({ ...cost, provider: mode });
534
+ const parsed = parseDecision(r.out, vocab, { view: a.view, players, avoid });
535
+ if (parsed.ok) { decision = parsed.decision; provider = mode; why = null; } else why = parsed.why;
536
+ // A fixed rule: a person's ask is answered the way agents.json says (its goal). A model that chose something
537
+ // else is overruled by the floor's answer; its line stays when it is one of the ask's.
538
+ const ask = asks.at(-1);
539
+ const want = ask ? vocab.asks[ask.k]?.goal : undefined;
540
+ if (decision && want && decision.goal !== want) {
541
+ const floor = scripted(vocab, { asks, view: a.view, players, avoid, quiet });
542
+ if (floor?.goal) { why = `the model chose ${decision.goal}; the ask was answered as agents.json says`; decision = { ...floor, say: floor.say ?? null, sayArgs: floor.sayArgs ?? {} }; provider = `${mode}+floor`; }
543
+ }
544
+ } catch (error) {
545
+ ms = this.now() - now;
546
+ why = String(error?.message ?? error).slice(0, 120);
547
+ }
548
+ }
549
+ // The floor: no AI, an AI that failed, or an answer that was not a decision.
550
+ if (!decision) decision = scripted(vocab, { asks, view: a.view, players, avoid, quiet });
551
+ const did = decision ? this.act(a, decision, quiet) : { goal: null, say: null };
552
+ if (asks.length && decision) {
553
+ for (const x of asks) a.answered.set(`${x.k}|${x.from}|${JSON.stringify(x.args ?? {})}`, now + 6000);
554
+ a.asks = [];
555
+ if (did.goal) a.carry = { goal: did.goal, until: now + GUIDE_RULES.carryMs };
556
+ }
557
+ a.provider = decision ? provider : a.provider;
558
+ this.decisions = [...this.decisions, {
559
+ at: now, seat: a.seat, guide: a.pass, provider: decision ? provider : 'none', ms, on: dueWhy,
560
+ goal: did.goal, args: did.goal ? decision.args : null, say: did.say, sayArgs: did.say ? decision.sayArgs : null,
561
+ ...(why ? { why } : {}), ...(cost.neurons ? { neurons: Math.round(cost.neurons * 100) / 100 } : {}), ...(cost.micros ? { micros: cost.micros } : {}),
562
+ }].slice(-LOG_MAX);
563
+ a.inFlight = false;
564
+ if (this.near(a) && !a.closed) this.due(a, now + BRAIN_CADENCE.everyMs, 'every 12 s');
565
+ return decision;
566
+ }
567
+
568
+ /** A decision becomes frames: `agent:do` (its goal) and, at most every 8 s, `say:<line>`. The relay checks both again. */
569
+ act(a, decision, quiet) {
570
+ const out = { goal: null, say: null };
571
+ if (a.closed) return out;
572
+ if (decision.goal) {
573
+ a.h.onMessage(JSON.stringify({ t: 'ev', k: 'agent:do', d: { goal: decision.goal, args: decision.args ?? {} } }));
574
+ out.goal = decision.goal;
575
+ }
576
+ if (decision.say && !quiet) {
577
+ a.h.onMessage(JSON.stringify({ t: 'ev', k: `say:${decision.say}`, d: { args: decision.sayArgs ?? {} } }));
578
+ a.sayAt = this.now();
579
+ out.say = decision.say;
580
+ }
581
+ return out;
582
+ }
583
+
584
+ /** What the office shows: the guides, the brain in use (and why not, when it is not), and the last decisions. */
585
+ facts() {
586
+ return {
587
+ brain: this.room.policy.brain, why: this.why, calls: this.calls,
588
+ spent: { neurons: Math.round(this.spent.neurons * 100) / 100, micros: Math.round(this.spent.micros) },
589
+ guides: this.live().map((a) => ({ name: a.name, seat: a.seat, provider: a.provider, asks: a.asks.length, dueAt: a.dueAt === Infinity ? null : a.dueAt })),
590
+ decisions: this.decisions.slice(-LOG_MAX),
591
+ };
592
+ }
593
+ }