@homie-rocks/studio 0.28.0 → 0.30.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/lib/mcp-tools.mjs CHANGED
@@ -49,6 +49,8 @@ export const UI = Object.freeze({
49
49
  ...ART_UI,
50
50
  });
51
51
 
52
+ /** Homie's own updates by email (0.29.0): the sign-up page on homie.rocks, double opt-in, for the first-run card. */
53
+ export const HOMIE_UPDATES = 'https://homie.rocks/updates/?from=plugin';
52
54
  const inside = (base, p) => p === base || p.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
53
55
  const realOr = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
54
56
 
@@ -214,6 +216,9 @@ async function setupCard(ctx, root, { fresh = false, made = null, install = null
214
216
  studios, checklist: steps,
215
217
  status: { rows: status.rows.map(({ parts, ...r }) => r), features: status.features, next: status.next, meanwhile: status.meanwhile, blocking: status.blocking },
216
218
  install: job ? jobView(job) : null, made,
219
+ // Homie's own updates by email (0.29.0): a link the person opens and signs up at themselves (double opt-in on
220
+ // homie.rocks). The card never asks for an address, and an agent never types one in for the person.
221
+ updates: HOMIE_UPDATES,
217
222
  };
218
223
  const text = [
219
224
  current ? `Studio: ${current.name} (${root})${current.installed ? '' : job ? ' (installing its toolkit)' : ''}` : `No studio yet${ctx.studiosDir ? ` in ${ctx.studiosDir}` : ''}.`,
@@ -223,6 +228,7 @@ async function setupCard(ctx, root, { fresh = false, made = null, install = null
223
228
  '',
224
229
  formatStatus(status),
225
230
  '',
231
+ `Homie updates by email (optional): ${HOMIE_UPDATES} (the person signs up there themselves; never type an address in for them).`,
226
232
  'The card on screen shows this; it follows the install by itself.',
227
233
  ].join('\n');
228
234
  return ok(text, data);
package/lib/scaffold.mjs CHANGED
@@ -30,6 +30,7 @@ import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema
30
30
  import { SERVERS_MIGRATION, SERVERS_MIGRATION_FILE } from '../worker/servers.mjs';
31
31
  import { CHAT_MIGRATION, CHAT_MIGRATION_FILE } from '../worker/chat-store.mjs';
32
32
  import { SHOP_MIGRATION, SHOP_MIGRATION_FILE } from '../worker/shop-store.mjs';
33
+ import { LOUNGE_MIGRATION, LOUNGE_MIGRATION_FILE } from '../worker/lounge-store.mjs';
33
34
  import { themeFile } from './site.mjs';
34
35
 
35
36
  export const COMPAT_DATE = '2026-06-01';
@@ -652,6 +653,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
652
653
  [`site/migrations/${SERVERS_MIGRATION_FILE}`]: SERVERS_MIGRATION,
653
654
  [`site/migrations/${CHAT_MIGRATION_FILE}`]: CHAT_MIGRATION,
654
655
  [`site/migrations/${SHOP_MIGRATION_FILE}`]: SHOP_MIGRATION,
656
+ [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
655
657
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
656
658
  '.claude/skills/.gitkeep': '',
657
659
  };
@@ -664,14 +666,14 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
664
666
  return files;
665
667
  }
666
668
 
667
- /** The template's first-run band on Home: one link that connects this studio to the Claude chat that set it up. */
668
- export const CONNECT_BAND = `<!-- A new studio from the Homie template. This band goes away once Claude works in the studio
669
+ /** The template's first-run band on Home: one link that connects this studio to the chat that set it up. */
670
+ export const CONNECT_BAND = `<!-- A new studio from the Homie template. This band goes away once a chat works in the studio
669
671
  (homie-studio setup attach removes it); delete it by hand any time. -->
670
672
  <div class="band-in" style="text-align:center">
671
673
  <p class="kicker">New studio</p>
672
674
  <h2>{{studio.name}} is live</h2>
673
- <p>It runs on your own Cloudflare account. Connect it to the Claude chat that set it up, and Claude takes it from here.</p>
674
- <p><a class="btn" href="/_studio/connect">Connect to Claude</a></p>
675
+ <p>It runs on your own Cloudflare account. Connect it to the chat that set it up, and that chat takes it from here.</p>
676
+ <p><a class="btn" href="/_studio/connect">Connect this chat</a></p>
675
677
  </div>
676
678
  `;
677
679
 
@@ -687,8 +689,9 @@ The button copies this studio into your GitHub, creates its Worker, database and
687
689
  deploys it with Workers Builds: every push to \`main\` goes live, and every other branch gets its own Preview.
688
690
  The site goes live at once with its own home page ("first game coming soon"); the games come next.
689
691
 
690
- Then open the site and tap **Connect to Claude**, or ask Claude in the Claude app to set up your studio with the
691
- Homie connector: it makes games, songs and videos here, in a pull request you merge with one tap.
692
+ Then open the site and tap **Connect this chat**, or ask Claude, Codex or Grok (with the Homie connector,
693
+ https://homie.rocks/mcp) to set up your studio: it makes games, songs and videos here, in a pull request you merge
694
+ with one tap. Grok has no Cloudflare connector. Approving Cloudflare in the browser is still your step.
692
695
 
693
696
  \`AGENTS.md\` says how everything here works.
694
697
  `;
@@ -766,13 +769,22 @@ function ensureChatMigration(root) {
766
769
  return `site/migrations/${CHAT_MIGRATION_FILE}`;
767
770
  }
768
771
 
772
+ /** A studio made before 0.29.0 has no Lounge tables: kept chat (only when an owner turns history on), play nights, moderators. */
773
+ function ensureLoungeMigration(root) {
774
+ const file = join(root, 'site', 'migrations', LOUNGE_MIGRATION_FILE);
775
+ if (existsSync(file)) return null;
776
+ mkdirSync(dirname(file), { recursive: true });
777
+ writeFileSync(file, LOUNGE_MIGRATION);
778
+ return `site/migrations/${LOUNGE_MIGRATION_FILE}`;
779
+ }
780
+
769
781
  export function ensureMigrations(root) {
770
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root)].filter(Boolean);
782
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root)].filter(Boolean);
771
783
  }
