walla-page 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,274 @@
1
+ # Walla Page
2
+
3
+ Walla Page is a CLI-first wall runtime for agents.
4
+
5
+ One room maps to one Cloudflare Durable Object. A controller (CLI/API/agent) schedules scenes and speech. Display clients subscribe over WebSocket and render the live state.
6
+
7
+ ## Package Manager
8
+
9
+ This project uses `npm`.
10
+
11
+ - Install dependencies with `npm install`
12
+ - Run the CLI locally with `npm run walla -- ...`
13
+ - Publish the package to npm as `wall-of-page`
14
+
15
+ Keeping one lockfile is simpler for contributors, and `npm` is the most common default in open source JavaScript projects.
16
+
17
+ ## Install
18
+
19
+ The published npm package is just the Node CLI. Installing it does not require Wrangler.
20
+
21
+ For CLI users:
22
+
23
+ ```bash
24
+ npm install -g wall-of-page
25
+ walla help
26
+ ```
27
+
28
+ For local development in this repo:
29
+
30
+ ```bash
31
+ npm install
32
+ ```
33
+
34
+ Wrangler is only needed if you are developing or deploying the Cloudflare worker from this repository.
35
+
36
+ ## Contributing
37
+
38
+ ```bash
39
+ npm install
40
+ npm run walla -- help
41
+ ```
42
+
43
+ ## Scope (Current Build)
44
+
45
+ - Control plane is CLI/API only
46
+ - Display UI is receiver-only (no room creation web app)
47
+ - No R2 dependency
48
+
49
+ This keeps the hackathon surface area small and testable.
50
+
51
+ ## Core Capabilities
52
+
53
+ - Show fullscreen HTML now (`show`)
54
+ - Schedule fullscreen HTML for later (`schedule`)
55
+ - Generate ElevenLabs speech and schedule it (`say`)
56
+ - Delete room state and disconnect clients (`delete`, `delete --force`)
57
+ - Rotate display access token (`display`)
58
+
59
+ ## Current Behavior Notes
60
+
61
+ - `delete --force` is the explicit “disconnect clients and remove room state” path.
62
+
63
+ ## Security Model
64
+
65
+ - Capability token auth for room operations
66
+ - Separate room-control and display token roles
67
+ - Display page requires user click (`Load Wall`) before connecting/playing audio
68
+ - Scene HTML is rendered in a sandboxed iframe (`srcdoc`)
69
+ - TTS is rejected unless at least one display websocket is connected
70
+ - TTS has server-side word limit (`TTS_MAX_WORDS`)
71
+ - CLI validates `say` word count up front (`--max-words`)
72
+
73
+ ## Room Model
74
+
75
+ One room contains:
76
+
77
+ - Current scene
78
+ - Scheduled upcoming scenes
79
+ - Pair tokens for room control and display access
80
+ - Live websocket sessions
81
+ - Room settings (for example display websocket limit)
82
+
83
+ ## Display Fanout and Limits
84
+
85
+ Displays are multi-screen by default.
86
+
87
+ - Default: unlimited display websocket connections per room
88
+ - Optional cap at room creation via `displayLimit`
89
+ - When capped and full, new display ws attempts return `429`
90
+
91
+ CLI flag:
92
+
93
+ - `walla create --display-limit <n>`
94
+ - `n >= 1` sets a cap
95
+ - `0` means unlimited
96
+
97
+ ## Alarm + Lifecycle
98
+
99
+ Each room maintains one DO alarm set to the next relevant timestamp:
100
+
101
+ - next scheduled scene start
102
+ - current scene end
103
+ - cleanup deadline
104
+
105
+ On alarm:
106
+
107
+ 1. expire finished scene
108
+ 2. activate due scenes
109
+ 3. evaluate cleanup conditions
110
+ 4. set next alarm
111
+
112
+ ## Cleanup Semantics
113
+
114
+ ### Automatic
115
+
116
+ - When last socket disconnects, room sets `idleSince` and `cleanupAt`
117
+ - Default idle TTL is 7 days (`ROOM_IDLE_TTL_MS=604800000`)
118
+ - If no sockets and no future scenes at cleanup time, room storage is deleted
119
+
120
+ ### Explicit
121
+
122
+ - `DELETE /api/rooms/:roomId`
123
+ - `walla delete [--force]`
124
+
125
+ Without `--force`, delete fails with `409` if sockets are connected.
126
+ With `--force`, all sockets are closed and room state is removed.
127
+
128
+ ## CLI
129
+
130
+ Run:
131
+
132
+ ```bash
133
+ npm run walla -- help
134
+ ```
135
+
136
+ Commands:
137
+
138
+ - `walla create [--display-limit <n>]`
139
+ - `walla delete [--force]`
140
+ - `walla display`
141
+ - `walla config`
142
+ - `walla logout`
143
+ - `walla status`
144
+ - `walla quickstart`
145
+ - `walla show <file.html> [--title <title>] [--duration <seconds>]`
146
+ - `walla schedule <file.html> --at <time> [--title <title>] [--duration <seconds>]`
147
+ - `walla say <text> [--at <time>] [--title <title>] [--duration <seconds>] [--voice-id <id>] [--max-words <n>]`
148
+
149
+ Config file:
150
+
151
+ ```text
152
+ ~/.config/wallapage/config.json
153
+ ```
154
+
155
+ ## HTTP API
156
+
157
+ Room lifecycle:
158
+
159
+ - `POST /api/rooms`
160
+ - `DELETE /api/rooms/:roomId` (`?force=1` for forced delete)
161
+
162
+ Room operations:
163
+
164
+ - `GET /api/rooms/:roomId/state`
165
+ - `POST /api/rooms/:roomId/pair`
166
+ - `POST /api/rooms/:roomId/schedule`
167
+ - `POST /api/rooms/:roomId/tts`
168
+ - `POST /api/rooms/:roomId/showcase`
169
+ - `GET /api/rooms/:roomId/ws`
170
+
171
+ Create payload:
172
+
173
+ ```json
174
+ {
175
+ "displayLimit": 0
176
+ }
177
+ ```
178
+
179
+ - `displayLimit >= 1`: cap displays
180
+ - `displayLimit = 0` or omitted: unlimited
181
+
182
+ Display route:
183
+
184
+ - `GET /rooms/:roomId/display?token=...`
185
+
186
+ Built-in audio route (allow-listed asset endpoint):
187
+
188
+ - `GET /audio/campfire.mp3`
189
+
190
+ ## Quickstart
191
+
192
+ Install and run local worker:
193
+
194
+ ```bash
195
+ npm install
196
+ npm run dev
197
+ ```
198
+
199
+ Create room (unlimited displays):
200
+
201
+ ```bash
202
+ npm run walla -- create
203
+ ```
204
+
205
+ Or create room with display cap:
206
+
207
+ ```bash
208
+ npm run walla -- create --display-limit 4
209
+ ```
210
+
211
+ Open printed display URL on wall screens and click `Load Wall`.
212
+
213
+ Drive the room:
214
+
215
+ ```bash
216
+ npm run walla -- status
217
+ npm run walla -- show ./examples/campfire.html --duration 120
218
+ npm run walla -- say "Dinner in five minutes."
219
+ npm run walla -- delete
220
+ # if displays are still connected:
221
+ npm run walla -- delete --force
222
+ ```
223
+
224
+ ## Environment
225
+
226
+ Worker vars:
227
+
228
+ - `BASE_URL`
229
+ - `ROOM_IDLE_TTL_MS`
230
+ - `CREATE_ROOM_LIMIT`
231
+ - `CREATE_ROOM_WINDOW_MS`
232
+ - `TTS_LIMIT`
233
+ - `TTS_WINDOW_MS`
234
+ - `TTS_MAX_WORDS`
235
+ - `DISPLAY_TOKEN_TTL_MS`
236
+ - `PRODUCER_TOKEN_TTL_MS`
237
+
238
+ Secrets:
239
+
240
+ - `ELEVENLABS_API_KEY` (required for `say` / `/tts`)
241
+ - `ELEVENLABS_VOICE_ID` (optional default voice)
242
+
243
+ Set secret:
244
+
245
+ ```bash
246
+ npx wrangler secret put ELEVENLABS_API_KEY
247
+ ```
248
+
249
+ Deploy:
250
+
251
+ ```bash
252
+ npm run deploy
253
+ ```
254
+
255
+ ## Testing
256
+
257
+ Typecheck:
258
+
259
+ ```bash
260
+ npm run check
261
+ ```
262
+
263
+ Smoke test:
264
+
265
+ ```bash
266
+ npm run test:smoke
267
+ ```
268
+
269
+ ## Future (Post-Hackathon)
270
+
271
+ - Multi-tenant room ownership and room listing
272
+ - PIN-based short display URLs
273
+ - Expanded allow-listed audio catalog
274
+ - More policy controls (rate/quotas by room/tenant)
package/bin/walla.mjs ADDED
@@ -0,0 +1,519 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "wallapage");
8
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
9
+ const LEGACY_CONFIG_PATH = path.join(os.homedir(), ".config", "wallapalooza", "config.json");
10
+ const DEFAULT_TTS_MAX_WORDS = 60;
11
+ const DEFAULT_SERVER = "https://wallapage.redo-page-8fa.workers.dev";
12
+
13
+ async function main() {
14
+ const args = process.argv.slice(2);
15
+ const command = args.shift();
16
+
17
+ if (!command || command === "help" || command === "--help") {
18
+ printHelp();
19
+ return;
20
+ }
21
+
22
+ try {
23
+ switch (command) {
24
+ case "create":
25
+ await createRoom(args);
26
+ return;
27
+ case "delete":
28
+ await deleteCurrentRoom(args);
29
+ return;
30
+ case "display":
31
+ await displayLink(args);
32
+ return;
33
+ case "config":
34
+ await showConfig(args);
35
+ return;
36
+ case "logout":
37
+ await clearConfig();
38
+ return;
39
+ case "status":
40
+ await status();
41
+ return;
42
+ case "quickstart":
43
+ quickstart();
44
+ return;
45
+ case "show":
46
+ await showOrSchedule("show", args);
47
+ return;
48
+ case "schedule":
49
+ await showOrSchedule("schedule", args);
50
+ return;
51
+ case "say":
52
+ await say(args);
53
+ return;
54
+ default:
55
+ throw new Error(`Unknown command: ${command}`);
56
+ }
57
+ } catch (error) {
58
+ console.error(error instanceof Error ? error.message : String(error));
59
+ process.exitCode = 1;
60
+ }
61
+ }
62
+
63
+ function printHelp() {
64
+ console.log(`Walla Page CLI
65
+
66
+ Usage:
67
+ walla create [--display-limit <n>] [--server <url>]
68
+ walla delete [--force]
69
+ walla display
70
+ walla config [--server <url>]
71
+ walla logout
72
+ walla status
73
+ walla quickstart
74
+ walla show <file.html> [--title <title>] [--duration <seconds>]
75
+ walla schedule <file.html> --at <time> [--title <title>] [--duration <seconds>]
76
+ walla say <text> [--at <time>] [--title <title>] [--duration <seconds>] [--voice-id <id>] [--max-words <n>]
77
+
78
+ Notes:
79
+ - HTML scene commands send raw HTML directly to the room runtime.
80
+ - Times can be ISO timestamps, "now", or relative values like "+5m", "+30s", "+2h".
81
+ - Set WALLA_SERVER or use --server to point the CLI at a different backend.
82
+ - The default CLI flow is create -> open display -> status/show/say. Use delete to remove a room and display to rotate the wall link.`);
83
+ }
84
+
85
+ function quickstart() {
86
+ console.log(`Wall quickstart
87
+
88
+ 1. Create a room:
89
+ walla create
90
+ walla create --keep
91
+ 2. Open the printed display link on the wall screen.
92
+ 3. Inspect the room:
93
+ walla status
94
+ 4. Generate speech:
95
+ walla say "Dinner soon."
96
+ 5. Delete room state when finished:
97
+ walla delete
98
+ walla delete --force
99
+
100
+ Notes:
101
+ - The display browser should be clicked once with "Load Wall" to unlock audio and join the live room.`);
102
+ }
103
+
104
+ async function createRoom(args) {
105
+ const flags = parseFlags(args);
106
+ const existing = await maybeCurrentRoom();
107
+ const server = resolveServer(existing, flags.server);
108
+ const displayLimit = parseDisplayLimitFlag(flags["display-limit"]);
109
+ const created = await fetchJson(`${server}/api/rooms`, {
110
+ method: "POST",
111
+ headers: { "content-type": "application/json" },
112
+ body: JSON.stringify({ displayLimit })
113
+ });
114
+ const next = await upsertRoom({
115
+ server,
116
+ roomId: created.roomId,
117
+ token: created.token ?? created.producer?.token
118
+ }, { voiceId: flags["voice-id"] || null });
119
+
120
+ console.log(`Created room ${created.roomId}`);
121
+ console.log(`Default room set to ${created.roomId}`);
122
+ if (existing && existing.roomId !== created.roomId) {
123
+ console.log(`Previous room still saved: ${existing.roomId}`);
124
+ }
125
+ if (displayLimit !== null) {
126
+ console.log(`Display WS limit: ${displayLimit}`);
127
+ } else {
128
+ console.log("Display WS limit: unlimited");
129
+ }
130
+ console.log(`Display: ${created.display.link}`);
131
+ await saveConfig(next);
132
+ }
133
+
134
+ async function displayLink() {
135
+ const room = await requireCurrentRoom();
136
+ const pair = await api(room, "POST", `/api/rooms/${room.roomId}/pair`, { role: "display" });
137
+ console.log(pair.link);
138
+ }
139
+
140
+ async function deleteCurrentRoom(args = []) {
141
+ const flags = parseFlags(args);
142
+ const force = Boolean(flags.force);
143
+ const config = await loadConfig();
144
+ if (!config) {
145
+ throw new Error("No Walla Page config saved. Run `walla create` first.");
146
+ }
147
+
148
+ const roomId = config.currentRoomId;
149
+ const room = roomId ? config.rooms?.[roomId] : null;
150
+ if (!room) {
151
+ throw new Error("No current Walla Page room configured. Run `walla create` first.");
152
+ }
153
+
154
+ const deleted = await deleteRoomByConfig(room, { force });
155
+ if (!deleted) {
156
+ if (force) {
157
+ throw new Error(`Failed to force-delete room ${room.roomId}`);
158
+ }
159
+ throw new Error(`Failed to delete room ${room.roomId}. If displays are connected, retry with --force.`);
160
+ }
161
+
162
+ delete config.rooms[room.roomId];
163
+ config.currentRoomId = Object.keys(config.rooms)[0] ?? null;
164
+
165
+ if (!config.currentRoomId) {
166
+ await rm(CONFIG_PATH, { force: true });
167
+ console.log(`Deleted room ${room.roomId}${force ? " (forced)" : ""}`);
168
+ console.log("Removed local CLI config.");
169
+ return;
170
+ }
171
+
172
+ await saveConfig(config);
173
+ console.log(`Deleted room ${room.roomId}${force ? " (forced)" : ""}`);
174
+ }
175
+
176
+ async function showConfig(args = []) {
177
+ const flags = parseFlags(args);
178
+ if (typeof flags.server === "string") {
179
+ await setCurrentRoomServer(flags.server);
180
+ return;
181
+ }
182
+ const config = await loadConfig();
183
+ if (!config) {
184
+ console.log("No Walla Page config saved.");
185
+ return;
186
+ }
187
+ console.log(JSON.stringify(config, null, 2));
188
+ }
189
+
190
+ async function clearConfig() {
191
+ await rm(CONFIG_PATH, { force: true });
192
+ console.log("Cleared Walla Page CLI config.");
193
+ }
194
+
195
+ async function status() {
196
+ const room = await requireCurrentRoom();
197
+ const snapshot = await api(room, "GET", `/api/rooms/${room.roomId}/state?token=${encodeURIComponent(room.token)}`);
198
+ console.log(JSON.stringify({
199
+ roomId: snapshot.roomId,
200
+ displayWsLimit: snapshot.displayWsLimit,
201
+ currentScene: snapshot.currentScene?.title ?? null,
202
+ upcomingScenes: snapshot.upcomingScenes.length,
203
+ sockets: snapshot.sockets
204
+ }, null, 2));
205
+ }
206
+
207
+ async function showOrSchedule(mode, args) {
208
+ const room = await requireCurrentRoom();
209
+ const flags = parseFlags(args);
210
+ const file = flags._[0];
211
+ if (!file) {
212
+ throw new Error(`walla ${mode} needs a path to an HTML file`);
213
+ }
214
+
215
+ const html = await readFile(path.resolve(file), "utf8");
216
+ const title = flags.title || path.basename(file);
217
+ const durationMs = Number(flags.duration || room.defaults.sceneDurationSeconds || 60) * 1000;
218
+ const at = mode === "show" ? (flags.at || "+3s") : flags.at;
219
+ if (!at) {
220
+ throw new Error("walla schedule needs --at");
221
+ }
222
+
223
+ const payload = {
224
+ title,
225
+ startAt: parseTime(at),
226
+ durationMs,
227
+ markup: html
228
+ };
229
+
230
+ const result = await api(room, "POST", `/api/rooms/${room.roomId}/schedule`, payload);
231
+ console.log(`Scheduled ${title}`);
232
+ console.log(JSON.stringify(result, null, 2));
233
+ }
234
+
235
+ async function say(args) {
236
+ const room = await requireCurrentRoom();
237
+ const flags = parseFlags(args);
238
+ const text = flags._.join(" ").trim();
239
+ if (!text) {
240
+ throw new Error("walla say needs text");
241
+ }
242
+ const ttsWordLimit = Number(flags["max-words"] || room.defaults.ttsMaxWords || DEFAULT_TTS_MAX_WORDS);
243
+ const wordCount = countWords(text);
244
+ if (wordCount > ttsWordLimit) {
245
+ throw new Error(`walla say text has ${wordCount} words; limit is ${ttsWordLimit}. Shorten it or pass --max-words.`);
246
+ }
247
+
248
+ const voiceId = flags["voice-id"] || room.defaults.voiceId || undefined;
249
+ const generated = await api(room, "POST", `/api/rooms/${room.roomId}/tts`, {
250
+ text,
251
+ voiceId
252
+ });
253
+
254
+ const durationMs = Number(flags.duration || room.defaults.sayDurationSeconds || 18) * 1000;
255
+ const title = flags.title || "Wall announcement";
256
+ const startAt = parseTime(flags.at || "+3s");
257
+
258
+ const payload = {
259
+ title,
260
+ startAt,
261
+ durationMs,
262
+ audioAssetKey: generated.key,
263
+ template: "poster",
264
+ templateInput: {
265
+ title,
266
+ headline: flags.headline || "Announcement",
267
+ body: text,
268
+ accent: flags.accent || "#f4a447",
269
+ kicker: flags.kicker || "WALL"
270
+ }
271
+ };
272
+
273
+ const scheduled = await api(room, "POST", `/api/rooms/${room.roomId}/schedule`, payload);
274
+ console.log(`Generated and scheduled ${generated.name}`);
275
+ console.log(JSON.stringify({
276
+ assetType: generated.key?.startsWith("data:audio/") ? "inline-audio" : "audio-asset",
277
+ schedule: scheduled.id ?? null
278
+ }, null, 2));
279
+ }
280
+
281
+ async function api(config, method, route, body) {
282
+ const response = await fetch(`${config.server}${route}`, {
283
+ method,
284
+ headers: {
285
+ authorization: `Bearer ${config.token}`,
286
+ ...(body ? { "content-type": "application/json" } : {})
287
+ },
288
+ body: body ? JSON.stringify(body) : undefined
289
+ });
290
+ const payload = await response.json().catch(() => ({}));
291
+ if (!response.ok) {
292
+ throw new Error(payload.error || `Request failed (${response.status})`);
293
+ }
294
+ return payload;
295
+ }
296
+
297
+ function parseFlags(args) {
298
+ const flags = { _: [] };
299
+ for (let i = 0; i < args.length; i += 1) {
300
+ const arg = args[i];
301
+ if (!arg.startsWith("--")) {
302
+ flags._.push(arg);
303
+ continue;
304
+ }
305
+ const key = arg.slice(2);
306
+ const next = args[i + 1];
307
+ if (!next || next.startsWith("--")) {
308
+ flags[key] = true;
309
+ continue;
310
+ }
311
+ flags[key] = next;
312
+ i += 1;
313
+ }
314
+ return flags;
315
+ }
316
+
317
+ function parseTime(value) {
318
+ if (value === "now") {
319
+ return Date.now();
320
+ }
321
+ const relative = /^([+-])(\d+)(s|m|h|d)$/i.exec(value);
322
+ if (relative) {
323
+ const amount = Number(relative[2]);
324
+ const unit = relative[3].toLowerCase();
325
+ const multipliers = {
326
+ s: 1000,
327
+ m: 60_000,
328
+ h: 3_600_000,
329
+ d: 86_400_000
330
+ };
331
+ const delta = amount * multipliers[unit];
332
+ return Date.now() + (relative[1] === "-" ? -delta : delta);
333
+ }
334
+ const parsed = new Date(value).getTime();
335
+ if (Number.isNaN(parsed)) {
336
+ throw new Error(`Invalid time value: ${value}`);
337
+ }
338
+ return parsed;
339
+ }
340
+
341
+ function normalizeServerUrl(value) {
342
+ const trimmed = value?.trim();
343
+ if (!trimmed) {
344
+ throw new Error("Server URL is required");
345
+ }
346
+ let parsed;
347
+ try {
348
+ parsed = new URL(trimmed);
349
+ } catch {
350
+ throw new Error(`Invalid server URL: ${trimmed}`);
351
+ }
352
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
353
+ throw new Error(`Invalid server URL: ${trimmed}`);
354
+ }
355
+ return parsed.toString().replace(/\/+$/, "");
356
+ }
357
+
358
+ function resolveServer(existingRoom, overrideServer) {
359
+ if (overrideServer) {
360
+ return normalizeServerUrl(overrideServer);
361
+ }
362
+ const envServer = process.env.WALLA_SERVER?.trim();
363
+ return normalizeServerUrl(envServer || existingRoom?.server || DEFAULT_SERVER);
364
+ }
365
+
366
+ async function loadConfig() {
367
+ try {
368
+ const raw = await readFile(CONFIG_PATH, "utf8");
369
+ return normalizeConfig(JSON.parse(raw));
370
+ } catch {
371
+ try {
372
+ const raw = await readFile(LEGACY_CONFIG_PATH, "utf8");
373
+ return normalizeConfig(JSON.parse(raw));
374
+ } catch {
375
+ return null;
376
+ }
377
+ }
378
+ }
379
+
380
+ async function requireCurrentRoom() {
381
+ const config = await loadConfig();
382
+ if (!config) {
383
+ throw new Error("No Walla Page config saved. Run `walla create` first.");
384
+ }
385
+ const roomId = config.currentRoomId;
386
+ const room = roomId ? config.rooms?.[roomId] : null;
387
+ if (!room) {
388
+ throw new Error("No current Walla Page room configured. Run `walla create` first.");
389
+ }
390
+ return room;
391
+ }
392
+
393
+ async function maybeCurrentRoom() {
394
+ const config = await loadConfig();
395
+ if (!config) {
396
+ return null;
397
+ }
398
+ const roomId = config.currentRoomId;
399
+ return roomId ? config.rooms?.[roomId] || null : null;
400
+ }
401
+
402
+ async function saveConfig(config) {
403
+ await mkdir(CONFIG_DIR, { recursive: true });
404
+ await writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
405
+ }
406
+
407
+ async function setCurrentRoomServer(serverUrl) {
408
+ const config = await loadConfig();
409
+ if (!config) {
410
+ throw new Error("No Walla Page config saved. Run `walla create` first.");
411
+ }
412
+ const roomId = config.currentRoomId;
413
+ const room = roomId ? config.rooms?.[roomId] : null;
414
+ if (!room) {
415
+ throw new Error("No current Walla Page room configured. Run `walla create` first.");
416
+ }
417
+ const server = normalizeServerUrl(serverUrl);
418
+ room.server = server;
419
+ await saveConfig(config);
420
+ console.log(`Current room server set to ${server}`);
421
+ }
422
+
423
+ function normalizeConfig(config) {
424
+ if (!config || typeof config !== "object") {
425
+ return null;
426
+ }
427
+
428
+ if (config.currentRoomId && config.rooms) {
429
+ return config;
430
+ }
431
+
432
+ if (config.server && config.roomId && config.token) {
433
+ const room = {
434
+ server: config.server,
435
+ roomId: config.roomId,
436
+ token: config.token,
437
+ defaults: {
438
+ sceneDurationSeconds: config.defaults?.sceneDurationSeconds || 60,
439
+ sayDurationSeconds: config.defaults?.sayDurationSeconds || 18,
440
+ template: config.defaults?.template || "poster",
441
+ voiceId: config.defaults?.voiceId || null,
442
+ ttsMaxWords: config.defaults?.ttsMaxWords || DEFAULT_TTS_MAX_WORDS
443
+ }
444
+ };
445
+ return {
446
+ currentRoomId: config.roomId,
447
+ rooms: {
448
+ [config.roomId]: room
449
+ }
450
+ };
451
+ }
452
+
453
+ return null;
454
+ }
455
+
456
+ async function upsertRoom(room, overrides = {}) {
457
+ const current = (await loadConfig()) || { currentRoomId: null, rooms: {} };
458
+ const existing = current.rooms[room.roomId];
459
+ current.rooms[room.roomId] = {
460
+ server: room.server,
461
+ roomId: room.roomId,
462
+ token: room.token,
463
+ defaults: {
464
+ sceneDurationSeconds: existing?.defaults?.sceneDurationSeconds || 60,
465
+ sayDurationSeconds: existing?.defaults?.sayDurationSeconds || 18,
466
+ template: existing?.defaults?.template || "poster",
467
+ voiceId: overrides.voiceId ?? existing?.defaults?.voiceId ?? null,
468
+ ttsMaxWords: existing?.defaults?.ttsMaxWords || DEFAULT_TTS_MAX_WORDS
469
+ }
470
+ };
471
+ current.currentRoomId = room.roomId;
472
+ return current;
473
+ }
474
+
475
+ async function deleteRoomByConfig(config, options = {}) {
476
+ const force = options.force === true;
477
+ const suffix = force ? "?force=1" : "";
478
+ try {
479
+ const response = await fetch(`${config.server}/api/rooms/${config.roomId}${suffix}`, {
480
+ method: "DELETE",
481
+ headers: {
482
+ authorization: `Bearer ${config.token}`
483
+ }
484
+ });
485
+ if (!response.ok) {
486
+ return false;
487
+ }
488
+ return true;
489
+ } catch {
490
+ return false;
491
+ }
492
+ }
493
+
494
+ async function fetchJson(url, init) {
495
+ const response = await fetch(url, init);
496
+ const payload = await response.json().catch(() => ({}));
497
+ if (!response.ok) {
498
+ throw new Error(payload.error || `Request failed (${response.status})`);
499
+ }
500
+ return payload;
501
+ }
502
+
503
+ function countWords(value) {
504
+ const trimmed = value.trim();
505
+ return trimmed ? trimmed.split(/\s+/).length : 0;
506
+ }
507
+
508
+ function parseDisplayLimitFlag(value) {
509
+ if (value === undefined || value === null || value === "") {
510
+ return null;
511
+ }
512
+ const parsed = Number(value);
513
+ if (!Number.isInteger(parsed) || parsed < 0) {
514
+ throw new Error("--display-limit must be an integer >= 0");
515
+ }
516
+ return parsed === 0 ? null : parsed;
517
+ }
518
+
519
+ main();
package/llms.txt ADDED
@@ -0,0 +1,104 @@
1
+ # Walla Page
2
+
3
+ Walla Page is a smart wall for agents.
4
+
5
+ It turns any browser-connected screen into a live surface an agent can control with HTML and audio.
6
+
7
+ ## Core Idea
8
+
9
+ Use Walla Page when an agent should communicate outside the terminal or browser tab.
10
+
11
+ Examples:
12
+ - show a progress view on a spare monitor
13
+ - speak when attention is needed
14
+ - schedule a scene for a meeting or reminder
15
+ - keep a room alive with scheduled scenes and brief speech
16
+
17
+ ## Core Actions
18
+
19
+ - `show`: display fullscreen HTML now
20
+ - `schedule`: display fullscreen HTML later
21
+ - `say`: generate speech with ElevenLabs and schedule it
22
+
23
+ ## Mental Model
24
+
25
+ - one room = one Cloudflare Durable Object
26
+ - one wall screen connects to that room
27
+ - a controller CLI, script, or agent sends updates to that room
28
+ - visuals and audio are separate primitives
29
+ - the display does not join automatically; a human starts the wall session on that screen
30
+
31
+ Cloudflare Durable Objects keep each room stateful and real time.
32
+ ElevenLabs provides voice generation for announcements.
33
+ Built-in audio assets are served by the Worker audio route.
34
+
35
+ ## Preferred Agent Workflow
36
+
37
+ 1. Create or select a room.
38
+ 2. Open the display link on the wall screen.
39
+ 3. Use `walla` or the HTTP API to send scenes and audio.
40
+ 4. Treat HTML scenes as fullscreen, glanceable, non-scroll layouts.
41
+ 5. Use audio for attention, not constant narration.
42
+
43
+ ## CLI
44
+
45
+ Main command: `walla`
46
+
47
+ Important commands:
48
+
49
+ ```bash
50
+ walla create
51
+ walla display
52
+ walla status
53
+ walla show ./scene.html
54
+ walla schedule ./scene.html --at +10m
55
+ walla say "Build finished."
56
+ walla delete
57
+ walla delete --force
58
+ walla quickstart
59
+ ```
60
+
61
+ ## Scene Guidance
62
+
63
+ Good scenes:
64
+ - fullscreen
65
+ - simple
66
+ - high contrast
67
+ - one main idea
68
+ - readable from across the room
69
+
70
+ Avoid:
71
+ - scrollable UIs
72
+ - dense dashboards with tiny text
73
+ - interactions that require keyboard or mouse
74
+ - pages that depend on app state outside the room
75
+
76
+ ## Audio Guidance
77
+
78
+ Use audio as a separate layer:
79
+ - speech can happen without replacing the current scene
80
+ - spoken messages should be brief and intentional
81
+ - short sound effects are useful for alerts and transitions
82
+ - keep spoken messages short; wall speech should be brief and intentional
83
+
84
+ ## Prompting Guidance
85
+
86
+ Good prompt examples:
87
+
88
+ - Make a beautiful ambient display for Walla Page that helps me monitor the situation.
89
+ - Build a fullscreen HTML scene for a wall display. Large type. One main action. No scrolling.
90
+ - Create a countdown scene for a meeting in 15 minutes. Add a calm, high-contrast visual treatment.
91
+ - Make a progress wall for an agent run with a strong headline, big status, and one supporting detail.
92
+ - Make a beautiful ambient display using walla.page that helps drive shareholder value. Make no mistakes.
93
+
94
+ ## Integration Guidance
95
+
96
+ Walla Page is best used from:
97
+ - a coding CLI
98
+ - an agent runtime
99
+ - a webhook or automation tool
100
+ - a small script that posts scenes and audio to the API
101
+
102
+ The browser wall is the receiver. The CLI or API is the sender.
103
+
104
+ Demo gallery pages are static examples. They do not connect to a live room.
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "walla-page",
3
+ "version": "1.0.0",
4
+ "description": "CLI for controlling Walla Page rooms from Node.",
5
+ "type": "module",
6
+ "files": [
7
+ "bin",
8
+ "README.md",
9
+ "llms.txt"
10
+ ],
11
+ "engines": {
12
+ "node": ">=18"
13
+ },
14
+ "bin": {
15
+ "walla": "./bin/walla.mjs"
16
+ },
17
+ "scripts": {
18
+ "dev": "wrangler dev",
19
+ "deploy": "wrangler deploy",
20
+ "check": "tsc --noEmit",
21
+ "walla": "node ./bin/walla.mjs",
22
+ "test:smoke": "node test/smoke.test.mjs"
23
+ },
24
+ "keywords": [
25
+ "cloudflare",
26
+ "durable-objects",
27
+ "hono",
28
+ "elevenlabs",
29
+ "wall-display"
30
+ ],
31
+ "author": "",
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "devDependencies": {
37
+ "@cloudflare/workers-types": "^4.20260329.1",
38
+ "hono": "^4.10.1",
39
+ "typescript": "^6.0.2",
40
+ "wrangler": "^4.78.0"
41
+ },
42
+ "package-lock": "false"
43
+ }