@homie-rocks/studio 0.16.1 → 0.18.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.
- package/README.md +27 -15
- package/agents/GUIDES.md +99 -0
- package/agents/agents.ts +395 -0
- package/bin/homie-studio.mjs +123 -16
- package/lib/agent-seat.mjs +185 -0
- package/lib/build.mjs +32 -5
- package/lib/cloudflare.mjs +163 -9
- package/lib/mcp-tools.mjs +56 -0
- package/lib/media.mjs +185 -16
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +29 -10
- package/lib/servers.mjs +66 -4
- package/lib/template-history.json +56 -0
- package/lib/upgrade.mjs +27 -0
- package/media/MEDIA.md +68 -17
- package/netplay/NETPLAY.md +157 -6
- package/netplay/netplay.ts +10 -4
- package/package.json +8 -2
- package/site/SITE.md +6 -0
- package/starters/ember-vale/agents.json +34 -0
- package/starters/ember-vale/index.html +8 -0
- package/starters/ember-vale/src/main.ts +252 -5
- package/worker/agents.mjs +359 -4
- package/worker/brain.mjs +417 -0
- package/worker/index.mjs +195 -21
- package/worker/office-page.mjs +13 -0
- package/worker/office.mjs +62 -8
- package/worker/room.mjs +115 -6
- package/worker/stats.mjs +4 -2
- package/worker/version.mjs +1 -1
package/worker/index.mjs
CHANGED
|
@@ -37,7 +37,10 @@
|
|
|
37
37
|
* knocks for a Homie box, so it stops knocking (a studio site is not a box)
|
|
38
38
|
* /.well-known/homie-studio.json what the homie.rocks directory reads
|
|
39
39
|
* /games/<id>/source.json a public game's source for remixing, with its credit and licence (worker/license.mjs)
|
|
40
|
-
* /media/<key> the studio's
|
|
40
|
+
* /media/<key> a loose file in the studio's R2 (`media put`), with byte ranges
|
|
41
|
+
* /music/<slug>/<file>, /videos/<slug>/<file> a song's or video's files: from the site's own files, or, for a
|
|
42
|
+
* file `media move` put in the studio's R2 (the catalogue names its key), from R2 at the
|
|
43
|
+
* same address, with byte ranges, HEAD, ETag and the site's own cache headers
|
|
41
44
|
* /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
|
|
42
45
|
* /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
|
|
43
46
|
* /_studio/office the owner's back office: live rooms and who is in them, kick, mute, announce, close,
|
|
@@ -62,9 +65,12 @@
|
|
|
62
65
|
* after `homie-studio storage add` (R2 needs a payment method on the account),
|
|
63
66
|
* MEDIA. Everything else runs on Cloudflare's free Workers plan.
|
|
64
67
|
*
|
|
65
|
-
* The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev
|
|
68
|
+
* The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 7), run
|
|
66
69
|
* unchanged inside the Table, so a game that plays in `homie-studio dev`
|
|
67
|
-
* plays the same way here.
|
|
70
|
+
* plays the same way here. A beginner server whose AI may talk also gets the
|
|
71
|
+
* Table's own house guides (worker/agents.mjs HouseAgents), whose brains run
|
|
72
|
+
* on the Table's alarm: Workers AI through the optional AI binding (deploy adds
|
|
73
|
+
* it when a server uses it), or the owner's own key (secret HOMIE_BRAIN_KEY).
|
|
68
74
|
*/
|
|
69
75
|
import { NetRoom, WATCH_POLICIES } from './room.mjs';
|
|
70
76
|
import { ROOM_ID, badRoomPage, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
|
|
@@ -72,7 +78,8 @@ import {
|
|
|
72
78
|
PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
|
|
73
79
|
setMembership,
|
|
74
80
|
} from './servers.mjs';
|
|
75
|
-
import { agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
|
|
81
|
+
import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
|
|
82
|
+
import { talks } from './brain.mjs';
|
|
76
83
|
import { doorPage, serverPage, serversPage } from './site.mjs';
|
|
77
84
|
import { qrSvg } from './qr.mjs';
|
|
78
85
|
import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
|
|
@@ -274,21 +281,50 @@ async function assetWithRange(request, env, url) {
|
|
|
274
281
|
return new Response(body.subarray(r.start, r.end + 1), { status: 206, headers });
|
|
275
282
|
}
|
|
276
283
|
|
|
277
|
-
/**
|
|
278
|
-
|
|
284
|
+
/** Does an If-None-Match header name this ETag (or `*`)? Weak and strong forms compare alike for a GET. */
|
|
285
|
+
function etagMatches(header, etag) {
|
|
286
|
+
if (!header || !etag) return false;
|
|
287
|
+
const bare = (t) => String(t).trim().replace(/^W\//, '');
|
|
288
|
+
return header.split(',').some((t) => t.trim() === '*' || bare(t) === bare(etag));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A file from the studio's R2 bucket, with byte ranges (206), HEAD, and If-None-Match (304).
|
|
293
|
+
* `cache`: the Cache-Control it is served with. /media/<key> keeps its hour; a song or video served at its own
|
|
294
|
+
* address (media move) gets exactly what the site's static files get, `public, max-age=0, must-revalidate` with an
|
|
295
|
+
* ETag, so moving a file to R2 changes nothing a browser or a phone sees. `type`: the catalogue's type, for an
|
|
296
|
+
* object stored without one.
|
|
297
|
+
*/
|
|
298
|
+
async function mediaObject(request, env, key, { cache = 'public, max-age=3600', type = null } = {}) {
|
|
279
299
|
const range = request.headers.get('range');
|
|
300
|
+
const inm = request.headers.get('if-none-match');
|
|
301
|
+
const headOnly = request.method === 'HEAD';
|
|
280
302
|
let head = null;
|
|
281
|
-
if (range
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
303
|
+
if (range || inm || headOnly) {
|
|
304
|
+
head = await env.MEDIA.head(key);
|
|
305
|
+
if (!head) return new Response('not found', { status: 404 });
|
|
306
|
+
}
|
|
307
|
+
const base = (obj) => {
|
|
308
|
+
const headers = new Headers();
|
|
309
|
+
obj.writeHttpMetadata?.(headers);
|
|
310
|
+
if (!headers.get('content-type') && type) headers.set('content-type', type);
|
|
311
|
+
if (obj.httpEtag) headers.set('etag', obj.httpEtag);
|
|
312
|
+
if (obj.uploaded) headers.set('last-modified', new Date(obj.uploaded).toUTCString());
|
|
313
|
+
headers.set('cache-control', cache);
|
|
314
|
+
headers.set('accept-ranges', 'bytes');
|
|
315
|
+
return headers;
|
|
316
|
+
};
|
|
317
|
+
if (head && inm && etagMatches(inm, head.httpEtag)) return new Response(null, { status: 304, headers: base(head) });
|
|
318
|
+
const r = head && range ? parseRange(range, head.size) : null;
|
|
319
|
+
if (r?.unsatisfiable) return new Response(null, { status: 416, headers: { 'content-range': `bytes */${head.size}`, 'accept-ranges': 'bytes' } });
|
|
320
|
+
if (headOnly) {
|
|
321
|
+
const headers = base(head);
|
|
322
|
+
headers.set('content-length', String(head.size));
|
|
323
|
+
return new Response(null, { headers });
|
|
324
|
+
}
|
|
285
325
|
const obj = await env.MEDIA.get(key, r ? { range: { offset: r.start, length: r.end - r.start + 1 } } : undefined);
|
|
286
326
|
if (!obj) return new Response('not found', { status: 404 });
|
|
287
|
-
const headers =
|
|
288
|
-
obj.writeHttpMetadata(headers);
|
|
289
|
-
headers.set('etag', obj.httpEtag);
|
|
290
|
-
headers.set('cache-control', 'public, max-age=3600');
|
|
291
|
-
headers.set('accept-ranges', 'bytes');
|
|
327
|
+
const headers = base(obj);
|
|
292
328
|
if (r) {
|
|
293
329
|
headers.set('content-range', `bytes ${r.start}-${r.end}/${head.size}`);
|
|
294
330
|
headers.set('content-length', String(r.end - r.start + 1));
|
|
@@ -298,6 +334,30 @@ async function mediaObject(request, env, key) {
|
|
|
298
334
|
return new Response(obj.body, { headers });
|
|
299
335
|
}
|
|
300
336
|
|
|
337
|
+
/** The same Cache-Control the site's static files are served with (Workers static assets' default). */
|
|
338
|
+
const ASSET_CACHE = 'public, max-age=0, must-revalidate';
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A song's or video's file that `media move` put in R2, by the address it always had (/music/<slug>/x.mp3,
|
|
342
|
+
* /videos/<slug>/x.mp4): the catalogue names its key (`r2`). Only files the catalogue lists as public are served this
|
|
343
|
+
* way, never any other key of the bucket.
|
|
344
|
+
*/
|
|
345
|
+
async function heldInR2(env, getCat, path) {
|
|
346
|
+
if (!env.MEDIA) return null;
|
|
347
|
+
const cat = await getCat();
|
|
348
|
+
let want;
|
|
349
|
+
try { want = decodeURIComponent(path); } catch { return null; }
|
|
350
|
+
for (const e of [...(cat.songs ?? []), ...(cat.videos ?? [])]) {
|
|
351
|
+
for (const f of e.files ?? []) {
|
|
352
|
+
if (typeof f.r2 !== 'string' || !f.r2 || f.r2.startsWith('players/') || typeof f.url !== 'string') continue;
|
|
353
|
+
let at;
|
|
354
|
+
try { at = decodeURIComponent(f.url); } catch { continue; }
|
|
355
|
+
if (at === want) return f;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
301
361
|
/**
|
|
302
362
|
* A catalogue media row as the directory reads it (absolute addresses). A song's cover (a video's poster) is its
|
|
303
363
|
* own, else the manifest's default, else the landing still of the game it was made for (site.mjs mediaArt).
|
|
@@ -613,7 +673,12 @@ async function route(request, env, ctx) {
|
|
|
613
673
|
}
|
|
614
674
|
if (parts.some((p) => p === '..')) return notFoundPage('Nothing here.', cat);
|
|
615
675
|
const file = await assetWithRange(request, env, url);
|
|
616
|
-
if (file.status === 404)
|
|
676
|
+
if (file.status === 404) {
|
|
677
|
+
// Not in the site's files: a song's or video's file that lives in the studio's R2 (media move), same address.
|
|
678
|
+
const held = await heldInR2(env, getCat, path);
|
|
679
|
+
if (held) return mediaObject(request, env, held.r2, { cache: ASSET_CACHE, type: held.type ?? null });
|
|
680
|
+
return notFoundPage('Nothing here.', cat);
|
|
681
|
+
}
|
|
617
682
|
return file;
|
|
618
683
|
}
|
|
619
684
|
if (path === '/' || path === '/index.html') {
|
|
@@ -924,7 +989,12 @@ async function weekOf(env, game) {
|
|
|
924
989
|
/** Everything else is a file of the built site (site/public, the games' files), with byte ranges for media. */
|
|
925
990
|
async function fallback(request, env, url, getCat) {
|
|
926
991
|
const asset = MEDIA_FILE.test(url.pathname) && request.headers.get('range') ? await assetWithRange(request, env, url) : await env.ASSETS.fetch(request);
|
|
927
|
-
if (asset.status === 404)
|
|
992
|
+
if (asset.status === 404) {
|
|
993
|
+
// A manifest file outside music/ and videos/ that media move put in R2 keeps its address too.
|
|
994
|
+
const held = (request.method === 'GET' || request.method === 'HEAD') ? await heldInR2(env, getCat, url.pathname) : null;
|
|
995
|
+
if (held) return mediaObject(request, env, held.r2, { cache: ASSET_CACHE, type: held.type ?? null });
|
|
996
|
+
return notFoundPage('Nothing here.', await getCat());
|
|
997
|
+
}
|
|
928
998
|
return asset;
|
|
929
999
|
}
|
|
930
1000
|
|
|
@@ -950,6 +1020,15 @@ export class Table {
|
|
|
950
1020
|
/** The owner's holds, banner and closed door (they outlive an empty room), and the controls already used. */
|
|
951
1021
|
this.officeSaved = null;
|
|
952
1022
|
this.seen = new Map();
|
|
1023
|
+
/** Revision 7: the game's vocabulary (agents.json, read once from the build), and the room's house guides. */
|
|
1024
|
+
this.vocabRead = null;
|
|
1025
|
+
this.house = null;
|
|
1026
|
+
/** The AI brains' day (worker/agents.mjs): what the studio allows a day and has used, and what is not counted yet. */
|
|
1027
|
+
this.brainDay = null;
|
|
1028
|
+
this.brainCap = { neurons: BRAIN_BUDGET.neurons, micros: BRAIN_BUDGET.usd * 1e6 };
|
|
1029
|
+
this.brainUsed = { neurons: 0, micros: 0 };
|
|
1030
|
+
this.brainPending = { calls: {}, neurons: 0, micros: 0 };
|
|
1031
|
+
this.brainReadAt = 0;
|
|
953
1032
|
ctx.blockConcurrencyWhile(async () => {
|
|
954
1033
|
this.saved = (await ctx.storage.get('net')) ?? null;
|
|
955
1034
|
this.recorded = (await ctx.storage.get('recorded')) ?? 0;
|
|
@@ -973,9 +1052,99 @@ export class Table {
|
|
|
973
1052
|
});
|
|
974
1053
|
if (this.saved) this.room.restore(this.saved);
|
|
975
1054
|
if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
|
|
1055
|
+
this.vocabRead = this.readVocab(game).catch(() => false);
|
|
976
1056
|
return this.room;
|
|
977
1057
|
}
|
|
978
1058
|
|
|
1059
|
+
/** The game's agents.json from the build (the only words an AI in this room may say), once. */
|
|
1060
|
+
async readVocab(game) {
|
|
1061
|
+
if (!this.env.ASSETS || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(game ?? ''))) return false;
|
|
1062
|
+
const res = await this.env.ASSETS.fetch(new Request(`https://assets.local/games/${game}/agents.json`));
|
|
1063
|
+
if (!res.ok) return false;
|
|
1064
|
+
const ok = this.room.setVocabulary(await res.json());
|
|
1065
|
+
if (ok) this.syncHouse();
|
|
1066
|
+
return ok;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* The room's house guides (worker/agents.mjs): seated while the server's AI may talk, the game has a vocabulary and a
|
|
1071
|
+
* person is playing; stood up otherwise. Called on the 1 s beat and whenever the policy changes. No brain runs here:
|
|
1072
|
+
* decisions run on the alarm.
|
|
1073
|
+
*/
|
|
1074
|
+
syncHouse() {
|
|
1075
|
+
const room = this.room;
|
|
1076
|
+
if (!room) return;
|
|
1077
|
+
if (!this.house) {
|
|
1078
|
+
if (!talks(room.policy) || !room.vocab || !(room.policy.guides > 0)) return;
|
|
1079
|
+
this.house = new HouseAgents({
|
|
1080
|
+
room, env: this.env,
|
|
1081
|
+
setAlarm: (at) => { this.ctx.storage.setAlarm(at).catch(() => {}); },
|
|
1082
|
+
budget: { left: (kind) => this.brainCap[kind] - this.brainUsed[kind], spend: (c) => this.brainSpend(c) },
|
|
1083
|
+
log: (line) => { try { console.log(JSON.stringify(line)); } catch { /* no console */ } },
|
|
1084
|
+
});
|
|
1085
|
+
this.ctx.waitUntil(this.readBrainDay().catch(() => {}));
|
|
1086
|
+
}
|
|
1087
|
+
this.house.sync();
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** What the studio allows its AI brains a day (meta `brain_budget`) and has used today (stats_daily), all games. */
|
|
1091
|
+
async readBrainDay() {
|
|
1092
|
+
const db = this.env.DB;
|
|
1093
|
+
if (!db) return;
|
|
1094
|
+
const day = today();
|
|
1095
|
+
try {
|
|
1096
|
+
const b = JSON.parse((await db.prepare("SELECT value FROM meta WHERE key = 'brain_budget'").first())?.value ?? 'null');
|
|
1097
|
+
if (b && Number.isFinite(Number(b.neurons))) this.brainCap.neurons = Math.max(0, Number(b.neurons));
|
|
1098
|
+
if (b && Number.isFinite(Number(b.usd))) this.brainCap.micros = Math.max(0, Number(b.usd)) * 1e6;
|
|
1099
|
+
} catch { /* the defaults */ }
|
|
1100
|
+
try {
|
|
1101
|
+
const { results } = await db.prepare("SELECT metric, SUM(n) AS n FROM stats_daily WHERE day = ?1 AND metric IN ('brain-neurons', 'brain-microdollars') GROUP BY metric").bind(day).all();
|
|
1102
|
+
const used = { neurons: 0, micros: 0 };
|
|
1103
|
+
for (const r of results ?? []) used[r.metric === 'brain-neurons' ? 'neurons' : 'micros'] = Number(r.n) || 0;
|
|
1104
|
+
// What this room spent and has not written yet is added on top.
|
|
1105
|
+
this.brainUsed = { neurons: used.neurons + this.brainPending.neurons, micros: used.micros + this.brainPending.micros };
|
|
1106
|
+
} catch { /* before migration 0002: nothing used */ }
|
|
1107
|
+
this.brainDay = day;
|
|
1108
|
+
this.brainReadAt = Date.now();
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
brainSpend(c) {
|
|
1112
|
+
if (this.brainDay && this.brainDay !== today()) { this.brainUsed = { neurons: 0, micros: 0 }; this.brainDay = today(); }
|
|
1113
|
+
this.brainUsed.neurons += c.neurons ?? 0;
|
|
1114
|
+
this.brainUsed.micros += c.micros ?? 0;
|
|
1115
|
+
this.brainPending.neurons += c.neurons ?? 0;
|
|
1116
|
+
this.brainPending.micros += c.micros ?? 0;
|
|
1117
|
+
const k = c.provider ?? 'script';
|
|
1118
|
+
this.brainPending.calls[k] = (this.brainPending.calls[k] ?? 0) + 1;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* The day's brain counters into D1 (stats_daily: brain-calls by provider, brain-neurons, brain-microdollars), and the
|
|
1123
|
+
* day's budget and usage read again (an owner's new budget, other rooms' use): at most once a minute, on the alarm.
|
|
1124
|
+
*/
|
|
1125
|
+
async flushBrain(force = false) {
|
|
1126
|
+
const p = this.brainPending;
|
|
1127
|
+
if (!this.env.DB || (!force && Date.now() - this.brainReadAt < 60_000)) return;
|
|
1128
|
+
if (!Object.keys(p.calls).length && p.neurons < 1 && p.micros < 1) { await this.readBrainDay(); return; }
|
|
1129
|
+
const neurons = Math.floor(p.neurons);
|
|
1130
|
+
const micros = Math.floor(p.micros);
|
|
1131
|
+
const rows = [
|
|
1132
|
+
...Object.entries(p.calls).map(([source, n]) => counter(this.env, { metric: 'brain-calls', subject: this.game ?? '', source, n })),
|
|
1133
|
+
neurons ? counter(this.env, { metric: 'brain-neurons', subject: this.game ?? '', n: neurons }) : null,
|
|
1134
|
+
micros ? counter(this.env, { metric: 'brain-microdollars', subject: this.game ?? '', n: micros }) : null,
|
|
1135
|
+
].filter(Boolean);
|
|
1136
|
+
this.brainPending = { calls: {}, neurons: p.neurons - neurons, micros: p.micros - micros };
|
|
1137
|
+
if (rows.length) await this.env.DB.batch(rows).catch(() => {});
|
|
1138
|
+
await this.readBrainDay();
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** The alarm: the house guides' brains decide (worker/agents.mjs), then the day's usage is written. */
|
|
1142
|
+
async alarm() {
|
|
1143
|
+
// A brain that throws never takes the room with it: the alarm is not retried, the guides answer from the floor.
|
|
1144
|
+
try { if (this.house) await this.house.onAlarm(); } catch (error) { try { console.log(JSON.stringify({ ev: 'brain-alarm-failed', room: this.code, error: String(error?.stack ?? error).slice(0, 400) })); } catch { /* no console */ } }
|
|
1145
|
+
try { await this.flushBrain(); } catch { /* counted next time */ }
|
|
1146
|
+
}
|
|
1147
|
+
|
|
979
1148
|
async fetch(request) {
|
|
980
1149
|
const url = new URL(request.url);
|
|
981
1150
|
const game = url.searchParams.get('game');
|
|
@@ -984,12 +1153,14 @@ export class Table {
|
|
|
984
1153
|
const room = this.roomFor(game, code, max);
|
|
985
1154
|
// The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
|
|
986
1155
|
// owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
|
|
987
|
-
if (url.pathname === '/__facts') return json(room.officeFacts());
|
|
1156
|
+
if (url.pathname === '/__facts') return json({ ...room.officeFacts(), ...(this.house ? { brains: this.house.facts() } : {}) });
|
|
988
1157
|
if (url.pathname === '/__office') {
|
|
989
1158
|
const ctl = await request.json().catch(() => null);
|
|
990
1159
|
const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
|
|
991
1160
|
if (why) return json({ ok: false, error: 'refused', why }, 403);
|
|
992
1161
|
const res = room.control(ctl.op, ctl.args && typeof ctl.args === 'object' ? ctl.args : {});
|
|
1162
|
+
// AI talk turned off (a policy) or a guide kicked: the house guides follow at once.
|
|
1163
|
+
if (this.house) this.house.sync();
|
|
993
1164
|
await this.ctx.storage.put('office', room.officeSaved()).catch(() => {});
|
|
994
1165
|
if (ctl.op === 'close' && res.ok) this.tellLobby({ closed: ctl.args?.reopen ? 0 : res.until });
|
|
995
1166
|
this.report();
|
|
@@ -1000,6 +1171,7 @@ export class Table {
|
|
|
1000
1171
|
// The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
|
|
1001
1172
|
const pol = decodeFacts(url.searchParams.get('pol'));
|
|
1002
1173
|
if (pol) room.setPolicy(pol);
|
|
1174
|
+
if (this.house) this.house.sync();
|
|
1003
1175
|
const agent = decodeFacts(url.searchParams.get('ag'));
|
|
1004
1176
|
const [client, server] = Object.values(new WebSocketPair());
|
|
1005
1177
|
server.accept();
|
|
@@ -1072,9 +1244,9 @@ export class Table {
|
|
|
1072
1244
|
// A launch change the room applied by itself (after its round): its stored state says so too.
|
|
1073
1245
|
if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
|
|
1074
1246
|
n += 1;
|
|
1075
|
-
if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); }
|
|
1247
|
+
if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
|
|
1076
1248
|
if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
|
|
1077
|
-
if (room.clients.size === 0 && room.watchers.size === 0) { clearInterval(this.timer); this.timer = null; this.report(); }
|
|
1249
|
+
if (room.clients.size === 0 && room.watchers.size === 0) { clearInterval(this.timer); this.timer = null; this.report(); if (this.house) { this.ctx.waitUntil(this.flushBrain(true).catch(() => {})); this.house = null; } }
|
|
1078
1250
|
}, 250);
|
|
1079
1251
|
}
|
|
1080
1252
|
|
|
@@ -1109,7 +1281,9 @@ export class Table {
|
|
|
1109
1281
|
const counts = room.facts().counts;
|
|
1110
1282
|
// Players are people (an agent never counts as one, DESIGN D9); agents and AI bodies are reported apart.
|
|
1111
1283
|
const players = counts.players;
|
|
1112
|
-
|
|
1284
|
+
// AIs with a pass (a house guide makes way for one, so the Lobby counts only these when it seats an AI).
|
|
1285
|
+
let agents = 0;
|
|
1286
|
+
for (const c of room.clients.values()) if (c.helloed && c.agent && c.seat !== null && !c.conn?.loopback) agents += 1;
|
|
1113
1287
|
const ai = counts.ai ?? 0;
|
|
1114
1288
|
// People for the stats: seated sockets that are not house QA (players is what the Lobby matches strangers by).
|
|
1115
1289
|
let people = 0;
|
package/worker/office-page.mjs
CHANGED
|
@@ -320,6 +320,14 @@ export const OFFICE_SCRIPT = String.raw`(function () {
|
|
|
320
320
|
if ((brain.value === 'workers-ai' || brain.value === 'owner-key') && !S.data.agentsTalk && !confirm('Let AI guides talk? They speak only the lines the game\'s agents.json gives them, at most one every 8 seconds, never about a person, and any player can quiet them.')) return;
|
|
321
321
|
act('/_studio/api/agents/brain', { game: g.id, server: sv.id, mode: brain.value });
|
|
322
322
|
})));
|
|
323
|
+
// What today's brains used of the day's budget (the whole studio), and what is missing for the one chosen.
|
|
324
|
+
var bd = S.data.brain;
|
|
325
|
+
if (bd && (sv.brain === 'workers-ai' || sv.brain === 'owner-key')) {
|
|
326
|
+
var used = sv.brain === 'workers-ai'
|
|
327
|
+
? 'Workers AI: ' + Math.round(bd.used.neurons).toLocaleString('en-US') + ' / ' + Math.round(bd.budget.neurons).toLocaleString('en-US') + ' neurons today' + (bd.ai ? '' : ' · not bound yet: deploy once more')
|
|
328
|
+
: 'Your key: $' + bd.used.usd.toFixed(2) + ' / $' + bd.budget.usd.toFixed(2) + ' today' + (bd.key ? '' : ' · no key yet: homie-studio agents brain key');
|
|
329
|
+
add(body, add(el('div', 'line'), el('span', 'faint', used + (g.vocab ? '' : ' · this game has no agents.json: its guides have no words'))));
|
|
330
|
+
}
|
|
323
331
|
}
|
|
324
332
|
if (sv.id !== 'public') {
|
|
325
333
|
var mbox = el('div', 'line'); mbox.appendChild(el('span', 'dim', 'Members:'));
|
|
@@ -635,6 +643,11 @@ export const OFFICE_SCRIPT = String.raw`(function () {
|
|
|
635
643
|
var bots = (r.slots || []).filter(function (s) { return s.bot; });
|
|
636
644
|
if (bots.length) people.appendChild(el('div', 'held', 'AI and bots: ' + bots.map(function (s) { return s.name + (s.agent ? ' (AI ' + (s.agent.role === 'guide' ? 'guide' : 'seat') + ')' : ''); }).join(', ')));
|
|
637
645
|
if (r.vote && r.vote.open) people.appendChild(el('div', 'held', 'The party is voting on the AI\'s level: ' + r.vote.voters + ' of ' + r.vote.of_total + ' voted.'));
|
|
646
|
+
// The house guides' brains: which one runs (and why not), and their last few decisions (ids only, never words).
|
|
647
|
+
if (r.brains) {
|
|
648
|
+
var last = (r.brains.decisions || []).filter(function (x) { return x.goal || x.say; }).slice(-4).reverse().map(function (x) { return 'seat ' + (x.seat + 1) + ': ' + (x.goal ? x.goal + (x.args && Object.keys(x.args).length ? ' ' + Object.keys(x.args).map(function (k) { return x.args[k]; }).join(' ') : '') : 'no change') + (x.say ? ', said ' + x.say : '') + ' (' + x.provider + (x.ms ? ', ' + (x.ms / 1000).toFixed(1) + ' s' : '') + ', ' + dur(now - x.at) + ' ago)'; });
|
|
649
|
+
people.appendChild(el('div', 'held', 'Guides\' brain: ' + (r.brains.brain || 'script') + (r.brains.why ? ' · ' + r.brains.why : '') + ' · ' + r.brains.calls + ' calls' + (last.length ? ' · ' + last.join('; ') : '')));
|
|
650
|
+
}
|
|
638
651
|
(r.bans || []).forEach(function (b) { people.appendChild(el('div', 'held', 'Kicked: ' + (b.name || 'someone') + ', may come back in ' + dur(b.until - now) + (b.address ? ' (their network too)' : ''))); });
|
|
639
652
|
box.appendChild(people);
|
|
640
653
|
}
|
package/worker/office.mjs
CHANGED
|
@@ -45,8 +45,9 @@
|
|
|
45
45
|
*/
|
|
46
46
|
import { SEAT_MAX, seatsOf } from './seats.mjs';
|
|
47
47
|
import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
|
|
48
|
-
import { aiName, fillSpot, passById, passCreate, passList, passRevoke } from './agents.mjs';
|
|
49
|
-
import {
|
|
48
|
+
import { BRAIN_BUDGET, aiName, fillSpot, passById, passCreate, passList, passRevoke } from './agents.mjs';
|
|
49
|
+
import { DEFAULT_MODEL, OWNER_MODEL } from './brain.mjs';
|
|
50
|
+
import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession, today } from './stats.mjs';
|
|
50
51
|
import { esc, layout, notFoundPage } from './site.mjs';
|
|
51
52
|
import { confirmPage, lockedPage, officePage } from './office-page.mjs';
|
|
52
53
|
import { licenseOf } from './license.mjs';
|
|
@@ -455,9 +456,35 @@ async function roomRow(env, meta, room, max) {
|
|
|
455
456
|
host: f.host ?? null, announce: f.announce ?? null,
|
|
456
457
|
slots: slots.slice(0, 64).map((s) => ({ slot: s.slot, seat: s.seat ?? null, name: oneLine(s.name, 40), bot: Boolean(s.bot), ...(s.agent ? { agent: { role: s.agent.role ?? 'party', seat: s.agent.seat ?? null } } : {}) })),
|
|
457
458
|
clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
|
|
459
|
+
// The house guides' brains (0.17.0): which brain, why not when it is not, and the last decisions (seats and ids).
|
|
460
|
+
...(f.brains && typeof f.brains === 'object' ? { brains: {
|
|
461
|
+
brain: String(f.brains.brain ?? ''), why: f.brains.why ? oneLine(f.brains.why, 120) : null, calls: Number(f.brains.calls) || 0,
|
|
462
|
+
guides: (Array.isArray(f.brains.guides) ? f.brains.guides : []).slice(0, 8).map((g) => ({ name: oneLine(g.name, 40), seat: g.seat ?? null, provider: g.provider ?? null })),
|
|
463
|
+
decisions: (Array.isArray(f.brains.decisions) ? f.brains.decisions : []).slice(-50),
|
|
464
|
+
} } : {}),
|
|
458
465
|
};
|
|
459
466
|
}
|
|
460
467
|
|
|
468
|
+
/** The AI brains' day for the whole studio: the budget (meta brain_budget) and what today used (stats_daily). */
|
|
469
|
+
export async function brainDay(env) {
|
|
470
|
+
const budget = { neurons: BRAIN_BUDGET.neurons, usd: BRAIN_BUDGET.usd };
|
|
471
|
+
const used = { neurons: 0, usd: 0, calls: 0 };
|
|
472
|
+
try {
|
|
473
|
+
const b = JSON.parse((await env.DB.prepare("SELECT value FROM meta WHERE key = 'brain_budget'").first())?.value ?? 'null');
|
|
474
|
+
if (b && Number.isFinite(Number(b.neurons))) budget.neurons = Number(b.neurons);
|
|
475
|
+
if (b && Number.isFinite(Number(b.usd))) budget.usd = Number(b.usd);
|
|
476
|
+
} catch { /* the defaults */ }
|
|
477
|
+
try {
|
|
478
|
+
const { results } = await env.DB.prepare("SELECT metric, SUM(n) AS n FROM stats_daily WHERE day = ?1 AND metric IN ('brain-calls', 'brain-neurons', 'brain-microdollars') GROUP BY metric").bind(today()).all();
|
|
479
|
+
for (const r of results ?? []) {
|
|
480
|
+
if (r.metric === 'brain-calls') used.calls = Number(r.n) || 0;
|
|
481
|
+
if (r.metric === 'brain-neurons') used.neurons = Number(r.n) || 0;
|
|
482
|
+
if (r.metric === 'brain-microdollars') used.usd = Math.round(Number(r.n) || 0) / 1e6;
|
|
483
|
+
}
|
|
484
|
+
} catch { /* nothing counted yet */ }
|
|
485
|
+
return { budget, used, ai: Boolean(env.AI), key: Boolean(env.HOMIE_BRAIN_KEY), model: env.HOMIE_BRAIN_MODEL || DEFAULT_MODEL, ownerModel: OWNER_MODEL };
|
|
486
|
+
}
|
|
487
|
+
|
|
461
488
|
async function invitesOf(env, game, origin) {
|
|
462
489
|
try {
|
|
463
490
|
const { results } = await env.DB.prepare('SELECT id, code, label, uses, max_uses, expires_at, revoked, created_at, server FROM office_invites WHERE game = ?1 ORDER BY created_at DESC LIMIT 100').bind(game).all();
|
|
@@ -500,7 +527,9 @@ export async function officeView(env, cat, origin) {
|
|
|
500
527
|
id: g.id, name: g.name, launch, launchFrom: row?.launch ? 'office' : g.launch ? 'game.json' : 'default',
|
|
501
528
|
// Servers (0.16.0): each with its policy and what is on it now; its build reads the dial only from netplay rev 6.
|
|
502
529
|
servers: servers.map((sv) => ({ ...serverView(sv, { origin, game: g.id }), live: byServer[sv.id] ?? { rooms: 0, players: 0, ai: 0 }, members: members[sv.id] ?? 0 })),
|
|
503
|
-
build: { netplayRev: Number.isInteger(g.netplayRev) ? g.netplayRev : null, predates: !(Number(g.netplayRev) >= 6), caps: g.caps ?? null },
|
|
530
|
+
build: { netplayRev: Number.isInteger(g.netplayRev) ? g.netplayRev : null, predates: !(Number(g.netplayRev) >= 6), caps: g.caps ?? null, guides: Number(g.netplayRev) >= 7 },
|
|
531
|
+
// 0.17.0: the game's AI guides have words of their own (agents.json): without it they play but never talk.
|
|
532
|
+
vocab: g.vocab === true,
|
|
504
533
|
passes: (await passList(env, { game: g.id })).filter((x) => x.live || Date.now() - x.createdAt < 7 * 86_400_000),
|
|
505
534
|
remix: remixOf(g, settings), remixBuilt: g.landing?.source !== false, license: licenseOf(g.license).kind,
|
|
506
535
|
seats: seatsOf(g), maxPlayers: max, maxSet: Number(row?.max_players) >= 1 ? Number(row.max_players) : null,
|
|
@@ -515,6 +544,7 @@ export async function officeView(env, cat, origin) {
|
|
|
515
544
|
// The fill-a-spot service (an AI seated in an empty spot for a price) is a money decision: not offered yet.
|
|
516
545
|
fillSpot: { available: Boolean(fillSpot()), note: 'Let a fill-a-spot service seat AI (coming later)' },
|
|
517
546
|
agentsTalk: await talkConsented(env),
|
|
547
|
+
brain: await brainDay(env),
|
|
518
548
|
};
|
|
519
549
|
}
|
|
520
550
|
async function memberCountsOf(env, game) {
|
|
@@ -624,7 +654,10 @@ function checkAction(cat, op, body) {
|
|
|
624
654
|
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
625
655
|
if (typeof body.server !== 'string' || !(body.server === 'public' || /^[a-z0-9][a-z0-9-]{1,19}$/.test(body.server))) return bad('server is the server\'s id');
|
|
626
656
|
if (!['off', 'script', 'workers-ai', 'owner-key'].includes(body.mode)) return bad('mode is off, script, workers-ai or owner-key');
|
|
627
|
-
|
|
657
|
+
// The day's budget for the studio's brains: Workers AI neurons (workers-ai) or dollars of the owner's key (owner-key).
|
|
658
|
+
const budget = body.budget === undefined || body.budget === null ? undefined : Number(body.budget);
|
|
659
|
+
if (budget !== undefined && (!['workers-ai', 'owner-key'].includes(body.mode) || !(budget >= 0) || budget > (body.mode === 'owner-key' ? 100 : 1e7))) return bad('budget goes with workers-ai (neurons a day; the free allocation is 10,000 an account) or owner-key (dollars a day, at most 100)');
|
|
660
|
+
return { ok: true, action: { op, game: meta.id, server: body.server, mode: body.mode, ...(budget !== undefined ? { budget } : {}) } };
|
|
628
661
|
}
|
|
629
662
|
default:
|
|
630
663
|
return bad('unknown control');
|
|
@@ -670,7 +703,7 @@ export function describe(cat, a) {
|
|
|
670
703
|
case 'pass': return a.action === 'revoke' ? `Revoke the agent pass ${a.id}: that AI leaves every room and cannot sit again.` : `Issue an agent pass for an AI called "${a.label} · AI".`;
|
|
671
704
|
case 'room-level': return `Set the AI in ${where} to ${levelName(a.level)} (level ${a.level}).`;
|
|
672
705
|
case 'agents-brain': return ['workers-ai', 'owner-key'].includes(a.mode)
|
|
673
|
-
? `Let the AI guides on ${gname}'s server ${a.server} talk: they speak only the lines the game's own agents.json gives them (never free text), at most one line every 8 seconds, never about a person, and a player can quiet them. Their brain runs on ${a.mode === 'workers-ai' ?
|
|
706
|
+
? `Let the AI guides on ${gname}'s server ${a.server} talk: they speak only the lines the game's own agents.json gives them (never free text), at most one line every 8 seconds, never about a person, and a player can quiet them. Their brain runs on ${a.mode === 'workers-ai' ? `this studio's own Workers AI (free allowance${a.budget !== undefined ? `; at most ${Math.round(a.budget).toLocaleString('en-US')} neurons a day` : ''})` : `your own AI provider key (claude-haiku-4-5, your money${a.budget !== undefined ? `, at most $${Number(a.budget).toFixed(2)} a day` : ', capped daily'})`}.`
|
|
674
707
|
: `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
|
|
675
708
|
default: return 'A control.';
|
|
676
709
|
}
|
|
@@ -686,7 +719,12 @@ export async function needsAsk(env, cat, a) {
|
|
|
686
719
|
const before = meta ? (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server) : null;
|
|
687
720
|
return narrows(before, a.fields);
|
|
688
721
|
}
|
|
689
|
-
if (a.op === 'agents-brain')
|
|
722
|
+
if (a.op === 'agents-brain') {
|
|
723
|
+
if (['workers-ai', 'owner-key'].includes(a.mode) && !(await talkConsented(env))) return true;
|
|
724
|
+
// Raising the cap on the owner's own money is the owner's to confirm; Workers AI's free allocation is not money.
|
|
725
|
+
if (a.mode === 'owner-key' && a.budget !== undefined) return a.budget > (await brainDay(env)).budget.usd;
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
690
728
|
return false;
|
|
691
729
|
}
|
|
692
730
|
|
|
@@ -765,7 +803,7 @@ export async function perform(env, cat, a) {
|
|
|
765
803
|
try { await writeServer(env, meta.id, sv); } catch (error) { return { ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
|
|
766
804
|
const view = serverView(sv, { origin: a.origin ?? '', game: meta.id });
|
|
767
805
|
const notes = [];
|
|
768
|
-
if (sv.policy === 'beginner' && sv.guides) notes.push(`${sv.guides} AI guide seat${sv.guides === 1 ? '' : 's'} in every room, marked AI: the game's
|
|
806
|
+
if (sv.policy === 'beginner' && sv.guides) notes.push(`${sv.guides} AI guide seat${sv.guides === 1 ? '' : 's'} in every room, marked AI: they play from the game's script, silent. To let them talk (only the game's own agents.json lines), turn on a brain: agents brain ${meta.id} ${sv.id} workers-ai (the first time asks the owner).${meta.vocab ? '' : ` ${meta.name} has no agents.json yet: write one first.`}`);
|
|
769
807
|
if (sv.policy === 'hybrid') notes.push(`${sv.aiSeats} seat${sv.aiSeats === 1 ? '' : 's'} in every room are AI companions, marked AI; the party votes their level.`);
|
|
770
808
|
if (sv.policy === 'humans-only') notes.push('No AI can join; the game\'s practice bots are off unless the owner turns them on (bots: fill).');
|
|
771
809
|
if (!(Number(meta.netplayRev) >= 6)) notes.push(`${meta.name}'s build predates servers (netplay revision ${meta.netplayRev ?? '5 or older'}): reserved AI seats stay empty and its bots do not read the dial until it is rebuilt with @homie-rocks/studio 0.16.`);
|
|
@@ -839,8 +877,24 @@ export async function perform(env, cat, a) {
|
|
|
839
877
|
await writeServer(env, meta.id, sv);
|
|
840
878
|
if (['workers-ai', 'owner-key'].includes(a.mode)) await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('agents_talk_ok', ?1)").bind(String(Date.now())).run();
|
|
841
879
|
} catch (error) { return { ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
|
|
880
|
+
if (a.budget !== undefined) {
|
|
881
|
+
const day = await brainDay(env);
|
|
882
|
+
const next = { neurons: day.budget.neurons, usd: day.budget.usd, ...(a.mode === 'workers-ai' ? { neurons: Math.round(a.budget) } : { usd: Math.round(a.budget * 100) / 100 }) };
|
|
883
|
+
await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('brain_budget', ?1)").bind(JSON.stringify(next)).run().catch(() => {});
|
|
884
|
+
}
|
|
842
885
|
await pushPolicy(env, meta, settings, sv);
|
|
843
|
-
|
|
886
|
+
const day = await brainDay(env);
|
|
887
|
+
const notes = [];
|
|
888
|
+
if (!meta.vocab && ['workers-ai', 'owner-key'].includes(a.mode)) notes.push(`${meta.name} has no agents.json yet: its guides play but have no words. Write one (the game skill's "Write the guide vocabulary"), build and deploy.`);
|
|
889
|
+
if (a.mode === 'workers-ai') notes.push(!env.AI ? 'This Worker has no Workers AI binding yet: run `npm run deploy` once more (it binds Workers AI when a server uses it). Until then the guides answer from the game\'s script.'
|
|
890
|
+
: !(day.budget.neurons > 0) ? 'The day\'s Workers AI budget is 0: the guides answer from the game\'s script (set --budget to let them think).'
|
|
891
|
+
: `The guides think with Workers AI (${day.model}) from their next decision: at most ${day.budget.neurons.toLocaleString('en-US')} neurons a day for the whole studio (${Math.round(day.used.neurons).toLocaleString('en-US')} used today), then the game's script until 00:00 UTC.`);
|
|
892
|
+
if (a.mode === 'owner-key') notes.push(!env.HOMIE_BRAIN_KEY ? 'Set your key on your own computer: `npx --no-install homie-studio agents brain key` opens a page there (the key goes straight to the Worker, never through a chat). Until then the guides answer from the game\'s script.'
|
|
893
|
+
: !(day.budget.usd > 0) ? 'The day\'s dollar cap is 0: the guides answer from the game\'s script.'
|
|
894
|
+
: `The guides think with your key (claude-haiku-4-5) from their next decision, at most $${day.budget.usd.toFixed(2)} a day ($${day.used.usd.toFixed(2)} used today), then the game's script until 00:00 UTC.`);
|
|
895
|
+
if (a.mode === 'script') notes.push('The guides play from the game\'s script, silent.');
|
|
896
|
+
if (a.mode === 'off') notes.push('The guides are the game\'s plain bots.');
|
|
897
|
+
return { ok: true, op: a.op, game: meta.id, server: sv.id, brain: sv.brain, budget: day.budget, note: `Saved. ${notes.join(' ')}`.trim() };
|
|
844
898
|
}
|
|
845
899
|
default: return { ok: false, error: 'op' };
|
|
846
900
|
}
|