772
784
 
773
785
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
774
786
  export function migrationWord(file) {
775
- return /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (it sells nothing until shop.json and the owner\'s Stripe key are in)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
787
+ return /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (it sells nothing until shop.json and the owner\'s Stripe key are in)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
776
788
  }
777
789
 
778
790
  /**
package/lib/setup.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * and answers with the studio's live address once its site is connected (the card's Cloudflare step);
7
7
  * - a copy of the public template (studio.json `template: true`) gets the name the person chose in the chat:
8
8
  * studio.json, the Worker's STUDIO_NAME, and AGENTS.md and README.md while they are untouched; its first-run
9
- * "Connect to Claude" band goes; studio.json takes the Worker and database names Cloudflare's form chose;
9
+ * "Connect this chat" band goes; studio.json takes the Worker and database names Cloudflare's form chose;
10
10
  * - the live address goes into .studio/local.json (git-ignored), so `check --url` and the card know it.
11
11
  *
12
12
  * It sends the repository's owner/name and the studio's name and slug, nothing else: no key, no path, no code.
@@ -16,6 +16,7 @@
16
16
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
  import { configNames } from './cloudflare.mjs';
19
+ import { studioClient } from './client.mjs';
19
20
  import { request } from './net.mjs';
20
21
  import { ENGINE_REPO, repoOf } from './repo.mjs';
21
22
  import { CONNECT_BAND, agentsMd, readme, slugify, templateReadme } from './scaffold.mjs';
@@ -24,8 +25,9 @@ import { STUDIO_VERSION } from './version.mjs';
24
25
 
25
26
  const SETUP_ID = /^hs_[a-f0-9]{32}$/;
26
27
 
27
- export async function setupAttach(root, id, { homie } = {}) {
28
- if (!SETUP_ID.test(String(id ?? ''))) return { ok: false, command: 'setup attach', why: 'attach to the setup id the Claude app\'s setup card showed: hs_ and 32 hex digits' };
28
+ export async function setupAttach(root, id, { homie, client } = {}) {
29
+ if (!SETUP_ID.test(String(id ?? ''))) return { ok: false, command: 'setup attach', why: 'attach to the setup id the setup card showed: hs_ and 32 hex digits' };
30
+ const who = studioClient(client);
29
31
  const studio = readStudio(root);
30
32
  const directory = String(homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
31
33
  const repo = repoOf(root);
@@ -34,7 +36,7 @@ export async function setupAttach(root, id, { homie } = {}) {
34
36
  }
35
37
  const sent = await request(`${directory}/api/studio/setup/${id}/attach`, {
36
38
  method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION}` },
37
- body: JSON.stringify({ repo, studio: { name: studio.name, slug: studio.slug, template: studio.template === true }, version: STUDIO_VERSION }),
39
+ body: JSON.stringify({ repo, studio: { name: studio.name, slug: studio.slug, template: studio.template === true }, version: STUDIO_VERSION, ...(who.id === 'chat' ? {} : { client: who.id }) }),
38
40
  });
39
41
  // What went wrong, as it is: the directory's own answer, or the connection's error. Never a guess at the network.
40
42
  if (!sent.ok) return { ok: false, command: 'setup attach', repo, ...(sent.status ? { status: sent.status } : {}), ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}), why: sent.why };
@@ -72,10 +74,11 @@ export async function setupAttach(root, id, { homie } = {}) {
72
74
  if (now.github !== repo) { writeStudio(root, { ...now, github: repo }); if (!renamed.includes('studio.json')) renamed.push('studio.json'); }
73
75
  if (body.site) writeLocal(root, { url: body.site, connectedAt: new Date().toISOString() });
74
76
  return {
75
- ok: true, command: 'setup attach', setup: id, repo, site: body.site ?? null, name: name ?? studio.name, renamed,
76
- message: body.site ? `This repository is ${name ?? studio.name}, live at ${body.site}. The Claude app's card shows it.` : `This repository is ${name ?? studio.name}. Its site is not connected yet: ${body.connect ?? 'open the site and tap Connect to Claude'}.`,
77
+ ok: true, command: 'setup attach', setup: id, repo, client: who.id, site: body.site ?? null, name: name ?? studio.name, renamed,
78
+ message: body.site ? `This repository is ${name ?? studio.name}, live at ${body.site}. ${who.card[0].toUpperCase()}${who.card.slice(1)} shows it.` : `This repository is ${name ?? studio.name}. Its site is not connected yet: ${body.connect ?? `open the site and tap ${who.connect}`}.`,
77
79
  next: [
78
80
  ...(renamed.length ? ['commit the renamed files on a branch and open a pull request (merging it names the live site)'] : []),
81
+ ...(who.id === 'grok' ? ['a Grok Bot in this folder runs the checklist from here (there is no second session to start)'] : []),
79
82
  'npm install, then npx --no-install homie-studio progress attach <hb_…> when the chat opened a build',
80
83
  ],
81
84
  };
@@ -1391,6 +1391,86 @@
1391
1391
  }
1392
1392
  }
1393
1393
  }
1394
+ },
1395
+ "0.29.0": {
1396
+ "files": {
1397
+ "CLAUDE.md": "ddf2b2a0376202f6",
1398
+ "README.md": "684b3cca8001673c",
1399
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
1400
+ "games/README.md": "5ad32fe80c61f117",
1401
+ "music/README.md": "a3dc042dadd39df8",
1402
+ "videos/README.md": "16babc3437d5545b",
1403
+ "posts/README.md": "37841aae476bd540",
1404
+ "site/README.md": "2928ca4f079ab76b",
1405
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1406
+ },
1407
+ "sections": {
1408
+ "AGENTS.md": {
1409
+ "(intro)": "214aa46eb3a895d5",
1410
+ "## Layout": "8baf49e4291e7e88",
1411
+ "## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
1412
+ "## The site": "366ecce1a424837a",
1413
+ "## Making games": "abf2f0d59341512d",
1414
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1415
+ "## Art direction and models": "aaea65236db52126",
1416
+ "## Running live games (the back office)": "4aa5f513a7cd2c3b",
1417
+ "## Servers and AI seats": "98d08705087a33ec",
1418
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1419
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1420
+ "## Rules": "7e8458adc1b81ece",
1421
+ "## Telling Homie": "80b46fdd9e578db8",
1422
+ "## Beta": "9d02638207a26e73"
1423
+ }
1424
+ },
1425
+ "named": {
1426
+ "Homie Arcade": {
1427
+ "sections": {
1428
+ "AGENTS.md": {
1429
+ "## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
1430
+ }
1431
+ }
1432
+ }
1433
+ }
1434
+ },
1435
+ "0.30.0": {
1436
+ "files": {
1437
+ "CLAUDE.md": "ddf2b2a0376202f6",
1438
+ "README.md": "684b3cca8001673c",
1439
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
1440
+ "games/README.md": "5ad32fe80c61f117",
1441
+ "music/README.md": "a3dc042dadd39df8",
1442
+ "videos/README.md": "16babc3437d5545b",
1443
+ "posts/README.md": "37841aae476bd540",
1444
+ "site/README.md": "2928ca4f079ab76b",
1445
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1446
+ },
1447
+ "sections": {
1448
+ "AGENTS.md": {
1449
+ "(intro)": "214aa46eb3a895d5",
1450
+ "## Layout": "8baf49e4291e7e88",
1451
+ "## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
1452
+ "## The site": "366ecce1a424837a",
1453
+ "## Making games": "abf2f0d59341512d",
1454
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1455
+ "## Art direction and models": "aaea65236db52126",
1456
+ "## Running live games (the back office)": "4aa5f513a7cd2c3b",
1457
+ "## Servers and AI seats": "98d08705087a33ec",
1458
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1459
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1460
+ "## Rules": "7e8458adc1b81ece",
1461
+ "## Telling Homie": "80b46fdd9e578db8",
1462
+ "## Beta": "9d02638207a26e73"
1463
+ }
1464
+ },
1465
+ "named": {
1466
+ "Homie Arcade": {
1467
+ "sections": {
1468
+ "AGENTS.md": {
1469
+ "## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
1470
+ }
1471
+ }
1472
+ }
1473
+ }
1394
1474
  }
1395
1475
  }
1396
1476
  }
package/mcp/ui/setup.js CHANGED
@@ -60,6 +60,8 @@
60
60
  acts.appendChild(C.btn('Check again', 'ghost small', function () {
61
61
  C.call('setup_status', { fresh: true }).then(function (r) { C.take(r); }).catch(function () {});
62
62
  }));
63
+ // Homie's own updates by email: the person signs up on homie.rocks (double opt-in); the card asks for nothing.
64
+ if (sc.updates) acts.appendChild(C.btn('Get Homie updates', 'ghost small', function () { C.open(sc.updates); }));
63
65
  foot.appendChild(acts);
64
66
  card.appendChild(foot);
65
67
  if (install && install.state === 'done') C.tell('install-' + install.job, (cur ? cur.name : 'The studio') + '\'s toolkit finished installing; the studio is ready to build.');
@@ -1230,6 +1230,7 @@ every watching shell. homie.rocks runs nothing: its room page opens the room's o
1230
1230
  | `swears` | `block`, `allow` (`block`) | Slurs, sexual words, threats and contact details are held whatever this says. |
1231
1231
  | `ai` | `true`, `false` (`true`) | The studio's review of typed text. |
1232
1232
  | `bubbles`, `watchers`, `hub` | `true` | A line may show over its sender's character; watchers may send (not only read); homie.rocks's page for the room may show it. |
1233
+ | `history` | 0–90 days (0) | 0.29.0: keep what is said (never a reaction) in the studio's D1 for that many days, and give it back as the window when a page opens; 0 keeps nothing past the window. A kids server, a capped room or chat `off` keeps 0. |
1233
1234
  | `emoji` | the five, then up to 3 of the game's own | `{ k, e }`: a kind and its glyph. |
1234
1235
  | `lines` | 8 default lines, or up to 12 of the game's own | `{ id, text }`: the game's words; each passes the floor at build. |
1235
1236
  | `capped` | `kids`, `server-lines`, `server-off` | Why the mode is lower than the game asks. |
@@ -1261,6 +1262,12 @@ stays in the room; clients get the rest in `welcome.policy.chat` and every `poli
1261
1262
  | `lines` | down, a watching shell as it opens | `lines` | The window: the last 50 lines of the last 15 minutes. Never to a page the room holds out. |
1262
1263
  | `unline` | down, everyone | `ids` | The owner took them down: hide them, and their bubbles. |
1263
1264
  | `held` / `slow` | down, the sender | `why`, `message`, `n?`, `until?` | Not sent. `why`: `off`, `emoji`, `lines`, `sign-in`, `sign-in-react`, `members`, `members-react`, `watchers`, `hub`, `muted`, `slow`, `repeat`, `words`, `harm`, `contact`, `link`, `swears`, `ai`, `busy`, `unknown`, `empty`, `ai_seat`. |
1265
+ | `unsay` | up, a watching shell | `id` | 0.29.0: take my own line down (a line of this browser, or of this signed-in account). Everyone gets `unline`. |
1266
+
1267
+ 0.29.0 adds to `line`: `card` (`{ url, title, studio, pitch, image }`, a "show what you made" card the Worker read
1268
+ from a Homie studio's manifest; `text` stays a plain fallback such as "Made: <title> by <studio>"), `mod` (a moderator
1269
+ the owner named), `kept` (it came from the room's history) and, on a page's own copy of the window only, `mine` (this
1270
+ browser's or account's own line). A page that knows none of these shows the line's `text`.
1264
1271
 
1265
1272
  A line's sender is the socket's own client, or for a watch socket the client of the same browser
1266
1273
  (its room key) or account in the room: its seat, name and colour. A page with no client in the
@@ -1361,7 +1368,10 @@ ticker.
1361
1368
 
1362
1369
  **Privacy.** Nothing a person says is stored: the room keeps the last 50 lines of the last 15
1363
1370
  minutes in its memory (never in Durable Object storage), and an empty room forgets them with
1364
- everything else a minute after its last person leaves. The counts (lines, reactions, held by why,
1371
+ everything else a minute after its last person leaves. Only where the owner turns `history` on (0.29.0; off by
1372
+ default for every game and the Lounge, never on a kids server) does the Table keep what was said in D1
1373
+ (`chat_history`: the words, the name shown, when, a signed-in sender's account id; never a reaction, an address or a
1374
+ browser key) for that many days; what is taken down there is deleted there, and a deleted account takes its lines. The counts (lines, reactions, held by why,
1365
1375
  reviews and neurons) are daily numbers in the studio's own D1, never a message or a sender. A
1366
1376
  report keeps one message for 30 days. The review sends the message's words (and nothing about who
1367
1377
  said it) to the studio's own Workers AI; Cloudflare says it does not keep or train on them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/site/SITE.md CHANGED
@@ -13,6 +13,7 @@ and every game gets a landing page. Anything the studio puts in `site/` wins.
13
13
  | `/music/`, `/videos/` | Songs and videos (`media/MEDIA.md`) | the studio has one published |
14
14
  | `/rooms/` | Every public room playing now across the studio's games, each with Join (`/api/rooms` is the same as JSON, cacheable for 15 s) | the studio has a game |
15
15
  | `/posts/` | The studio's posts, newest first, with `/posts/feed.xml` (Atom) and `/posts/feed.json` (JSON Feed) | the studio has a post |
16
+ | `/lounge/` | The Lounge (0.29.0): the studio's community room, with play nights, live rooms and "show what you made" (`chat/LOUNGE.md`) | studio.json has `"lounge"` |
16
17
 
17
18
  A section with nothing in it has no tab, and its address answers 404. Every page ends with a
18
19
  "Made with Homie" link to https://homie.rocks/studio/. studio.json may add `"tagline"` (one line, on Home and in
@@ -35,6 +36,18 @@ the hub offers Watch beside Join. From 0.23.0 a room whose chat is on and whose
35
36
  socket from the visitor's browser and shows its lines and reactions live (NETPLAY.md section 19). A visitor there
36
37
  sends what a watcher may send (reactions and quick lines when the room's `react` is `anyone`), never types.
37
38
 
39
+ ## The Lounge: `/lounge/` (0.29.0)
40
+
41
+ With studio.json `"lounge": true` (or `{ "name", "tab", "blurb", "featured", "kids", "chat" }`), `/lounge/` is the
42
+ studio's community room (worker/lounge.mjs, lounge-page.mjs; chat/LOUNGE.md): room chat in a room of its own, in the
43
+ studio's look, one column on a phone ("Chat" and "What's on") and the talk beside a sidebar on a computer. Its script
44
+ is `/_homie/lounge.js`; its socket is `/lounge/__watch` (a page of this site carries its signed-in account; a page of
45
+ another site is a watcher that reacts, never types). `/lounge/api/now` is its public facts as JSON (rules, play
46
+ nights, live rooms; CORS open, cacheable for 30 s), and the manifest names it (`lounge: { name, page, now }`).
47
+ `/lounge/api/history`, `show`, `report`, `delete` and `mod` are this site's pages' only. The owner's tools are
48
+ `/_studio/api/lounge` and `/_studio/api/lounge/rules`, `night`, `mod`, `remove` and `hold`. A game called `lounge`
49
+ keeps its page, and the build says the Lounge is off.
50
+
38
51
  ## Room chat on the play, watch and TV pages
39
52
 
40
53
  Every play page, big screen and watch page carries room chat (worker/chat-page.mjs; chat/CHAT.md): the Chat pill
@@ -1,7 +1,8 @@
1
1
  /**
2
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
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.
4
+ * room's memory and nowhere else (worker/room.mjs), unless an owner turns a room's `history` on (0.29.0: then
5
+ * worker/lounge-store.mjs keeps it for that many days). A report keeps the one message it is about, for 30 days.
5
6
  *
6
7
  * chat_rules (game, server, rules JSON, updated_at): server '' is the whole game; a server's row wins over it
7
8
  * chat_reports one reported message: its words, its sender's room name and (when signed in) account id, the room,
package/worker/chat.mjs CHANGED
@@ -20,6 +20,9 @@
20
20
  *
21
21
  * Emoji and quick lines are the studio's own words: they never need a review, so they reach every screen at once.
22
22
  * NOTHING is stored here: the relay keeps a short window in memory (section 19), and a report keeps only the message.
23
+ * A room keeps its words longer only when its owner turns `history` on (a number of days, 0.29.0; off for every game
24
+ * by default, never on a kids server): then the Table writes what was said, never a reaction, to the studio's own D1
25
+ * (worker/lounge-store.mjs) and forgets it after that many days.
23
26
  */
24
27
  import { WORDS_B64 } from './chat-words.mjs';
25
28
  import { clefRun, localAiOf } from './brain.mjs';
@@ -55,6 +58,8 @@ export const CHAT_LIMITS = Object.freeze({
55
58
  /** A typed message: `max` is the room's, at most this (homie.rocks's LINE_MAX). */
56
59
  text: 280, minText: 20, name: 24,
57
60
  lineText: 60, lines: 12, extraEmoji: 3, words: 64, word: 32, slowMax: 120,
61
+ /** `history`: the most days a room may keep what was said (0, the default, keeps nothing past the window). */
62
+ historyMax: 90,
58
63
  /** The room's window: the last `keep` messages from the last `keepMs` (memory only), for a page that just opened. */
59
64
  keep: 50, keepMs: 15 * 60_000,
60
65
  /** The same words from the same person within this long are not sent again. */
@@ -77,9 +82,12 @@ export const CHAT_RATES = Object.freeze({
77
82
  /** A game with no "chat" in its game.json: emoji and quick lines for everyone, typing for signed-in players (reviewed). */
78
83
  export const CHAT_DEFAULTS = Object.freeze({
79
84
  mode: 'text', who: 'signed-in', react: 'anyone', slow: 2, max: 140, links: 'block', swears: 'block', ai: true,
80
- bubbles: true, watchers: true, hub: true,
85
+ bubbles: true, watchers: true, hub: true, history: 0,
81
86
  });
82
87
 
88
+ /** How long a room with these rules keeps what was said: its `history` days, else the window's few minutes. */
89
+ export const windowMsOf = (rules) => (rules?.history > 0 ? rules.history * 86_400_000 : CHAT_LIMITS.keepMs);
90
+
83
91
  const ID = /^[a-z][a-z0-9_]{0,15}$/;
84
92
  const PICTO = /\p{Extended_Pictographic}/u;
85
93
  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();
@@ -151,6 +159,8 @@ export function normalizeChat(raw, { kids = false, speech = 'game' } = {}) {
151
159
  links: r.links === 'allow' ? 'allow' : 'block',
152
160
  swears: r.swears === 'allow' ? 'allow' : 'block',
153
161
  ai: bool('ai'), bubbles: bool('bubbles'), watchers: bool('watchers'), hub: bool('hub'),
162
+ // Kept words (0.29.0): only when the owner asks, never on a kids server or a capped one, never with chat off.
163
+ history: kids || capped || mode === 'off' ? 0 : int(r.history, 0, CHAT_LIMITS.historyMax, CHAT_DEFAULTS.history),
154
164
  emoji: [...REACTIONS.map((x) => ({ ...x })), ...extraEmoji(r.emoji)],
155
165
  lines: quickLines(r.lines),
156
166
  block: wordList(r.block), allow: wordList(r.allow),
@@ -191,6 +201,7 @@ export function checkChatRules(body) {
191
201
  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]; }
192
202
  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; }
193
203
  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; }
204
+ if (b.history !== undefined) { const n = Math.floor(Number(b.history)); if (!Number.isFinite(n) || n < 0 || n > CHAT_LIMITS.historyMax) return bad(`history is 0 (keep nothing) to ${CHAT_LIMITS.historyMax} days`); f.history = n; }
194
205
  if (b.links !== undefined) { if (!['block', 'allow'].includes(b.links)) return bad('links is block or allow'); f.links = b.links; }
195
206
  if (b.swears !== undefined) { if (!['block', 'allow'].includes(b.swears)) return bad('swears is block or allow'); f.swears = b.swears; }
196
207
  for (const k of ['ai', 'bubbles', 'watchers', 'hub']) if (b[k] !== undefined) f[k] = b[k] === true || b[k] === 'on' || b[k] === 'true';
@@ -208,6 +219,7 @@ export function chatProblems(raw) {
208
219
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return ['"chat" is an object (or false for no chat)'];
209
220
  const out = [];
210
221
  if (raw.mode !== undefined && !CHAT_MODES.includes(raw.mode)) out.push('chat.mode is off, emoji, lines or text');
222
+ if (raw.history !== undefined && !(Number.isInteger(raw.history) && raw.history >= 0 && raw.history <= CHAT_LIMITS.historyMax)) out.push(`chat.history is a number of days, 0 to ${CHAT_LIMITS.historyMax} (0 keeps nothing past the room's few minutes)`);
211
223
  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`);
212
224
  // Where chat sits on the screen is the page's (game.json "screen": { "chat" }), never the room's rules.
213
225
  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": … } }`);
@@ -67,6 +67,8 @@ export function robotsTxt(cat, origin, { preview = false } = {}) {
67
67
  'Disallow: /shop/thanks',
68
68
  'Disallow: /shop/parent/',
69
69
  'Disallow: /__homie',
70
+ // The Lounge's socket and APIs (0.29.0); its page may be crawled.
71
+ ...(cat.studio?.lounge ? ['Disallow: /lounge/api/', 'Disallow: /lounge/__'] : []),
70
72
  ...games.flatMap((id) => [`Disallow: /${id}/play`, `Disallow: /${id}/tv`, `Disallow: /${id}/watch`, `Disallow: /${id}/live`, `Disallow: /${id}/invite`, `Disallow: /${id}/api/`, `Disallow: /${id}/__`, `Disallow: /${id}/s/*/play`, `Disallow: /${id}/s/*/tv`, `Disallow: /${id}/s/*/home`]),
71
73
  '',
72
74
  `Sitemap: ${origin}/sitemap.xml`,
@@ -96,6 +98,8 @@ export function sitemapEntries(cat, { all = null } = {}) {
96
98
  if (songs.length) { add('/music/', newest(songs.map(mediaDate))); for (const e of songs) add(`/music/${e.slug}/`, mediaDate(e)); }
97
99
  if (videos.length) { add('/videos/', newest(videos.map(mediaDate))); for (const e of videos) add(`/videos/${e.slug}/`, mediaDate(e)); }
98
100
  if (posts.length) { add('/posts/', newest(posts.map(postDate))); for (const p of posts) add(`/posts/${p.slug}/`, postDate(p)); }
101
+ // The Lounge (0.29.0), when studio.json turns it on: its page (what is said there is not in the page).
102
+ if (cat.studio?.lounge) add('/lounge/');
99
103
  for (const p of ownPages(cat, all)) add(p);
100
104
  return out;
101
105
  }
@@ -210,6 +214,7 @@ export function llmsTxt(cat, origin, { remixable = () => false, directory = 'htt
210
214
  }
211
215
  out.push('', '## Optional', '');
212
216
  if (games.length) out.push(`- [Rooms](${origin}/rooms/): every public room playing now (${origin}/api/rooms is the same as JSON)`);
217
+ if (cat.studio?.lounge) out.push(`- [${label(cat.studio.lounge.name ?? 'The Lounge')}](${origin}/lounge/): the studio's community room: play nights, live rooms and chat (${origin}/lounge/api/now is its play nights and rules as JSON; what people say there is not published)`);
213
218
  out.push(`- [Studio manifest](${origin}/.well-known/homie-studio.json): the games, songs, videos and posts as JSON`);
214
219
  out.push(`- [Sitemap](${origin}/sitemap.xml): every public page`);
215
220
  if (!full) out.push(`- [Everything, in full](${origin}/llms-full.txt): each game's whole description, how to play and credits, and every post's text`);
package/worker/index.mjs CHANGED
@@ -92,6 +92,8 @@ import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts
92
92
  import { CLEF, DECIDE, clefRun, costOf, localAiOf, picksOf, talks } from './brain.mjs';
93
93
  import { REACTIONS, reviewChat } from './chat.mjs';
94
94
  import { REPORT_REASONS, chatDay, chatOf, chatRowsOf, fileReport } from './chat-store.mjs';
95
+ import { forgetLines, historyOf, keepLines, keptLine } from './lounge-store.mjs';
96
+ import { isLoungePath, loungeRoutes } from './lounge.mjs';
95
97
  import { doorPage, serverPage, serversPage } from './site.mjs';
96
98
  import { isLocalOrigin, qrSvg } from './qr.mjs';
97
99
  import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
@@ -587,7 +589,7 @@ async function route(request, env, ctx) {
587
589
  const getAll = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
588
590
  const getCat = () => (pubP ??= Promise.all([getAll(), settingsOf(env)]).then(([cat, settings]) => publicCatalogue(cat, settings, env)));
589
591
 
590
- // "Connect to Claude" (the template's first-run band): the directory's setup page, for this site's address.
592
+ // "Connect this chat" (the template's first-run band): the directory's setup page, for this site's address.
591
593
  if (path === '/_studio/connect' && read) {
592
594
  const directory = directoryOf(await getCat());
593
595
  if (!directory || env.HOMIE_PREVIEW === '1') return json({ ok: false, error: 'no-directory', message: 'This studio is not connected to a directory.' }, 404);
@@ -601,6 +603,11 @@ async function route(request, env, ctx) {
601
603
  if (shopped) return shopped;
602
604
  }
603
605
  if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
606
+ // The Lounge (0.29.0, chat/LOUNGE.md): the studio's own community room, when studio.json turns it on.
607
+ if (isLoungePath(path)) {
608
+ const lounged = await loungeRoutes(request, env, ctx, url, { catalogueOf: getCat, roomsOf });
609
+ if (lounged) return lounged;
610
+ }
604
611
  if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
605
612
  if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
606
613
  if (path === '/api/stats') {
@@ -658,6 +665,8 @@ async function route(request, env, ctx) {
658
665
  // The studio's posts, for the hub (the full text is in /posts/feed.json).
659
666
  posts: (cat.posts ?? []).slice(0, 20).map((p) => ({ slug: p.slug, title: p.title, date: p.date, summary: p.summary, page: `${url.origin}/posts/${p.slug}/`, image: p.image ? (p.image.startsWith('/') ? `${url.origin}${p.image}` : p.image) : null, links: p.links ?? {} })),
660
667
  ...(shareRooms ? { rooms: `${url.origin}/api/rooms` } : {}),
668
+ // The studio's Lounge (0.29.0), for a directory that shows lounges: its page and its public facts.
669
+ ...(cat.studio?.lounge ? { lounge: { name: cat.studio.lounge.name, page: `${url.origin}/lounge/`, now: `${url.origin}/lounge/api/now` } } : {}),
661
670
  // Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
662
671
  ...(played ? { played } : {}),
663
672
  // The shop (0.24.0): whether it sells and its till, never a key or a sale; and referrals: this studio takes
@@ -1200,11 +1209,30 @@ export class Table {
1200
1209
  this.vocabRead = this.readVocab(game).catch(() => false);
1201
1210
  // Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
1202
1211
  this.room.review = (text, opts) => this.reviewChat(text, opts);
1212
+ // Kept chat (0.29.0): a room whose rules keep `history` writes what was said to D1 and deletes what is taken down,
1213
+ // one after another (a line removed the moment it went out is never written after its delete).
1214
+ this.room.onPublished = (rec) => this.keptQueue(() => keepLines(this.env, this.game, this.code, [rec]));
1215
+ this.room.onUnsaid = (ids) => this.keptQueue(() => forgetLines(this.env, this.game, this.code, ids));
1203
1216
  // A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
1204
1217
  this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
1205
1218
  return this.room;
1206
1219
  }
1207
1220
 
1221
+ /** Kept chat's writes, in order, each finished before the next (D1 calls from one room never pass each other). */
1222
+ keptQueue(fn) {
1223
+ if (!this.env.DB) return;
1224
+ this.kept = (this.kept ?? Promise.resolve()).then(fn).catch((error) => { try { console.log(JSON.stringify({ ev: 'chat-history-failed', room: this.code, error: String(error?.message ?? error).slice(0, 200) })); } catch { /* no console */ } });
1225
+ this.ctx.waitUntil(this.kept);
1226
+ }
1227
+
1228
+ /** A room that keeps history puts what it kept back in its window before its first page opens (once per opening). */
1229
+ async hydrateHistory(room) {
1230
+ const days = room.chatRules().history;
1231
+ if (!(days > 0) || room.hydrated || !this.env.DB) return;
1232
+ room.hydrated = true;
1233
+ try { await this.kept; room.hydrate(await historyOf(this.env, this.game, this.code, { days })); } catch { /* the window alone (before migration 0009) */ }
1234
+ }
1235
+
1208
1236
  /** Whether this game asked for decisions (game.json "decide": true, in the built catalogue), read once a minute. */
1209
1237
  async decideOn() {
1210
1238
  if (this.decideRead && Date.now() - this.decideRead.at < 60_000) return this.decideRead.on;
@@ -1411,14 +1439,16 @@ export class Table {
1411
1439
  // A chat line as the room keeps it (section 19), for a report: the Worker files the room's own copy, never a reporter's.
1412
1440
  if (url.pathname === '/__chat') {
1413
1441
  const id = url.searchParams.get('id');
1414
- const r = room.chatLog.find((x) => x.id === id);
1415
- return json({ ok: Boolean(r), line: r ? { id: r.id, kind: r.kind, text: r.text ?? null, glyph: r.glyph ?? null, name: r.name, seat: r.seat, at: r.at, by: r.by, player: r.from?.player ?? null } : null });
1442
+ // A line older than the window is in the room's kept history, when it keeps one (0.29.0).
1443
+ const r = room.chatLog.find((x) => x.id === id) ?? (room.chatRules().history > 0 ? await keptLine(this.env, this.game, this.code, id) : null);
1444
+ return json({ ok: Boolean(r), line: r ? { id: r.id, kind: r.kind, text: r.text ?? null, glyph: r.glyph ?? null, name: r.name, seat: r.seat, at: r.at, by: r.by, player: r.from?.player ?? null, owner: Boolean(r.owner), mod: Boolean(r.mod) } : null });
1416
1445
  }
1417
1446
  if (url.pathname === '/__office') {
1418
1447
  const ctl = await request.json().catch(() => null);
1419
1448
  const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
1420
1449
  if (why) return json({ ok: false, error: 'refused', why }, 403);
1421
- const res = room.control(ctl.op, ctl.args && typeof ctl.args === 'object' ? ctl.args : {});
1450
+ // A Lounge card waits for the studio's review (0.29.0); every other control answers at once.
1451
+ const res = await room.control(ctl.op, ctl.args && typeof ctl.args === 'object' ? ctl.args : {});
1422
1452
  // AI talk turned off (a policy) or a guide kicked: the house guides follow at once.
1423
1453
  if (this.house) this.house.sync();
1424
1454
  await this.ctx.storage.put('office', room.officeSaved()).catch(() => {});
@@ -1436,6 +1466,8 @@ export class Table {
1436
1466
  if (pol) room.setPolicy(pol);
1437
1467
  if (this.house) this.house.sync();
1438
1468
  const agent = decodeFacts(url.searchParams.get('ag'));
1469
+ // Kept chat (0.29.0): the room's history is back in its window before this page hears it.
1470
+ await this.hydrateHistory(room);
1439
1471
  const [client, server] = Object.values(new WebSocketPair());
1440
1472
  server.accept();
1441
1473
  const via = url.searchParams.get('via');
@@ -1459,6 +1491,9 @@ export class Table {
1459
1491
  acct: url.searchParams.get('acct') === '1',
1460
1492
  member: url.searchParams.get('mem') === '1',
1461
1493
  hub: url.pathname === '/__watch' && url.searchParams.get('hub') === '1',
1494
+ // The Lounge (0.29.0): the signed-in account's name and a moderator's mark, the Worker's word on a watch socket.
1495
+ ...(url.pathname === '/__watch' && url.searchParams.get('nm') ? { name: String(url.searchParams.get('nm')).slice(0, 40) } : {}),
1496
+ ...(url.pathname === '/__watch' && url.searchParams.get('mod') === '1' ? { mod: true } : {}),
1462
1497
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
1463
1498
  close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
1464
1499
  buffered: () => 0,