@homie-rocks/studio 0.22.0 → 0.23.0

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.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * ROOM CHAT IN THE STUDIO'S OWN D1 (@homie-rocks/studio 0.23.0, NETPLAY.md section 19): the owner's chat rules per game
3
+ * and per server, and the reports players make. Never the chat itself: what people say lives a few minutes in the
4
+ * room's memory and nowhere else (worker/room.mjs). A report keeps the one message it is about, for 30 days.
5
+ *
6
+ * chat_rules (game, server, rules JSON, updated_at): server '' is the whole game; a server's row wins over it
7
+ * chat_reports one reported message: its words, its sender's room name and (when signed in) account id, the room,
8
+ * the reason a player picked, when; never who reported it, never an address
9
+ */
10
+ import { CHAT_BUDGET, composeChat } from './chat.mjs';
11
+
12
+ export const CHAT_MIGRATION_FILE = '0007_studio_chat.sql';
13
+ export const CHAT_MIGRATION = `-- Room chat (@homie-rocks/studio 0.23.0): the owner's chat rules per game and per server, and players' reports.
14
+ -- The chat itself is never stored: it lives a few minutes in the room's memory. A report keeps only the message it
15
+ -- is about (its words, its sender's room name and account id if they had one, the room), for 30 days; never who
16
+ -- reported it, never an address.
17
+ CREATE TABLE IF NOT EXISTS chat_rules (
18
+ game TEXT NOT NULL,
19
+ server TEXT NOT NULL DEFAULT '',
20
+ rules TEXT NOT NULL,
21
+ updated_at INTEGER NOT NULL,
22
+ PRIMARY KEY (game, server)
23
+ ) WITHOUT ROWID;
24
+ CREATE TABLE IF NOT EXISTS chat_reports (
25
+ id TEXT PRIMARY KEY,
26
+ game TEXT NOT NULL,
27
+ room TEXT NOT NULL,
28
+ line TEXT NOT NULL,
29
+ kind TEXT NOT NULL,
30
+ text TEXT NOT NULL,
31
+ sender_name TEXT,
32
+ sender_player TEXT,
33
+ sender_seat INTEGER,
34
+ said_at INTEGER NOT NULL,
35
+ reason TEXT NOT NULL,
36
+ state TEXT NOT NULL DEFAULT 'open',
37
+ created_at INTEGER NOT NULL,
38
+ expires_at INTEGER NOT NULL
39
+ ) WITHOUT ROWID;
40
+ CREATE INDEX IF NOT EXISTS chat_reports_game ON chat_reports (game, created_at);
41
+ CREATE UNIQUE INDEX IF NOT EXISTS chat_reports_line ON chat_reports (game, room, line);
42
+ `;
43
+
44
+ /** Why a player reports a message: a short list, so a report is never a second message. */
45
+ export const REPORT_REASONS = Object.freeze(['mean', 'hate', 'sexual', 'unsafe', 'spam', 'other']);
46
+ export const REPORT_WORDS = Object.freeze({ mean: 'Mean or bullying', hate: 'Hate or a slur', sexual: 'Sexual', unsafe: 'Asks for personal details, or unsafe', spam: 'Spam or ads', other: 'Something else' });
47
+ export const REPORT_DAYS = 30;
48
+
49
+ const cache = new WeakMap();
50
+ /** Every chat rule row (D1), read at most every 5 s per Worker instance; empty before migration 0007. */
51
+ export async function chatRowsOf(env, { fresh = false } = {}) {
52
+ if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
53
+ const hit = cache.get(env.DB);
54
+ if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
55
+ let map = new Map();
56
+ try {
57
+ const { results } = await env.DB.prepare('SELECT game, server, rules, updated_at FROM chat_rules LIMIT 1000').all();
58
+ for (const r of results ?? []) {
59
+ let rules = null;
60
+ try { rules = JSON.parse(r.rules); } catch { rules = null; }
61
+ if (rules && typeof rules === 'object') map.set(`${r.game}/${r.server ?? ''}`, { ...rules, at: Number(r.updated_at) || 0 });
62
+ }
63
+ } catch { map = new Map(); }
64
+ cache.set(env.DB, { at: Date.now(), map });
65
+ return map;
66
+ }
67
+ export const forgetChat = (env) => { if (env?.DB) cache.delete(env.DB); };
68
+
69
+ /**
70
+ * A room's chat rules: the game's game.json "chat", the owner's rules for the game, then for this server (a server's
71
+ * own game.json seed `chat` sits under the owner's), capped by the server's speech and kids.
72
+ */
73
+ export function chatOf(meta, server, rows) {
74
+ const game = meta?.chat === false ? { mode: 'off' } : meta?.chat;
75
+ const own = rows?.get(`${meta?.id}/`) ?? null;
76
+ const seed = server?.chat && typeof server.chat === 'object' ? server.chat : null;
77
+ const srv = server && server.id !== 'public' ? rows?.get(`${meta?.id}/${server.id}`) ?? null : rows?.get(`${meta?.id}/public`) ?? null;
78
+ return composeChat({ game, office: own, server: srv ?? seed, kids: Boolean(server?.kids), speech: server?.speech ?? 'game' });
79
+ }
80
+
81
+ /** The owner's change: merged into the game's (server '') or one server's row. Returns the stored rules. */
82
+ export async function writeChatRules(env, game, server, fields) {
83
+ const key = String(server ?? '');
84
+ const row = await env.DB.prepare('SELECT rules FROM chat_rules WHERE game = ?1 AND server = ?2').bind(game, key).first();
85
+ let before = {};
86
+ try { before = row ? JSON.parse(row.rules) : {}; } catch { before = {}; }
87
+ const next = { ...before, ...fields };
88
+ for (const [k, v] of Object.entries(next)) if (v === null || v === undefined) delete next[k];
89
+ const now = Date.now();
90
+ await env.DB.prepare(`INSERT INTO chat_rules (game, server, rules, updated_at) VALUES (?1, ?2, ?3, ?4)
91
+ ON CONFLICT(game, server) DO UPDATE SET rules = excluded.rules, updated_at = excluded.updated_at`).bind(game, key, JSON.stringify(next), now).run();
92
+ forgetChat(env);
93
+ return { rules: next, at: now };
94
+ }
95
+
96
+ /** Back to the game's own rules: the owner's row for the game or a server goes. */
97
+ export async function clearChatRules(env, game, server) {
98
+ await env.DB.prepare('DELETE FROM chat_rules WHERE game = ?1 AND server = ?2').bind(game, String(server ?? '')).run();
99
+ forgetChat(env);
100
+ }
101
+
102
+ function randomHex(bytes) {
103
+ const raw = new Uint8Array(bytes);
104
+ crypto.getRandomValues(raw);
105
+ return [...raw].map((b) => b.toString(16).padStart(2, '0')).join('');
106
+ }
107
+
108
+ /** A player's report of one message (the room's own copy of it, never the reporter's words). Once per message. */
109
+ export async function fileReport(env, { game, room, line, reason }) {
110
+ const now = Date.now();
111
+ const id = `cr_${randomHex(8)}`;
112
+ await env.DB.batch([
113
+ env.DB.prepare(`INSERT INTO chat_reports (id, game, room, line, kind, text, sender_name, sender_player, sender_seat, said_at, reason, state, created_at, expires_at)
114
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'open', ?12, ?13) ON CONFLICT(game, room, line) DO NOTHING`)
115
+ .bind(id, game, room, line.id, line.kind, String(line.text ?? line.glyph ?? '').slice(0, 300), String(line.name ?? '').slice(0, 40), line.player ?? null, Number.isInteger(line.seat) ? line.seat : null, Number(line.at) || now, reason, now, now + REPORT_DAYS * 86_400_000),
116
+ // Reports older than 30 days go (a report is about one moment; the owner acts on it or lets it go).
117
+ env.DB.prepare('DELETE FROM chat_reports WHERE expires_at < ?1').bind(now),
118
+ ]);
119
+ return { id };
120
+ }
121
+
122
+ /** The open reports, newest first (the office's list); `game` narrows it. */
123
+ export async function reportsOf(env, { game = null, limit = 50 } = {}) {
124
+ try {
125
+ const q = game
126
+ ? env.DB.prepare("SELECT * FROM chat_reports WHERE game = ?1 AND state = 'open' AND expires_at >= ?2 ORDER BY created_at DESC LIMIT ?3").bind(game, Date.now(), limit)
127
+ : env.DB.prepare("SELECT * FROM chat_reports WHERE state = 'open' AND expires_at >= ?1 ORDER BY created_at DESC LIMIT ?2").bind(Date.now(), limit);
128
+ const { results } = await q.all();
129
+ return (results ?? []).map((r) => ({
130
+ id: r.id, game: r.game, room: r.room, line: r.line, kind: r.kind, text: r.text, name: r.sender_name, player: r.sender_player, seat: r.sender_seat,
131
+ saidAt: Number(r.said_at), reason: r.reason, reasonText: REPORT_WORDS[r.reason] ?? r.reason, at: Number(r.created_at), expiresAt: Number(r.expires_at),
132
+ }));
133
+ } catch { return []; }
134
+ }
135
+
136
+ /** The owner is done with a report: it is deleted (nothing of it stays). */
137
+ export async function dismissReport(env, id) {
138
+ const r = await env.DB.prepare('DELETE FROM chat_reports WHERE id = ?1').bind(id).run();
139
+ return { ok: true, removed: Number(r?.meta?.changes ?? 0) };
140
+ }
141
+
142
+ /** The chat review's day for the whole studio: the budget (meta `chat_budget`) and what today used (stats_daily). */
143
+ export async function chatDay(env, today) {
144
+ const budget = { neurons: CHAT_BUDGET.neurons };
145
+ const used = { neurons: 0, reviews: 0, held: 0, errors: 0 };
146
+ if (!env?.DB) return { budget, used, ai: Boolean(env?.AI) };
147
+ try {
148
+ const b = JSON.parse((await env.DB.prepare("SELECT value FROM meta WHERE key = 'chat_budget'").first())?.value ?? 'null');
149
+ if (b && Number.isFinite(Number(b.neurons))) budget.neurons = Math.max(0, Number(b.neurons));
150
+ } catch { /* the default */ }
151
+ try {
152
+ const { results } = await env.DB.prepare("SELECT metric, source, SUM(n) AS n FROM stats_daily WHERE day = ?1 AND metric IN ('chat-neurons', 'chat-reviews') GROUP BY metric, source").bind(today).all();
153
+ for (const r of results ?? []) {
154
+ if (r.metric === 'chat-neurons') used.neurons += Number(r.n) || 0;
155
+ else if (r.source === 'held') { used.held += Number(r.n) || 0; used.reviews += Number(r.n) || 0; }
156
+ else if (r.source === 'error') used.errors += Number(r.n) || 0;
157
+ else used.reviews += Number(r.n) || 0;
158
+ }
159
+ } catch { /* before migration 0002: nothing used */ }
160
+ return { budget, used, ai: Boolean(env.AI) };
161
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The built-in floor of room chat (worker/chat.mjs): a short list of English words and phrases that never go out,
3
+ * whatever any model says. Kept as base64 only so this source does not read as a list of slurs; it is not a secret.
4
+ * `npx --no-install homie-studio chat words` prints it, and a studio adds its own words (game.json "chat": { "block" },
5
+ * or the office) and lets a word through (`"allow"`).
6
+ *
7
+ * slurs always held (whole words; `anywhere`: also inside a word)
8
+ * sexual always held
9
+ * swears held unless the game or the office says `"swears": "allow"`
10
+ * harm phrases that tell a person to hurt themselves, always held
11
+ * contact phrases that move a player off the game (another app, pictures), always held
12
+ */
13
+ export const WORDS_B64 = [
14
+ 'eyJzbHVycyI6WyJmYWciLCJmYWdzIiwiZmFnZ3kiLCJkeWtlIiwiZHlrZXMiLCJraWtlIiwia2lrZXMiLCJzcGljIiwic3BpY3MiLCJjaGluay',
15
+ 'IsImNoaW5rcyIsImdvb2siLCJnb29rcyIsImNvb24iLCJjb29ucyIsImJlYW5lciIsImJlYW5lcnMiLCJwYWtpIiwicGFraXMiLCJjdW50Iiwi',
16
+ 'Y3VudHMiLCJyZXRhcmQiLCJyZXRhcmRzIiwicmV0YXJkZWQiLCJ0cmFubnkiLCJ0cmFubmllcyIsImhvbW8iLCJob21vcyIsInNwYXoiLCJnb2',
17
+ 'xsaXdvZyIsImRhcmtpZSIsImRhcmt5IiwiamlnYWJvbyIsInBvcmNobW9ua2V5IiwiemlwcGVyaGVhZCIsInNoZW1hbGUiXSwic2x1cnNBbnl3',
18
+ 'aGVyZSI6WyJuaWdnZXIiLCJuaWdnYSIsIm5pZ2dheiIsImZhZ2dvdCIsImZhZ290Iiwid2V0YmFjayIsInRvd2VsaGVhZCIsInJhZ2hlYWQiLC',
19
+ 'JzYW5kbmlnZ2VyIiwia2lrZXMiLCJ0cmFubmllIl0sInNleHVhbCI6WyJwb3JuIiwicG9ybm8iLCJudWRlIiwibnVkZXMiLCJzZXgiLCJzZXh5',
20
+ 'Iiwic2V4dCIsInNleHRpbmciLCJkaWNrIiwiZGlja3MiLCJjb2NrIiwiY29ja3MiLCJwdXNzeSIsInB1c3NpZXMiLCJwZW5pcyIsInZhZ2luYS',
21
+ 'IsImJvb2IiLCJib29icyIsInRpdHMiLCJ0aXR0aWVzIiwiYmxvd2pvYiIsImhhbmRqb2IiLCJjdW0iLCJjdW1taW5nIiwiaG9ybnkiLCJtaWxm',
22
+ 'IiwiaGVudGFpIiwiYW5hbCIsIm9yZ2FzbSIsImRpbGRvIiwicmFwZSIsInJhcGVkIiwicmFwaW5nIiwicmFwaXN0IiwibW9sZXN0IiwibW9sZX',
23
+ 'N0ZXIiLCJwZWRvIiwicGVkb3MiLCJvbmx5ZmFucyIsIm5zZnciLCJqZXJrb2ZmIiwid2FuayIsImJvbmVyIiwiZXJlY3Rpb24iLCJjbGl0Iiwi',
24
+ 'dGhvdCIsIndob3JlIiwid2hvcmVzIiwic2x1dCIsInNsdXRzIiwic2thbmsiXSwic2V4dWFsQW55d2hlcmUiOlsicGVkb3BoaWwiLCJwYWVkb3',
25
+ 'BoaWwiLCJibG93am9iIiwiaGFuZGpvYiIsInBvcm5odWIiLCJ4dmlkZW9zIiwibWFzdHVyYmF0IiwibW9sZXN0Il0sInN3ZWFycyI6WyJzaGl0',
26
+ 'Iiwic2hpdHMiLCJzaGl0dHkiLCJiaXRjaCIsImJpdGNoZXMiLCJiYXN0YXJkIiwiYmFzdGFyZHMiLCJhc3MiLCJhc3NlcyIsInBpc3MiLCJwaX',
27
+ 'NzZWQiLCJ3dGYiLCJzdGZ1IiwidHdhdCIsIndhbmtlciIsImJvbGxvY2tzIiwicHJpY2siLCJkb3VjaGUiLCJkb3VjaGViYWciLCJkdW1iYXNz',
28
+ 'IiwiamFja2FzcyIsImRpcHNoaXQiLCJidWxsc2hpdCIsImdvZGRhbW4iLCJhcnNlIiwiYXJzZWhvbGUiLCJiZWxsZW5kIiwidG9zc2VyIiwiZn',
29
+ 'VrIiwiZnVxIiwiZnVrZXIiLCJmdWtpbiIsImZ1a24iXSwic3dlYXJzQW55d2hlcmUiOlsiZnVjayIsImZjayIsInNoaXRoZWFkIiwiYXNzaG9s',
30
+ 'ZSIsIm1vdGhlcmYiLCJiaXRjaGFzcyIsImNvY2tzdWNrIiwiZGlja2hlYWQiXSwiaGFybSI6WyJraWxsIHlvdXJzZWxmIiwia2lsbCB1cnNlbG',
31
+ 'YiLCJraWxsIHVyIHNlbGYiLCJraWxsIHlvdXIgc2VsZiIsImt5cyIsImdvIGRpZSIsImdvIGtpbGwgeW91cnNlbGYiLCJuZWNrIHlvdXJzZWxm',
32
+ 'IiwiaGFuZyB5b3Vyc2VsZiIsInlvdSBzaG91bGQgZGllIiwiZHJpbmsgYmxlYWNoIiwic2xpdCB5b3VyIHdyaXN0cyIsImVuZCB5b3Vyc2VsZi',
33
+ 'IsInVuYWxpdmUgeW91cnNlbGYiLCJpIGhvcGUgeW91IGRpZSJdLCJjb250YWN0IjpbInNuYXAgbWUiLCJhZGQgbWUgb24gc25hcCIsImFkZCBt',
34
+ 'ZSBvbiBpbnN0YSIsImFkZCBtZSBvbiBkaXNjb3JkIiwiYWRkIG1lIG9uIHRpa3RvayIsImFkZCBtZSBvbiB3aGF0c2FwcCIsImFkZCBtZSBvbi',
35
+ 'B0ZWxlZ3JhbSIsImFkZCBtZSBvbiBraWsiLCJkbSBtZSBvbiIsIm15IHNuYXAgaXMiLCJteSBpbnN0YSBpcyIsIm15IGRpc2NvcmQgaXMiLCJz',
36
+ 'ZW5kIHBpY3MiLCJzZW5kIG1lIHBpY3MiLCJzZW5kIGEgcGljIiwic2VuZCBtZSBhIHBpYyIsInNlbmQgbnVkZXMiLCJzZW5kIG1lIG51ZGVzIi',
37
+ 'wic2VuZCBwaG90b3MiLCJzZW5kIG1lIHBob3RvcyIsInNlbmQgbWUgYSBwaG90byIsIm1lZXQgbWUgaXJsIiwibWVldCB1cCBpcmwiLCJ3aGF0',
38
+ 'cyB5b3VyIGFkZHJlc3MiLCJ3aGF0IGlzIHlvdXIgYWRkcmVzcyIsIndoYXRzIHlvdXIgbnVtYmVyIiwid2hhdCBpcyB5b3VyIG51bWJlciIsIm',
39
+ 'dpdmUgbWUgeW91ciBudW1iZXIiLCJob3cgb2xkIGFyZSB5b3UiLCJob3cgb2xkIHIgdSIsImhvdyBvbGQgcnUiLCJ3aGF0IGlzIHlvdXIgYWdl',
40
+ 'Iiwid2hhdHMgeW91ciBhZ2UiLCJ3aGVyZSBkbyB5b3UgbGl2ZSIsIndoZXJlIGRvIHUgbGl2ZSIsIndoaWNoIGNpdHkgZG8geW91IGxpdmUgaW',
41
+ '4iLCJ3aGF0IGNpdHkgZG8geW91IGxpdmUgaW4iLCJ3aGF0IHNjaG9vbCBkbyB5b3UgZ28gdG8iLCJ3aGljaCBzY2hvb2wgZG8geW91IGdvIHRv',
42
+ 'Iiwid2hhdCBncmFkZSBhcmUgeW91IGluIiwiYXJlIHlvdSBob21lIGFsb25lIiwic2VuZCBtZSB5b3VyIGFkZHJlc3MiLCJsZXRzIG1lZXQgdX',
43
+ 'AiLCJ3YW5uYSBtZWV0IHVwIiwid2hhdHMgeW91ciBzbmFwY2hhdCIsIndoYXQgaXMgeW91ciBzbmFwY2hhdCIsIndoYXRzIHlvdXIgc25hcCIs',
44
+ 'IndoYXRzIHVyIHNuYXAiLCJ3aGF0cyB5b3VyIGluc3RhIiwid2hhdHMgeW91ciBkaXNjb3JkIiwiYWRkIG1lIG9uIGluc3RhZ3JhbSIsImFkZC',
45
+ 'BtZSBvbiBzbmFwY2hhdCIsIm15IHNuYXBjaGF0IGlzIiwibXkgaW5zdGFncmFtIGlzIl19',
46
+ ].join('');
@@ -0,0 +1,419 @@
1
+ /**
2
+ * ROOM CHAT (@homie-rocks/studio 0.23.0, NETPLAY.md section 19): what a room's chat allows, the floor every typed
3
+ * message passes before anyone sees it, and the studio's own Cloudflare decision model that reviews it. Pure: no
4
+ * Worker import and no Node built-in, so the relay (room.mjs), the Worker, the build, the office and the tests use it
5
+ * as it is.
6
+ *
7
+ * rules what a room allows: `mode` off | emoji | lines | text (each allows what the one before it does),
8
+ * who may type (`who`) and who may react or send a quick line (`react`): anyone | signed-in | members,
9
+ * slow mode, the length, links, swears, the AI review, bubbles over characters, watchers, homie.rocks.
10
+ * The game's game.json "chat" sets its defaults; the owner's office overrides them per game and per
11
+ * server; a server's speech (quick lines, off) and kids mode cap them.
12
+ * floor every typed message, before anything else: a short built-in list (slurs, sexual words, telling someone
13
+ * to hurt themselves, moving a player to another app), contact details (an email, a phone number),
14
+ * links, swears, and the studio's own words. It always runs, and it needs nothing.
15
+ * review typed text the floor let through, on the studio's own Workers AI: Cloudflare's Clef decision model
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.
18
+ *
19
+ * Emoji and quick lines are the studio's own words: they never need a review, so they reach every screen at once.
20
+ * NOTHING is stored here: the relay keeps a short window in memory (section 19), and a report keeps only the message.
21
+ */
22
+ import { WORDS_B64 } from './chat-words.mjs';
23
+
24
+ export const CHAT_MODES = Object.freeze(['off', 'emoji', 'lines', 'text']);
25
+ export const CHAT_WHO = Object.freeze(['anyone', 'signed-in', 'members']);
26
+ const RANK = Object.freeze({ off: 0, emoji: 1, lines: 2, text: 3 });
27
+
28
+ /**
29
+ * The reactions every Homie room has, in the order homie.rocks's room page shows them (its TV float draws the same
30
+ * five): the kind on the wire, the glyph on the screen. A game may add up to three of its own.
31
+ */
32
+ export const REACTIONS = Object.freeze([
33
+ Object.freeze({ k: 'fire', e: '🔥' }),
34
+ Object.freeze({ k: 'clap', e: '👏' }),
35
+ Object.freeze({ k: 'laugh', e: '😂' }),
36
+ Object.freeze({ k: 'heart', e: '❤️' }),
37
+ Object.freeze({ k: 'wow', e: '🤯' }),
38
+ ]);
39
+ /** The quick lines a game gets when its game.json names none: short, kind, and fine for every age. */
40
+ export const DEFAULT_LINES = Object.freeze([
41
+ Object.freeze({ id: 'hi', text: 'Hi!' }),
42
+ Object.freeze({ id: 'gg', text: 'Good game!' }),
43
+ Object.freeze({ id: 'nice', text: 'Nice one!' }),
44
+ Object.freeze({ id: 'go', text: 'Let\'s go!' }),
45
+ Object.freeze({ id: 'wait', text: 'Wait for me!' }),
46
+ Object.freeze({ id: 'help', text: 'Help!' }),
47
+ Object.freeze({ id: 'thanks', text: 'Thanks!' }),
48
+ Object.freeze({ id: 'again', text: 'Again?' }),
49
+ ]);
50
+
51
+ export const CHAT_LIMITS = Object.freeze({
52
+ /** A typed message: `max` is the room's, at most this (homie.rocks's LINE_MAX). */
53
+ text: 280, minText: 20, name: 24,
54
+ lineText: 60, lines: 12, extraEmoji: 3, words: 64, word: 32, slowMax: 120,
55
+ /** The room's window: the last `keep` messages from the last `keepMs` (memory only), for a page that just opened. */
56
+ keep: 50, keepMs: 15 * 60_000,
57
+ /** The same words from the same person within this long are not sent again. */
58
+ repeatMs: 30_000,
59
+ /** How long a typed message may wait for the review; past it the floor alone decides. Messages waiting at once. */
60
+ reviewMs: 1500, pending: 8,
61
+ });
62
+ /**
63
+ * Token buckets (homie.rocks's room chat is 5 at once, then one every 2 s). Reactions refill faster: a burst of
64
+ * fire is the point. A network address shares one bigger bucket across its sockets. A room fans out at most so many
65
+ * reactions a second (the float draws at most six a second anyway).
66
+ */
67
+ export const CHAT_RATES = Object.freeze({
68
+ react: Object.freeze({ burst: 6, refillMs: 400 }),
69
+ say: Object.freeze({ burst: 4, refillMs: 2000 }),
70
+ address: Object.freeze({ burst: 30, refillMs: 150 }),
71
+ roomReactsPerSecond: 40,
72
+ });
73
+
74
+ /** A game with no "chat" in its game.json: emoji and quick lines for everyone, typing for signed-in players (reviewed). */
75
+ export const CHAT_DEFAULTS = Object.freeze({
76
+ mode: 'text', who: 'signed-in', react: 'anyone', slow: 2, max: 140, links: 'block', swears: 'block', ai: true,
77
+ bubbles: true, watchers: true, hub: true,
78
+ });
79
+
80
+ const ID = /^[a-z][a-z0-9_]{0,15}$/;
81
+ const PICTO = /\p{Extended_Pictographic}/u;
82
+ const oneLine = (v, max) => [...String(v ?? '').normalize('NFKC').replace(/[\p{Cc}\p{Cf}\p{Co}\p{Cs}\u2028\u2029]+/gu, ' ').replace(/\s+/g, ' ').trim()].slice(0, max).join('').trim();
83
+
84
+ /** One emoji, as a game may add one: a short run with a pictograph in it (a flag, a skin tone, a ZWJ family). */
85
+ function glyphOk(g) {
86
+ const s = String(g ?? '');
87
+ return s.length >= 1 && s.length <= 16 && PICTO.test(s) && !/[\p{L}\p{N}\s<>&"'`]/u.test(s.replace(/\p{Extended_Pictographic}|[\u200d\ufe0f\u{1f3fb}-\u{1f3ff}\u{1f1e6}-\u{1f1ff}⃣#*0-9]/gu, ''));
88
+ }
89
+
90
+ /** Words a studio adds (blocked) or lets through (allowed): lowercase, one word or a short phrase, `word*` inside words. */
91
+ function wordList(raw) {
92
+ const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(/[,\n]/) : [];
93
+ const out = [];
94
+ for (const w of list) {
95
+ const s = oneLine(w, CHAT_LIMITS.word).toLowerCase();
96
+ if (s && /[\p{L}\p{N}]/u.test(s) && !out.includes(s)) out.push(s);
97
+ if (out.length >= CHAT_LIMITS.words) break;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /** The game's extra reactions: `{ "gem": "💎" }` or `[{ "k": "gem", "e": "💎" }]`, at most three, never a built-in kind. */
103
+ function extraEmoji(raw) {
104
+ const pairs = Array.isArray(raw) ? raw.map((x) => [x?.k, x?.e]) : raw && typeof raw === 'object' ? Object.entries(raw) : [];
105
+ const out = [];
106
+ for (const [k, e] of pairs) {
107
+ if (!ID.test(String(k ?? '')) || REACTIONS.some((r) => r.k === k) || out.some((r) => r.k === k) || !glyphOk(e)) continue;
108
+ out.push({ k: String(k), e: String(e) });
109
+ if (out.length >= CHAT_LIMITS.extraEmoji) break;
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /** The game's quick lines: `{ "gg": "Good game!" }` or `[{ "id", "text" }]`; at most 12, each passes the floor. */
115
+ function quickLines(raw) {
116
+ if (raw === undefined || raw === null) return DEFAULT_LINES.map((l) => ({ ...l }));
117
+ const pairs = Array.isArray(raw) ? raw.map((x) => [x?.id, x?.text]) : typeof raw === 'object' ? Object.entries(raw) : [];
118
+ const out = [];
119
+ for (const [id, text] of pairs) {
120
+ const t = oneLine(text, CHAT_LIMITS.lineText);
121
+ if (!ID.test(String(id ?? '')) || !t || out.some((l) => l.id === id) || !floor(t, { swears: 'block', links: 'block' }).ok) continue;
122
+ out.push({ id: String(id), text: t });
123
+ if (out.length >= CHAT_LIMITS.lines) break;
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * A room's chat rules, every field checked (a bad one never breaks a room). `kids` and `speech` are the server's:
130
+ * speech `lines` (a beginner server) or kids keeps chat to emoji and quick lines; speech `off` turns it off.
131
+ */
132
+ export function normalizeChat(raw, { kids = false, speech = 'game' } = {}) {
133
+ const r = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
134
+ const pick = (k, list) => (list.includes(r[k]) ? r[k] : CHAT_DEFAULTS[k]);
135
+ const bool = (k) => (r[k] === undefined || r[k] === null ? CHAT_DEFAULTS[k] : r[k] === true || r[k] === 'on' || r[k] === 1);
136
+ const int = (v, lo, hi, d) => { const n = Math.floor(Number(v)); return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : d; };
137
+ let mode = r.mode === false ? 'off' : pick('mode', CHAT_MODES);
138
+ const cap = speech === 'off' ? 'off' : speech === 'lines' || kids ? 'lines' : 'text';
139
+ const why = speech === 'off' ? 'server-off' : kids ? 'kids' : 'server-lines';
140
+ // Capped now, or capped before (rules the Worker already capped keep saying why when the room reads them again).
141
+ const capped = RANK[mode] > RANK[cap] || (r.capped === why && RANK[mode] === RANK[cap]) ? why : null;
142
+ if (capped) mode = cap;
143
+ return {
144
+ v: 1, at: Math.max(0, Number(r.at) || 0), mode,
145
+ who: pick('who', CHAT_WHO), react: pick('react', CHAT_WHO),
146
+ slow: int(r.slow, 0, CHAT_LIMITS.slowMax, CHAT_DEFAULTS.slow),
147
+ max: int(r.max, CHAT_LIMITS.minText, CHAT_LIMITS.text, CHAT_DEFAULTS.max),
148
+ links: r.links === 'allow' ? 'allow' : 'block',
149
+ swears: r.swears === 'allow' ? 'allow' : 'block',
150
+ ai: bool('ai'), bubbles: bool('bubbles'), watchers: bool('watchers'), hub: bool('hub'),
151
+ emoji: [...REACTIONS.map((x) => ({ ...x })), ...extraEmoji(r.emoji)],
152
+ lines: quickLines(r.lines),
153
+ block: wordList(r.block), allow: wordList(r.allow),
154
+ kids: Boolean(kids), ...(capped ? { capped } : {}),
155
+ };
156
+ }
157
+
158
+ /** What every client is told (the policy's `chat`): the rules without the studio's own word lists. */
159
+ export function publicChat(rules) {
160
+ if (!rules) return null;
161
+ const { block: _b, allow: _a, ...rest } = rules;
162
+ return rest;
163
+ }
164
+
165
+ /**
166
+ * A room's rules from its layers: the game's game.json "chat", then the owner's for the game, then the owner's for this
167
+ * server (each replaces what it names), capped by the server's speech and kids. `at` is the newest owner's change.
168
+ */
169
+ export function composeChat({ game = null, office = null, server = null, kids = false, speech = 'game' } = {}) {
170
+ const layer = (x) => (x && typeof x === 'object' && !Array.isArray(x) ? x : {});
171
+ const { at: a1 = 0, ...o } = layer(office);
172
+ const { at: a2 = 0, ...s } = layer(server);
173
+ return normalizeChat({ ...layer(game), ...o, ...s, at: Math.max(Number(a1) || 0, Number(a2) || 0) }, { kids, speech });
174
+ }
175
+
176
+ /** What may be sent in this mode: a reaction (emoji and up), a quick line (lines and up), typed text (text). */
177
+ export function allows(rules, kind) {
178
+ const m = RANK[rules?.mode] ?? 0;
179
+ return kind === 'react' ? m >= 1 : kind === 'line' ? m >= 2 : kind === 'text' ? m >= 3 : false;
180
+ }
181
+
182
+ /** An owner's change from the office (or the API): `{ ok, fields }` with only what was given, or `{ ok: false, message }`. */
183
+ export function checkChatRules(body) {
184
+ const bad = (message) => ({ ok: false, error: 'bad-request', message });
185
+ const f = {};
186
+ const b = body && typeof body === 'object' ? body : {};
187
+ if (b.mode !== undefined) { if (!CHAT_MODES.includes(b.mode)) return bad('mode is off, emoji, lines or text'); f.mode = b.mode; }
188
+ for (const k of ['who', 'react']) if (b[k] !== undefined) { if (!CHAT_WHO.includes(b[k])) return bad(`${k} is anyone, signed-in or members`); f[k] = b[k]; }
189
+ if (b.slow !== undefined) { const n = Math.floor(Number(b.slow)); if (!Number.isFinite(n) || n < 0 || n > CHAT_LIMITS.slowMax) return bad(`slow is 0 to ${CHAT_LIMITS.slowMax} seconds`); f.slow = n; }
190
+ if (b.max !== undefined) { const n = Math.floor(Number(b.max)); if (!Number.isFinite(n) || n < CHAT_LIMITS.minText || n > CHAT_LIMITS.text) return bad(`max is ${CHAT_LIMITS.minText} to ${CHAT_LIMITS.text} characters`); f.max = n; }
191
+ if (b.links !== undefined) { if (!['block', 'allow'].includes(b.links)) return bad('links is block or allow'); f.links = b.links; }
192
+ if (b.swears !== undefined) { if (!['block', 'allow'].includes(b.swears)) return bad('swears is block or allow'); f.swears = b.swears; }
193
+ for (const k of ['ai', 'bubbles', 'watchers', 'hub']) if (b[k] !== undefined) f[k] = b[k] === true || b[k] === 'on' || b[k] === 'true';
194
+ if (b.block !== undefined) f.block = wordList(b.block);
195
+ if (b.allow !== undefined) f.allow = wordList(b.allow);
196
+ if (b.lines !== undefined) { if (b.lines !== null && typeof b.lines !== 'object') return bad('lines is { "id": "text" }'); f.lines = b.lines === null ? null : quickLines(b.lines); }
197
+ if (b.emoji !== undefined) f.emoji = extraEmoji(b.emoji);
198
+ return { ok: true, fields: f };
199
+ }
200
+
201
+ /** A game.json "chat" as the build checks it: the problems in words (an empty list is fine). */
202
+ export function chatProblems(raw) {
203
+ if (raw === undefined) return [];
204
+ if (raw === false) return [];
205
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return ['"chat" is an object (or false for no chat)'];
206
+ const out = [];
207
+ if (raw.mode !== undefined && !CHAT_MODES.includes(raw.mode)) out.push('chat.mode is off, emoji, lines or text');
208
+ for (const k of ['who', 'react']) if (raw[k] !== undefined && !CHAT_WHO.includes(raw[k])) out.push(`chat.${k} is anyone, signed-in or members`);
209
+ if (raw.lines !== undefined && raw.lines !== null) {
210
+ const pairs = Array.isArray(raw.lines) ? raw.lines.map((x) => [x?.id, x?.text]) : typeof raw.lines === 'object' ? Object.entries(raw.lines) : null;
211
+ if (!pairs) out.push('chat.lines is { "id": "text" }');
212
+ else {
213
+ if (pairs.length > CHAT_LIMITS.lines) out.push(`chat.lines: at most ${CHAT_LIMITS.lines}`);
214
+ for (const [id, text] of pairs) {
215
+ if (!ID.test(String(id ?? ''))) out.push(`chat.lines: "${oneLine(id, 20)}" is not an id (a-z, 0-9 and _, 16 at most)`);
216
+ else if (!oneLine(text, CHAT_LIMITS.lineText)) out.push(`chat.lines.${id} has no text`);
217
+ else if (String(text).length > CHAT_LIMITS.lineText) out.push(`chat.lines.${id} is over ${CHAT_LIMITS.lineText} characters`);
218
+ else if (!floor(oneLine(text, CHAT_LIMITS.lineText), { swears: 'block', links: 'block' }).ok) out.push(`chat.lines.${id} does not pass the chat floor (a word, a link or contact details)`);
219
+ }
220
+ }
221
+ }
222
+ if (raw.emoji !== undefined) {
223
+ const pairs = Array.isArray(raw.emoji) ? raw.emoji.map((x) => [x?.k, x?.e]) : raw.emoji && typeof raw.emoji === 'object' ? Object.entries(raw.emoji) : null;
224
+ if (!pairs) out.push('chat.emoji is { "kind": "💎" }');
225
+ else for (const [k, e] of pairs) if (!ID.test(String(k ?? '')) || !glyphOk(e)) out.push(`chat.emoji: "${oneLine(k, 20)}" needs an id and one emoji`);
226
+ }
227
+ return out.slice(0, 8);
228
+ }
229
+
230
+ /* ------------------------------------------------------------------ the floor */
231
+
232
+ let WORDS = null;
233
+ /** The built-in list (worker/chat-words.mjs), decoded once. */
234
+ export function builtinWords() {
235
+ if (WORDS) return WORDS;
236
+ const text = typeof atob === 'function' ? new TextDecoder().decode(Uint8Array.from(atob(WORDS_B64), (c) => c.charCodeAt(0))) : '';
237
+ const raw = JSON.parse(text);
238
+ const norm = (list) => (list ?? []).map((w) => tokens(matchForm(w)).join(' ')).filter(Boolean);
239
+ WORDS = Object.freeze({
240
+ slurs: new Set(norm(raw.slurs)), slursAnywhere: norm(raw.slursAnywhere),
241
+ sexual: new Set(norm(raw.sexual)), sexualAnywhere: norm(raw.sexualAnywhere),
242
+ swears: new Set(norm(raw.swears)), swearsAnywhere: norm(raw.swearsAnywhere),
243
+ harm: norm(raw.harm), contact: norm(raw.contact),
244
+ });
245
+ return WORDS;
246
+ }
247
+
248
+ /** Letters a filter is dodged with: digits and signs for letters, and look-alike letters from other scripts. */
249
+ const LEET = Object.freeze({ 0: 'o', 1: 'i', 3: 'e', 4: 'a', 5: 's', 7: 't', 8: 'b', 9: 'g', '@': 'a', $: 's', '!': 'i', '|': 'l', '€': 'e', '£': 'l', '+': 't' });
250
+ const LOOK = Object.freeze({ а: 'a', е: 'e', о: 'o', р: 'p', с: 'c', у: 'y', х: 'x', і: 'i', ѕ: 's', к: 'k', м: 'm', т: 't', в: 'b', н: 'h', ӏ: 'l', ј: 'j', ԁ: 'd', ɡ: 'g', ı: 'i', ο: 'o', α: 'a', ε: 'e', ν: 'v', κ: 'k', τ: 't', ρ: 'p' });
251
+ /** Text as the floor reads it: lower case, no accents, look-alikes and leet made letters, everything else a space. */
252
+ export function matchForm(text) {
253
+ const s = String(text ?? '').normalize('NFKD').replace(/\p{M}+/gu, '').toLowerCase();
254
+ let out = '';
255
+ for (const ch of s) out += LOOK[ch] ?? LEET[ch] ?? (/[a-z]/.test(ch) ? ch : ' ');
256
+ return out;
257
+ }
258
+ const tokens = (form) => form.split(/\s+/).filter(Boolean);
259
+ /** "f u c k", "f.u.c.k": three or more single letters in a row are read as one word too. */
260
+ function joinedSingles(list) {
261
+ const out = [];
262
+ let run = [];
263
+ for (const t of [...list, '']) {
264
+ if (t.length === 1) { run.push(t); continue; }
265
+ if (run.length >= 3) out.push(run.join(''));
266
+ run = [];
267
+ }
268
+ return out;
269
+ }
270
+ const hasDouble = (w) => /(.)\1/.test(w);
271
+ /** A word's readings: as typed, three or more of a letter as one or as two, and every double as one. */
272
+ function variants(t) {
273
+ return [...new Set([t, t.replace(/(.)\1{2,}/g, '$1'), t.replace(/(.)\1{2,}/g, '$1$1'), t.replace(/(.)\1+/g, '$1')])];
274
+ }
275
+
276
+ const LINK = /\b(?:https?:\/\/|www\.)\S+|\b[a-z0-9][a-z0-9-]{0,62}\.(?:com|net|org|gg|io|co|me|tv|ly|xyz|app|dev|link|site|online|store|shop|live|info|biz|us|uk|ca|de|fr|ru|cn|jp|br|in|au|nl|pl|es|it|club|fun|game|games|chat|social|click|top|vip|win|bet|cc|to|sh|ai)\b|\b(?:dot|d0t)\s*(?:com|net|org|gg)\b/i;
277
+ const EMAIL = /[a-z0-9._%+-]+\s*(?:@|\(at\)|\[at\])\s*[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}/i;
278
+ const PHONE = /(?:\+\d{1,3}[\s.-]?)?\(?\d{2,4}\)?[\s.-]?\d{3,4}[\s.-]?\d{3,4}/;
279
+
280
+ /**
281
+ * The floor: `{ ok: true }`, or `{ ok: false, why }` with `why` one of words, harm, contact, link, swears. Whole words
282
+ * and phrases, and a few that are held even inside another word. `rules` brings the room's links and swears and the
283
+ * studio's own `block` and `allow` words.
284
+ */
285
+ export function floor(text, rules = {}) {
286
+ const W = builtinWords();
287
+ const form = matchForm(text);
288
+ const list = tokens(form);
289
+ const allow = new Set((rules.allow ?? []).map((w) => tokens(matchForm(w)).join(' ')).filter(Boolean));
290
+ const words = [...list, ...joinedSingles(list)].filter((t) => !allow.has(t));
291
+ const reads = words.flatMap(variants);
292
+ const flat = ` ${list.join(' ')} `;
293
+ const whole = (set) => reads.some((v) => set.has(v));
294
+ const inside = (subs) => reads.some((v) => subs.some((s) => v.includes(s)));
295
+ const phrase = (list2) => list2.some((p) => !allow.has(p) && flat.includes(` ${p} `));
296
+ if (whole(W.slurs) || inside(W.slursAnywhere)) return { ok: false, why: 'words' };
297
+ if (phrase(W.harm)) return { ok: false, why: 'harm' };
298
+ if (whole(W.sexual) || inside(W.sexualAnywhere)) return { ok: false, why: 'words' };
299
+ const raw = String(text ?? '');
300
+ if (phrase(W.contact) || EMAIL.test(raw) || PHONE.test(raw)) return { ok: false, why: 'contact' };
301
+ if (rules.links !== 'allow' && LINK.test(raw)) return { ok: false, why: 'link' };
302
+ if (rules.swears !== 'allow' && (whole(W.swears) || inside(W.swearsAnywhere))) return { ok: false, why: 'swears' };
303
+ for (const b of rules.block ?? []) {
304
+ const star = b.endsWith('*');
305
+ const w = tokens(matchForm(star ? b.slice(0, -1) : b)).join(' ');
306
+ if (!w) continue;
307
+ if (w.includes(' ') ? flat.includes(` ${w} `) : star ? inside([w]) : reads.includes(w)) return { ok: false, why: 'words' };
308
+ }
309
+ return { ok: true };
310
+ }
311
+
312
+ /** A typed message as it goes out: one line, no control or invisible characters (a joiner inside an emoji stays). */
313
+ export function cleanText(raw, max = CHAT_LIMITS.text) {
314
+ const keep = String(raw ?? '').normalize('NFKC')
315
+ .replace(/[\p{Cc}\p{Co}\p{Cs}\u2028\u2029]+/gu, ' ')
316
+ .replace(/(\p{Extended_Pictographic}\ufe0f?)\u200d(?=\p{Extended_Pictographic})/gu, '$1\u{e000}')
317
+ .replace(/\p{Cf}+/gu, '')
318
+ .replace(/\u{e000}/gu, '\u200d')
319
+ .replace(/\s+/g, ' ').trim();
320
+ return [...keep].slice(0, Math.max(1, max)).join('').trim();
321
+ }
322
+
323
+ /** What a sender is told when a message is not sent (the shell says it; the reason stays a word on the wire). */
324
+ export const HELD_WORDS = Object.freeze({
325
+ off: 'Chat is off in this room.',
326
+ emoji: 'This room keeps chat to emoji.',
327
+ lines: 'This room keeps chat to emoji and quick lines.',
328
+ 'sign-in': 'Sign in to type here. Emoji are open to everyone.',
329
+ 'sign-in-react': 'Sign in to chat in this room.',
330
+ members: 'Only members of this server can type here.',
331
+ 'members-react': 'Only members of this server can chat here.',
332
+ watchers: 'Watchers read the chat here; only players send.',
333
+ hub: 'This room\'s chat stays on the studio\'s own site.',
334
+ muted: 'The studio muted you: your chat reaches nobody for now.',
335
+ slow: 'One line at a time.',
336
+ repeat: 'You just said that.',
337
+ words: 'Not sent: it has a word this game doesn\'t allow.',
338
+ harm: 'Not sent: this game doesn\'t allow that.',
339
+ contact: 'Not sent: no personal questions, contact details or other apps in game chat.',
340
+ link: 'Not sent: links aren\'t allowed in this room\'s chat.',
341
+ swears: 'Not sent: this game keeps chat clean.',
342
+ ai: 'Not sent: the studio\'s filter held that message.',
343
+ busy: 'Chat is busy right now; try again in a moment.',
344
+ ai_seat: 'An AI never types in room chat.',
345
+ unknown: 'That is not one of this room\'s reactions or lines.',
346
+ empty: 'Nothing to send.',
347
+ });
348
+
349
+ /* ------------------------------------------------------------------ the review: Cloudflare's decision models */
350
+
351
+ /** The default model (var HOMIE_CHAT_MODEL changes it), and what each costs in neurons per million input tokens. */
352
+ export const CHAT_MODEL = '@cf/cloudflare/clef-flash';
353
+ export const CHAT_MODELS = Object.freeze({
354
+ // Clef: Cloudflare's decision models (2026-10-01). $0.09 / $0.24 per M input tokens, no output charge
355
+ // (Workers AI pricing, 2026-10-02: $0.011 per 1,000 neurons).
356
+ '@cf/cloudflare/clef-flash': Object.freeze({ kind: 'clef', name: 'clef-flash', input: 8182, output: 0 }),
357
+ '@cf/cloudflare/clef': Object.freeze({ kind: 'clef', name: 'clef', input: 21818, output: 0 }),
358
+ // Llama Guard 3: Meta's fixed safety categories (no insults or bullying among them), about 500 ms.
359
+ '@cf/meta/llama-guard-3-8b': Object.freeze({ kind: 'guard', input: 44003, output: 2730 }),
360
+ });
361
+ /** What a studio's chat review may use a day (meta `chat_budget`): with the AI guides' 8,000, the 10,000 free a day. */
362
+ export const CHAT_BUDGET = Object.freeze({ neurons: 2000 });
363
+ /**
364
+ * The one question the decision model answers about a message: which of these it is. A message whose `ok` is below
365
+ * `threshold` is held, and the most likely other answer says why (the office counts them; nobody else sees it).
366
+ */
367
+ export const VERDICT = Object.freeze({
368
+ type: 'choice',
369
+ instructions: 'A chat message in a public online game; some players are children. What is it?',
370
+ criteria: Object.freeze({
371
+ ok: 'Normal game chat, jokes, banter, mild trash talk',
372
+ insult: 'Insults, bullying or harassment of a person',
373
+ hate: 'Slurs or hate against a group',
374
+ sexual: 'Sexual content or advances',
375
+ grooming: 'Asks age, location, photos, contacts, or to move apps',
376
+ harm: 'Threats, violence, or urging self-harm',
377
+ spam: 'Ads, scams or spam',
378
+ }),
379
+ });
380
+ const VERDICT_CHARS = JSON.stringify(VERDICT).length;
381
+ export const REVIEW_THRESHOLD = 0.5;
382
+
383
+ const timeout = (p, ms) => Promise.race([p, new Promise((_, no) => setTimeout(() => no(new Error(`the review took over ${ms} ms`)), ms))]);
384
+
385
+ /**
386
+ * One typed message, reviewed on the studio's own Workers AI: `{ ok, why, p, neurons, ms, model }`. `ok: false`: held
387
+ * (`why` is the most likely other answer). Throws when there is no binding, the model fails or it takes too long:
388
+ * the caller lets the floor decide.
389
+ */
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
+ const at = now();
394
+ if (spec.kind === 'guard') {
395
+ const r = await timeout(env.AI.run(model, { messages: [{ role: 'user', content: text }], temperature: 0, max_tokens: 16, response_format: { type: 'json_object' } }), ms);
396
+ let v = r?.response;
397
+ if (typeof v === 'string') { try { v = JSON.parse(v); } catch { v = { safe: /^\s*safe\b/i.test(v), categories: (v.match(/S\d{1,2}/g) ?? []) }; } }
398
+ if (!v || typeof v.safe !== 'boolean') throw new Error('the review answered nothing it could use');
399
+ const input = Number(r?.usage?.prompt_tokens) || Math.ceil(text.length / 4) + 200;
400
+ const output = Number(r?.usage?.completion_tokens) || 4;
401
+ return { ok: v.safe, why: v.safe ? null : `guard:${(v.categories ?? []).slice(0, 3).join(',') || 'unsafe'}`, p: v.safe ? 1 : 0, neurons: (input * spec.input + output * spec.output) / 1e6, ms: now() - at, model };
402
+ }
403
+ // A room that allows links says so, so a plain link is not read as spam.
404
+ 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);
406
+ const a = r?.answers?.verdict;
407
+ if (!a || typeof a !== 'object') throw new Error('the review answered nothing it could use');
408
+ const probs = a.probabilities && typeof a.probabilities === 'object' ? a.probabilities : null;
409
+ const choice = typeof a.choice === 'string' ? a.choice : null;
410
+ const pOk = probs && Number.isFinite(Number(probs.ok)) ? Number(probs.ok) : choice === 'ok' ? 1 : 0;
411
+ let why = null;
412
+ if (pOk < threshold) {
413
+ why = choice && choice !== 'ok' ? choice : null;
414
+ if (!why && probs) why = Object.entries(probs).filter(([k]) => k !== 'ok').sort((x, y) => Number(y[1]) - Number(x[1]))[0]?.[0] ?? null;
415
+ why = why ?? 'held';
416
+ }
417
+ 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 };
419
+ }