@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/chat.mjs
CHANGED
|
@@ -14,12 +14,15 @@
|
|
|
14
14
|
* links, swears, and the studio's own words. It always runs, and it needs nothing.
|
|
15
15
|
* review typed text the floor let through, on the studio's own Workers AI: Cloudflare's Clef decision model
|
|
16
16
|
* (`@cf/cloudflare/clef-flash`, launched 2026-10-01: a probability for each answer, about 40 ms), within a
|
|
17
|
-
* day's budget of neurons.
|
|
17
|
+
* day's budget of neurons. Under `homie-studio dev` with no binding, the same questions go to the person's
|
|
18
|
+
* own Ollama (clef-flash) when dev found it there: free. No model, no budget left or an error: the floor
|
|
19
|
+
* alone decides.
|
|
18
20
|
*
|
|
19
21
|
* Emoji and quick lines are the studio's own words: they never need a review, so they reach every screen at once.
|
|
20
22
|
* NOTHING is stored here: the relay keeps a short window in memory (section 19), and a report keeps only the message.
|
|
21
23
|
*/
|
|
22
24
|
import { WORDS_B64 } from './chat-words.mjs';
|
|
25
|
+
import { clefRun, localAiOf } from './brain.mjs';
|
|
23
26
|
|
|
24
27
|
export const CHAT_MODES = Object.freeze(['off', 'emoji', 'lines', 'text']);
|
|
25
28
|
export const CHAT_WHO = Object.freeze(['anyone', 'signed-in', 'members']);
|
|
@@ -387,9 +390,11 @@ const timeout = (p, ms) => Promise.race([p, new Promise((_, no) => setTimeout(()
|
|
|
387
390
|
* (`why` is the most likely other answer). Throws when there is no binding, the model fails or it takes too long:
|
|
388
391
|
* the caller lets the floor decide.
|
|
389
392
|
*/
|
|
390
|
-
export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || CHAT_MODEL, ms = CHAT_LIMITS.reviewMs, threshold = REVIEW_THRESHOLD, links = 'block', now = () => Date.now() } = {}) {
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
+
export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || CHAT_MODEL, ms = CHAT_LIMITS.reviewMs, threshold = REVIEW_THRESHOLD, links = 'block', now = () => Date.now(), fetch: fetchImpl = null } = {}) {
|
|
394
|
+
// No binding: the person's own Ollama (`homie-studio dev` found clef-flash there), with the same question. Free.
|
|
395
|
+
const local = env?.AI && typeof env.AI.run === 'function' ? null : localAiOf(env);
|
|
396
|
+
if (!local && (!env?.AI || typeof env.AI.run !== 'function')) throw new Error('this Worker has no Workers AI binding (AI)');
|
|
397
|
+
const spec = local ? { kind: 'clef', name: local.model, input: 0, output: 0 } : CHAT_MODELS[model] ?? CHAT_MODELS[CHAT_MODEL];
|
|
393
398
|
const at = now();
|
|
394
399
|
if (spec.kind === 'guard') {
|
|
395
400
|
const r = await timeout(env.AI.run(model, { messages: [{ role: 'user', content: text }], temperature: 0, max_tokens: 16, response_format: { type: 'json_object' } }), ms);
|
|
@@ -402,7 +407,9 @@ export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || C
|
|
|
402
407
|
}
|
|
403
408
|
// A room that allows links says so, so a plain link is not read as spam.
|
|
404
409
|
const verdict = links === 'allow' ? { ...VERDICT, criteria: { ...VERDICT.criteria, spam: 'Ads, scams or spam (a plain link is fine here)' } } : VERDICT;
|
|
405
|
-
const r =
|
|
410
|
+
const r = local
|
|
411
|
+
? await clefRun(env, { state: text, questions: { verdict }, ms: Math.max(ms, 4000), local, fetch: fetchImpl })
|
|
412
|
+
: await timeout(env.AI.run(model, { model: spec.name, state: text, questions: { verdict } }), ms);
|
|
406
413
|
const a = r?.answers?.verdict;
|
|
407
414
|
if (!a || typeof a !== 'object') throw new Error('the review answered nothing it could use');
|
|
408
415
|
const probs = a.probabilities && typeof a.probabilities === 'object' ? a.probabilities : null;
|
|
@@ -415,5 +422,5 @@ export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || C
|
|
|
415
422
|
why = why ?? 'held';
|
|
416
423
|
}
|
|
417
424
|
const input = Number(r?.usage?.input_tokens ?? r?.usage?.prompt_tokens) || Math.ceil((text.length + VERDICT_CHARS) / 4);
|
|
418
|
-
return { ok: pOk >= threshold, why, p: Math.round(pOk * 1000) / 1000, neurons: (input * spec.input) / 1e6, ms: now() - at, model };
|
|
425
|
+
return { ok: pOk >= threshold, why, p: Math.round(pOk * 1000) / 1000, neurons: (input * spec.input) / 1e6, ms: now() - at, model: local ? local.model : model, ...(local ? { local: true } : {}) };
|
|
419
426
|
}
|
package/worker/index.mjs
CHANGED
|
@@ -86,7 +86,7 @@ import {
|
|
|
86
86
|
setMembership,
|
|
87
87
|
} from './servers.mjs';
|
|
88
88
|
import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
|
|
89
|
-
import { talks } from './brain.mjs';
|
|
89
|
+
import { CLEF, DECIDE, clefRun, costOf, localAiOf, picksOf, talks } from './brain.mjs';
|
|
90
90
|
import { REACTIONS, reviewChat } from './chat.mjs';
|
|
91
91
|
import { REPORT_REASONS, chatDay, chatOf, chatRowsOf, fileReport } from './chat-store.mjs';
|
|
92
92
|
import { doorPage, serverPage, serversPage } from './site.mjs';
|
|
@@ -1163,16 +1163,71 @@ export class Table {
|
|
|
1163
1163
|
this.vocabRead = this.readVocab(game).catch(() => false);
|
|
1164
1164
|
// Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
|
|
1165
1165
|
this.room.review = (text, opts) => this.reviewChat(text, opts);
|
|
1166
|
+
// A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
|
|
1167
|
+
this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
|
|
1166
1168
|
return this.room;
|
|
1167
1169
|
}
|
|
1168
1170
|
|
|
1171
|
+
/** Whether this game asked for decisions (game.json "decide": true, in the built catalogue), read once a minute. */
|
|
1172
|
+
async decideOn() {
|
|
1173
|
+
if (this.decideRead && Date.now() - this.decideRead.at < 60_000) return this.decideRead.on;
|
|
1174
|
+
let on = false;
|
|
1175
|
+
try {
|
|
1176
|
+
const res = this.env.ASSETS ? await this.env.ASSETS.fetch(new Request('https://assets.local/games.json')) : null;
|
|
1177
|
+
const cat = res?.ok ? await res.json() : null;
|
|
1178
|
+
const g = (cat?.games ?? []).find((x) => x.id === this.game);
|
|
1179
|
+
on = Boolean(g && (g.decide === true || (g.decide && typeof g.decide === 'object' && g.decide.on !== false)));
|
|
1180
|
+
} catch { on = false; }
|
|
1181
|
+
this.decideRead = { at: Date.now(), on };
|
|
1182
|
+
return on;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* One of a game's own decisions (worker/brain.mjs checkDecide, picksOf): Clef on the studio's Workers AI (model
|
|
1187
|
+
* HOMIE_DECIDE_MODEL, else clef-flash), or the person's own Ollama under `homie-studio dev`, within the AI brains' day
|
|
1188
|
+
* (the same budget the guides spend: decisions and guides are one day of neurons). Not opted in, no AI, over budget, too
|
|
1189
|
+
* slow or failed: `{ ok: false, why }`, and the host's floor answers.
|
|
1190
|
+
*/
|
|
1191
|
+
async decide(state, questions) {
|
|
1192
|
+
if (!(await this.decideOn())) return { ok: false, why: 'off' };
|
|
1193
|
+
const local = this.env.AI ? null : localAiOf(this.env);
|
|
1194
|
+
if (!this.env.AI && !local) return { ok: false, why: 'no-ai' };
|
|
1195
|
+
if (this.env.AI) {
|
|
1196
|
+
if (!this.brainDay || Date.now() - this.brainReadAt > 60_000) await this.readBrainDay().catch(() => {});
|
|
1197
|
+
if (this.brainCap.neurons - this.brainUsed.neurons <= 0) return { ok: false, why: 'budget' };
|
|
1198
|
+
}
|
|
1199
|
+
const model = CLEF.models[this.env.HOMIE_DECIDE_MODEL] ? this.env.HOMIE_DECIDE_MODEL : '@cf/cloudflare/clef-flash';
|
|
1200
|
+
const t0 = Date.now();
|
|
1201
|
+
try {
|
|
1202
|
+
const r = await clefRun(this.env, { state, questions, model, ms: DECIDE.ms, local });
|
|
1203
|
+
const ms = Date.now() - t0;
|
|
1204
|
+
const usage = r.usage ?? { input_tokens: Math.ceil(JSON.stringify({ state, questions }).length / 4) + 400, output_tokens: 0 };
|
|
1205
|
+
const cost = costOf({ provider: r.engine === 'local' ? 'local' : 'workers-ai', model: r.model, usage });
|
|
1206
|
+
this.brainSpend({ neurons: cost.neurons, micros: 0, provider: r.engine === 'local' ? 'decide-local' : 'decide' });
|
|
1207
|
+
this.flushSoon();
|
|
1208
|
+
const { picks, p } = picksOf(r.answers, questions);
|
|
1209
|
+
return { ok: true, by: r.engine === 'local' ? 'local' : 'ai', picks, p, ms, neurons: cost.neurons };
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
return { ok: false, why: /took over/.test(String(error?.message ?? error)) ? 'slow' : 'error' };
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** The day's counters go to D1 on an alarm: set one a minute out when none is set (a house guide's alarm also flushes). */
|
|
1216
|
+
flushSoon() {
|
|
1217
|
+
this.ctx.waitUntil((async () => { if ((await this.ctx.storage.getAlarm()) === null) await this.ctx.storage.setAlarm(Date.now() + 60_000); })().catch(() => {}));
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1169
1220
|
/**
|
|
1170
1221
|
* One typed chat message, reviewed by the studio's decision model (worker/chat.mjs reviewChat) within the day's
|
|
1171
1222
|
* budget for the whole studio. No binding (`homie-studio dev` without --remote-ai), no budget left, or an error: the
|
|
1172
1223
|
* floor alone has decided (`by` says which), and the message goes out.
|
|
1173
1224
|
*/
|
|
1174
1225
|
async reviewChat(text, { links = 'block' } = {}) {
|
|
1175
|
-
|
|
1226
|
+
// Under `homie-studio dev` with no binding, the person's own Ollama reviews (free, so no budget).
|
|
1227
|
+
if (!this.env.AI) {
|
|
1228
|
+
if (!localAiOf(this.env)) return { ok: true, by: 'none' };
|
|
1229
|
+
try { const v = await reviewChat(this.env, text, { links }); return { ok: v.ok, by: 'local', why: v.why, ms: v.ms, p: v.p }; } catch { return { ok: true, by: 'error' }; }
|
|
1230
|
+
}
|
|
1176
1231
|
if (this.chatCap === null || Date.now() - this.chatReadAt > 60_000) await this.readChatDay().catch(() => {});
|
|
1177
1232
|
if (this.chatUsed >= (this.chatCap ?? 0)) return { ok: true, by: 'budget' };
|
|
1178
1233
|
try {
|
package/worker/office.mjs
CHANGED
|
@@ -56,9 +56,9 @@
|
|
|
56
56
|
*/
|
|
57
57
|
import { SEAT_MAX, seatsOf } from './seats.mjs';
|
|
58
58
|
import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
|
|
59
|
-
import { BRAIN_BUDGET, aiName, fillSpot, passById, passCreate, passList, passRevoke } from './agents.mjs';
|
|
60
|
-
import { DEFAULT_MODEL, OWNER_MODEL } from './brain.mjs';
|
|
61
|
-
import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession, today } from './stats.mjs';
|
|
59
|
+
import { BRAIN_BUDGET, aiName, brainFor, fillSpot, passById, passCreate, passList, passRevoke, think } from './agents.mjs';
|
|
60
|
+
import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain.mjs';
|
|
61
|
+
import { OWNER_COOKIE, cookieValues, counter, ownerAllowed, ownerSession, today } from './stats.mjs';
|
|
62
62
|
import { esc, layout, notFoundPage } from './site.mjs';
|
|
63
63
|
import { confirmPage, lockedPage, officePage } from './office-page.mjs';
|
|
64
64
|
import { licenseOf } from './license.mjs';
|
|
@@ -473,6 +473,8 @@ async function roomRow(env, meta, room, max) {
|
|
|
473
473
|
clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
|
|
474
474
|
// Room chat (0.23.0): the room's last minutes, with who sent each line (a client id and account; never an address).
|
|
475
475
|
chat: office.chat ? { mode: office.chat.rules?.mode ?? null, pending: office.chat.pending ?? 0, lines: (office.chat.lines ?? []).slice(-50).map((l) => ({ id: l.id, at: l.at, t: l.t, kind: l.kind, name: oneLine(l.name, 40), seat: l.seat ?? null, by: l.by, text: l.text ?? null, glyph: l.glyph ?? null, react: l.react ?? null, say: l.say ?? null, acct: Boolean(l.acct), owner: Boolean(l.owner), client: l.client ?? null, player: l.player ?? null, browser: l.browser ?? null, ...(l.review ? { review: l.review } : {}) })) } : null,
|
|
476
|
+
// A game's own decisions (0.24.4, net.decide): how many, who answered, the model's median time, the neurons.
|
|
477
|
+
...(office.decides && typeof office.decides === 'object' ? { decides: { n: Number(office.decides.n) || 0, by: office.decides.by ?? {}, msP50: office.decides.msP50 ?? null, neurons: Number(office.decides.neurons) || 0 } } : {}),
|
|
476
478
|
// The house guides' brains (0.17.0): which brain, why not when it is not, and the last decisions (seats and ids).
|
|
477
479
|
...(f.brains && typeof f.brains === 'object' ? { brains: {
|
|
478
480
|
brain: String(f.brains.brain ?? ''), why: f.brains.why ? oneLine(f.brains.why, 120) : null, calls: Number(f.brains.calls) || 0,
|
|
@@ -482,6 +484,62 @@ async function roomRow(env, meta, room, max) {
|
|
|
482
484
|
};
|
|
483
485
|
}
|
|
484
486
|
|
|
487
|
+
/**
|
|
488
|
+
* `agents try` (0.24.4): a guide's brain on one moment the owner gives, with no seat taken and nobody in a room: the
|
|
489
|
+
* game's own vocabulary, the view, the asks and the party's lines in; the decision out, with the brain's own pick
|
|
490
|
+
* before the fixed rules, Clef's probabilities, the model's time and the neurons. It runs exactly what a house guide
|
|
491
|
+
* runs (agents.mjs think) and spends from the same day's budget, so a creator can test an agents.json, and compare a
|
|
492
|
+
* model (`model`: one of the models this toolkit prices) before setting it.
|
|
493
|
+
*/
|
|
494
|
+
export async function agentsTry(env, cat, body) {
|
|
495
|
+
const bad = (message) => ({ ok: false, error: 'bad-request', message });
|
|
496
|
+
const meta = (cat.games ?? []).find((g) => g.id === body.game);
|
|
497
|
+
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
498
|
+
const mode = body.mode ?? 'workers-ai';
|
|
499
|
+
if (!['workers-ai', 'owner-key', 'script'].includes(mode)) return bad('mode is workers-ai, owner-key or script');
|
|
500
|
+
if (body.model !== undefined && !(typeof body.model === 'string' && NEURONS_PER_M[body.model])) return bad(`model is one of ${Object.keys(NEURONS_PER_M).join(', ')}`);
|
|
501
|
+
const view = body.view && typeof body.view === 'object' && !Array.isArray(body.view) ? body.view : null;
|
|
502
|
+
if (!view || JSON.stringify(view).length > 2048) return bad('view is the game state a guide sees (an object, under 2 KB)');
|
|
503
|
+
const int = (v) => Number.isInteger(v) && v >= 0 && v < 64;
|
|
504
|
+
const asks = (Array.isArray(body.asks) ? body.asks : []).slice(0, 3).filter((a) => a && typeof a.k === 'string' && int(a.from)).map((a, i) => ({ k: a.k, args: a.args && typeof a.args === 'object' ? a.args : {}, from: a.from, at: Date.now() - i }));
|
|
505
|
+
const party = (Array.isArray(body.party) ? body.party : []).slice(-3).filter((p) => p && int(p.seat) && typeof p.line === 'string').map((p) => ({ seat: p.seat, line: p.line, args: p.args ?? {} }));
|
|
506
|
+
const seats = (list) => (Array.isArray(list) ? list.filter(int).slice(0, 32) : []);
|
|
507
|
+
let vocab = null;
|
|
508
|
+
try {
|
|
509
|
+
const res = env.ASSETS ? await env.ASSETS.fetch(new Request(`https://assets.local/games/${meta.id}/agents.json`)) : null;
|
|
510
|
+
if (res?.ok) vocab = vocabularyOf(await res.json()).vocab;
|
|
511
|
+
} catch { vocab = null; }
|
|
512
|
+
if (!vocab) return { ok: false, error: 'no-vocabulary', message: `${meta.name} has no agents.json in this build: its guides have nothing to choose from.` };
|
|
513
|
+
const day = await brainDay(env);
|
|
514
|
+
const budget = { left: (k) => (k === 'neurons' ? day.budget.neurons - day.used.neurons : day.budget.usd * 1e6 - day.used.usd * 1e6) };
|
|
515
|
+
const e = body.model ? { ...env, HOMIE_BRAIN_MODEL: body.model } : env;
|
|
516
|
+
const brain = brainFor(mode, e, { budget });
|
|
517
|
+
const ctx = {
|
|
518
|
+
view, me: { seat: int(body.seat) ? body.seat : 7 }, goal: view.goal ?? null, asks, party, speech: ['game', 'lines', 'off'].includes(body.speech) ? body.speech : 'lines',
|
|
519
|
+
kids: body.kids === true, quiet: body.quiet === true, players: seats(body.players ?? (Array.isArray(view.party) ? view.party.map((p) => p?.seat) : [])), avoid: seats(body.avoid),
|
|
520
|
+
// What a house guide remembers: who is new to it, and its own last lines (ids, arguments, how long ago).
|
|
521
|
+
newHere: seats(body.newHere),
|
|
522
|
+
said: (Array.isArray(body.said) ? body.said : []).slice(-4).filter((x) => x && typeof x.line === 'string' && vocab.lines[x.line]).map((x) => ({ line: x.line, args: x.args && typeof x.args === 'object' ? x.args : {}, ago: Math.max(0, Number(x.agoMs) || 0) })),
|
|
523
|
+
};
|
|
524
|
+
const t = await think(vocab, { mode, brain, ctx });
|
|
525
|
+
if (t.engine && env.DB) {
|
|
526
|
+
const rows = [counter(env, { metric: 'brain-calls', subject: meta.id, source: 'try' })];
|
|
527
|
+
const n = Math.round(t.cost.neurons);
|
|
528
|
+
if (n > 0) rows.push(counter(env, { metric: 'brain-neurons', subject: meta.id, n }));
|
|
529
|
+
if (t.cost.micros >= 1) rows.push(counter(env, { metric: 'brain-microdollars', subject: meta.id, n: Math.round(t.cost.micros) }));
|
|
530
|
+
await env.DB.batch(rows.filter(Boolean)).catch(() => {});
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
ok: true, game: meta.id, mode, model: t.model ?? (mode === 'workers-ai' ? e.HOMIE_BRAIN_MODEL || DEFAULT_MODEL : null), engine: t.engine, provider: t.provider,
|
|
534
|
+
decision: t.decision, own: t.own, overruled: /\+floor$/.test(t.provider), why: t.why, p: t.p, ms: t.ms,
|
|
535
|
+
// A decision model's whole answer: every option's probability, per question (ids and numbers only).
|
|
536
|
+
...(t.answers ? { answers: Object.fromEntries(Object.entries(t.answers).map(([k, a]) => [k, a?.probabilities ? Object.fromEntries(Object.entries(a.probabilities).map(([o, v]) => [o, Math.round(Number(v) * 100) / 100])) : a?.noul ?? a?.score ?? null])) } : {}),
|
|
537
|
+
...(t.usage ? { tokens: { input: Number(t.usage.input_tokens ?? t.usage.prompt_tokens) || null, output: Number(t.usage.output_tokens ?? t.usage.completion_tokens) || 0 } } : {}),
|
|
538
|
+
neurons: Math.round(t.cost.neurons * 1000) / 1000, micros: Math.round(t.cost.micros),
|
|
539
|
+
budget: { neurons: day.budget.neurons, usedBefore: Math.round(day.used.neurons) },
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
485
543
|
/** The AI brains' day for the whole studio: the budget (meta brain_budget) and what today used (stats_daily). */
|
|
486
544
|
export async function brainDay(env) {
|
|
487
545
|
const budget = { neurons: BRAIN_BUDGET.neurons, usd: BRAIN_BUDGET.usd };
|
|
@@ -1163,6 +1221,8 @@ async function api(request, env, url, cat) {
|
|
|
1163
1221
|
return json({ ok: true, game, server, members });
|
|
1164
1222
|
} catch (error) { return json({ ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503); }
|
|
1165
1223
|
}
|
|
1224
|
+
// A guide's brain on a moment the owner gives (0.24.4): what it would decide, why, how sure, how long and what it cost.
|
|
1225
|
+
if (path === '/_studio/api/agents/try' && request.method === 'POST') return json(await agentsTry(env, cat, body));
|
|
1166
1226
|
// Room chat (0.23.0): every game's rules, every live room's last minutes, the reports, the review's day.
|
|
1167
1227
|
if (path === '/_studio/api/chat' && request.method === 'GET') {
|
|
1168
1228
|
const metas = (cat.games ?? []).filter((g) => !url.searchParams.get('game') || g.id === url.searchParams.get('game'));
|
package/worker/room.mjs
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
* =============================================================================
|
|
45
45
|
*/
|
|
46
46
|
import { aiName, skillPreset, stripAi } from './agents.mjs';
|
|
47
|
-
import { checkArgs, talks, vocabularyOf } from './brain.mjs';
|
|
47
|
+
import { DECIDE, checkArgs, checkDecide, talks, vocabularyOf } from './brain.mjs';
|
|
48
48
|
import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normalizeChat, publicChat } from './chat.mjs';
|
|
49
49
|
|
|
50
50
|
export const NET_VERSION = 1;
|
|
@@ -103,6 +103,8 @@ export const LIMITS = Object.freeze({
|
|
|
103
103
|
hello: 2048, snap: 16384, in: 2048, ev: 4096, ckpt: 65536, state: 8192, round: 8192, roster: 4096, ping: 256, other: 512,
|
|
104
104
|
// Room chat (revision 8): a typed line (280 characters, emoji count double), a reaction.
|
|
105
105
|
say: 1536, react: 256,
|
|
106
|
+
// A game's own decision (section 20): a few typed questions about its state.
|
|
107
|
+
decide: DECIDE.bytes,
|
|
106
108
|
});
|
|
107
109
|
/**
|
|
108
110
|
* What grows with the seats (every body, every result, every slot): the checkpoint, round and roster caps double
|
|
@@ -112,7 +114,7 @@ export const LIMITS = Object.freeze({
|
|
|
112
114
|
export const GROWS = Object.freeze(['ckpt', 'round', 'roster']);
|
|
113
115
|
export const capOf = (t, seats) => (LIMITS[t] ?? LIMITS.other) * (GROWS.includes(t) && seats > 16 ? 2 : 1);
|
|
114
116
|
/** Rate caps (messages per rolling second, per client). Over the cap a message is dropped, counted and reported. */
|
|
115
|
-
export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8, say: 4, react: 10 });
|
|
117
|
+
export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8, say: 4, react: 10, decide: 2 });
|
|
116
118
|
/** `ev` per second by the sender's role: a screen is a spectator, and the host is somebody's phone. */
|
|
117
119
|
export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
|
|
118
120
|
/** The keyed state channel: at most this many keys and bytes per room. */
|
|
@@ -274,7 +276,7 @@ export class NetRoom {
|
|
|
274
276
|
this.stats = {
|
|
275
277
|
snaps: 0, ins: 0, evs: 0, ckpts: 0, states: 0, rounds: 0, drops: 0, oversize: 0, stFixed: 0, reclaimed: 0, seated: 0,
|
|
276
278
|
kicked: 0, refused: 0, persists: 0, restoredFrom: null, bytesIn: 0, bytesOut: 0, elections: [], promotions: 0,
|
|
277
|
-
speechDrops: 0, agentDrops: 0, labelled: 0, votes: 0,
|
|
279
|
+
speechDrops: 0, agentDrops: 0, labelled: 0, votes: 0, decides: 0, decided: {}, decideMs: [],
|
|
278
280
|
};
|
|
279
281
|
this.snapTimes = [];
|
|
280
282
|
/** The owner's controls (section 15): who is held out of this room until when, who is muted, the banner, a closed door. */
|
|
@@ -299,6 +301,13 @@ export class NetRoom {
|
|
|
299
301
|
this.chatReacts = [];
|
|
300
302
|
this.chatPending = 0;
|
|
301
303
|
this.review = null;
|
|
304
|
+
/**
|
|
305
|
+
* A game's own decisions (section 20): the host's typed questions, answered by the studio's decision model (the Table
|
|
306
|
+
* sets `decider`; null: the host's floor answers), at most one every 3 s and 20 a minute a room, two at a time.
|
|
307
|
+
*/
|
|
308
|
+
this.decider = null;
|
|
309
|
+
this.decideTimes = [];
|
|
310
|
+
this.decidePending = 0;
|
|
302
311
|
this.watchIds = new WeakMap();
|
|
303
312
|
this.defaultChat = null;
|
|
304
313
|
}
|
|
@@ -403,6 +412,39 @@ export class NetRoom {
|
|
|
403
412
|
peers() { return this.live().map((c) => this.peer(c)); }
|
|
404
413
|
stateObject() { const o = {}; for (const [k, v] of this.state) o[k] = v.d; return o; }
|
|
405
414
|
|
|
415
|
+
/**
|
|
416
|
+
* A game's own decision (section 20): the host's typed questions about its state, answered by the studio's decision
|
|
417
|
+
* model through the Table (`decider`). Only the host asks; the answer (`decided`, option ids and numbers only) goes
|
|
418
|
+
* back to that socket alone. Off, paced, over budget, too slow or wrong: `ok: false` with why, and the host's own floor
|
|
419
|
+
* answers. `n` is the host's own id for the request.
|
|
420
|
+
*/
|
|
421
|
+
onDecide(c, m, isHost) {
|
|
422
|
+
const n = typeof m.n === 'string' || Number.isInteger(m.n) ? String(m.n).slice(0, 16) : '';
|
|
423
|
+
const reply = (o) => this.send(c, { t: 'decided', n, ...o });
|
|
424
|
+
if (!isHost || this.lite(c)) return reply({ ok: false, why: 'not-host' });
|
|
425
|
+
if (typeof this.decider !== 'function') return reply({ ok: false, why: 'off' });
|
|
426
|
+
const now = this.now();
|
|
427
|
+
while (this.decideTimes.length && this.decideTimes[0] <= now - 60_000) this.decideTimes.shift();
|
|
428
|
+
const last = this.decideTimes.at(-1) ?? -Infinity;
|
|
429
|
+
if (now - last < DECIDE.gapMs - 100 || this.decideTimes.length >= DECIDE.perMinute) return reply({ ok: false, why: 'pace', retryMs: Math.max(DECIDE.gapMs - (now - last), this.decideTimes.length >= DECIDE.perMinute ? this.decideTimes[0] + 60_000 - now : 0) });
|
|
430
|
+
if (this.decidePending >= DECIDE.inFlight) return reply({ ok: false, why: 'busy' });
|
|
431
|
+
const q = checkDecide(m.state, m.questions);
|
|
432
|
+
if (!q.ok) return reply({ ok: false, why: 'bad', message: q.why });
|
|
433
|
+
this.decideTimes.push(now);
|
|
434
|
+
this.decidePending += 1;
|
|
435
|
+
this.stats.decides += 1;
|
|
436
|
+
Promise.resolve()
|
|
437
|
+
.then(() => this.decider(q.state, q.questions, { room: this.code, kids: this.policy.kids }))
|
|
438
|
+
.then((r) => {
|
|
439
|
+
const by = r?.ok ? r.by ?? 'ai' : r?.why ?? 'error';
|
|
440
|
+
this.stats.decided[by] = (this.stats.decided[by] ?? 0) + 1;
|
|
441
|
+
if (r?.ok) { this.stats.decideMs.push(r.ms ?? 0); if (this.stats.decideMs.length > 40) this.stats.decideMs.shift(); this.stats.decideNeurons = (this.stats.decideNeurons ?? 0) + (Number(r.neurons) || 0); }
|
|
442
|
+
reply(r?.ok ? { ok: true, by: r.by ?? 'ai', picks: r.picks ?? {}, p: r.p ?? {}, ms: r.ms ?? null } : { ok: false, why: r?.why ?? 'error' });
|
|
443
|
+
})
|
|
444
|
+
.catch(() => { this.stats.decided.error = (this.stats.decided.error ?? 0) + 1; reply({ ok: false, why: 'error' }); })
|
|
445
|
+
.finally(() => { this.decidePending -= 1; });
|
|
446
|
+
}
|
|
447
|
+
|
|
406
448
|
/** Drop the socket from the room and close it. */
|
|
407
449
|
kick(c, why, code = 1008) {
|
|
408
450
|
this.stats.kicked += 1;
|
|
@@ -515,6 +557,7 @@ export class NetRoom {
|
|
|
515
557
|
}
|
|
516
558
|
return;
|
|
517
559
|
}
|
|
560
|
+
case 'decide': return this.onDecide(c, m, isHost);
|
|
518
561
|
case 'ckpt': {
|
|
519
562
|
if (!isHost) return;
|
|
520
563
|
this.lastCkpt = { k: Number(m.k) || 0, st: this.stamp(m.st), d: m.d ?? null, ...(Array.isArray(m.c) ? { c: m.c.slice(0, 64) } : {}) };
|
|
@@ -1821,6 +1864,8 @@ export class NetRoom {
|
|
|
1821
1864
|
pending: this.chatPending,
|
|
1822
1865
|
lines: this.recentChat(now).map((r) => ({ ...this.wireLine(r), kind: r.kind, client: r.from?.client ?? null, player: r.from?.player ?? null, browser: browserTag(r.from?.browser), ...(r.review ? { review: r.review } : {}) })),
|
|
1823
1866
|
},
|
|
1867
|
+
// A game's own decisions (section 20): how many, who answered, the model's time and neurons (never a state).
|
|
1868
|
+
...(this.stats.decides ? { decides: { n: this.stats.decides, by: { ...this.stats.decided }, msP50: [...this.stats.decideMs].sort((x, y) => x - y)[Math.floor(this.stats.decideMs.length / 2)] ?? null, neurons: Math.round((this.stats.decideNeurons ?? 0) * 100) / 100 } } : {}),
|
|
1824
1869
|
},
|
|
1825
1870
|
};
|
|
1826
1871
|
}
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.24.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.24.4';
|