@homie-rocks/studio 0.24.3 → 0.24.5
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 +51 -1
- package/README.md +21 -5
- package/agents/GUIDES.md +13 -6
- package/bin/homie-studio.mjs +34 -10
- package/chat/CHAT.md +38 -5
- package/lib/agent-seat.mjs +60 -2
- package/lib/brain-probe.mjs +6 -5
- package/lib/build.mjs +6 -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 +17 -4
- package/lib/servers.mjs +31 -2
- package/lib/template-history.json +78 -0
- package/netplay/NETPLAY.md +95 -17
- package/netplay/netplay.ts +82 -0
- package/package.json +1 -1
- package/site/SITE.md +4 -0
- 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-page.mjs +173 -28
- package/worker/chat.mjs +17 -7
- 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/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);
|