pi-roundtable-web 0.24.2 → 0.26.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.
Files changed (223) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +195 -16
  3. package/dist/account-zk4efb87.css +102 -0
  4. package/dist/chunk-account-hw49gd0d.js +1 -0
  5. package/dist/chunk-action-menu-rm5cyqkj.js +1 -0
  6. package/dist/chunk-agent-b017vswv.js +1 -0
  7. package/dist/chunk-arranged-rooms-q5v3dm1r.js +1 -0
  8. package/dist/chunk-character-creator-5dxcvd16.js +1 -0
  9. package/dist/chunk-codeeditor-pyt09fwg.js +2 -0
  10. package/dist/chunk-confirm-521yqmta.js +1 -0
  11. package/dist/chunk-connectors-hrm06nx8.js +1 -0
  12. package/dist/chunk-conversations-e26rrf01.js +1 -0
  13. package/dist/chunk-drag-v41gx2mc.js +1 -0
  14. package/dist/chunk-entry-jxxb3ath.js +1 -0
  15. package/dist/chunk-folder-list-b97xk12b.js +1 -0
  16. package/dist/chunk-folds-b8q7eags.js +1 -0
  17. package/dist/chunk-gate-8k61stsc.js +1 -0
  18. package/dist/chunk-group-scene-dx6wt19m.js +1 -0
  19. package/dist/chunk-group-table-e4jr8r3a.js +1 -0
  20. package/dist/chunk-host-state-g7e7c0k5.js +1 -0
  21. package/dist/chunk-host-ui-x7k6n07v.js +939 -0
  22. package/dist/chunk-index-014rjv9k.js +2 -0
  23. package/dist/chunk-index-2d0kg096.js +2 -0
  24. package/dist/chunk-index-2qd81wbx.js +2 -0
  25. package/dist/chunk-index-2stynxw6.css +1 -0
  26. package/dist/chunk-index-459drsft.js +2 -0
  27. package/dist/chunk-index-4mfj596y.js +2 -0
  28. package/dist/chunk-index-66nskrdj.js +47 -0
  29. package/dist/chunk-index-awx6nnta.js +2 -0
  30. package/dist/chunk-index-bbsytycb.js +2 -0
  31. package/dist/chunk-index-c07p8k0x.js +4 -0
  32. package/dist/chunk-index-cq1e9xdz.js +2 -0
  33. package/dist/chunk-index-d1prgfbp.js +2 -0
  34. package/dist/chunk-index-dhpt8qr9.js +2 -0
  35. package/dist/chunk-index-e5sd30xy.js +2 -0
  36. package/dist/chunk-index-g351921r.js +1 -0
  37. package/dist/chunk-index-hby0ap91.js +2 -0
  38. package/dist/chunk-index-hssjbr1k.js +2 -0
  39. package/dist/chunk-index-k2qq1gdj.js +2 -0
  40. package/dist/chunk-index-ke9ynm0n.js +2 -0
  41. package/dist/chunk-index-km40bhsz.js +2 -0
  42. package/dist/chunk-index-kpscvg85.js +2 -0
  43. package/dist/chunk-index-m0d100mg.js +2 -0
  44. package/dist/chunk-index-mb72snk9.js +2 -0
  45. package/dist/chunk-index-mjqqver5.js +4 -0
  46. package/dist/chunk-index-nqfkwkfp.js +2 -0
  47. package/dist/chunk-index-nwqk447w.js +2 -0
  48. package/dist/chunk-index-pcfhpm8v.js +302 -0
  49. package/dist/chunk-index-q8h8xzbm.js +2 -0
  50. package/dist/chunk-index-r5vszgfs.js +2 -0
  51. package/dist/{chunk-index-mpm90318.js → chunk-index-rhtvcbsq.js} +2 -2
  52. package/dist/chunk-index-rjv7vrwd.js +13 -0
  53. package/dist/chunk-index-v23m38p6.js +50 -0
  54. package/dist/chunk-index-va0g7d88.js +1 -0
  55. package/dist/{chunk-index-6t68gbex.js → chunk-index-wnjp6wv0.js} +2 -2
  56. package/dist/chunk-index-xgkd15n3.js +2 -0
  57. package/dist/chunk-index-y8rnq8zf.js +14 -0
  58. package/dist/chunk-index-z3k18atc.js +2 -0
  59. package/dist/chunk-lang-css-6bepqpnx.js +1 -0
  60. package/dist/{chunk-lang-dockerfile-ey0nvhhm.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
  61. package/dist/{chunk-lang-go-403qsw9f.js → chunk-lang-go-x9ahv6e5.js} +3 -3
  62. package/dist/chunk-lang-html-8jc72zt4.js +1 -0
  63. package/dist/chunk-lang-javascript-2p2qtbnf.js +1 -0
  64. package/dist/chunk-lang-json-hsemyd0r.js +1 -0
  65. package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
  66. package/dist/{chunk-lang-python-9jk5a49y.js → chunk-lang-python-tewx7006.js} +2 -2
  67. package/dist/chunk-lang-rust-0ydx84s1.js +1 -0
  68. package/dist/{chunk-lang-shell-cgq6q0kj.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
  69. package/dist/chunk-lang-sql-xt1qyycg.js +2 -0
  70. package/dist/{chunk-lang-toml-xh45nm68.js → chunk-lang-toml-0hamc5fc.js} +1 -1
  71. package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
  72. package/dist/{chunk-lang-xml-1mh879j9.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
  73. package/dist/{chunk-lang-yaml-jac98srt.js → chunk-lang-yaml-7vk521h1.js} +1 -1
  74. package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
  75. package/dist/chunk-menu-items-1n9qargb.js +2 -0
  76. package/dist/chunk-notes-t15bh04m.js +1 -0
  77. package/dist/chunk-notice-bell-w3y0h0sr.js +1 -0
  78. package/dist/chunk-notices-fa3g7qze.js +1 -0
  79. package/dist/chunk-onboarding-34v3nr17.js +1 -0
  80. package/dist/chunk-overview-km5vq375.js +1 -0
  81. package/dist/chunk-palette-a8txdgd0.js +1 -0
  82. package/dist/chunk-placement-items-wgaxsbvs.js +1 -0
  83. package/dist/chunk-profile-ypxdbsk1.js +1 -0
  84. package/dist/chunk-prompt-history-wm9dsy6s.js +1 -0
  85. package/dist/chunk-prompts-7mhhq7zs.js +1 -0
  86. package/dist/chunk-room-confirms-t11a1gsw.js +1 -0
  87. package/dist/chunk-room-menu-2tm2xtfx.js +1 -0
  88. package/dist/chunk-rooms-menu-button-wxnsxjwc.js +1 -0
  89. package/dist/chunk-schedules-2ft6xxyw.js +1 -0
  90. package/dist/chunk-seat-menu-d3npdtx1.js +1 -0
  91. package/dist/chunk-sheet-hosts-vngvp7wv.js +1 -0
  92. package/dist/chunk-skill-editor-65n2hsq4.js +8 -0
  93. package/dist/chunk-skills-shkdwes8.js +1 -0
  94. package/dist/chunk-switcher-list-g6hhmr1t.js +1 -0
  95. package/dist/chunk-table-1b6bsq2y.js +1 -0
  96. package/dist/chunk-table-seats-vy1qhdks.js +1 -0
  97. package/dist/chunk-table2d-n26b7gd0.js +1 -0
  98. package/dist/chunk-table3d-8aysyf56.js +4220 -0
  99. package/dist/chunk-transcript-355faceq.js +1 -0
  100. package/dist/chunk-where-j5qdt8a4.js +1 -0
  101. package/dist/chunk-workspace-vrbs6zx1.js +1 -0
  102. package/dist/chunk-zh-tw-zeg600bk.js +1 -0
  103. package/dist/index.html +1 -1
  104. package/dist/sw.js +1 -1
  105. package/package.json +5 -4
  106. package/src/api-types.ts +515 -6
  107. package/src/app/api-http.ts +66 -0
  108. package/src/app/app.ts +214 -33
  109. package/src/app/card-log.ts +6 -0
  110. package/src/app/desk.ts +47 -22
  111. package/src/app/files.ts +73 -7
  112. package/src/app/layout-routes.ts +239 -0
  113. package/src/app/layout.ts +133 -0
  114. package/src/app/presence.ts +2 -2
  115. package/src/app/prompt-routes.ts +167 -0
  116. package/src/app/protocol.ts +63 -1
  117. package/src/app/room-log.ts +148 -14
  118. package/src/app/rooms.ts +23 -0
  119. package/src/app/routes.ts +278 -156
  120. package/src/app/skill-doc-routes.ts +122 -0
  121. package/src/app/socket.ts +309 -77
  122. package/src/app/sockets.ts +108 -20
  123. package/src/app/surface.ts +72 -36
  124. package/src/app/team-routes.ts +457 -0
  125. package/src/app/threads.ts +14 -0
  126. package/src/app/uploads.ts +33 -7
  127. package/src/auth/authenticator.ts +71 -8
  128. package/src/auth/next.ts +52 -0
  129. package/src/auth/oidc-pages.ts +60 -0
  130. package/src/auth/oidc.ts +611 -0
  131. package/src/auth/sessions.ts +193 -0
  132. package/src/authz.ts +172 -0
  133. package/src/console-api.ts +122 -25
  134. package/src/console-server.ts +70 -6
  135. package/src/gate.ts +90 -0
  136. package/src/host/access.ts +161 -0
  137. package/src/host/cards.ts +99 -0
  138. package/src/host/context.ts +141 -0
  139. package/src/host/forms.ts +237 -0
  140. package/src/host/frames.ts +342 -0
  141. package/src/host/host-app.ts +176 -0
  142. package/src/host/host-context.ts +49 -0
  143. package/src/host/host-routes.ts +363 -0
  144. package/src/host/pages.ts +266 -0
  145. package/src/host/reads.ts +155 -0
  146. package/src/host/retention.ts +41 -0
  147. package/src/host/room-views.ts +137 -0
  148. package/src/host/rooms-service.ts +414 -0
  149. package/src/host/stored-json.ts +13 -0
  150. package/src/host/views.ts +103 -0
  151. package/src/i18n/zh-tw.ts +309 -10
  152. package/src/layout-order.ts +210 -0
  153. package/src/notices/api.ts +43 -0
  154. package/src/notices/mount.ts +292 -0
  155. package/src/notices/service.ts +294 -0
  156. package/src/notices/store.ts +298 -0
  157. package/src/notices/urls.ts +38 -0
  158. package/src/onboarding/api.ts +32 -0
  159. package/src/onboarding/service.ts +149 -0
  160. package/src/onboarding/steps.ts +75 -0
  161. package/src/onboarding/waivers.ts +40 -0
  162. package/src/options.ts +498 -34
  163. package/src/profile/api.ts +175 -0
  164. package/src/profile/links.ts +162 -0
  165. package/src/push/api.ts +189 -52
  166. package/src/push/dispatch.ts +156 -0
  167. package/src/push/drain.ts +139 -0
  168. package/src/push/mutes.ts +17 -0
  169. package/src/push/outbox.ts +218 -0
  170. package/src/push/prefs.ts +118 -0
  171. package/src/push/sender.ts +7 -2
  172. package/src/push/triggers.ts +56 -5
  173. package/src/push/vapid.ts +19 -0
  174. package/src/push/web-push-transport.ts +106 -0
  175. package/src/push-sender/index.ts +14 -0
  176. package/src/push-sender/run.ts +118 -0
  177. package/src/push-sender/session.ts +54 -0
  178. package/src/visitors.ts +94 -6
  179. package/src/web-app.ts +674 -0
  180. package/src/web-plugin.ts +293 -26
  181. package/dist/chunk-action-menu-7v5fce2v.js +0 -1
  182. package/dist/chunk-agent-2rw4ydw2.js +0 -1
  183. package/dist/chunk-agent-sheet-2q24dkkf.js +0 -3
  184. package/dist/chunk-character-creator-8q6ympxj.js +0 -1
  185. package/dist/chunk-codeeditor-fgmq298t.js +0 -2
  186. package/dist/chunk-connectors-mr53r7fh.js +0 -1
  187. package/dist/chunk-conversations-4vy2z6w9.js +0 -1
  188. package/dist/chunk-entry-jg1eem82.js +0 -1
  189. package/dist/chunk-group-scene-na95b1te.js +0 -1
  190. package/dist/chunk-index-11rjg8a8.js +0 -1
  191. package/dist/chunk-index-1y50d6sj.js +0 -2
  192. package/dist/chunk-index-5y2r0eaj.js +0 -47
  193. package/dist/chunk-index-7fphxxzd.js +0 -14
  194. package/dist/chunk-index-80f1zkp6.css +0 -1
  195. package/dist/chunk-index-87h45frg.js +0 -2
  196. package/dist/chunk-index-bnwmssyn.js +0 -2
  197. package/dist/chunk-index-ckgbszwt.js +0 -51
  198. package/dist/chunk-index-fmjjekhd.js +0 -2
  199. package/dist/chunk-index-t1m65yn6.js +0 -13
  200. package/dist/chunk-index-t711t17m.js +0 -2
  201. package/dist/chunk-index-txv7qvyy.js +0 -2
  202. package/dist/chunk-index-wr3dg24d.js +0 -2
  203. package/dist/chunk-index-yx683d5n.js +0 -2
  204. package/dist/chunk-lang-css-fkdg3x8x.js +0 -1
  205. package/dist/chunk-lang-html-v8h9g977.js +0 -1
  206. package/dist/chunk-lang-javascript-ykb2vhaa.js +0 -1
  207. package/dist/chunk-lang-json-2rze3rpy.js +0 -1
  208. package/dist/chunk-lang-markdown-6w2htnfc.js +0 -6
  209. package/dist/chunk-lang-rust-24fggcqx.js +0 -1
  210. package/dist/chunk-lang-sql-gjywb9ke.js +0 -2
  211. package/dist/chunk-lang-typescript-qh6rebyb.js +0 -1
  212. package/dist/chunk-menu-items-zjpyj51b.js +0 -2
  213. package/dist/chunk-notes-sjba7kw3.js +0 -1
  214. package/dist/chunk-overview-26r77kb6.js +0 -1
  215. package/dist/chunk-room-confirms-851184ep.js +0 -1
  216. package/dist/chunk-room-menu-8y9c026y.js +0 -1
  217. package/dist/chunk-skills-56s4hdbn.js +0 -1
  218. package/dist/chunk-table-qykpvp32.js +0 -1
  219. package/dist/chunk-table2d-k60ressj.js +0 -1
  220. package/dist/chunk-table3d-bv2ajk39.js +0 -4220
  221. package/dist/chunk-transcript-vekz1mh6.js +0 -1
  222. package/dist/chunk-workspace-hevefd0d.js +0 -1
  223. package/dist/chunk-zh-tw-5r2wjre0.js +0 -1
@@ -1,6 +1,7 @@
1
1
  import { QUESTIONNAIRE_LIMITS } from "pi-roundtable";
2
2
  import {
3
3
  type ClientFrame,
4
+ type FormInput,
4
5
  MAX_MESSAGE_FILES,
5
6
  type PromptAnswer,
6
7
  } from "../api-types.ts";
@@ -40,6 +41,31 @@ function parseAnswer(value: unknown): PromptAnswer | undefined {
40
41
  };
41
42
  }
42
43
 
44
+ /** The most fields and the longest value a form's input may carry. */
45
+ const FORM_FIELDS = 40;
46
+ const FORM_VALUE_CHARS = 32_000;
47
+
48
+ /** A form's input: text, or a list of text, by field name. */
49
+ function parseInput(value: unknown): FormInput | undefined {
50
+ if (!isRecord(value)) return undefined;
51
+ const entries = Object.entries(value);
52
+ if (entries.length > FORM_FIELDS) return undefined;
53
+ const input: Record<string, string | string[]> = {};
54
+ for (const [name, field] of entries) {
55
+ if (name.length === 0 || name.length > ID_CHARS) return undefined;
56
+ if (typeof field === "string" && field.length <= FORM_VALUE_CHARS)
57
+ input[name] = field;
58
+ else if (
59
+ Array.isArray(field) &&
60
+ field.length <= FORM_FIELDS &&
61
+ field.every((one) => isText(one) && one.length <= FORM_VALUE_CHARS)
62
+ )
63
+ input[name] = field as string[];
64
+ else return undefined;
65
+ }
66
+ return input;
67
+ }
68
+
43
69
  function parseAnswers(value: Record<string, unknown>): ClientFrame | undefined {
44
70
  const { prompt, answers, note } = value;
45
71
  if (!isId(prompt) || !Array.isArray(answers)) return undefined;
@@ -99,8 +125,10 @@ export function parseClientFrame(
99
125
  ...(value.room === undefined ? {} : { room: value.room as string }),
100
126
  };
101
127
  case "send": {
102
- const { id, room, text, uploads } = value;
128
+ const { id, room, text, uploads, mode } = value;
103
129
  if (!isId(id) || !isId(room) || !isText(text)) return undefined;
130
+ if (mode !== undefined && mode !== "note" && mode !== "turn")
131
+ return undefined;
104
132
  if (
105
133
  uploads !== undefined &&
106
134
  !(
@@ -118,8 +146,42 @@ export function parseClientFrame(
118
146
  ...(uploads === undefined || uploads.length === 0
119
147
  ? {}
120
148
  : { uploads: uploads as string[] }),
149
+ ...(mode === undefined ? {} : { mode }),
121
150
  };
122
151
  }
152
+ case "subscribe":
153
+ case "unsubscribe":
154
+ return isId(value.room)
155
+ ? { type: value.type, room: value.room }
156
+ : undefined;
157
+ case "act": {
158
+ const { id, room, page, action, card, input } = value;
159
+ if (!isId(id) || !isId(action)) return undefined;
160
+ // One target: a room or a page, never both and never neither.
161
+ if ((room === undefined) === (page === undefined)) return undefined;
162
+ if (!optional(room, isId) || !optional(page, isId)) return undefined;
163
+ if (!optional(card, isId)) return undefined;
164
+ const parsed = input === undefined ? undefined : parseInput(input);
165
+ if (input !== undefined && !parsed) return undefined;
166
+ return {
167
+ type: "act",
168
+ id,
169
+ action,
170
+ ...(room === undefined ? {} : { room: room as string }),
171
+ ...(page === undefined ? {} : { page: page as string }),
172
+ ...(card === undefined ? {} : { card: card as string }),
173
+ ...(parsed ? { input: parsed } : {}),
174
+ };
175
+ }
176
+ case "ack":
177
+ return isId(value.tag) ? { type: "ack", tag: value.tag } : undefined;
178
+ case "read":
179
+ return isId(value.room) &&
180
+ typeof value.upTo === "number" &&
181
+ Number.isSafeInteger(value.upTo) &&
182
+ value.upTo >= 0
183
+ ? { type: "read", room: value.room, upTo: value.upTo }
184
+ : undefined;
123
185
  case "stop":
124
186
  return isId(value.room) ? { type: "stop", room: value.room } : undefined;
125
187
  case "approval":
@@ -1,6 +1,12 @@
1
1
  import type { SQL } from "bun";
2
2
  import type { Migration } from "pi-roundtable";
3
- import type { RoomFile, RoomMessage } from "../api-types.ts";
3
+ import type {
4
+ HostAuthorKind,
5
+ PictureRef,
6
+ RoomFile,
7
+ RoomMessage,
8
+ } from "../api-types.ts";
9
+ import { storedJson } from "../host/stored-json.ts";
4
10
 
5
11
  /** A message to store; the log numbers it and stamps its time. */
6
12
  export interface NewRoomMessage {
@@ -11,6 +17,14 @@ export interface NewRoomMessage {
11
17
  author: string;
12
18
  text: string;
13
19
  files?: RoomFile[];
20
+ /** Host rooms: who wrote it, as the host's kinds of author name them. */
21
+ authorKind?: HostAuthorKind;
22
+ authorLabel?: string;
23
+ picture?: PictureRef;
24
+ /** Host rooms: the host's own id for the message, unique within the room. */
25
+ reference?: string;
26
+ /** When it was written, for an import; the log's clock otherwise. Retention counts from it. */
27
+ at?: Date;
14
28
  }
15
29
 
16
30
  interface Row {
@@ -23,16 +37,17 @@ interface Row {
23
37
  text: string;
24
38
  files: RoomFile[] | string;
25
39
  created_at: Date;
40
+ author_kind: HostAuthorKind | null;
41
+ author_label: string | null;
42
+ picture: PictureRef | string | null;
43
+ edited_at: Date | null;
26
44
  }
27
45
 
28
46
  /** The most messages one page of history holds. */
29
47
  export const PAGE_LIMIT = 100;
30
48
 
31
49
  function messageOf(row: Row): RoomMessage {
32
- const files =
33
- typeof row.files === "string"
34
- ? (JSON.parse(row.files) as RoomFile[])
35
- : row.files;
50
+ const files = storedJson(row.files, "message files");
36
51
  return {
37
52
  id: Number(row.id),
38
53
  room: row.room,
@@ -43,6 +58,14 @@ function messageOf(row: Row): RoomMessage {
43
58
  text: row.text,
44
59
  files,
45
60
  at: row.created_at.toISOString(),
61
+ ...(row.author_kind === null ? {} : { authorKind: row.author_kind }),
62
+ ...(row.author_label === null ? {} : { authorLabel: row.author_label }),
63
+ ...(row.picture === null
64
+ ? {}
65
+ : {
66
+ picture: storedJson(row.picture, "picture"),
67
+ }),
68
+ ...(row.edited_at === null ? {} : { edited: true as const }),
46
69
  };
47
70
  }
48
71
 
@@ -79,29 +102,133 @@ export class RoomLog {
79
102
  },
80
103
  };
81
104
 
105
+ /** The columns a host room's messages add: who wrote it, the host's reference, and when it was changed. */
106
+ static readonly hostMigration: Migration = {
107
+ name: "web-room-messages-host",
108
+ up: async (sql) => {
109
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS author_kind text`;
110
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS author_label text`;
111
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS picture jsonb`;
112
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS reference text`;
113
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS edited_at timestamptz`;
114
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS web_room_messages_reference ON web_room_messages (room, reference) WHERE reference IS NOT NULL`;
115
+ await sql`CREATE INDEX IF NOT EXISTS web_room_messages_created ON web_room_messages (created_at)`;
116
+ },
117
+ };
118
+
82
119
  async append(message: NewRoomMessage): Promise<RoomMessage> {
83
- const files = JSON.stringify(message.files ?? []);
84
- const [row]: Row[] = await this.#sql`
85
- INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files)
86
- VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
87
- ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb)
88
- RETURNING *`;
120
+ const [row] = await this.#insert(message, false);
89
121
  if (!row) throw new Error("the room log stored nothing");
90
122
  return messageOf(row);
91
123
  }
92
124
 
93
- /** Changes the text of a message stored in the room; undefined when the room holds no such message. */
125
+ /**
126
+ * Stores a host room's message once per `reference`: a second post with the same reference in
127
+ * the room stores nothing and answers the message already there, with `created` false.
128
+ */
129
+ async appendOnce(
130
+ message: NewRoomMessage,
131
+ ): Promise<{ message: RoomMessage; created: boolean }> {
132
+ const [row] = await this.#insert(message, message.reference !== undefined);
133
+ if (row) return { message: messageOf(row), created: true };
134
+ const [existing]: Row[] = await this.#sql`
135
+ SELECT * FROM web_room_messages
136
+ WHERE room = ${message.room} AND reference = ${message.reference ?? null}`;
137
+ if (!existing) throw new Error("the room log stored nothing");
138
+ return { message: messageOf(existing), created: false };
139
+ }
140
+
141
+ async #insert(message: NewRoomMessage, once: boolean): Promise<Row[]> {
142
+ const files = JSON.stringify(message.files ?? []);
143
+ const picture = message.picture ? JSON.stringify(message.picture) : null;
144
+ const at = message.at ?? new Date();
145
+ return once
146
+ ? await this.#sql`
147
+ INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
148
+ author_kind, author_label, picture, reference, created_at)
149
+ VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
150
+ ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
151
+ ${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
152
+ ${message.reference ?? null}, ${at})
153
+ ON CONFLICT (room, reference) WHERE reference IS NOT NULL DO NOTHING
154
+ RETURNING *`
155
+ : await this.#sql`
156
+ INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
157
+ author_kind, author_label, picture, reference, created_at)
158
+ VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
159
+ ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
160
+ ${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
161
+ ${message.reference ?? null}, ${at})
162
+ RETURNING *`;
163
+ }
164
+
165
+ /**
166
+ * Changes the text of a message stored in the room; undefined when the room holds no such
167
+ * message. `mark` shows it as edited, as a host's edit of someone's words does.
168
+ */
94
169
  async edit(
95
170
  room: string,
96
171
  id: number,
97
172
  text: string,
173
+ mark = false,
98
174
  ): Promise<RoomMessage | undefined> {
175
+ const [row]: Row[] = mark
176
+ ? await this.#sql`
177
+ UPDATE web_room_messages SET text = ${text}, edited_at = now()
178
+ WHERE id = ${id} AND room = ${room} RETURNING *`
179
+ : await this.#sql`
180
+ UPDATE web_room_messages SET text = ${text}
181
+ WHERE id = ${id} AND room = ${room} RETURNING *`;
182
+ return row && messageOf(row);
183
+ }
184
+
185
+ /** The message the host's reference names, if the room holds one. */
186
+ async byReference(
187
+ room: string,
188
+ reference: string,
189
+ ): Promise<number | undefined> {
190
+ const [row]: { id: string | number | bigint }[] = await this.#sql`
191
+ SELECT id FROM web_room_messages WHERE room = ${room} AND reference = ${reference}`;
192
+ return row === undefined ? undefined : Number(row.id);
193
+ }
194
+
195
+ /** One stored message of the room, or undefined. */
196
+ async get(room: string, id: number): Promise<RoomMessage | undefined> {
197
+ const [row]: Row[] = await this.#sql`
198
+ SELECT * FROM web_room_messages WHERE room = ${room} AND id = ${id}`;
199
+ return row && messageOf(row);
200
+ }
201
+
202
+ /** Deletes one message of the room; the message as it was, or undefined when the room held none. */
203
+ async remove(room: string, id: number): Promise<RoomMessage | undefined> {
99
204
  const [row]: Row[] = await this.#sql`
100
- UPDATE web_room_messages SET text = ${text}
101
- WHERE id = ${id} AND room = ${room} RETURNING *`;
205
+ DELETE FROM web_room_messages WHERE room = ${room} AND id = ${id} RETURNING *`;
102
206
  return row && messageOf(row);
103
207
  }
104
208
 
209
+ /**
210
+ * Deletes the messages older than `before` of the rooms whose key starts with `prefix`, the
211
+ * kind's rooms; the deleted messages, so their files can go too. Age is the message's own time.
212
+ */
213
+ async sweepKind(prefix: string, before: Date): Promise<RoomMessage[]> {
214
+ const rows: Row[] = await this.#sql`
215
+ DELETE FROM web_room_messages
216
+ WHERE starts_with(room, ${prefix}) AND created_at < ${before} RETURNING *`;
217
+ return rows.map(messageOf);
218
+ }
219
+
220
+ /** The newest message id of each room that has one, for the read cursors. */
221
+ async newestIds(rooms: readonly string[]): Promise<Map<string, number>> {
222
+ const found = new Map<string, number>();
223
+ if (rooms.length === 0) return found;
224
+ const rows: { room: string; id: string | number | bigint }[] = await this
225
+ .#sql`
226
+ SELECT room, max(id) AS id FROM web_room_messages
227
+ WHERE room IN ${this.#sql(rooms)} GROUP BY room`;
228
+ for (const row of rows) found.set(row.room, Number(row.id));
229
+ return found;
230
+ }
231
+
105
232
  /** The newest `limit` messages older than `before` (all of them when absent), oldest first. */
106
233
  async page(
107
234
  room: string,
@@ -123,6 +250,13 @@ export class RoomLog {
123
250
  };
124
251
  }
125
252
 
253
+ /** Deletes every message of a room that is gone for good; returns how many. */
254
+ async removeRoom(room: string): Promise<number> {
255
+ const rows: unknown[] = await this
256
+ .#sql`DELETE FROM web_room_messages WHERE room = ${room} RETURNING 1`;
257
+ return rows.length;
258
+ }
259
+
126
260
  /** The newest message of each of the rooms that has one. */
127
261
  async lastOf(rooms: readonly string[]): Promise<Map<string, RoomMessage>> {
128
262
  const found = new Map<string, RoomMessage>();
package/src/app/rooms.ts CHANGED
@@ -123,6 +123,27 @@ export class Rooms {
123
123
  : undefined;
124
124
  }
125
125
 
126
+ /**
127
+ * The keys of the rooms an owner can pin or put in a folder: the active agents' and groups' rooms,
128
+ * without Home, which stays at the top, and without threads, which follow their room.
129
+ */
130
+ placeable(): Set<string> {
131
+ const { surface, directory } = this.#options;
132
+ const home = this.homeKey();
133
+ return new Set<string>(
134
+ [
135
+ ...directory
136
+ .agents()
137
+ .filter((agent) => agent.status === "active")
138
+ .map((agent) => agentRoomKey(surface, agent.name)),
139
+ ...directory
140
+ .groups()
141
+ .filter((group) => group.status === "active")
142
+ .map((group) => groupRoomKey(surface, group.name)),
143
+ ].filter((key) => key !== home),
144
+ );
145
+ }
146
+
126
147
  /** The agent whose avatar path to show, as a path under the mount. */
127
148
  avatarOf(agent: Agent): string {
128
149
  // The studio gives a full URL with a public address and a bare path without one.
@@ -136,6 +157,7 @@ export class Rooms {
136
157
  const { surface, directory, modes, mutes, activity, log, threads } =
137
158
  this.#options;
138
159
  const home = this.homeKey();
160
+ const coordinator = this.#options.team.coordinator();
139
161
  const agents = directory.agents().filter((a) => a.status === "active");
140
162
  const groups = directory.groups().filter((g) => g.status === "active");
141
163
  const shown = await threads.list();
@@ -163,6 +185,7 @@ export class Rooms {
163
185
  displayName: agent.displayName,
164
186
  avatar: this.avatarOf(agent),
165
187
  character: characterOf(agent),
188
+ ...(agent.name === coordinator ? { coordinator: true as const } : {}),
166
189
  }),
167
190
  ),
168
191
  ...groups.map((group: AgentGroup) =>