@homie-rocks/studio 0.24.2 → 0.24.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/worker/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. No binding, no budget left or an error: the floor alone decides.
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
- if (!env?.AI || typeof env.AI.run !== 'function') throw new Error('this Worker has no Workers AI binding (AI)');
392
- const spec = CHAT_MODELS[model] ?? CHAT_MODELS[CHAT_MODEL];
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 = await timeout(env.AI.run(model, { model: spec.name, state: text, questions: { verdict } }), ms);
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
- if (!this.env.AI) return { ok: true, by: 'none' };
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'));
@@ -1213,7 +1273,8 @@ async function api(request, env, url, cat) {
1213
1273
  }
1214
1274
  }
1215
1275
  const result = await perform(env, cat, action);
1216
- return json({ ...result, what: describe(cat, action), by: who }, result.ok ? 200 : result.error === 'no-player' ? 404 : 400);
1276
+ // A refund Stripe holds for a person's approval (an Agent-tagged key) is accepted, not refused: it happens there.
1277
+ return json({ ...result, what: describe(cat, action), by: who }, result.ok ? 200 : result.held ? 202 : result.error === 'no-player' ? 404 : 400);
1217
1278
  }
1218
1279
 
1219
1280
  /** /_studio/office, /_studio/confirm/<ask> and /_studio/api/*; null for any other path. */
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
  }
@@ -206,16 +206,32 @@ export function checkShop(raw, { games = null, audience = 'general' } = {}) {
206
206
  }
207
207
  }
208
208
 
209
+ // Where `homie-studio shop catalog` made the items' Products in Stripe (0.24.3): the Worker's checkouts then name them.
210
+ let catalog = [];
211
+ if (raw.catalog !== undefined && raw.catalog !== null) {
212
+ if (!Array.isArray(raw.catalog) || raw.catalog.some((m) => m !== 'test' && m !== 'live')) err('catalog', 'catalog lists where the items are Products in Stripe: ["test"], ["test", "live"] (homie-studio shop catalog writes it)');
213
+ else catalog = [...new Set(raw.catalog)].sort();
214
+ }
215
+
209
216
  const kids = audience === 'kids';
210
217
  const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
211
218
  if (kids && till !== 'off') warnings.push({ at: 'till', message: 'studio.json says "audience": "kids": the studio sells nothing in its games (the till is off whatever shop.json says).' });
212
219
  const shop = {
213
220
  v: 1, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
214
- open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}),
221
+ open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}), ...(catalog.length ? { catalog } : {}),
215
222
  };
216
223
  return { ok: errors.length === 0, shop, errors, warnings };
217
224
  }
218
225
 
226
+ /**
227
+ * The Stripe product tax code an item gets when shop.json names none: video games, downloaded, permanent rights
228
+ * (txcd_10201000), or limited rights for an item that lasts a number of days or ends on a date (txcd_10201001).
229
+ * Managed Payments needs one of its eligible codes on every product; these two are.
230
+ */
231
+ export function defaultTaxCode(item) {
232
+ return item?.taxCode ?? (item?.days || item?.ends ? 'txcd_10201001' : 'txcd_10201000');
233
+ }
234
+
219
235
  /** studio.json "audience": "general" (the default), "teens" or "kids". */
