pi-roundtable-web 0.22.5 → 0.23.1

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +53 -1
  2. package/README.md +444 -104
  3. package/assets/icons/192.png +0 -0
  4. package/assets/icons/512.png +0 -0
  5. package/assets/icons/apple-180.png +0 -0
  6. package/assets/icons/badge-96.png +0 -0
  7. package/assets/icons/mark-maskable.svg +1 -0
  8. package/assets/icons/mark.svg +1 -0
  9. package/assets/icons/maskable-512.png +0 -0
  10. package/dist/chunk-character-creator-660d2t6p.js +1 -0
  11. package/dist/chunk-connectors-d19v8cjc.js +1 -0
  12. package/dist/chunk-conversations-4cmexmqq.js +1 -0
  13. package/dist/chunk-entry-8xpx35ef.js +1 -0
  14. package/dist/chunk-index-19g8jmr8.js +2 -0
  15. package/dist/chunk-index-362dfedj.js +1 -0
  16. package/dist/chunk-index-6g69tqt0.js +47 -0
  17. package/dist/chunk-index-89q2qdkj.css +1 -0
  18. package/dist/chunk-index-9tvf6es7.js +41 -0
  19. package/dist/chunk-index-p8h0e861.js +2 -0
  20. package/dist/chunk-index-xw5hhmfq.js +2 -0
  21. package/dist/chunk-notes-fefwds14.js +1 -0
  22. package/dist/chunk-overview-bxpwpmn6.js +1 -0
  23. package/dist/chunk-skills-pce3zgxy.js +1 -0
  24. package/dist/chunk-table-zmpbrfvz.js +1 -0
  25. package/dist/chunk-table2d-ac2gez71.js +1 -0
  26. package/dist/chunk-table3d-g258jemr.js +4229 -0
  27. package/dist/chunk-transcript-yhtjp3pd.js +1 -0
  28. package/dist/chunk-zh-tw-anqhqpaz.js +1 -0
  29. package/dist/index.html +6 -2
  30. package/dist/sw.js +1 -0
  31. package/package.json +8 -3
  32. package/src/api-types.ts +448 -1
  33. package/src/app/app.ts +351 -0
  34. package/src/app/card-log.ts +119 -0
  35. package/src/app/desk.ts +533 -0
  36. package/src/app/files.ts +262 -0
  37. package/src/app/frames.ts +132 -0
  38. package/src/app/multipart.ts +172 -0
  39. package/src/app/presence.ts +73 -0
  40. package/src/app/protocol.ts +144 -0
  41. package/src/app/room-log.ts +137 -0
  42. package/src/app/rooms.ts +189 -0
  43. package/src/app/routes.ts +434 -0
  44. package/src/app/socket.ts +406 -0
  45. package/src/app/sockets.ts +84 -0
  46. package/src/app/surface-threads.ts +92 -0
  47. package/src/app/surface.ts +384 -0
  48. package/src/app/threads.ts +99 -0
  49. package/src/app/uploads.ts +363 -0
  50. package/src/assets.ts +1 -3
  51. package/src/auth/authenticator.ts +167 -0
  52. package/src/auth/login-page.ts +44 -0
  53. package/src/auth/none.ts +38 -0
  54. package/src/auth/password.ts +250 -0
  55. package/src/auth/session-key.ts +65 -0
  56. package/src/cleanup-api.ts +1 -1
  57. package/src/cloudflare-access.ts +5 -2
  58. package/src/console-api.ts +13 -15
  59. package/src/console-server.ts +77 -49
  60. package/src/features.ts +6 -1
  61. package/src/http.ts +8 -0
  62. package/src/i18n/index.ts +13 -0
  63. package/src/i18n/locale.ts +11 -0
  64. package/src/i18n/zh-tw.ts +696 -0
  65. package/src/index.ts +1 -1
  66. package/src/options.ts +308 -12
  67. package/src/page-colors.ts +9 -0
  68. package/src/page-presentation.ts +17 -12
  69. package/src/push/api.ts +190 -0
  70. package/src/push/channel.ts +50 -0
  71. package/src/push/encrypt.ts +136 -0
  72. package/src/push/endpoint.ts +54 -0
  73. package/src/push/sender.ts +109 -0
  74. package/src/push/subscriptions.ts +140 -0
  75. package/src/push/test-fetch.ts +17 -0
  76. package/src/push/triggers.ts +155 -0
  77. package/src/push/vapid.ts +124 -0
  78. package/src/pwa.ts +130 -0
  79. package/src/request-body.ts +20 -0
  80. package/src/verifier.ts +7 -1
  81. package/src/web-plugin.ts +123 -11
  82. package/dist/chunk-82frzte9.js +0 -36
  83. package/dist/chunk-e8z9q554.css +0 -1
