@homie-rocks/studio 0.24.3 → 0.24.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -1
- package/README.md +21 -5
- package/agents/GUIDES.md +13 -6
- package/bin/homie-studio.mjs +34 -10
- package/chat/CHAT.md +38 -5
- package/lib/agent-seat.mjs +60 -2
- package/lib/brain-probe.mjs +6 -5
- package/lib/build.mjs +6 -0
- package/lib/cloudflare.mjs +2 -0
- package/lib/doctor.mjs +14 -0
- package/lib/local-ai.mjs +66 -0
- package/lib/mcp-tools.mjs +10 -2
- package/lib/scaffold.mjs +17 -4
- package/lib/servers.mjs +31 -2
- package/lib/template-history.json +78 -0
- package/netplay/NETPLAY.md +95 -17
- package/netplay/netplay.ts +82 -0
- package/package.json +1 -1
- package/site/SITE.md +4 -0
- package/starters/ember-vale/game.json +1 -0
- package/starters/ember-vale/src/main.ts +80 -6
- package/worker/agents.mjs +93 -47
- package/worker/brain.mjs +356 -5
- package/worker/chat-page.mjs +173 -28
- package/worker/chat.mjs +17 -7
- package/worker/index.mjs +57 -2
- package/worker/office.mjs +63 -3
- package/worker/room.mjs +48 -3
- package/worker/version.mjs +1 -1
package/worker/chat-page.mjs
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* screen (the TV view) and the watch page. It speaks on the page's own watch socket, so every game has it, old builds
|
|
4
4
|
* too, with no game code:
|
|
5
5
|
*
|
|
6
|
-
* play a Chat pill beside the room button (the corner game.json screen.share keeps clear of the game's HUD
|
|
7
|
-
* its sheet (the room's last minutes, the room's emoji, its
|
|
8
|
-
* Report on a line, and for the studio's owner Remove, Mute and
|
|
9
|
-
* status chip sits
|
|
6
|
+
* play a Chat pill beside the room button (the corner game.json screen.share keeps clear of the game's HUD; the
|
|
7
|
+
* round icon on a phone, as the room button is), its sheet (the room's last minutes, the room's emoji, its
|
|
8
|
+
* quick lines, typing where the rules allow, Report on a line, and for the studio's owner Remove, Mute and
|
|
9
|
+
* Kick), a short strip of new lines where the status chip sits or where game.json screen.chat puts it
|
|
10
|
+
* (chatPlaces; "sheet-only" keeps them in the sheet), and reactions floating up the screen
|
|
10
11
|
* tv the room's lines in a corner (no typing: a television is read across a room) and the float
|
|
11
12
|
* watch a Chat button in the watch page's band, its panel over the stage, and the float
|
|
12
13
|
*
|
|
@@ -22,17 +23,86 @@ import { REACTIONS } from './chat.mjs';
|
|
|
22
23
|
import { REPORT_REASONS, REPORT_WORDS } from './chat-store.mjs';
|
|
23
24
|
import { NET_PALETTE } from './room.mjs';
|
|
24
25
|
|
|
26
|
+
/** Where room chat's strip of new lines may sit: a corner, or the middle of the top or bottom edge. */
|
|
27
|
+
export const CHAT_PLACES = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'];
|
|
28
|
+
const CHAT_CORNERS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
|
29
|
+
const CHAT_DEVICES = ['desk', 'phone', 'sideways', 'tv'];
|
|
30
|
+
const CHAT_FIELDS = ['at', 'x', 'y', 'lines'];
|
|
31
|
+
|
|
32
|
+
/** One entry of game.json `screen.chat` as written (a place, false, or an object), keeping only the fields that are right. */
|
|
33
|
+
function chatEntry(v) {
|
|
34
|
+
if (v === false) return { lines: 'sheet-only' };
|
|
35
|
+
if (typeof v === 'string') return CHAT_PLACES.includes(v) ? { at: v } : {};
|
|
36
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
|
|
37
|
+
const o = {};
|
|
38
|
+
if (CHAT_PLACES.includes(v.at)) o.at = v.at;
|
|
39
|
+
for (const k of ['x', 'y']) if (v[k] !== undefined && v[k] !== null && v[k] !== '' && Number.isFinite(Number(v[k]))) o[k] = Math.round(Number(v[k]));
|
|
40
|
+
if (v.lines === 'strip' || v.lines === 'sheet-only') o.lines = v.lines;
|
|
41
|
+
return o;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where room chat sits on each screen, from game.json `screen.chat` (0.24.5), so a game with a busy HUD keeps its chat
|
|
46
|
+
* off its clock, its title and its controls. The Chat pill itself sits in the room button's band (`screen.share`
|
|
47
|
+
* places both); this places the strip of new lines that shows for a moment, and the big screen's corner of lines:
|
|
48
|
+
*
|
|
49
|
+
* "chat": "bottom-right" every device's strip there (and the big screen's corner)
|
|
50
|
+
* "chat": false no strip: new lines wait in the sheet, the pill counts them
|
|
51
|
+
* "chat": { "at": "top-left", "y": 120 } moved 120 px in from its edge (x: from its side)
|
|
52
|
+
* "chat": { "lines": "strip", "phone": { "lines": "sheet-only" }, "tv": { "at": "top-right" } }
|
|
53
|
+
*
|
|
54
|
+
* `at` is a place from CHAT_PLACES (default: the bottom left, or the bottom right when the room button is there); `x`
|
|
55
|
+
* and `y` move it in, 0 to 600 px (in the middle of an edge, `x` moves it either way); `lines` is "strip" (the default)
|
|
56
|
+
* or "sheet-only". `desk`, `phone` (held upright), `sideways` (else as `phone`) and `tv` (the big screen) each change
|
|
57
|
+
* only what they name. The big screen has no sheet: it takes a corner only, its place follows the top level's when that
|
|
58
|
+
* is a corner, and its lines stay unless its own entry says "sheet-only" (then it shows only the float).
|
|
59
|
+
*/
|
|
60
|
+
export function chatPlaces(value) {
|
|
61
|
+
const top = chatEntry(value);
|
|
62
|
+
const per = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
63
|
+
const done = (o) => {
|
|
64
|
+
const at = o.at ?? null;
|
|
65
|
+
const clamp = (n, lo) => Math.max(lo, Math.min(600, n ?? 0));
|
|
66
|
+
return { at, x: clamp(o.x, at && at.endsWith('center') ? -600 : 0), y: clamp(o.y, 0), lines: o.lines ?? 'strip' };
|
|
67
|
+
};
|
|
68
|
+
const phone = { ...top, ...chatEntry(per.phone) };
|
|
69
|
+
const { lines: _l, ...where } = top;
|
|
70
|
+
const own = chatEntry(per.tv);
|
|
71
|
+
if (own.at && !CHAT_CORNERS.includes(own.at)) delete own.at;
|
|
72
|
+
const tv = { ...(CHAT_CORNERS.includes(where.at) ? where : {}), ...own };
|
|
73
|
+
return { desk: done({ ...top, ...chatEntry(per.desk) }), phone: done(phone), sideways: done({ ...phone, ...chatEntry(per.sideways) }), tv: done(tv) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** game.json `screen.chat` as the build reads it: the problems in words (the page uses the default for anything wrong). */
|
|
77
|
+
export function screenChatProblems(value) {
|
|
78
|
+
if (value === undefined || value === null || value === false) return [];
|
|
79
|
+
const out = [];
|
|
80
|
+
const one = (v, name, top) => {
|
|
81
|
+
if (v === false) return;
|
|
82
|
+
if (typeof v === 'string') { if (!CHAT_PLACES.includes(v)) out.push(`${name} "${v}" is not a place (${CHAT_PLACES.join(', ')})`); return; }
|
|
83
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) { out.push(`${name} is a place, false, or { "at", "x", "y", "lines" }`); return; }
|
|
84
|
+
for (const k of Object.keys(v)) {
|
|
85
|
+
if (top && CHAT_DEVICES.includes(k)) continue;
|
|
86
|
+
if (!CHAT_FIELDS.includes(k)) out.push(`${name}.${k} is not a field (${k === 'corner' || k === 'place' ? 'the place is "at"' : `${CHAT_FIELDS.join(', ')}${top ? `, or ${CHAT_DEVICES.join(', ')}` : ''}`})`);
|
|
87
|
+
}
|
|
88
|
+
if (v.at !== undefined && !CHAT_PLACES.includes(v.at)) out.push(`${name}.at "${v.at}" is not a place (${CHAT_PLACES.join(', ')})`);
|
|
89
|
+
if (name.endsWith('.tv') && v.at !== undefined && !CHAT_CORNERS.includes(v.at)) out.push(`${name}.at: the big screen takes a corner`);
|
|
90
|
+
for (const k of ['x', 'y']) if (v[k] !== undefined && !Number.isFinite(Number(v[k]))) out.push(`${name}.${k} is a number of pixels`);
|
|
91
|
+
if (v.lines !== undefined && v.lines !== 'strip' && v.lines !== 'sheet-only') out.push(`${name}.lines is "strip" or "sheet-only"`);
|
|
92
|
+
};
|
|
93
|
+
one(value, 'screen.chat', true);
|
|
94
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) for (const d of CHAT_DEVICES) if (value[d] !== undefined) one(value[d], `screen.chat.${d}`, false);
|
|
95
|
+
return out.slice(0, 8);
|
|
96
|
+
}
|
|
97
|
+
|
|
25
98
|
/** What a page's chat starts from (`window.__HOMIE_CHAT`): its surface, the game, and who this browser is to the studio. */
|
|
26
|
-
export function chatBoot(g, { surface = 'play', owner = false, acct = false, member = false, room = null
|
|
27
|
-
const screen = g?.screen?.chat;
|
|
28
|
-
const ticker = screen === false ? false : typeof screen === 'string' ? screen : screen && typeof screen === 'object' && typeof screen.at === 'string' ? screen.at : null;
|
|
99
|
+
export function chatBoot(g, { surface = 'play', owner = false, acct = false, member = false, room = null } = {}) {
|
|
29
100
|
return {
|
|
30
101
|
surface, game: g.id, name: g.name, owner: Boolean(owner), acct: Boolean(acct), member: Boolean(member), palette: NET_PALETTE,
|
|
31
102
|
reactions: REACTIONS, reasons: REPORT_REASONS.map((k) => ({ k, text: REPORT_WORDS[k] })),
|
|
32
103
|
...(room ? { room } : {}),
|
|
33
|
-
// game.json "screen": { "chat":
|
|
34
|
-
|
|
35
|
-
...(place ? { place } : {}),
|
|
104
|
+
// game.json "screen": { "chat": … }: where the strip of new lines sits on each device, or "sheet-only" (chatPlaces).
|
|
105
|
+
places: chatPlaces(g?.screen?.chat),
|
|
36
106
|
account: '/account/',
|
|
37
107
|
};
|
|
38
108
|
}
|
|
@@ -42,7 +112,11 @@ export const CHAT_CSS = `
|
|
|
42
112
|
.cpill .cico { width: 15px; height: 15px; flex: none; }
|
|
43
113
|
.cpill .cbadge { min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; border-radius: 8px; background: var(--hot); color: #0b0b10; font: 800 10px/16px ui-sans-serif, system-ui, sans-serif; text-align: center; }
|
|
44
114
|
.cpill.dim .cbadge { display: inline-block; }
|
|
115
|
+
/* The pill as the room button's round icon (on a phone, beside a room button kept an icon, or where its word would reach
|
|
116
|
+
the game's clock or title): its word goes, and the count rides its corner. */
|
|
117
|
+
.cpill.icon-only, .cpill.icon-only:hover, .cpill.icon-only:focus-visible, .cpill.icon-only[aria-expanded="true"] { position: relative; width: 34px; padding: 0; justify-content: center; }
|
|
45
118
|
.cpill.icon-only span.cword { display: none; }
|
|
119
|
+
.cpill.icon-only .cbadge { position: absolute; top: -5px; right: -5px; min-width: 16px; box-shadow: 0 0 0 2px rgba(6,9,16,.85); }
|
|
46
120
|
.csheet { display: flex; flex-direction: column; gap: 8px; width: min(340px, calc(100vw - 16px)); }
|
|
47
121
|
.chead { display: flex; align-items: center; gap: 8px; margin: 0 2px; }
|
|
48
122
|
.chead b { font: 800 15px/1.2 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.01em; }
|
|
@@ -88,24 +162,25 @@ export const CHAT_CSS = `
|
|
|
88
162
|
.creport { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin: 6px 0 2px; }
|
|
89
163
|
.creport p { grid-column: 1 / -1; margin: 0; color: #c3cad9; font: 600 12.5px/1.35 ui-sans-serif, system-ui, sans-serif; }
|
|
90
164
|
.creport button { min-height: 32px; padding: 0 8px; border-radius: 9px; border: 1px solid rgba(255,255,255,.16); background: transparent; color: inherit; font: 700 12px/1.2 ui-sans-serif, system-ui, sans-serif; cursor: pointer; text-align: left; }
|
|
91
|
-
|
|
92
|
-
.ctick
|
|
93
|
-
.ctick.at-bottom-
|
|
94
|
-
.ctick.at-bottom-
|
|
95
|
-
.ctick.at-
|
|
96
|
-
.ctick.at-top-
|
|
97
|
-
.ctick.at-top-
|
|
165
|
+
/* The strip of new lines (game.json screen.chat: its place, moved in by --chat-x / --chat-y). */
|
|
166
|
+
.ctick { --chat-x: 0px; --chat-y: 0px; position: fixed; z-index: 5; display: flex; flex-direction: column; gap: 4px; max-width: min(320px, calc(100vw - 24px)); pointer-events: none; }
|
|
167
|
+
.ctick.at-bottom-left { left: calc(max(10px, env(safe-area-inset-left)) + var(--chat-x)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px + var(--chat-y)); align-items: flex-start; }
|
|
168
|
+
.ctick.at-bottom-right { right: calc(max(10px, env(safe-area-inset-right)) + var(--chat-x)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px + var(--chat-y)); align-items: flex-end; }
|
|
169
|
+
.ctick.at-bottom-center { left: calc(50% + var(--chat-x)); transform: translateX(-50%); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px + var(--chat-y)); align-items: center; }
|
|
170
|
+
.ctick.at-top-left { left: calc(max(10px, env(safe-area-inset-left)) + var(--chat-x)); top: calc(max(10px, env(safe-area-inset-top)) + 48px + var(--chat-y)); align-items: flex-start; flex-direction: column-reverse; }
|
|
171
|
+
.ctick.at-top-right { right: calc(max(10px, env(safe-area-inset-right)) + var(--chat-x)); top: calc(max(10px, env(safe-area-inset-top)) + 48px + var(--chat-y)); align-items: flex-end; flex-direction: column-reverse; }
|
|
172
|
+
.ctick.at-top-center { left: calc(50% + var(--chat-x)); transform: translateX(-50%); top: calc(max(10px, env(safe-area-inset-top)) + 48px + var(--chat-y)); align-items: center; flex-direction: column-reverse; }
|
|
98
173
|
.ctick div { padding: 5px 10px; border-radius: 12px; background: rgba(6,9,16,.66); color: #eef1f8; font: 600 13px/1.3 ui-sans-serif, system-ui, -apple-system, sans-serif; overflow-wrap: anywhere; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: ctin .2s ease-out; transition: opacity .5s; }
|
|
99
174
|
.ctick div b { font-weight: 800; margin-right: 6px; }
|
|
100
175
|
.ctick div.cout { opacity: 0; }
|
|
101
176
|
@keyframes ctin { from { opacity: 0; transform: translateY(6px); } }
|
|
102
177
|
.cfloat { position: fixed; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 4; }
|
|
103
|
-
.ctv { position: fixed; z-index: 6; box-sizing: border-box; width: min(26rem, 30vw); max-height: 42vh; display: flex; flex-direction: column; justify-content: flex-end; gap: .3em; padding: .55em .8em; border-radius: 16px; background: rgba(8,12,22,.7); border: 1px solid rgba(255,255,255,.12); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 24px)/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; pointer-events: none; overflow: hidden; }
|
|
178
|
+
.ctv { --chat-x: 0px; --chat-y: 0px; position: fixed; z-index: 6; box-sizing: border-box; width: min(26rem, 30vw); max-height: 42vh; display: flex; flex-direction: column; justify-content: flex-end; gap: .3em; padding: .55em .8em; border-radius: 16px; background: rgba(8,12,22,.7); border: 1px solid rgba(255,255,255,.12); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 24px)/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; pointer-events: none; overflow: hidden; }
|
|
104
179
|
.ctv:empty { display: none; }
|
|
105
|
-
.ctv.at-bottom-left { left: max(16px, env(safe-area-inset-left)); bottom: max(16px, env(safe-area-inset-bottom)); }
|
|
106
|
-
.ctv.at-bottom-right { right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); }
|
|
107
|
-
.ctv.at-top-left { left: max(16px, env(safe-area-inset-left)); top: max(16px, env(safe-area-inset-top)); justify-content: flex-start; }
|
|
108
|
-
.ctv.at-top-right { right: max(16px, env(safe-area-inset-right)); top: max(16px, env(safe-area-inset-top)); justify-content: flex-start; }
|
|
180
|
+
.ctv.at-bottom-left { left: calc(max(16px, env(safe-area-inset-left)) + var(--chat-x)); bottom: calc(max(16px, env(safe-area-inset-bottom)) + var(--chat-y)); }
|
|
181
|
+
.ctv.at-bottom-right { right: calc(max(16px, env(safe-area-inset-right)) + var(--chat-x)); bottom: calc(max(16px, env(safe-area-inset-bottom)) + var(--chat-y)); }
|
|
182
|
+
.ctv.at-top-left { left: calc(max(16px, env(safe-area-inset-left)) + var(--chat-x)); top: calc(max(16px, env(safe-area-inset-top)) + var(--chat-y)); justify-content: flex-start; }
|
|
183
|
+
.ctv.at-top-right { right: calc(max(16px, env(safe-area-inset-right)) + var(--chat-x)); top: calc(max(16px, env(safe-area-inset-top)) + var(--chat-y)); justify-content: flex-start; }
|
|
109
184
|
.ctv div { overflow-wrap: anywhere; transition: opacity 1s; }
|
|
110
185
|
.ctv div.cout { opacity: 0; }
|
|
111
186
|
.ctv b { font-weight: 800; margin-right: .4em; }
|
|
@@ -221,21 +296,41 @@ export const CHAT_JS = String.raw`(function () {
|
|
|
221
296
|
if (parts.length) raf = requestAnimationFrame(paint);
|
|
222
297
|
}
|
|
223
298
|
|
|
224
|
-
/* ------------------------------------------------------------
|
|
299
|
+
/* ------------------------------------------------------------ where chat sits on this screen (game.json screen.chat) */
|
|
300
|
+
// This screen as the room button knows it (desk, phone held upright, phone turned sideways), else by its size.
|
|
301
|
+
function device() {
|
|
302
|
+
if (surface === 'tv') return 'tv';
|
|
303
|
+
var s = window.__shell && window.__shell.share;
|
|
304
|
+
if (s && s.device) return s.device;
|
|
305
|
+
var w = window.innerWidth || 0; var h = window.innerHeight || 0;
|
|
306
|
+
return w && h && Math.min(w, h) <= 540 ? (w > h ? 'sideways' : 'phone') : 'desk';
|
|
307
|
+
}
|
|
308
|
+
function placeHere() { var p = cfg.places || {}; return p[device()] || p.desk || { at: null, x: 0, y: 0, lines: 'strip' }; }
|
|
309
|
+
|
|
310
|
+
/* ------------------------------------------------------------ the strip: new lines, for a moment, where the chip sits */
|
|
225
311
|
var tickEl = null;
|
|
226
312
|
function tickPlace() {
|
|
227
|
-
|
|
313
|
+
var p = placeHere();
|
|
314
|
+
if (p.at) return p.at;
|
|
228
315
|
var s = window.__shell && window.__shell.share;
|
|
229
316
|
return s && s.at === 'bottom-left' ? 'bottom-right' : 'bottom-left';
|
|
230
317
|
}
|
|
231
318
|
function tick(m) {
|
|
232
|
-
if (surface === 'tv' ||
|
|
319
|
+
if (surface === 'tv' || !st.show || st.open) return;
|
|
320
|
+
// "sheet-only": a busy HUD keeps new lines in the sheet; the pill's count says they are there.
|
|
321
|
+
var p = placeHere();
|
|
322
|
+
if (p.lines === 'sheet-only') return;
|
|
233
323
|
if (!tickEl) {
|
|
234
324
|
var host = (surface === 'watch' ? document.querySelector('.stage') : null) || document.body;
|
|
235
325
|
if (!host) return;
|
|
236
326
|
tickEl = el('div', 'ctick'); tickEl.setAttribute('aria-hidden', 'true'); host.appendChild(tickEl); if (surface === 'watch') tickEl.style.position = 'absolute';
|
|
237
327
|
}
|
|
238
|
-
|
|
328
|
+
var at = tickPlace();
|
|
329
|
+
tickEl.className = 'ctick at-' + at;
|
|
330
|
+
// Moved in by the game's x / y, and past the room button when the strip shares its place.
|
|
331
|
+
var s = window.__shell && window.__shell.share; var same = surface === 'play' && s && s.at === at;
|
|
332
|
+
tickEl.style.setProperty('--chat-x', ((p.x || 0) + (same ? s.x || 0 : 0)) + 'px');
|
|
333
|
+
tickEl.style.setProperty('--chat-y', ((p.y || 0) + (same ? s.y || 0 : 0)) + 'px');
|
|
239
334
|
var row = el('div');
|
|
240
335
|
if (m.name) { var b = el('b', '', m.name); b.style.color = colourOf(m); row.appendChild(b); }
|
|
241
336
|
row.appendChild(document.createTextNode(m.glyph && m.react ? m.glyph : (m.text || '')));
|
|
@@ -247,13 +342,16 @@ export const CHAT_JS = String.raw`(function () {
|
|
|
247
342
|
/* ------------------------------------------------------------ the TV's corner */
|
|
248
343
|
var tvEl = null;
|
|
249
344
|
function tvLine(m) {
|
|
250
|
-
|
|
345
|
+
var p = placeHere();
|
|
346
|
+
if (!st.show || p.lines === 'sheet-only') return;
|
|
251
347
|
if (!tvEl) {
|
|
252
348
|
if (!document.body) return;
|
|
253
349
|
tvEl = el('div', 'ctv');
|
|
254
350
|
var join = document.querySelector('[data-join]');
|
|
255
351
|
var at = join && /join-bottom-left/.test(join.className) ? 'bottom-right' : join && /join-top-left|join-top-right/.test(join.className) ? 'bottom-left' : 'bottom-left';
|
|
256
|
-
tvEl.className = 'ctv at-' + (
|
|
352
|
+
tvEl.className = 'ctv at-' + (p.at || at);
|
|
353
|
+
tvEl.style.setProperty('--chat-x', (p.x || 0) + 'px');
|
|
354
|
+
tvEl.style.setProperty('--chat-y', (p.y || 0) + 'px');
|
|
257
355
|
document.body.appendChild(tvEl);
|
|
258
356
|
}
|
|
259
357
|
var row = el('div', m.t === 'react' ? 'creact' : '');
|
|
@@ -277,6 +375,7 @@ export const CHAT_JS = String.raw`(function () {
|
|
|
277
375
|
pill = el('button', 'pill cpill'); pill.type = 'button'; pill.setAttribute('data-chat-toggle', ''); pill.setAttribute('aria-expanded', 'false'); pill.setAttribute('aria-controls', 'chat-sheet'); pill.setAttribute('aria-label', 'Room chat');
|
|
278
376
|
pill.innerHTML = ICON; pill.appendChild(el('span', 'cword', 'Chat')); badge = el('b', 'cbadge'); badge.hidden = true; pill.appendChild(badge);
|
|
279
377
|
band.appendChild(pill);
|
|
378
|
+
watchBand(band);
|
|
280
379
|
sheet = el('div', 'sheet csheet'); sheet.id = 'chat-sheet'; sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Room chat'); sheet.hidden = true;
|
|
281
380
|
ui.appendChild(sheet);
|
|
282
381
|
// One sheet at a time: the room's and the server's close this one, and this one closes theirs.
|
|
@@ -318,6 +417,52 @@ export const CHAT_JS = String.raw`(function () {
|
|
|
318
417
|
renderFeed();
|
|
319
418
|
paintRules();
|
|
320
419
|
}
|
|
420
|
+
/*
|
|
421
|
+
* The pill's word, as the room button's: on a phone (either way up), or beside a room button that game.json keeps an
|
|
422
|
+
* icon ("label": false), the pill is the round icon from the start. Elsewhere its word shows unless, with it, the band
|
|
423
|
+
* would leave the screen or reach from its corner into the middle third of the screen's width (where games keep a
|
|
424
|
+
* clock or a title); once it has, it stays the icon until the screen changes size, so it never flickers as the room
|
|
425
|
+
* button's own label comes and goes.
|
|
426
|
+
*/
|
|
427
|
+
var narrow = false;
|
|
428
|
+
function shareHere() { var s = window.__shell && window.__shell.share; return s || {}; }
|
|
429
|
+
function wordCollides() {
|
|
430
|
+
var band = pill && pill.parentNode;
|
|
431
|
+
var w = window.innerWidth || 0;
|
|
432
|
+
if (!band || !band.getBoundingClientRect || !w) return false;
|
|
433
|
+
var had = pill.classList.contains('icon-only');
|
|
434
|
+
if (had) pill.classList.remove('icon-only');
|
|
435
|
+
var r = band.getBoundingClientRect();
|
|
436
|
+
if (had) pill.classList.add('icon-only');
|
|
437
|
+
if (!r.width) return false;
|
|
438
|
+
if (r.left < 4 || r.right > w - 4) return true;
|
|
439
|
+
var at = shareHere().at || 'top-right';
|
|
440
|
+
if (/center/.test(at)) return false;
|
|
441
|
+
return /right/.test(at) ? r.left < w * 2 / 3 : r.right > w / 3;
|
|
442
|
+
}
|
|
443
|
+
function fitPill() {
|
|
444
|
+
if (!pill || surface !== 'play') return;
|
|
445
|
+
var s = shareHere();
|
|
446
|
+
if (!narrow && s.device !== 'phone' && s.device !== 'sideways' && s.label !== false) narrow = wordCollides();
|
|
447
|
+
var icon = s.device === 'phone' || s.device === 'sideways' || s.label === false || narrow;
|
|
448
|
+
if (icon) { pill.classList.add('icon-only'); pill.title = 'Room chat'; } else { pill.classList.remove('icon-only'); pill.title = ''; }
|
|
449
|
+
st.compact = icon;
|
|
450
|
+
}
|
|
451
|
+
var fitQueued = false;
|
|
452
|
+
function fitSoon() {
|
|
453
|
+
if (fitQueued) return;
|
|
454
|
+
fitQueued = true;
|
|
455
|
+
var go = function () { fitQueued = false; fitPill(); };
|
|
456
|
+
if (window.requestAnimationFrame) window.requestAnimationFrame(go); else setTimeout(go, 16);
|
|
457
|
+
}
|
|
458
|
+
function watchBand(band) {
|
|
459
|
+
fitPill();
|
|
460
|
+
// The band changes as the room code arrives, the server's pill shows and the labels fade to dots: look again.
|
|
461
|
+
try { if (window.ResizeObserver) new ResizeObserver(fitSoon).observe(band); } catch (e) {}
|
|
462
|
+
// A new size (a turned phone, a resized window): the room button moves first, then the word is measured afresh.
|
|
463
|
+
window.addEventListener('resize', function () { setTimeout(function () { narrow = false; fitPill(); }, 60); });
|
|
464
|
+
}
|
|
465
|
+
st.fit = fitPill;
|
|
321
466
|
function openChat(on, quiet) {
|
|
322
467
|
if (!sheet) return;
|
|
323
468
|
st.open = Boolean(on);
|
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']);
|
|
@@ -206,7 +209,10 @@ export function chatProblems(raw) {
|
|
|
206
209
|
const out = [];
|
|
207
210
|
if (raw.mode !== undefined && !CHAT_MODES.includes(raw.mode)) out.push('chat.mode is off, emoji, lines or text');
|
|
208
211
|
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
|
-
|
|
212
|
+
// Where chat sits on the screen is the page's (game.json "screen": { "chat" }), never the room's rules.
|
|
213
|
+
for (const k of ['corner', 'place', 'at']) if (raw[k] !== undefined && (k !== 'at' || typeof raw.at === 'string')) out.push(`chat.${k}: where chat sits on the screen goes in "screen": { "chat": { "at": … } }`);
|
|
214
|
+
if (raw.lines === 'strip' || raw.lines === 'sheet-only') out.push(`chat.lines is the quick lines ({ "id": "text" }); "${raw.lines}" goes in "screen": { "chat": { "lines": "${raw.lines}" } }`);
|
|
215
|
+
else if (raw.lines !== undefined && raw.lines !== null) {
|
|
210
216
|
const pairs = Array.isArray(raw.lines) ? raw.lines.map((x) => [x?.id, x?.text]) : typeof raw.lines === 'object' ? Object.entries(raw.lines) : null;
|
|
211
217
|
if (!pairs) out.push('chat.lines is { "id": "text" }');
|
|
212
218
|
else {
|
|
@@ -387,9 +393,11 @@ const timeout = (p, ms) => Promise.race([p, new Promise((_, no) => setTimeout(()
|
|
|
387
393
|
* (`why` is the most likely other answer). Throws when there is no binding, the model fails or it takes too long:
|
|
388
394
|
* the caller lets the floor decide.
|
|
389
395
|
*/
|
|
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
|
|
396
|
+
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 } = {}) {
|
|
397
|
+
// No binding: the person's own Ollama (`homie-studio dev` found clef-flash there), with the same question. Free.
|
|
398
|
+
const local = env?.AI && typeof env.AI.run === 'function' ? null : localAiOf(env);
|
|
399
|
+
if (!local && (!env?.AI || typeof env.AI.run !== 'function')) throw new Error('this Worker has no Workers AI binding (AI)');
|
|
400
|
+
const spec = local ? { kind: 'clef', name: local.model, input: 0, output: 0 } : CHAT_MODELS[model] ?? CHAT_MODELS[CHAT_MODEL];
|
|
393
401
|
const at = now();
|
|
394
402
|
if (spec.kind === 'guard') {
|
|
395
403
|
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 +410,9 @@ export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || C
|
|
|
402
410
|
}
|
|
403
411
|
// A room that allows links says so, so a plain link is not read as spam.
|
|
404
412
|
const verdict = links === 'allow' ? { ...VERDICT, criteria: { ...VERDICT.criteria, spam: 'Ads, scams or spam (a plain link is fine here)' } } : VERDICT;
|
|
405
|
-
const r =
|
|
413
|
+
const r = local
|
|
414
|
+
? await clefRun(env, { state: text, questions: { verdict }, ms: Math.max(ms, 4000), local, fetch: fetchImpl })
|
|
415
|
+
: await timeout(env.AI.run(model, { model: spec.name, state: text, questions: { verdict } }), ms);
|
|
406
416
|
const a = r?.answers?.verdict;
|
|
407
417
|
if (!a || typeof a !== 'object') throw new Error('the review answered nothing it could use');
|
|
408
418
|
const probs = a.probabilities && typeof a.probabilities === 'object' ? a.probabilities : null;
|
|
@@ -415,5 +425,5 @@ export async function reviewChat(env, text, { model = env?.HOMIE_CHAT_MODEL || C
|
|
|
415
425
|
why = why ?? 'held';
|
|
416
426
|
}
|
|
417
427
|
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 };
|
|
428
|
+
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
429
|
}
|
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'));
|