@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/CHANGELOG.md +28 -1
- package/README.md +18 -3
- package/agents/GUIDES.md +13 -6
- package/bin/homie-studio.mjs +34 -10
- package/chat/CHAT.md +2 -0
- package/lib/agent-seat.mjs +60 -2
- package/lib/brain-probe.mjs +6 -5
- package/lib/build.mjs +3 -0
- package/lib/cloudflare.mjs +2 -0
- package/lib/doctor.mjs +14 -0
- package/lib/local-ai.mjs +66 -0
- package/lib/mcp-tools.mjs +10 -2
- package/lib/scaffold.mjs +7 -0
- package/lib/servers.mjs +31 -2
- package/lib/template-history.json +39 -0
- package/netplay/NETPLAY.md +86 -12
- package/netplay/netplay.ts +82 -0
- package/package.json +1 -1
- package/starters/ember-vale/game.json +1 -0
- package/starters/ember-vale/src/main.ts +80 -6
- package/worker/agents.mjs +93 -47
- package/worker/brain.mjs +356 -5
- package/worker/chat.mjs +13 -6
- package/worker/index.mjs +57 -2
- package/worker/office.mjs +63 -3
- package/worker/room.mjs +48 -3
- package/worker/version.mjs +1 -1
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
|
|
551
|
+
/** The brain this server asked for, if it can run now (brainFor above). */
|
|
478
552
|
providerFor(mode) {
|
|
479
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
/**
|
|
345
|
-
|
|
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);
|