@homie-rocks/studio 0.24.3 → 0.24.4

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
@@ -16,7 +16,7 @@
16
16
  * Nothing here imports the rest of the Worker (only brain.mjs, which is pure), so the relay (room.mjs) can use the
17
17
  * dial and the names as they are.
18
18
  */
19
- import { GUIDE_RULES, costOf, decisionSchema, ownerKey, parseDecision, promptFor, scripted, talks, workersAi } from './brain.mjs';
19
+ import { GUIDE_RULES, costOf, decisionSchema, localAiOf, localClef, ownerKey, parseDecision, promptFor, scripted, talks, workersAi } from './brain.mjs';
20
20
 
21
21
  /** The skill dial (DESIGN D8; NETPLAY.md section 17). Level 3, Fair, is what the port kit's bots always were. */
22
22
  export const SKILLS = Object.freeze([
@@ -271,6 +271,78 @@ export const BRAIN_BUDGET = Object.freeze({ neurons: 8000, usd: 1 });
271
271
  export const HOUSE_PASS = 'house-';
272
272
  const LOG_MAX = 50;
273
273
 
274
+ /**
275
+ * The brain a server asked for, if it can run now (`{ run, engine }`), or why not (`{ why }`): its Workers AI binding
276
+ * or the owner's key is there and today's budget is not spent. With neither (`homie-studio dev`, or a key not set yet),
277
+ * the person's own Ollama runs it when dev found clef-flash there (`engine: 'local'`, free). `providers` stand in for
278
+ * the network in tests.
279
+ */
280
+ export function brainFor(mode, env, { budget = null, providers = {} } = {}) {
281
+ const left = (k) => (budget ? budget.left(k) : Infinity);
282
+ const p = { 'workers-ai': workersAi, 'owner-key': ownerKey, local: localClef, ...providers };
283
+ const local = () => (localAiOf(env) ? { run: (args) => p.local(env, args), engine: 'local' } : null);
284
+ if (mode === 'workers-ai') {
285
+ if (!env?.AI) return local() ?? { why: 'no Workers AI binding (deploy once more to add it)' };
286
+ if (left('neurons') <= 0) return { why: 'today\'s Workers AI budget is spent (scripted until 00:00 UTC)' };
287
+ return { run: (args) => p['workers-ai'](env, args), engine: 'workers-ai' };
288
+ }
289
+ if (mode === 'owner-key') {
290
+ if (!env?.HOMIE_BRAIN_KEY) return local() ?? { why: 'no key yet (homie-studio agents brain key)' };
291
+ if (left('micros') <= 0) return { why: 'today\'s dollar cap is reached (scripted until 00:00 UTC)' };
292
+ return { run: (args) => p['owner-key'](env, args), engine: 'owner-key' };
293
+ }
294
+ return { why: 'scripted' };
295
+ }
296
+
297
+ /**
298
+ * ONE DECISION, the way every brain makes it (the house guides on their alarm, the office's `agents try`): the brain
299
+ * asked (Clef's questions or a chat model's prompt), its answer checked by parseDecision, the fixed rule applied (a
300
+ * person's ask is answered the way agents.json says; a model that chose another goal is overruled), and the floor when
301
+ * nothing else decided.
302
+ *
303
+ * ctx: { view, me, goal, asks, party, speech, quiet, players, avoid, kids }. Returns { decision, provider, why, ms,
304
+ * cost, own (the brain's own pick, before any rule), p (Clef's probabilities), engine, model }.
305
+ */
306
+ export async function think(vocab, { mode, brain, ctx, ms = BRAIN_CADENCE.timeoutMs, now = Date.now }) {
307
+ const { asks = [], view = null, players = null, avoid = [], quiet = false } = ctx;
308
+ const out = { decision: null, provider: 'script', why: brain?.why ?? null, ms: 0, cost: { neurons: 0, micros: 0 }, own: null, p: null, answers: null, usage: null, engine: null, model: null };
309
+ if (brain?.run) {
310
+ const t0 = now();
311
+ const prompt = promptFor(vocab, ctx);
312
+ const label = brain.engine === 'local' ? 'local' : mode;
313
+ try {
314
+ const r = await brain.run({ ...prompt, schema: decisionSchema(vocab), ms, vocab, ctx });
315
+ out.ms = now() - t0;
316
+ out.engine = r.engine ?? brain.engine ?? mode;
317
+ out.model = r.model ?? null;
318
+ out.p = r.p ?? null;
319
+ out.answers = r.answers ?? null;
320
+ out.usage = r.usage ?? null;
321
+ out.cost = costOf({ provider: out.engine === 'local' ? 'local' : mode, model: r.model, usage: r.usage, system: r.jsonMode === 'clef' ? '' : prompt.system, user: r.jsonMode === 'clef' ? '' : prompt.user, text: typeof r.out === 'string' ? r.out : JSON.stringify(r.out ?? '') });
322
+ out.cost.provider = label;
323
+ const parsed = r.out === null || r.out === undefined ? { ok: false, why: r.why ?? 'no answer' } : parseDecision(r.out, vocab, { view, players, avoid });
324
+ if (parsed.ok) { out.decision = parsed.decision; out.own = parsed.decision; out.provider = label; out.why = null; } else out.why = parsed.why;
325
+ // A fixed rule: a person's ask is answered the way agents.json says (its goal, with the values the person asked
326
+ // for). A model that chose another goal, or the asked goal with another value ("Help me with King Slime" answered
327
+ // with a slime hunt), is overruled by the floor's answer.
328
+ const ask = asks.at(-1);
329
+ const want = ask ? vocab.asks[ask.k]?.goal : undefined;
330
+ const other = out.decision && want && out.decision.goal === want
331
+ ? Object.entries(ask.args ?? {}).find(([k, v]) => vocab.goals[want].args[k] && vocab.goals[want].args[k].kind !== 'player' && out.decision.args?.[k] !== v) : null;
332
+ if (out.decision && want && (out.decision.goal !== want || other)) {
333
+ const floor = scripted(vocab, { asks, view, players, avoid, quiet });
334
+ if (floor?.goal) { out.why = other ? `the model chose ${want} with ${other[0]} ${String(out.decision.args?.[other[0]]).slice(0, 24)}; the ask was answered as agents.json says` : `the model chose ${out.decision.goal}; the ask was answered as agents.json says`; out.decision = { ...floor, say: floor.say ?? null, sayArgs: floor.sayArgs ?? {} }; out.provider = `${label}+floor`; }
335
+ }
336
+ } catch (error) {
337
+ out.ms = now() - t0;
338
+ out.why = String(error?.message ?? error).slice(0, 120);
339
+ }
340
+ }
341
+ // The floor: no AI, an AI that failed, or an answer that was not a decision.
342
+ if (!out.decision) { out.decision = scripted(vocab, { asks, view, players, avoid, quiet }); out.provider = out.decision ? 'script' : 'none'; }
343
+ return out;
344
+ }
345
+
274
346
  export class HouseAgents {
275
347
  /**
276
348
  * @param {object} o
@@ -286,7 +358,7 @@ export class HouseAgents {
286
358
  this.now = now;
287
359
  this.setAlarm = setAlarm;
288
360
  this.budget = budget ?? { left: () => Infinity, spend: () => {} };
289
- this.providers = { 'workers-ai': workersAi, 'owner-key': ownerKey, ...providers };
361
+ this.providers = { 'workers-ai': workersAi, 'owner-key': ownerKey, local: localClef, ...providers };
290
362
  this.log = log;
291
363
  this.agents = [];
292
364
  this.decisions = [];
@@ -340,7 +412,7 @@ export class HouseAgents {
340
412
  const label = names.length > n ? names[n] : `${names[n % names.length]} ${Math.floor(n / names.length) + 1}`;
341
413
  const a = {
342
414
  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,
415
+ asks: [], answered: new Map(), carry: null, party: [], said: [], seen: new Map(), avoid: new Map(), calls: [], lastCallAt: -Infinity, dueAt: Infinity, dueWhy: null, sayAt: -Infinity, inFlight: false, provider: null,
344
416
  };
345
417
  a.conn = {
346
418
  loopback: true, ip: null, browser: null, via: null, player: null, qa: false, watch: false,
@@ -405,6 +477,8 @@ export class HouseAgents {
405
477
  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
478
  a.zone = d.zone ?? null;
407
479
  a.danger = d.danger ?? null;
480
+ // Who is new to this guide (first in its view, for 20 s): a reason to greet them once.
481
+ for (const p of Array.isArray(d.party) ? d.party.slice(0, 16) : []) if (Number.isInteger(p?.seat) && !a.seen.has(p.seat)) a.seen.set(p.seat, now);
408
482
  // Asks the game lists in the view (the host's own player's asks reach a guide this way).
409
483
  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
484
  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');
@@ -474,19 +548,9 @@ export class HouseAgents {
474
548
  return due.length;
475
549
  }
476
550
 
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. */
551
+ /** The brain this server asked for, if it can run now (brainFor above). */
478
552
  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' };
553
+ return brainFor(mode, this.env, { budget: this.budget, providers: this.providers });
490
554
  }
491
555
 
492
556
  async decide(a) {
@@ -513,41 +577,20 @@ export class HouseAgents {
513
577
  const mode = this.room.policy.brain;
514
578
  const p = this.providerFor(mode);
515
579
  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
580
  // Every decision keeps the 3 s pace (the relay takes one goal every 3 s); AI calls also count to 10 a minute.
522
581
  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
- }
582
+ if (p.run) a.calls = [...a.calls.filter((t) => t > now - 60_000), now];
583
+ const said = a.said.filter((x) => now - x.at < 5 * 60_000).map((x) => ({ line: x.line, args: x.args, ago: now - x.at }));
584
+ const newHere = [...a.seen].filter(([seat, at]) => now - at < 20_000 && players.includes(seat)).map(([seat]) => seat);
585
+ const ctx = { view: a.view, me: { seat: a.seat }, goal: a.view?.goal ?? null, asks, party: a.party, speech: this.room.policy.speech, kids: this.room.policy.kids, quiet, players, avoid, said, newHere };
586
+ const t = await think(vocab, { mode, brain: p, ctx, ms: BRAIN_CADENCE.timeoutMs, now: this.now });
587
+ const { decision, provider, why, ms, cost } = t;
588
+ if (t.engine) {
589
+ this.calls += 1;
590
+ this.spent.neurons += cost.neurons;
591
+ this.spent.micros += cost.micros;
592
+ this.budget.spend({ neurons: cost.neurons, micros: cost.micros, provider: cost.provider ?? mode });
548
593
  }
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
594
  const did = decision ? this.act(a, decision, quiet) : { goal: null, say: null };
552
595
  if (asks.length && decision) {
553
596
  for (const x of asks) a.answered.set(`${x.k}|${x.from}|${JSON.stringify(x.args ?? {})}`, now + 6000);
@@ -559,6 +602,8 @@ export class HouseAgents {
559
602
  at: now, seat: a.seat, guide: a.pass, provider: decision ? provider : 'none', ms, on: dueWhy,
560
603
  goal: did.goal, args: did.goal ? decision.args : null, say: did.say, sayArgs: did.say ? decision.sayArgs : null,
561
604
  ...(why ? { why } : {}), ...(cost.neurons ? { neurons: Math.round(cost.neurons * 100) / 100 } : {}), ...(cost.micros ? { micros: cost.micros } : {}),
605
+ // What the brain itself chose when a rule overruled it (ids only), and how sure a decision model was.
606
+ ...(t.own && /\+floor$/.test(provider) ? { own: { goal: t.own.goal, args: t.own.args } } : {}), ...(t.p ? { p: t.p } : {}), ...(t.model ? { model: t.model } : {}),
562
607
  }].slice(-LOG_MAX);
563
608
  a.inFlight = false;
564
609
  if (this.near(a) && !a.closed) this.due(a, now + BRAIN_CADENCE.everyMs, 'every 12 s');
@@ -576,6 +621,7 @@ export class HouseAgents {
576
621
  if (decision.say && !quiet) {
577
622
  a.h.onMessage(JSON.stringify({ t: 'ev', k: `say:${decision.say}`, d: { args: decision.sayArgs ?? {} } }));
578
623
  a.sayAt = this.now();
624
+ a.said = [...a.said, { line: decision.say, args: decision.sayArgs ?? {}, at: a.sayAt }].slice(-4);
579
625
  out.say = decision.say;
580
626
  }
581
627
  return out;
package/worker/brain.mjs CHANGED
@@ -12,8 +12,13 @@
12
12
  * parseDecision(out, vocab) one JSON object { goal, args, say, sayArgs }, every id and argument checked; anything
13
13
  * else (prose, an unknown id, a wrong or extra argument) is nothing at all
14
14
  * scripted(vocab, ctx) the floor: a player's ask answered the way agents.json says (its `goal` and `say`)
15
- * workersAi / ownerKey the providers: the studio's own Workers AI (env.AI, model HOMIE_BRAIN_MODEL) and the
16
- * owner's own key (the official @anthropic-ai/sdk, claude-haiku-4-5, a JSON schema output)
15
+ * clefQuestions / clefDecision (0.24.4) a decision asked as Clef's typed questions (Choices of goal, argument and line)
16
+ * and the answers composed back into one decision: Cloudflare's decision model never
17
+ * writes text, it only ever picks among the options the vocabulary and the view offer
18
+ * workersAi / ownerKey the providers: the studio's own Workers AI (env.AI, model HOMIE_BRAIN_MODEL: Clef is
19
+ * asked questions, a chat model a prompt) and the owner's own key (the official
20
+ * @anthropic-ai/sdk, claude-haiku-4-5, a JSON schema output)
21
+ * localClef the person's own computer: clef-flash on Ollama (`homie-studio dev` finds it)
17
22
  * costOf what one call cost: neurons (Workers AI) or microdollars (the owner's key)
18
23
  *
19
24
  * SAFETY (DESIGN section 9): the model never types. It picks ids; the game renders the creator's text. A prompt
@@ -341,12 +346,23 @@ export function scripted(vocab, { asks = [], view = null, players = null, avoid
341
346
 
342
347
  /* ------------------------------------------------------------------ providers and what they cost */
343
348
 
344
- /** The default Workers AI model (var HOMIE_BRAIN_MODEL changes it) and the owner-key model. Checked 2026-10-01. */
345
- export const DEFAULT_MODEL = '@cf/meta/llama-3.1-8b-instruct-fp8-fast';
349
+ /**
350
+ * The default Workers AI model (var HOMIE_BRAIN_MODEL changes it) and the owner-key model. Since 0.24.4 the default is
351
+ * Cloudflare's Clef decision model: on 64 recorded Ember Vale moments it answered 36 of 40 open asks itself (Llama 3.1 8B:
352
+ * 0 of 40), and three blind judges preferred its decisions in 51 of 64 (none for Llama; mean 4.4 against 1.9 out of 5),
353
+ * at a model time of p50 259 ms, p90 433 ms, for about 8.6 neurons a decision (Llama: 4.1). Measured 2026-10-03.
354
+ */
355
+ export const DEFAULT_MODEL = '@cf/cloudflare/clef-flash';
356
+ /** The chat model the guides thought with until 0.24.4 (HOMIE_BRAIN_MODEL picks it, or any text-generation model). */
357
+ export const LLAMA_MODEL = '@cf/meta/llama-3.1-8b-instruct-fp8-fast';
346
358
  export const OWNER_MODEL = 'claude-haiku-4-5';
347
359
  /** Neurons per million input and output tokens (Cloudflare's Workers AI pricing, 2026-10-01). An unknown model is
348
360
  * counted at the dearest of these, so a budget never runs over by a cheaper guess. */
349
361
  export const NEURONS_PER_M = Object.freeze({
362
+ // Clef: Cloudflare's decision models (2026-10-01): $0.09 and $0.24 per million input tokens, nothing for output
363
+ // ($0.011 per 1,000 neurons).
364
+ '@cf/cloudflare/clef-flash': [8182, 0],
365
+ '@cf/cloudflare/clef': [21818, 0],
350
366
  '@cf/meta/llama-3.1-8b-instruct-fp8-fast': [4119, 34868],
351
367
  '@cf/meta/llama-3.1-8b-instruct-fp8': [13778, 26128],
352
368
  '@cf/meta/llama-3.1-8b-instruct': [25608, 75147],
@@ -372,10 +388,340 @@ export function costOf({ provider, model, usage = null, system = '', user = '',
372
388
  const [i, o] = MICROS_PER_TOKEN[model] ?? [5, 25];
373
389
  return { neurons: 0, micros: input * i + output * o, input, output };
374
390
  }
391
+ // The person's own computer (Ollama): free, but its tokens are still counted.
392
+ if (provider === 'local') return { neurons: 0, micros: 0, input, output };
375
393
  return { neurons: 0, micros: 0, input: 0, output: 0 };
376
394
  }
377
395
 
378
396
  const timeout = (p, ms, what) => Promise.race([p, new Promise((_, no) => setTimeout(() => no(new Error(`${what} took over ${ms} ms`)), ms))]);
397
+
398
+ /* ------------------------------------------------------------------ Clef: decisions, not text (0.24.4) */
399
+
400
+ /**
401
+ * CLEF (Cloudflare's decision models, Apache 2.0, launched 2026-10-01): a state and a schema of typed questions in, a
402
+ * probability for every allowed answer out, in one pass. It never writes text, so a guide's decision is asked as the
403
+ * Choices it already is: which goal (only goals whose arguments the view can fill), which value for each argument
404
+ * (only values the view, the list or the room offers; a seat that said "no thanks" is not offered), which line or none
405
+ * (only when the guide may speak). The answer is composed back into `{ goal, args, say, sayArgs }` and still goes
406
+ * through parseDecision and every fixed rule.
407
+ *
408
+ * @cf/cloudflare/clef-flash 9B, the studio's Workers AI ($0.09 per million input tokens, no output charge)
409
+ * @cf/cloudflare/clef 27B, the same, $0.24
410
+ * clef-flash on Ollama the person's own computer (`ollama pull clef-flash`, about 11 GB; Ollama 0.35.1 or later):
411
+ * /v1/systemone, the same questions and answers, free
412
+ */
413
+ export const CLEF = Object.freeze({
414
+ models: Object.freeze({ '@cf/cloudflare/clef-flash': 'clef-flash', '@cf/cloudflare/clef': 'clef' }),
415
+ /** Workers AI takes 2 to 255 options and Ollama 2 to 26; a guide's questions keep to 26. Scores: 2 to 10 levels. */
416
+ options: 26, levels: 10, questions: 64,
417
+ local: 'clef-flash',
418
+ none: '_none',
419
+ });
420
+ /** Whether a model id is a Clef decision model (Workers AI's id, or an Ollama tag such as clef-flash:9b). */
421
+ export const isClef = (model) => Boolean(CLEF.models[model]) || /^clef(?:-flash)?(?::[A-Za-z0-9._-]+)?$/.test(String(model ?? ''));
422
+
423
+ /**
424
+ * The person's own Ollama, when this Worker may use it: `HOMIE_LOCAL_AI` is a loopback address (only `homie-studio
425
+ * dev` sets it, after it found clef-flash there). A deployed Worker never reaches a loopback address, and any other
426
+ * value is ignored.
427
+ */
428
+ export function localAiOf(env) {
429
+ const raw = String(env?.HOMIE_LOCAL_AI ?? '').trim().replace(/\/+$/, '');
430
+ if (!/^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d{2,5}$/.test(raw)) return null;
431
+ const model = String(env?.HOMIE_LOCAL_AI_MODEL ?? CLEF.local).trim();
432
+ return { base: raw, model: isClef(model) ? model : CLEF.local };
433
+ }
434
+
435
+ const optionText = (v, max = 100) => oneLine(v, max);
436
+
437
+ /** The values one argument may take now, as Clef options: `[{ key, value, about }]` (at most 26). */
438
+ function optionsOf(vocab, spec, { view, players, avoid, self }) {
439
+ const out = [];
440
+ const add = (value, about) => {
441
+ if (out.length >= CLEF.options) return;
442
+ const key = typeof value === 'number' ? `seat_${value}` : String(value);
443
+ if (!key || out.some((o) => o.key === key)) return;
444
+ out.push({ key, value, about });
445
+ };
446
+ if (spec.kind === 'player') {
447
+ const party = Array.isArray(view?.party) ? view.party : [];
448
+ const seats = (players ?? party.map((p) => p?.seat)).filter((s) => Number.isInteger(s) && s >= 0 && s < 64 && s !== self && !avoid.includes(s));
449
+ for (const s of seats) {
450
+ const row = party.find((p) => p?.seat === s);
451
+ const facts = row ? Object.entries(sanitizeView(row) ?? {}).filter(([k]) => k !== 'seat').map(([k, v]) => `${k} ${typeof v === 'object' ? JSON.stringify(v) : v}`).join(', ') : '';
452
+ add(s, `seat ${s}${facts ? `: ${facts}` : ''}`);
453
+ }
454
+ } else if (spec.kind === 'list') {
455
+ for (const v of spec.values) add(v, labelOf(vocab, v));
456
+ } else if (spec.kind === 'view') {
457
+ const offered = view?.[spec.key];
458
+ const list = Array.isArray(offered) ? offered : offered !== null && offered !== undefined && typeof offered !== 'object' ? [offered] : [];
459
+ for (const o of list) {
460
+ const v = o && typeof o === 'object' ? o.id : o;
461
+ if ((typeof v === 'string' && VALUE.test(v)) || Number.isFinite(v)) add(v, labelOf(vocab, v));
462
+ }
463
+ }
464
+ return out;
465
+ }
466
+
467
+ /**
468
+ * One guide decision as Clef questions: `{ state, questions, plan }`, or null when no goal can be chosen (the floor
469
+ * decides). ctx: { view, me: { seat }, goal, asks, party, speech, quiet, players, avoid, speak }.
470
+ * goal Choice of goal: only goals whose every argument has a value to take
471
+ * say Choice of line, or none (only when the guide may speak now)
472
+ * arg.<name> Choice of an argument's value, one question per set of values (a goal's quest and a
473
+ * line's quest are one question, so what the guide does and says agree); only when there
474
+ * is more than one value to take
475
+ * speak yes/no, when no ask is open: is there anything to say at all (a line needs both;
476
+ * `speak: false` skips it)
477
+ */
478
+ export function clefQuestions(vocab, ctx = {}) {
479
+ if (!vocab) return null;
480
+ const view = sanitizeView(ctx.view ?? {});
481
+ const avoid = Array.isArray(ctx.avoid) ? ctx.avoid : [];
482
+ const self = Number.isInteger(ctx.me?.seat) ? ctx.me.seat : null;
483
+ const opt = { view: ctx.view ?? {}, players: Array.isArray(ctx.players) ? ctx.players : null, avoid, self };
484
+ const asks = (Array.isArray(ctx.asks) ? ctx.asks : []).slice(-3);
485
+ // What each goal answers: the asks of agents.json that it is how a player's ask is answered.
486
+ const answers = {};
487
+ for (const a of Object.values(vocab.asks ?? {})) if (a.goal) (answers[a.goal] ??= []).push(`"${a.text.replace(SLOT_IN_TEXT, (m, n) => `<${n}>`)}"`);
488
+ const questions = {};
489
+ const plan = { goals: {}, lines: {}, args: {} };
490
+ // One question per set of values (a goal's quest and a line's quest are the same question, so they agree).
491
+ const sig = (spec) => (spec.kind === 'player' ? 'player' : spec.kind === 'list' ? `list:${spec.values.join('|')}` : `view:${spec.key}`);
492
+ const groups = new Map();
493
+ const argsFor = (specs) => {
494
+ const out = {};
495
+ for (const [name, spec] of Object.entries(specs)) {
496
+ const s = sig(spec);
497
+ let g = groups.get(s);
498
+ if (!g) { g = { id: null, names: new Set(), opts: optionsOf(vocab, spec, opt), kind: spec.kind }; groups.set(s, g); }
499
+ if (!g.opts.length) return null;
500
+ g.names.add(name);
501
+ out[name] = s;
502
+ }
503
+ return out;
504
+ };
505
+ for (const [id, g] of Object.entries(vocab.goals)) { const a = argsFor(g.args); if (a) plan.goals[id] = a; }
506
+ const goalIds = Object.keys(plan.goals);
507
+ if (!goalIds.length) return null;
508
+ const last = asks.at(-1);
509
+ const newest = last ? { from: last.from, says: renderLine(vocab, last.k, last.args ?? {}, { kind: 'asks', nameOf: (x) => `seat ${x}`, view: ctx.view ?? null }) ?? last.k, wants: vocab.asks?.[last.k]?.goal ?? null } : null;
510
+ if (goalIds.length > 1) {
511
+ questions.goal = {
512
+ type: 'choice',
513
+ // The newest open ask, in the question itself: the one thing a guide must not miss.
514
+ instructions: newest ? optionText(`Seat ${newest.from} just asked the guide: "${newest.says}".${newest.wants ? ` The game's answer to that is ${newest.wants} (${vocab.goals[newest.wants]?.about ?? ''}).` : ''} What should the guide do next?`, 280) : 'What should the guide do next?',
515
+ criteria: Object.fromEntries(goalIds.slice(0, CLEF.options).map((id) => [id, optionText(`${vocab.goals[id].about}${answers[id] ? ` (answers ${answers[id].join(', ')})` : ''}`, 160)])),
516
+ };
517
+ }
518
+ let lineIds = [];
519
+ if (ctx.mayTalk !== false && !ctx.quiet) {
520
+ for (const [id, l] of Object.entries(vocab.lines ?? {})) { const a = argsFor(l.args); if (a) plan.lines[id] = a; }
521
+ lineIds = Object.keys(plan.lines).slice(0, CLEF.options - 1);
522
+ if (lineIds.length) {
523
+ questions.say = {
524
+ type: 'choice',
525
+ instructions: 'Which line should the guide say now, if any? Most moments need none: answer an open ask with the line it wants, greet a player it has not greeted, warn of new danger.',
526
+ criteria: { [CLEF.none]: 'say nothing now', ...Object.fromEntries(lineIds.map((id) => [id, optionText(`"${vocab.lines[id].text}"`, 160)])) },
527
+ };
528
+ // An open ask always gets its line; with none, a line also needs a yes to "anything to say at all?".
529
+ if (ctx.speak !== false && !asks.length) questions.speak = { type: 'noul', instructions: 'Should the guide say anything at all right now?', criteria: { true: 'A player is new here (newHere), new danger came, or something changed for the party', false: 'Nothing new: the guide already said its piece, so talking would be noise' } };
530
+ }
531
+ }
532
+ // The argument questions, for the sets of values that more than one option could fill.
533
+ const used = new Set();
534
+ for (const [s, g] of groups) {
535
+ const names = [...g.names];
536
+ let id = `arg.${names[0]}`;
537
+ for (let n = 2; used.has(id); n += 1) id = `arg.${names[0]}${n}`;
538
+ used.add(id);
539
+ g.id = id;
540
+ plan.args[s] = { id, opts: g.opts };
541
+ if (g.opts.length < 2) continue;
542
+ const hint = g.kind === 'player' ? ' The player who asked; else one new here (to greet); else who needs the guide most.' : ' The one an open ask names, else what fits the game.';
543
+ questions[id] = { type: 'choice', instructions: optionText(`Which ${names.join(' / ')}?${hint}`, 200), criteria: Object.fromEntries(g.opts.map((o) => [o.key, optionText(o.about)])) };
544
+ }
545
+ // At most 64 questions (a vocabulary at its limits could ask for more): the goal's own come first.
546
+ const keys = Object.keys(questions);
547
+ if (keys.length > CLEF.questions) for (const k of keys.slice(CLEF.questions)) delete questions[k];
548
+ const { asks: _a, goal: _g, ...game } = view && typeof view === 'object' && !Array.isArray(view) ? view : {};
549
+ const state = {
550
+ guide: oneLine(`An AI guide in a multiplayer game.${vocab.persona ? ` ${vocab.persona}` : ''}`, 500),
551
+ ...(self !== null ? { you: { seat: self } } : {}),
552
+ game,
553
+ ...(ctx.goal?.goal ? { yourGoal: sanitizeView({ goal: ctx.goal.goal, args: ctx.goal.args ?? {}, state: ctx.goal.state ?? 'active' }) } : {}),
554
+ // An open ask, and how agents.json says it is answered (the same words a chat model's prompt gets).
555
+ ...(asks.length ? { openAsks: asks.map((a) => ({ fromSeat: a.from, says: renderLine(vocab, a.k, a.args ?? {}, { kind: 'asks', nameOf: (s) => `seat ${s}`, view: ctx.view ?? null }) ?? a.k, ...(vocab.asks[a.k]?.goal ? { wantsGoal: vocab.asks[a.k].goal } : {}), ...(vocab.asks[a.k]?.say ? { wantsLine: vocab.asks[a.k].say } : {}), args: sanitizeView(a.args ?? {}) })) } : {}),
556
+ ...(avoid.length ? { leaveAlone: avoid.map((s) => `seat ${s} said no thanks`) } : {}),
557
+ // Players new to this guide (it first saw them in the last 20 s): someone to greet, once.
558
+ ...(Array.isArray(ctx.newHere) && ctx.newHere.length ? { newHere: ctx.newHere.slice(0, 8).map((s) => `seat ${s}`) } : {}),
559
+ // What this guide said lately (its own lines, so it does not greet the same player twice).
560
+ ...(Array.isArray(ctx.said) && ctx.said.length ? { youSaid: ctx.said.slice(-4).map((x) => ({ said: renderLine(vocab, x.line, x.args ?? {}, { nameOf: (s) => `seat ${s}`, me: 'the guide', view: ctx.view ?? null }) ?? x.line, secondsAgo: Math.max(0, Math.round(Number(x.ago) / 1000) || 0) })) } : {}),
561
+ };
562
+ const party = (Array.isArray(ctx.party) ? ctx.party : []).slice(-3).map((p) => {
563
+ if (p.line) return { seat: p.seat, said: renderLine(vocab, p.line, p.args ?? {}, { nameOf: (s) => `seat ${s}`, me: 'a guide' }) ?? p.line };
564
+ // Free text only when the server's speech is "game" (never on a kids or beginner server): quoted, short, data.
565
+ if (p.chat && ctx.speech === 'game' && !ctx.kids) return { seat: p.seat, typed: oneLine(p.chat, 120).replace(SECRET_VALUE, '[redacted]') };
566
+ return null;
567
+ }).filter(Boolean);
568
+ if (party.length) state.recentLines = party;
569
+ return { state, questions, plan };
570
+ }
571
+
572
+ /** The chosen option of a Choice answer (its highest probability), as one of `opts`; else the first. */
573
+ function picked(answer, opts) {
574
+ if (!opts?.length) return null;
575
+ const probs = answer?.probabilities && typeof answer.probabilities === 'object' ? answer.probabilities : null;
576
+ let key = typeof answer?.choice === 'string' ? answer.choice : null;
577
+ if (probs) { const best = Object.entries(probs).filter(([k]) => opts.some((o) => o.key === k)).sort((a, b) => Number(b[1]) - Number(a[1]))[0]; if (best) key = best[0]; }
578
+ return opts.find((o) => o.key === key) ?? null;
579
+ }
580
+
581
+ /**
582
+ * Clef's answers as one decision `{ goal, args, say, sayArgs }` (null when the goal was not answered), and how sure it
583
+ * was (`p`: the goal's and the line's probabilities).
584
+ */
585
+ export function clefDecision(answers, plan) {
586
+ if (!answers || typeof answers !== 'object' || !plan) return null;
587
+ const goalIds = Object.keys(plan.goals);
588
+ const goalOpts = goalIds.map((id) => ({ key: id }));
589
+ const g = goalIds.length === 1 ? goalOpts[0] : picked(answers.goal, goalOpts);
590
+ if (!g) return null;
591
+ const fill = (specs) => Object.fromEntries(Object.entries(specs).map(([name, s]) => { const { id, opts } = plan.args[s]; return [name, (opts.length === 1 ? opts[0] : picked(answers[id], opts) ?? opts[0]).value]; }));
592
+ const out = { goal: g.key, args: fill(plan.goals[g.key]), say: null, sayArgs: {} };
593
+ const lineIds = Object.keys(plan.lines);
594
+ const s = lineIds.length && answers.say ? picked(answers.say, [{ key: CLEF.none }, ...lineIds.map((id) => ({ key: id }))]) : null;
595
+ const gate = answers.speak && Number.isFinite(Number(answers.speak.noul)) ? Number(answers.speak.noul) >= 0.5 : true;
596
+ if (s && s.key !== CLEF.none && plan.lines[s.key] && gate) { out.say = s.key; out.sayArgs = fill(plan.lines[s.key]); }
597
+ const p = (a, k) => (a?.probabilities && Number.isFinite(Number(a.probabilities[k])) ? Math.round(Number(a.probabilities[k]) * 1000) / 1000 : null);
598
+ return { decision: out, p: { goal: goalIds.length === 1 ? 1 : p(answers.goal, g.key), say: s ? p(answers.say, s.key) : null } };
599
+ }
600
+
601
+ /**
602
+ * One Clef call: `{ answers, usage, model, engine }`. On Workers AI (`env.AI`) with the model's id, or on the person's
603
+ * own Ollama (`local`: { base, model }) at /v1/systemone. Questions and answers are the same on both.
604
+ */
605
+ export async function clefRun(env, { state, questions, model = '@cf/cloudflare/clef-flash', ms = 8000, local = null, fetch: fetchImpl = null } = {}) {
606
+ if (local) {
607
+ const f = fetchImpl ?? globalThis.fetch;
608
+ const res = await timeout(f(`${local.base}/v1/systemone`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model: local.model, state, questions }) }), ms, 'Ollama');
609
+ const body = await res.json().catch(() => null);
610
+ if (!res.ok || !body?.answers) throw new Error(`Ollama answered ${res.status}${body?.error ? `: ${String(body.error).slice(0, 120)}` : ''}`);
611
+ return { answers: body.answers, usage: body.usage ?? null, model: local.model, engine: 'local' };
612
+ }
613
+ if (!env?.AI || typeof env.AI.run !== 'function') throw new Error('this Worker has no Workers AI binding (AI)');
614
+ const name = CLEF.models[model];
615
+ if (!name) throw new Error(`${String(model).slice(0, 60)} is not a Clef model`);
616
+ const r = await timeout(env.AI.run(model, { model: name, state, questions }), ms, 'Workers AI');
617
+ if (!r?.answers || typeof r.answers !== 'object') throw new Error('Clef answered nothing it could use');
618
+ return { answers: r.answers, usage: r.usage ?? null, model, engine: 'workers-ai' };
619
+ }
620
+
621
+ /**
622
+ * A guide's decision from Clef: `{ out, usage, model, answers, p, jsonMode: 'clef' }`. `out` is the composed decision
623
+ * (an object, which parseDecision checks like any provider's), or null when no goal could be asked (the floor decides).
624
+ */
625
+ export async function clefDecide(env, { vocab, ctx = {}, model, ms = 8000, local = null, fetch: fetchImpl = null } = {}) {
626
+ const q = clefQuestions(vocab, ctx);
627
+ if (!q) return { out: null, usage: null, model, answers: null, jsonMode: 'clef', why: 'no goal fits the view' };
628
+ if (!Object.keys(q.questions).length) {
629
+ // One goal, every argument already decided, nothing to say: nothing to ask a model.
630
+ const d = clefDecision({}, q.plan);
631
+ return { out: d?.decision ?? null, usage: { input_tokens: 0, output_tokens: 0 }, model, answers: {}, p: d?.p ?? null, jsonMode: 'clef', asked: 0 };
632
+ }
633
+ const r = await clefRun(env, { state: q.state, questions: q.questions, model, ms, local, fetch: fetchImpl });
634
+ const d = clefDecision(r.answers, q.plan);
635
+ return { out: d?.decision ?? null, usage: r.usage, model: r.model, answers: r.answers, p: d?.p ?? null, jsonMode: 'clef', engine: r.engine, asked: Object.keys(q.questions).length, chars: JSON.stringify(q.state).length + JSON.stringify(q.questions).length };
636
+ }
637
+
638
+ /* ------------------------------------------------------------------ decisions at play speed (0.24.4) */
639
+
640
+ /**
641
+ * A GAME'S OWN DECISIONS (NETPLAY.md section 20): the host asks the room a few typed questions about its own state
642
+ * (`net.decide`): which tactic the slimes take, whether a wave comes now, how hard to push. The room asks Clef on the
643
+ * studio's Workers AI (or the person's own Ollama under dev) within the AI brains' day budget, at most one question set
644
+ * every 3 s a room, and the host's own floor answers whenever it cannot (no AI, over budget, slow, off). The answers
645
+ * are option ids, yes/no and a number: nothing a player reads comes from a model.
646
+ *
647
+ * checkDecide(state, questions) the request as the relay passes it on: game state only (sanitizeView), at most
648
+ * 8 questions; Choice 2 to 26 options, Score 2 to 10 levels, a yes/no; short words
649
+ * picksOf(answers, questions) the answers as the game uses them: { tactic: 'surround', wave: false, pressure: 2.6 }
650
+ */
651
+ export const DECIDE = Object.freeze({ questions: 8, options: 26, levels: 10, words: 160, option: 40, stateBytes: 2048, bytes: 6144, gapMs: 3000, perMinute: 20, inFlight: 2, ms: 2500 });
652
+ const QID = /^[A-Za-z][A-Za-z0-9_.-]{0,39}$/;
653
+ const OPT = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$/;
654
+
655
+ /** `{ ok, state, questions }`, or `{ ok: false, why }`: what the relay sends the decision model, every field checked. */
656
+ export function checkDecide(state, questions) {
657
+ const no = (why) => ({ ok: false, why });
658
+ if (!questions || typeof questions !== 'object' || Array.isArray(questions)) return no('questions is an object of id: question');
659
+ const ids = Object.keys(questions);
660
+ if (!ids.length || ids.length > DECIDE.questions) return no(`1 to ${DECIDE.questions} questions`);
661
+ const out = {};
662
+ for (const id of ids) {
663
+ if (!QID.test(id)) return no(`question id "${oneLine(id, 20)}" (letters, digits, _ . -)`);
664
+ const q = questions[id];
665
+ if (!q || typeof q !== 'object') return no(`${id}: a question is { type, instructions, criteria }`);
666
+ const instructions = oneLine(q.instructions, DECIDE.words);
667
+ if (!instructions) return no(`${id}: instructions say what to decide`);
668
+ if (q.type === 'choice') {
669
+ const c = q.criteria;
670
+ const keys = c && typeof c === 'object' && !Array.isArray(c) ? Object.keys(c) : Array.isArray(c) ? c : [];
671
+ if (keys.length < 2 || keys.length > DECIDE.options) return no(`${id}: a choice has 2 to ${DECIDE.options} options`);
672
+ const criteria = {};
673
+ for (const k of keys) {
674
+ if (!OPT.test(String(k))) return no(`${id}: option "${oneLine(k, 20)}" (letters, digits, _ . -)`);
675
+ const about = Array.isArray(c) ? null : c[k];
676
+ criteria[k] = about === null || about === undefined ? null : oneLine(about, DECIDE.words) || null;
677
+ }
678
+ out[id] = { type: 'choice', instructions, criteria };
679
+ } else if (q.type === 'noul' || q.type === 'yes-no') {
680
+ const c = q.criteria && typeof q.criteria === 'object' ? q.criteria : {};
681
+ out[id] = { type: 'noul', instructions, ...(c.true || c.false ? { criteria: { ...(c.true ? { true: oneLine(c.true, DECIDE.words) } : {}), ...(c.false ? { false: oneLine(c.false, DECIDE.words) } : {}) } } : {}) };
682
+ } else if (q.type === 'score') {
683
+ const levels = Array.isArray(q.criteria) ? q.criteria.map((x) => oneLine(x, DECIDE.option * 2)) : [];
684
+ if (levels.length < 2 || levels.length > DECIDE.levels || levels.some((x) => !x)) return no(`${id}: a score has 2 to ${DECIDE.levels} levels, lowest first`);
685
+ out[id] = { type: 'score', instructions, criteria: levels };
686
+ } else return no(`${id}: type is choice, noul (yes/no) or score`);
687
+ }
688
+ const s = sanitizeView(state && typeof state === 'object' ? state : { state: String(state ?? '') });
689
+ if (JSON.stringify(s).length > DECIDE.stateBytes) return no(`the state is over ${DECIDE.stateBytes} bytes`);
690
+ return { ok: true, state: s, questions: out };
691
+ }
692
+
693
+ /** Answers as picks: a choice's option id, a yes/no as true or false, a score's probability-weighted level. */
694
+ export function picksOf(answers, questions) {
695
+ const picks = {};
696
+ const p = {};
697
+ for (const [id, q] of Object.entries(questions ?? {})) {
698
+ const a = answers?.[id];
699
+ if (!a) continue;
700
+ if (q.type === 'choice') {
701
+ const keys = Object.keys(q.criteria);
702
+ const probs = a.probabilities && typeof a.probabilities === 'object' ? a.probabilities : {};
703
+ const best = Object.entries(probs).filter(([k]) => keys.includes(k)).sort((x, y) => Number(y[1]) - Number(x[1]))[0]?.[0] ?? (keys.includes(a.choice) ? a.choice : null);
704
+ if (best) { picks[id] = best; p[id] = Object.fromEntries(keys.map((k) => [k, Math.round((Number(probs[k]) || 0) * 1000) / 1000])); }
705
+ } else if (q.type === 'noul') {
706
+ const v = Number(a.noul);
707
+ if (Number.isFinite(v)) { picks[id] = v >= 0.5; p[id] = { yes: Math.round(v * 1000) / 1000 }; }
708
+ } else if (q.type === 'score') {
709
+ const v = Number(a.score);
710
+ if (Number.isFinite(v)) { picks[id] = Math.round(Math.max(0, Math.min(q.criteria.length - 1, v)) * 100) / 100; if (a.probabilities) p[id] = Object.fromEntries(Object.entries(a.probabilities).map(([k, x]) => [k, Math.round((Number(x) || 0) * 1000) / 1000])); }
711
+ }
712
+ }
713
+ return { picks, p };
714
+ }
715
+
716
+ /**
717
+ * The person's own computer as a guide's brain: Clef on Ollama (`HOMIE_LOCAL_AI`, set by `homie-studio dev` when it
718
+ * found clef-flash there). Throws when there is none; the caller falls back to the floor.
719
+ */
720
+ export async function localClef(env, { vocab, ctx, ms = 8000, fetch: fetchImpl = null } = {}) {
721
+ const local = localAiOf(env);
722
+ if (!local) throw new Error('no local Clef (Ollama with clef-flash on this computer)');
723
+ return clefDecide(env, { vocab, ctx, model: local.model, ms, local, fetch: fetchImpl });
724
+ }
379
725
  /** Models seen to refuse JSON mode in this isolate: asked without it from then on. */
380
726
  const NO_JSON_MODE = new Set();
381
727
 
@@ -383,8 +729,13 @@ const NO_JSON_MODE = new Set();
383
729
  * Workers AI (the AI binding): one decision. `{ out, usage, model, jsonMode }`; `out` is text or (JSON mode) an object.
384
730
  * A model that does not do JSON mode is asked again without it, and from then on.
385
731
  */
386
- export async function workersAi(env, { system, user, schema, model = env?.HOMIE_BRAIN_MODEL || DEFAULT_MODEL, ms = 8000 }) {
732
+ export async function workersAi(env, { system, user, schema, model = env?.HOMIE_BRAIN_MODEL || DEFAULT_MODEL, ms = 8000, vocab = null, ctx = null }) {
387
733
  if (!env?.AI || typeof env.AI.run !== 'function') throw new Error('this Worker has no Workers AI binding (AI)');
734
+ // A decision model is asked the decision's own questions, not a prompt (Clef, above).
735
+ if (CLEF.models[model]) {
736
+ if (!vocab) throw new Error('a Clef brain needs the vocabulary');
737
+ return clefDecide(env, { vocab, ctx: ctx ?? {}, model, ms });
738
+ }
388
739
  const base = { messages: [{ role: 'system', content: system }, { role: 'user', content: user }], max_tokens: 120, temperature: 0.2 };
389
740
  const ask = (json) => timeout(env.AI.run(model, json ? { ...base, response_format: { type: 'json_schema', json_schema: schema } } : base), ms, 'Workers AI');
390
741
  let json = !NO_JSON_MODE.has(model);