220
236
  export function audienceOf(studio) {
221
237
  const a = studio?.audience;
package/worker/shop.mjs CHANGED
@@ -22,17 +22,23 @@
22
22
  * player's account: it changes nothing until it is decided, and a lost one is a refund of that one item.
23
23
  *
24
24
  * SECRETS (Worker secrets, put there by `homie-studio shop connect` from a page on the owner's own computer):
25
- * STRIPE_KEY (a restricted key: Checkout Sessions write, Charges write for refunds, PaymentIntents and Disputes read)
26
- * and STRIPE_WEBHOOK_SECRET. Never in a file, a chat, a log or a page.
25
+ * STRIPE_KEY (a restricted key: Checkout Sessions write, Charges write for refunds, PaymentIntents and Disputes read;
26
+ * from 0.24.3 also Webhook Endpoints write, which only the connect page on the owner's computer uses) and
27
+ * STRIPE_WEBHOOK_SECRET. Never in a file, a chat, a log or a page.
28
+ *
29
+ * THE CATALOG (0.24.3): once `homie-studio shop catalog` has made the items' Products in Stripe (shop.json `catalog`
30
+ * names the modes), a checkout names its item's Product, so the Dashboard, Stripe's reports and Stripe's MCP see sales
31
+ * by product. The price is always shop.json's (the rules checked it), never one read from Stripe. A Product missing
32
+ * in this mode falls back to the item described inline, so a sale never fails over the catalog.
27
33
  */
28
34
  import { players } from './players.mjs';
29
35
  import { serversOf } from './servers.mjs';
30
- import { CAP_CEILING, audienceOf, bandOf, checkShop, money, wayFor } from './shop-rules.mjs';
36
+ import { CAP_CEILING, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
31
37
  import {
32
38
  bandOfPlayer, forgetPlayerShop, grantStatements, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, ownsOf, restoreStatement, revokeStatement,
33
39
  setBand, shopDataOf, spentThisMonth,
34
40
  } from './shop-store.mjs';
35
- import { KEY_SHAPE, StripeError, WEBHOOK_SECRET_SHAPE, createCheckoutSession, createRefund, dashboardLink, modeOf, verifyWebhook } from './stripe.mjs';
41
+ import { KEY_SHAPE, StripeError, WEBHOOK_SECRET_SHAPE, createCheckoutSession, createRefund, dashboardLink, isApprovalRequired, isMissingProduct, modeOf, productIdOf, verifyWebhook } from './stripe.mjs';
36
42
  import { arrivalOf, booksOf, lineFor, receiveStatement, referrerOk, sendStatements, settle, statementFor, statementsIn, voidLineStatements } from './referrals.mjs';
37
43
  import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
38
44
  import { notFoundPage } from './site.mjs';
@@ -270,11 +276,10 @@ async function ageRoute(request, env) {
270
276
  return json({ ok: true, asked: true });
271
277
  }
272
278
 
273
- function sessionParams(shop, item, order, { origin, amount, player, game, parent = false, studio }) {
279
+ export function sessionParams(shop, item, order, { origin, amount, player, game, parent = false, studio, product = null }) {
274
280
  const managed = shop.till === 'stripe-managed';
275
- const limited = Boolean(item.days || item.ends);
276
281
  // Managed Payments needs an eligible digital-goods tax code on every product (video games, downloaded).
277
- const taxCode = item.taxCode ?? (managed ? (limited ? 'txcd_10201001' : 'txcd_10201000') : undefined);
282
+ const taxCode = item.taxCode ?? (managed ? defaultTaxCode(item) : undefined);
278
283
  const back = game ? `&game=${encodeURIComponent(game)}` : '';
279
284
  return {
280
285
  mode: 'payment',
@@ -282,7 +287,8 @@ function sessionParams(shop, item, order, { origin, amount, player, game, parent
282
287
  quantity: 1,
283
288
  price_data: {
284
289
  currency: shop.currency, unit_amount: amount, tax_behavior: 'exclusive',
285
- product_data: { name: item.name.slice(0, 120), ...(item.blurb ? { description: item.blurb.slice(0, 300) } : {}), ...(taxCode ? { tax_code: taxCode } : {}), metadata: { item: item.id } },
290
+ // The catalog's Product (its name and tax code are Stripe's copy of shop.json's), or the item described inline.
291
+ ...(product ? { product } : { product_data: { name: item.name.slice(0, 120), ...(item.blurb ? { description: item.blurb.slice(0, 300) } : {}), ...(taxCode ? { tax_code: taxCode } : {}), metadata: { item: item.id } } }),
286
292
  },
287
293
  }],
288
294
  client_reference_id: player,
@@ -306,8 +312,18 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
306
312
  await env.DB.prepare("INSERT INTO shop_orders (id, player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'started', ?9, ?10, ?11, ?11)")
307
313
  .bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till, modeOf(env.STRIPE_KEY), parent ? 1 : 0, order.via, now).run();
308
314
  let session = null;
315
+ const mode = modeOf(env.STRIPE_KEY);
316
+ const product = Array.isArray(shop.catalog) && shop.catalog.includes(mode) ? productIdOf(cat.studio?.slug, item.id) : null;
317
+ const params = (p) => sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name, product: p });
309
318
  try {
310
- session = await createCheckoutSession(env, sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name }), { idempotencyKey: `checkout-${order.id}` });
319
+ try {
320
+ session = await createCheckoutSession(env, params(product), { idempotencyKey: `checkout-${order.id}` });
321
+ } catch (error) {
322
+ // shop.json says the catalog is made in this mode, but Stripe has no such Product (made in the sandbox only, or
323
+ // archived by hand): the item described inline still sells, and `homie-studio shop catalog` says what to make.
324
+ if (!product || !isMissingProduct(error)) throw error;
325
+ session = await createCheckoutSession(env, params(null), { idempotencyKey: `checkout-${order.id}-inline` });
326
+ }
311
327
  } catch (error) {
312
328
  await env.DB.prepare("UPDATE shop_orders SET status = 'failed', note = ?2, updated_at = ?3 WHERE id = ?1").bind(order.id, String(error?.code ?? 'stripe').slice(0, 60), Date.now()).run();
313
329
  const why = error instanceof StripeError ? error.message : 'Stripe did not answer';
@@ -441,6 +457,7 @@ async function selfRefundRoute(request, env, shop, ready) {
441
457
  if (!refundableNow(orderView(o), Number(used?.n) ? [{ item: o.item, used: true }] : [], shop)) return fail(403, 'not-refundable', `Only an unused item, within ${shop.refundDays} days. Ask the studio for anything else.`);
442
458
  if (!KEY_SHAPE.test(String(env.STRIPE_KEY ?? ''))) return fail(503, 'closed', MISSING_WORDS['stripe-key']);
443
459
  const r = await refundOrder(env, o, { reason: 'requested_by_customer', by: 'player' });
460
+ if (r.held) return json({ ok: false, held: true, error: 'held', message: 'The studio approves this refund in Stripe first. Your money comes back once they do, and the item leaves your account then.' }, 202);
444
461
  return json(r, r.ok ? 200 : 502);
445
462
  }
446
463
 
@@ -553,6 +570,9 @@ async function disputeClosed(env, payment, status) {
553
570
  return 'kept';
554
571
  }
555
572
 
573
+ /** What the owner reads when Stripe holds a refund for approval (the shop's key is an Agent key). */
574
+ export const REFUND_HELD = 'Stripe is holding this refund until a person approves it in Stripe (Settings, Approvals, Requests): the shop\'s Stripe key is marked as an Agent key, and Stripe asks a person before an agent\'s refund. Approve it there; Stripe then refunds, and the item leaves the player\'s account when Stripe says so. To refund with one tap again, connect the shop with a plain restricted key (homie-studio shop connect), not one made for "Authorizing agent access".';
575
+
556
576
  /** Refund one order in full with Stripe, then take back that one item. The webhook confirms the same (idempotent). */
557
577
  export async function refundOrder(env, o, { reason = 'requested_by_customer', by = 'owner' } = {}) {
558
578
  if (o.status !== 'paid') return { ok: false, error: 'state', message: o.status === 'disputed' ? 'This order is disputed: the card network decides it now (answer it in Stripe).' : `This order is ${o.status}, not paid.` };
@@ -561,6 +581,9 @@ export async function refundOrder(env, o, { reason = 'requested_by_customer', by
561
581
  try {
562
582
  refund = await createRefund(env, { payment_intent: o.payment, reason: ['duplicate', 'fraudulent', 'requested_by_customer'].includes(reason) ? reason : 'requested_by_customer', metadata: { order: o.id, by } }, { idempotencyKey: `refund-${o.id}` });
563
583
  } catch (error) {
584
+ // An Agent-tagged key: Stripe holds an agent's refund for a person's approval. Nothing happened yet; once someone
585
+ // approves it in Stripe, Stripe refunds and the webhook (refund.created, charge.refunded) takes the item back.
586
+ if (isApprovalRequired(error)) return { ok: false, error: 'approval', held: true, message: REFUND_HELD };
564
587
  return { ok: false, error: 'stripe', message: `Stripe did not refund it (${error instanceof StripeError ? error.message : 'no answer'}). Nothing changed.` };
565
588
  }
566
589
  const now = Date.now();
package/worker/stripe.mjs CHANGED
@@ -4,6 +4,10 @@
4
4
  *
5
5
  * createCheckoutSession(env, params) POST /v1/checkout/sessions (Stripe's hosted page; the TV never calls it)
6
6
  * createRefund(env, params) POST /v1/refunds (only ever from the owner's tap, or a confirmed ask)
7
+ * webhook endpoints list, create and turn off: only `homie-studio shop connect` calls these, on the
8
+ * owner's computer with the key the owner pasted there (0.24.3), so a new
9
+ * endpoint's signing secret goes straight to the Worker secret
10
+ * productIdOf(slug, item) the catalog Product a shop item is (`homie-studio shop catalog`, 0.24.3)
7
11
  * verifyWebhook(payload, header, secret) the `Stripe-Signature` header: t=<seconds>,v1=<hex HMAC-SHA256 of
8
12
  * "<t>.<payload>"> with the endpoint's whsec_ secret, within 5 minutes
9
13
  *
@@ -54,9 +58,30 @@ export class StripeError extends Error {
54
58
  this.status = status;
55
59
  this.code = e.code ?? e.type ?? 'stripe';
56
60
  this.type = e.type ?? null;
61
+ this.param = typeof e.param === 'string' ? e.param.slice(0, 120) : null;
57
62
  }
58
63
  }
59
64
 
65
+ /** Stripe has no such Product (a catalog Product the shop names, in a mode the catalog was never made in). */
66
+ export const isMissingProduct = (error) => error instanceof StripeError && error.code === 'resource_missing' && /product/.test(String(error.param ?? error.message));
67
+ /**
68
+ * Stripe is holding the call for a person's approval: an Agent-tagged key's refund meets Stripe's approval rules
69
+ * (docs.stripe.com/account/approvals). Nothing happened yet; it happens when someone approves it in Stripe.
70
+ */
71
+ export const isApprovalRequired = (error) => error instanceof StripeError && error.code === 'approval_required';
72
+ /** The key may not do this (a restricted key without the permission). */
73
+ export const isPermissionError = (error) => error instanceof StripeError && (error.status === 403 || error.status === 401 || /permission|restricted key/i.test(error.message));
74
+
75
+ /**
76
+ * The catalog Product a shop item is, the same id in a sandbox and in live mode: `homie_<studio slug>_<item id>`
77
+ * (Stripe takes a product id of our choosing). Null without a slug or an item id.
78
+ */
79
+ export function productIdOf(slug, item) {
80
+ const s = String(slug ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 48);
81
+ const i = String(item ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
82
+ return s && i ? `homie_${s}_${i}` : null;
83
+ }
84
+
60
85
  /** One call with the studio's key. `idempotencyKey` makes a retried POST do the work once (Stripe keeps it 24 h). */
61
86
  export async function stripeCall(env, method, path, params = null, { idempotencyKey = null, fetcher = fetch } = {}) {
62
87
  const key = String(env?.STRIPE_KEY ?? '');
@@ -83,6 +108,11 @@ export async function stripeCall(env, method, path, params = null, { idempotency
83
108
  export const createCheckoutSession = (env, params, opts) => stripeCall(env, 'POST', '/v1/checkout/sessions', params, opts);
84
109
  export const retrieveCheckoutSession = (env, id, opts) => stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(id)}`, null, opts);
85
110
  export const createRefund = (env, params, opts) => stripeCall(env, 'POST', '/v1/refunds', params, opts);
111
+ export const expireCheckoutSession = (env, id, opts) => stripeCall(env, 'POST', `/v1/checkout/sessions/${encodeURIComponent(id)}/expire`, {}, opts);
112
+ export const listWebhookEndpoints = (env, opts) => stripeCall(env, 'GET', '/v1/webhook_endpoints', { limit: 100 }, opts);
113
+ /** The answer carries the endpoint's signing secret (`secret`), once: the caller puts it in the Worker and drops it. */
114
+ export const createWebhookEndpoint = (env, params, opts) => stripeCall(env, 'POST', '/v1/webhook_endpoints', params, opts);
115
+ export const updateWebhookEndpoint = (env, id, params, opts) => stripeCall(env, 'POST', `/v1/webhook_endpoints/${encodeURIComponent(id)}`, params, opts);
86
116
 
87
117
  /* ------------------------------------------------------------------ webhooks */
88
118
 
@@ -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';
2
+ export const STUDIO_VERSION_TAG = '0.24.4';