@@ -0,0 +1,384 @@
1
+ import {
2
+ type AttachmentContent,
3
+ type AttachmentRef,
4
+ type ChannelKey,
5
+ type ChatSurface,
6
+ type InboundMessage,
7
+ type InterimPosts,
8
+ type Logger,
9
+ type OutboundReply,
10
+ PluginError,
11
+ type PostAuthor,
12
+ type PromptScope,
13
+ type Prompts,
14
+ roomOf,
15
+ type TurnProgress,
16
+ } from "pi-roundtable";
17
+ import type { RoomFile, RoomMessage } from "../api-types.ts";
18
+ import type { OwnerDesk } from "./desk.ts";
19
+ import { type FileDir, typeByName } from "./files.ts";
20
+ import type { NewRoomMessage, RoomLog } from "./room-log.ts";
21
+ import type { AppSocket, Sockets } from "./sockets.ts";
22
+ import { AppSurfaceThreads } from "./surface-threads.ts";
23
+ import type { WebThreads } from "./threads.ts";
24
+ import type { Uploads } from "./uploads.ts";
25
+
26
+ /**
27
+ * What the model is told about the app's rendering, as the surface's `formatting`. English: it is
28
+ * model-facing text, not a message-catalog string.
29
+ */
30
+ const APP_FORMATTING =
31
+ "Reply formatting (web app): Markdown renders, including tables, headings, lists, quotes, links, inline code and fenced code blocks. Raw HTML does not render. Files you attach appear under your message.";
32
+
33
+ /** The name a reply shows when its sender names none. */
34
+ const ASSISTANT = "Assistant";
35
+
36
+ export interface AppSurfaceOptions {
37
+ /** The key prefix of the rooms, such as `app`. */
38
+ surface: string;
39
+ sockets: Sockets;
40
+ desk: OwnerDesk;
41
+ log: RoomLog;
42
+ /** Where the files agents attach to replies are kept. */
43
+ files: FileDir;
44
+ uploads: Uploads;
45
+ threads: Pick<WebThreads, "open" | "close">;
46
+ /** Whether a room can host a thread: an agent's or group's room that exists. */
47
+ canHost(parent: ChannelKey): Promise<boolean>;
48
+ /** Tells the sockets the room list differs, once changes settle. */
49
+ changed(): void;
50
+ /**
51
+ * Hears of an agent's reply in a room that no message from a page started, such as a
52
+ * schedule's, a report's or a late answer's. Never throws into the turn.
53
+ */
54
+ unprompted?(room: ChannelKey, author: string, text: string): void;
55
+ /** Called when a room's activity changes, so the presence table can follow. */
56
+ activity?(): void;
57
+ /** The clock, in milliseconds; default `Date.now`. */
58
+ now?: () => number;
59
+ logger: Logger;
60
+ }
61
+
62
+ /**
63
+ * A turn that starts this soon after a page's message in its room is that message's. The core
64
+ * does not tell a surface what started a turn (`startTyping` carries only the channel), so this
65
+ * is a heuristic: a schedule's turn within the window of a page message in the same room reads as
66
+ * asked, and a slow reply is still taken for asked since its typing began in time.
67
+ */
68
+ const ASKED_WINDOW_MS = 30_000;
69
+
70
+ /** The counts and marks behind a room's `activity`. */
71
+ interface Activity {
72
+ typing: number;
73
+ stoppable: number;
74
+ /** The tool calls that have started and not ended. */
75
+ tools: Set<string>;
76
+ /** The latest author of an interim text or reply: the member speaking in a group round. */
77
+ agent?: string;
78
+ /** How the turn's last reply went, once it posted one. */
79
+ outcome?: "done" | "failed";
80
+ }
81
+
82
+ /** What a room's `activity` says apart from its counts. */
83
+ export interface RoomActivity {
84
+ typing: boolean;
85
+ stoppable: boolean;
86
+ phase?: "thinking" | "working";
87
+ agent?: string;
88
+ }
89
+
90
+ /**
91
+ * The owner app's rooms as a chat surface: what the host shows in a room reaches every socket of
92
+ * every owner as a frame, and what a room says is stored in the room log. The agent team runs
93
+ * in the rooms (`agentRooms`) as it runs in its Discord channels, but each room is a
94
+ * conversation of its own. A reply arrives whole, in Markdown, with its files stored, and an
95
+ * agent's interim text is shown live until the final reply replaces it.
96
+ */
97
+ export class AppSurface implements ChatSurface {
98
+ readonly surface: string;
99
+ readonly agentRooms = true;
100
+ readonly supportsFiles = true;
101
+ readonly formatting = APP_FORMATTING;
102
+ /** The turn takes an upload up to the most the app lets a person upload. */
103
+ readonly maxAttachmentBytes: number;
104
+ readonly threads: AppSurfaceThreads;
105
+ readonly #options: AppSurfaceOptions;
106
+ readonly #activity = new Map<ChannelKey, Activity>();
107
+ /** When a page last sent a message to each room. */
108
+ readonly #askedAt = new Map<ChannelKey, number>();
109
+ /** The turns running in each room that a page's message started. */
110
+ readonly #asked = new Map<ChannelKey, number>();
111
+ #deliver: ((message: InboundMessage) => void) | undefined;
112
+
113
+ constructor(options: AppSurfaceOptions) {
114
+ this.surface = options.surface;
115
+ this.maxAttachmentBytes = options.uploads.maxBytes;
116
+ this.#options = options;
117
+ this.threads = new AppSurfaceThreads({
118
+ surface: options.surface,
119
+ threads: options.threads,
120
+ publish: (row) => this.publish(row),
121
+ edit: (room, id, text) => this.edit(room, id, text),
122
+ changed: options.changed,
123
+ canHost: options.canHost,
124
+ });
125
+ }
126
+
127
+ async start(deliver: (message: InboundMessage) => void): Promise<void> {
128
+ this.#deliver = deliver;
129
+ }
130
+
131
+ async stop(): Promise<void> {
132
+ this.#deliver = undefined;
133
+ }
134
+
135
+ /** Hands a message to the host; throws before the host started the surface. */
136
+ deliver(message: InboundMessage): void {
137
+ if (!this.#deliver)
138
+ throw new PluginError(
139
+ "the web app surface has not started; messages are taken once the host runs",
140
+ );
141
+ this.#noteAsked(message.channel);
142
+ this.#deliver(message);
143
+ }
144
+
145
+ /** Whether the host started the surface, so messages are taken. */
146
+ get started(): boolean {
147
+ return this.#deliver !== undefined;
148
+ }
149
+
150
+ /** Stores a message of a room and shows it to every open socket, except `except`. */
151
+ async publish(row: NewRoomMessage, except?: AppSocket): Promise<RoomMessage> {
152
+ const message = await this.#options.log.append(row);
153
+ this.#options.sockets.broadcast(
154
+ { type: "message", room: row.room, message },
155
+ undefined,
156
+ except,
157
+ );
158
+ return message;
159
+ }
160
+
161
+ /** Changes the text of a stored message and shows the change to every open socket. */
162
+ async edit(room: ChannelKey, id: number, text: string): Promise<void> {
163
+ const message = await this.#options.log.edit(room, id, text);
164
+ if (!message) return;
165
+ this.#options.sockets.broadcast({
166
+ type: "edited",
167
+ room: message.room,
168
+ message,
169
+ });
170
+ }
171
+
172
+ /** Whether a turn works in the room and whether Stop applies, as `activity` frames say. */
173
+ activityOf(room: ChannelKey): RoomActivity {
174
+ const found = this.#activity.get(room);
175
+ const typing = (found?.typing ?? 0) > 0;
176
+ return {
177
+ typing,
178
+ stoppable: (found?.stoppable ?? 0) > 0,
179
+ ...(typing ? { phase: found?.tools.size ? "working" : "thinking" } : {}),
180
+ ...(typing && found?.agent ? { agent: found.agent } : {}),
181
+ };
182
+ }
183
+
184
+ /** The agents with a turn working in an app room now: a room's own agent, or a group's speaker. */
185
+ workingAgents(): Set<string> {
186
+ const names = new Set<string>();
187
+ for (const [room, entry] of this.#activity) {
188
+ if (entry.typing === 0) continue;
189
+ const named = roomOf(room, [this.surface]);
190
+ if (named?.kind === "agent") names.add(named.name);
191
+ if (entry.agent) names.add(entry.agent);
192
+ }
193
+ return names;
194
+ }
195
+
196
+ #entry(room: ChannelKey): Activity {
197
+ const found = this.#activity.get(room);
198
+ if (found) return found;
199
+ const entry: Activity = { typing: 0, stoppable: 0, tools: new Set() };
200
+ this.#activity.set(room, entry);
201
+ return entry;
202
+ }
203
+
204
+ #count(room: ChannelKey, key: "typing" | "stoppable"): () => void {
205
+ const entry = this.#entry(room);
206
+ // A new turn forgets how the last one ended.
207
+ if (key === "typing" && entry.typing === 0) {
208
+ delete entry.outcome;
209
+ delete entry.agent;
210
+ }
211
+ entry[key] += 1;
212
+ this.#announce(room);
213
+ let on = true;
214
+ return () => {
215
+ if (!on) return;
216
+ on = false;
217
+ entry[key] -= 1;
218
+ const idle = entry.typing === 0;
219
+ if (idle) entry.tools.clear();
220
+ // The outcome rides the frame on which typing turns false, and is read before the entry goes.
221
+ const outcome = key === "typing" && idle ? entry.outcome : undefined;
222
+ if (idle && entry.stoppable === 0) this.#activity.delete(room);
223
+ this.#announce(room, outcome);
224
+ };
225
+ }
226
+
227
+ #announce(room: ChannelKey, outcome?: "done" | "failed"): void {
228
+ this.#options.sockets.broadcast({
229
+ type: "activity",
230
+ room,
231
+ ...this.activityOf(room),
232
+ ...(outcome ? { outcome } : {}),
233
+ });
234
+ this.#options.activity?.();
235
+ }
236
+
237
+ /** Names the member speaking in the room; a changed speaker is shown at once. */
238
+ #speaks(room: ChannelKey, agent: string | undefined): void {
239
+ const entry = this.#activity.get(room);
240
+ if (!entry || entry.typing === 0 || !agent || entry.agent === agent) return;
241
+ entry.agent = agent;
242
+ this.#announce(room);
243
+ }
244
+
245
+ startTyping(channel: ChannelKey): () => void {
246
+ const stop = this.#count(channel, "typing");
247
+ const askedAt = this.#askedAt.get(channel);
248
+ if (askedAt === undefined) return stop;
249
+ if (this.#now() - askedAt > ASKED_WINDOW_MS) {
250
+ this.#askedAt.delete(channel);
251
+ return stop;
252
+ }
253
+ this.#asked.set(channel, (this.#asked.get(channel) ?? 0) + 1);
254
+ let on = true;
255
+ return () => {
256
+ stop();
257
+ if (!on) return;
258
+ on = false;
259
+ const left = (this.#asked.get(channel) ?? 1) - 1;
260
+ if (left > 0) this.#asked.set(channel, left);
261
+ else this.#asked.delete(channel);
262
+ };
263
+ }
264
+
265
+ /** Notes a page's message, and forgets the rooms whose last message is past the window. */
266
+ #noteAsked(channel: ChannelKey): void {
267
+ const now = this.#now();
268
+ for (const [room, at] of this.#askedAt)
269
+ if (now - at > ASKED_WINDOW_MS) this.#askedAt.delete(room);
270
+ this.#askedAt.set(channel, now);
271
+ }
272
+
273
+ #now(): number {
274
+ return (this.#options.now ?? Date.now)();
275
+ }
276
+
277
+ showStop(channel: ChannelKey): () => void {
278
+ return this.#count(channel, "stoppable");
279
+ }
280
+
281
+ async sendReply(channel: ChannelKey, reply: OutboundReply): Promise<void> {
282
+ this.#speaks(channel, reply.author?.agent);
283
+ const entry = this.#activity.get(channel);
284
+ if (entry) entry.outcome = reply.failed ? "failed" : "done";
285
+ const files: RoomFile[] = (reply.files ?? []).map((file) =>
286
+ this.#options.files.put(file.name, typeByName(file.name), file.data),
287
+ );
288
+ await this.publish({
289
+ room: channel,
290
+ kind: "agent",
291
+ ...(reply.author?.agent ? { agent: reply.author.agent } : {}),
292
+ author: reply.author?.name ?? ASSISTANT,
293
+ text: reply.chunks.join("\n"),
294
+ files,
295
+ });
296
+ if ((this.#asked.get(channel) ?? 0) === 0) {
297
+ try {
298
+ this.#options.unprompted?.(
299
+ channel,
300
+ reply.author?.name ?? ASSISTANT,
301
+ reply.chunks.join("\n"),
302
+ );
303
+ } catch (error) {
304
+ this.#options.logger.warn(
305
+ { room: channel, err: error },
306
+ "a reply's listener failed",
307
+ );
308
+ }
309
+ }
310
+ }
311
+
312
+ async markFresh(channel: ChannelKey): Promise<void> {
313
+ await this.publish({
314
+ room: channel,
315
+ kind: "system",
316
+ author: ASSISTANT,
317
+ text: "Conversation started over",
318
+ });
319
+ }
320
+
321
+ prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
322
+ return this.#options.desk.prompts(channel, scope);
323
+ }
324
+
325
+ interim(channel: ChannelKey, as?: PostAuthor): InterimPosts {
326
+ return {
327
+ post: async (text) => {
328
+ this.#speaks(channel, as?.agent);
329
+ const live = crypto.randomUUID();
330
+ const show = (current: string) =>
331
+ this.#options.sockets.broadcast({
332
+ type: "message",
333
+ room: channel,
334
+ message: {
335
+ id: 0,
336
+ room: channel,
337
+ kind: "agent",
338
+ ...(as?.agent ? { agent: as.agent } : {}),
339
+ author: as?.name ?? ASSISTANT,
340
+ text: current,
341
+ files: [],
342
+ at: new Date().toISOString(),
343
+ live,
344
+ },
345
+ });
346
+ show(text);
347
+ return {
348
+ edit: async (edited) => {
349
+ show(edited);
350
+ },
351
+ };
352
+ },
353
+ };
354
+ }
355
+
356
+ readAttachment(
357
+ _channel: ChannelKey,
358
+ ref: AttachmentRef,
359
+ ): Promise<AttachmentContent> {
360
+ return this.#options.uploads.read(ref);
361
+ }
362
+
363
+ progress(channel: ChannelKey, event: TurnProgress): void {
364
+ this.#toolEvent(channel, event);
365
+ this.#options.sockets.broadcast(
366
+ { type: "progress", room: channel, event },
367
+ (connection) => connection.focus === channel,
368
+ );
369
+ }
370
+
371
+ /** A tool call that starts moves the room to `working`; the last one that ends moves it back. */
372
+ #toolEvent(channel: ChannelKey, event: TurnProgress): void {
373
+ const entry = this.#activity.get(channel);
374
+ if (!entry || entry.typing === 0) return;
375
+ if (event.type === "tool_start") {
376
+ const was = entry.tools.size;
377
+ entry.tools.add(event.id);
378
+ if (was === 0) this.#announce(channel);
379
+ } else if (event.type === "tool_end") {
380
+ if (entry.tools.delete(event.id) && entry.tools.size === 0)
381
+ this.#announce(channel);
382
+ }
383
+ }
384
+ }
@@ -0,0 +1,99 @@
1
+ import type { SQL } from "bun";
2
+ import type { Migration } from "pi-roundtable";
3
+
4
+ /** A dispatch thread of the app: a read-only room that holds one exchange between agents. */
5
+ export interface WebThread {
6
+ /** `thread.<uuid>`; the room key is `<surface>:<id>`. */
7
+ id: string;
8
+ parent: string;
9
+ title: string;
10
+ open: boolean;
11
+ createdAt: Date;
12
+ }
13
+
14
+ interface Row {
15
+ id: string;
16
+ parent: string;
17
+ title: string;
18
+ open: boolean;
19
+ created_at: Date;
20
+ }
21
+
22
+ const threadOf = (row: Row): WebThread => ({
23
+ id: row.id,
24
+ parent: row.parent,
25
+ title: row.title,
26
+ open: row.open,
27
+ createdAt: row.created_at,
28
+ });
29
+
30
+ /** The most closed threads the room list still shows. */
31
+ const CLOSED_LISTED = 30;
32
+
33
+ /** The most characters of a thread's title. */
34
+ const TITLE_CHARS = 80;
35
+
36
+ /**
37
+ * The threads of the app in `web_threads`. A thread is a room the page can read and nobody can
38
+ * write in. `open` makes one under a parent room and returns its id; `close` ends it, and a
39
+ * closed thread stays readable. Posting into a thread and showing it in the page are the app's;
40
+ * this is only the record.
41
+ */
42
+ export class WebThreads {
43
+ readonly #sql: SQL;
44
+
45
+ constructor(sql: SQL) {
46
+ this.#sql = sql;
47
+ }
48
+
49
+ static readonly migration: Migration = {
50
+ name: "web-threads",
51
+ up: async (sql) => {
52
+ await sql`
53
+ CREATE TABLE IF NOT EXISTS web_threads (
54
+ id text PRIMARY KEY,
55
+ parent text NOT NULL,
56
+ title text NOT NULL,
57
+ open boolean NOT NULL DEFAULT true,
58
+ created_at timestamptz NOT NULL DEFAULT now()
59
+ )`;
60
+ },
61
+ };
62
+
63
+ /** Starts a thread under `parent`. */
64
+ async open(parent: string, title: string): Promise<WebThread> {
65
+ const id = `thread.${crypto.randomUUID()}`;
66
+ const cut =
67
+ Array.from(title.trim()).slice(0, TITLE_CHARS).join("") || "Thread";
68
+ const [row]: Row[] = await this.#sql`
69
+ INSERT INTO web_threads (id, parent, title) VALUES (${id}, ${parent}, ${cut})
70
+ RETURNING *`;
71
+ if (!row) throw new Error("the thread was not stored");
72
+ return threadOf(row);
73
+ }
74
+
75
+ async close(id: string): Promise<void> {
76
+ await this.#sql`UPDATE web_threads SET open = false WHERE id = ${id}`;
77
+ }
78
+
79
+ async get(id: string): Promise<WebThread | undefined> {
80
+ const [row]: Row[] = await this
81
+ .#sql`SELECT * FROM web_threads WHERE id = ${id}`;
82
+ return row && threadOf(row);
83
+ }
84
+
85
+ /**
86
+ * The threads the room list shows, newest first: every open one, and the `closedLimit` newest
87
+ * closed ones, which stay readable but are not kept in view for ever.
88
+ */
89
+ async list(closedLimit = CLOSED_LISTED): Promise<WebThread[]> {
90
+ const rows: Row[] = await this.#sql`
91
+ SELECT * FROM (
92
+ SELECT * FROM web_threads WHERE open
93
+ UNION ALL
94
+ (SELECT * FROM web_threads WHERE NOT open
95
+ ORDER BY created_at DESC, id LIMIT ${closedLimit})
96
+ ) shown ORDER BY created_at DESC, id`;
97
+ return rows.map(threadOf);
98
+ }
99
+ }