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 +21 -0
- package/README.md +274 -0
- package/bin/walla.mjs +519 -0
- package/llms.txt +104 -0
- package/package.json +43 -0
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
|
+
}
|