pi-roundtable-web 0.24.2 → 0.25.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 (148) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +51 -9
  3. package/dist/chunk-action-menu-qe839g4b.js +1 -0
  4. package/dist/chunk-agent-0pm9p3nn.js +1 -0
  5. package/dist/chunk-arranged-rooms-enpfswmg.js +1 -0
  6. package/dist/chunk-character-creator-6yf29xwx.js +1 -0
  7. package/dist/chunk-codeeditor-gdbdg7rp.js +2 -0
  8. package/dist/chunk-confirm-xrw7d732.js +1 -0
  9. package/dist/chunk-connectors-anhghy3k.js +1 -0
  10. package/dist/chunk-conversations-ry228aes.js +1 -0
  11. package/dist/chunk-drag-q4fx9b22.js +1 -0
  12. package/dist/chunk-entry-he1embgp.js +1 -0
  13. package/dist/chunk-folder-list-5cp8stna.js +1 -0
  14. package/dist/chunk-folds-hff104tk.js +1 -0
  15. package/dist/chunk-group-scene-h228jdat.js +1 -0
  16. package/dist/chunk-index-0p5h2kdz.js +2 -0
  17. package/dist/chunk-index-0pt08yd9.js +1 -0
  18. package/dist/chunk-index-12bw9yj8.js +2 -0
  19. package/dist/chunk-index-18gxy79n.js +47 -0
  20. package/dist/{chunk-index-mpm90318.js → chunk-index-27qca4mr.js} +2 -2
  21. package/dist/chunk-index-2ad17eh4.js +2 -0
  22. package/dist/chunk-index-31wh5twj.js +52 -0
  23. package/dist/chunk-index-4dy94pbz.js +2 -0
  24. package/dist/chunk-index-4ecjtdy8.js +13 -0
  25. package/dist/chunk-index-4v1b7ks9.js +2 -0
  26. package/dist/chunk-index-5d8yjj4m.js +2 -0
  27. package/dist/chunk-index-89ajmxh7.js +2 -0
  28. package/dist/chunk-index-9b1kt7qw.css +1 -0
  29. package/dist/chunk-index-9jqcskb4.js +14 -0
  30. package/dist/chunk-index-b9ny4623.js +2 -0
  31. package/dist/chunk-index-bvfqjh77.js +2 -0
  32. package/dist/chunk-index-d5abvq7v.js +2 -0
  33. package/dist/chunk-index-e1dr6g10.js +2 -0
  34. package/dist/chunk-index-gpkzhq1g.js +2 -0
  35. package/dist/chunk-index-hk3xyy20.js +2 -0
  36. package/dist/chunk-index-jm23vn03.js +4 -0
  37. package/dist/chunk-index-k7gh0jvz.js +2 -0
  38. package/dist/{chunk-index-6t68gbex.js → chunk-index-krtk6nn2.js} +2 -2
  39. package/dist/chunk-index-ky42ev0b.js +2 -0
  40. package/dist/chunk-index-pa2yyqh0.js +2 -0
  41. package/dist/chunk-index-pcvt668q.js +2 -0
  42. package/dist/chunk-index-pqw27eye.js +1 -0
  43. package/dist/chunk-index-s681nny9.js +2 -0
  44. package/dist/chunk-index-wmbmmrd4.js +302 -0
  45. package/dist/chunk-index-ymbjrsp6.js +2 -0
  46. package/dist/chunk-index-zfd00hwb.js +2 -0
  47. package/dist/chunk-lang-css-t85r8rz0.js +1 -0
  48. package/dist/{chunk-lang-dockerfile-ey0nvhhm.js → chunk-lang-dockerfile-d5mzxs60.js} +1 -1
  49. package/dist/{chunk-lang-go-403qsw9f.js → chunk-lang-go-830fmhba.js} +3 -3
  50. package/dist/chunk-lang-html-n6jparge.js +1 -0
  51. package/dist/chunk-lang-javascript-xj00cp97.js +1 -0
  52. package/dist/chunk-lang-json-5eaarz7j.js +1 -0
  53. package/dist/chunk-lang-markdown-zxx8eke8.js +6 -0
  54. package/dist/{chunk-lang-python-9jk5a49y.js → chunk-lang-python-fjw84w10.js} +2 -2
  55. package/dist/chunk-lang-rust-5rbp2zdk.js +1 -0
  56. package/dist/{chunk-lang-shell-cgq6q0kj.js → chunk-lang-shell-vzagpym3.js} +1 -1
  57. package/dist/chunk-lang-sql-0c2w9c2y.js +2 -0
  58. package/dist/{chunk-lang-toml-xh45nm68.js → chunk-lang-toml-yr9fv6eb.js} +1 -1
  59. package/dist/chunk-lang-typescript-8f6yywt3.js +1 -0
  60. package/dist/{chunk-lang-xml-1mh879j9.js → chunk-lang-xml-eze08p42.js} +1 -1
  61. package/dist/{chunk-lang-yaml-jac98srt.js → chunk-lang-yaml-c4b6ft3y.js} +1 -1
  62. package/dist/chunk-layout-actions-bv0chj41.js +1 -0
  63. package/dist/chunk-menu-items-jrwxycr1.js +2 -0
  64. package/dist/chunk-notes-9b7pgqfe.js +1 -0
  65. package/dist/chunk-overview-8yw71esz.js +1 -0
  66. package/dist/chunk-palette-frms7axe.js +1 -0
  67. package/dist/chunk-placement-items-fvfy9m0y.js +1 -0
  68. package/dist/chunk-prompt-history-yrqehads.js +1 -0
  69. package/dist/chunk-prompts-g6609tqx.js +1 -0
  70. package/dist/chunk-room-confirms-0a2y0925.js +1 -0
  71. package/dist/chunk-room-menu-g11q97br.js +1 -0
  72. package/dist/chunk-rooms-menu-button-h9qge48n.js +1 -0
  73. package/dist/chunk-schedules-ezmacrbj.js +1 -0
  74. package/dist/chunk-seat-menu-jv4s7hmj.js +1 -0
  75. package/dist/chunk-sheet-hosts-b0azyfhb.js +1 -0
  76. package/dist/chunk-skill-editor-h6k6whwg.js +8 -0
  77. package/dist/chunk-skills-gqcfhq56.js +1 -0
  78. package/dist/chunk-switcher-list-mcpamqnj.js +1 -0
  79. package/dist/chunk-table-s8cde1xz.js +1 -0
  80. package/dist/chunk-table-seats-wqkjn8bc.js +1 -0
  81. package/dist/chunk-table2d-4sje1fr0.js +1 -0
  82. package/dist/chunk-table3d-h64skazp.js +4220 -0
  83. package/dist/chunk-transcript-c59c6mpm.js +1 -0
  84. package/dist/chunk-workspace-0cyspkk4.js +1 -0
  85. package/dist/chunk-zh-tw-nc93jm10.js +1 -0
  86. package/dist/index.html +1 -1
  87. package/dist/sw.js +1 -1
  88. package/package.json +3 -3
  89. package/src/api-types.ts +214 -2
  90. package/src/app/api-http.ts +66 -0
  91. package/src/app/app.ts +18 -1
  92. package/src/app/card-log.ts +6 -0
  93. package/src/app/layout-routes.ts +239 -0
  94. package/src/app/layout.ts +133 -0
  95. package/src/app/prompt-routes.ts +167 -0
  96. package/src/app/room-log.ts +7 -0
  97. package/src/app/rooms.ts +23 -0
  98. package/src/app/routes.ts +155 -131
  99. package/src/app/skill-doc-routes.ts +122 -0
  100. package/src/app/team-routes.ts +457 -0
  101. package/src/app/threads.ts +14 -0
  102. package/src/i18n/zh-tw.ts +177 -10
  103. package/src/layout-order.ts +210 -0
  104. package/src/push/mutes.ts +17 -0
  105. package/src/web-plugin.ts +25 -0
  106. package/dist/chunk-action-menu-7v5fce2v.js +0 -1
  107. package/dist/chunk-agent-2rw4ydw2.js +0 -1
  108. package/dist/chunk-agent-sheet-2q24dkkf.js +0 -3
  109. package/dist/chunk-character-creator-8q6ympxj.js +0 -1
  110. package/dist/chunk-codeeditor-fgmq298t.js +0 -2
  111. package/dist/chunk-connectors-mr53r7fh.js +0 -1
  112. package/dist/chunk-conversations-4vy2z6w9.js +0 -1
  113. package/dist/chunk-entry-jg1eem82.js +0 -1
  114. package/dist/chunk-group-scene-na95b1te.js +0 -1
  115. package/dist/chunk-index-11rjg8a8.js +0 -1
  116. package/dist/chunk-index-1y50d6sj.js +0 -2
  117. package/dist/chunk-index-5y2r0eaj.js +0 -47
  118. package/dist/chunk-index-7fphxxzd.js +0 -14
  119. package/dist/chunk-index-80f1zkp6.css +0 -1
  120. package/dist/chunk-index-87h45frg.js +0 -2
  121. package/dist/chunk-index-bnwmssyn.js +0 -2
  122. package/dist/chunk-index-ckgbszwt.js +0 -51
  123. package/dist/chunk-index-fmjjekhd.js +0 -2
  124. package/dist/chunk-index-t1m65yn6.js +0 -13
  125. package/dist/chunk-index-t711t17m.js +0 -2
  126. package/dist/chunk-index-txv7qvyy.js +0 -2
  127. package/dist/chunk-index-wr3dg24d.js +0 -2
  128. package/dist/chunk-index-yx683d5n.js +0 -2
  129. package/dist/chunk-lang-css-fkdg3x8x.js +0 -1
  130. package/dist/chunk-lang-html-v8h9g977.js +0 -1
  131. package/dist/chunk-lang-javascript-ykb2vhaa.js +0 -1
  132. package/dist/chunk-lang-json-2rze3rpy.js +0 -1
  133. package/dist/chunk-lang-markdown-6w2htnfc.js +0 -6
  134. package/dist/chunk-lang-rust-24fggcqx.js +0 -1
  135. package/dist/chunk-lang-sql-gjywb9ke.js +0 -2
  136. package/dist/chunk-lang-typescript-qh6rebyb.js +0 -1
  137. package/dist/chunk-menu-items-zjpyj51b.js +0 -2
  138. package/dist/chunk-notes-sjba7kw3.js +0 -1
  139. package/dist/chunk-overview-26r77kb6.js +0 -1
  140. package/dist/chunk-room-confirms-851184ep.js +0 -1
  141. package/dist/chunk-room-menu-8y9c026y.js +0 -1
  142. package/dist/chunk-skills-56s4hdbn.js +0 -1
  143. package/dist/chunk-table-qykpvp32.js +0 -1
  144. package/dist/chunk-table2d-k60ressj.js +0 -1
  145. package/dist/chunk-table3d-bv2ajk39.js +0 -4220
  146. package/dist/chunk-transcript-vekz1mh6.js +0 -1
  147. package/dist/chunk-workspace-hevefd0d.js +0 -1
  148. package/dist/chunk-zh-tw-5r2wjre0.js +0 -1
package/src/app/app.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  } from "pi-roundtable";
15
15
  import { channelKey } from "pi-roundtable";
16
16
  import type { Authenticated, Authenticator } from "../auth/authenticator.ts";
17
+ import type { ConsolePresentation } from "../features.ts";
17
18
  import { PushApi } from "../push/api.ts";
18
19
  import { webPushChannel } from "../push/channel.ts";
19
20
  import { EndpointPolicy } from "../push/endpoint.ts";
@@ -26,10 +27,11 @@ import type { Vapid } from "../push/vapid.ts";
26
27
  import { CardLog } from "./card-log.ts";
27
28
  import { OwnerDesk } from "./desk.ts";
28
29
  import { FileDir } from "./files.ts";
30
+ import { RoomLayout } from "./layout.ts";
29
31
  import { Presence } from "./presence.ts";
30
32
  import { RoomLog } from "./room-log.ts";
31
33
  import { Rooms } from "./rooms.ts";
32
- import { AppApi, partsOf } from "./routes.ts";
34
+ import { AppApi, type AppApiOptions, partsOf } from "./routes.ts";
33
35
  import { SocketRoute } from "./socket.ts";
34
36
  import { type AppSocket, Sockets } from "./sockets.ts";
35
37
  import { AppSurface } from "./surface.ts";
@@ -49,9 +51,12 @@ export const APP_MIGRATIONS: readonly Migration[] = [
49
51
  WebThreads.migration,
50
52
  PushSubscriptions.migration,
51
53
  PushMutes.migration,
54
+ RoomLayout.migration,
52
55
  ];
53
56
 
54
57
  export interface OwnerAppOptions {
58
+ /** The host's wording, for the refusals the API answers with. */
59
+ presentation?: ConsolePresentation;
55
60
  /** The key prefix of the rooms, such as `app`. */
56
61
  surface: string;
57
62
  /** The path the console is served under, without a trailing slash. */
@@ -87,6 +92,13 @@ export interface OwnerAppOptions {
87
92
  };
88
93
  database: SQL;
89
94
  agents: AgentServer;
95
+ /**
96
+ * Present when the host has the skills addon: the catalog an agent's skills are chosen from,
97
+ * how they change, and the registry's documents.
98
+ */
99
+ skills?: AppApiOptions["skills"];
100
+ /** Present when the host has prompt documents: the main prompts the owner edits. */
101
+ prompts?: AppApiOptions["prompts"];
90
102
  /** Present when the workspace pane is served. */
91
103
  workspace?: WorkspaceFiles;
92
104
  modes: ConversationModes;
@@ -259,6 +271,7 @@ export class OwnerApp {
259
271
  push,
260
272
  });
261
273
  this.#api = new AppApi({
274
+ ...(options.presentation ? { presentation: options.presentation } : {}),
262
275
  surface,
263
276
  rooms,
264
277
  log,
@@ -267,12 +280,16 @@ export class OwnerApp {
267
280
  files: stored,
268
281
  modes,
269
282
  mutes,
283
+ threads,
284
+ layout: new RoomLayout(options.database),
270
285
  conversations: options.conversations,
271
286
  identity,
272
287
  ...(options.schedules ? { schedules: options.schedules } : {}),
273
288
  ...(options.workspace ? { workspace: options.workspace } : {}),
274
289
  directory: agents.directory,
275
290
  team: agents.team,
291
+ ...(options.skills ? { skills: options.skills } : {}),
292
+ ...(options.prompts ? { prompts: options.prompts } : {}),
276
293
  avatars: agents.avatars,
277
294
  mount: options.mount,
278
295
  authMode: options.authMode,
@@ -95,6 +95,12 @@ export class CardLog {
95
95
  ON CONFLICT (prompt_id) DO NOTHING`;
96
96
  }
97
97
 
98
+ /** Deletes every ended card of a room that is gone for good, once the writes under way are in. */
99
+ async removeRoom(room: string): Promise<void> {
100
+ await Promise.allSettled([...this.#writing]);
101
+ await this.#sql`DELETE FROM web_room_cards WHERE room = ${room}`;
102
+ }
103
+
98
104
  /**
99
105
  * The room's cards that opened at or after `from` (all of them when absent) and before the
100
106
  * stored message `before` of the same room was written (all of them when absent), oldest
@@ -0,0 +1,239 @@
1
+ import type { Speaker } from "pi-roundtable";
2
+ import type { ApiError, LayoutView } from "../api-types.ts";
3
+ import type { Authenticated } from "../auth/authenticator.ts";
4
+ import { json } from "../http.ts";
5
+ import {
6
+ createFolder,
7
+ LayoutError,
8
+ placeFolder,
9
+ placeRoom,
10
+ removeFolder,
11
+ renameFolder,
12
+ setPinned,
13
+ shown,
14
+ } from "../layout-order.ts";
15
+ import type { PushMutes } from "../push/mutes.ts";
16
+ import { readLimited } from "../request-body.ts";
17
+ import type { LayoutStore } from "./layout.ts";
18
+ import type { ResolvedRoom } from "./rooms.ts";
19
+
20
+ /** The largest JSON body of a layout write: 4 KiB. */
21
+ const MAX_BODY_BYTES = 4 * 1024;
22
+ const FOLDER_ID = /^folder\.[0-9a-f-]{36}$/;
23
+
24
+ /** A refusal with the status the owner is answered with. */
25
+ class LayoutRefusal extends Error {
26
+ constructor(
27
+ readonly status: number,
28
+ message: string,
29
+ ) {
30
+ super(message);
31
+ }
32
+ }
33
+
34
+ const bad = (message: string) => new LayoutRefusal(400, message);
35
+
36
+ /** A refusal of the pure layout edits as the answer the owner reads. */
37
+ function refusalOf(error: LayoutError): LayoutRefusal {
38
+ switch (error.code) {
39
+ case "bad-name":
40
+ return new LayoutRefusal(
41
+ 400,
42
+ "Give the folder a name of 1 to 40 characters.",
43
+ );
44
+ case "no-folder":
45
+ return new LayoutRefusal(404, "There is no such folder.");
46
+ case "duplicate":
47
+ return new LayoutRefusal(409, "A folder of that name exists.");
48
+ case "full":
49
+ return new LayoutRefusal(409, "There are 50 folders already.");
50
+ }
51
+ }
52
+
53
+ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
54
+ const raw = await readLimited(request, MAX_BODY_BYTES);
55
+ if (!raw) throw new LayoutRefusal(413, "The body is too large.");
56
+ let value: unknown;
57
+ try {
58
+ value = JSON.parse(raw.toString("utf8"));
59
+ } catch {
60
+ throw bad("The body is not JSON.");
61
+ }
62
+ if (typeof value !== "object" || value === null || Array.isArray(value))
63
+ throw bad("The body is not an object.");
64
+ return value as Record<string, unknown>;
65
+ }
66
+
67
+ /** `before` of a body: a key or id, none for the end; anything else is refused. */
68
+ function beforeOf(body: Record<string, unknown>): string | null {
69
+ const { before } = body;
70
+ if (before === undefined || before === null) return null;
71
+ if (typeof before !== "string") throw bad("before must be a key or null.");
72
+ return before;
73
+ }
74
+
75
+ export interface LayoutRoutesOptions {
76
+ layout: LayoutStore;
77
+ /** The rooms whose push the owners muted. */
78
+ mutes: Pick<PushMutes, "setMany">;
79
+ /** The keys of the rooms that may be placed: the active agents' and groups' rooms but Home. */
80
+ placeable(): ReadonlySet<string>;
81
+ /** Home's key, which stays at the top. */
82
+ home(): string | undefined;
83
+ /** Reads the person's tier again; refuses with 403 when they are no owner. */
84
+ owner(admission: Authenticated): Promise<Speaker>;
85
+ /** Called after a change the other pages should refetch. */
86
+ changed(): void;
87
+ }
88
+
89
+ /**
90
+ * The room list's arrangement over HTTP: `GET api/rooms` carries it, `api/folders` makes, renames,
91
+ * places and removes folders, and `PUT api/rooms/<key>/folder` and `…/pin` place one room. Every
92
+ * write is an owner's (tier read again) and answers with the whole layout, so the writing page
93
+ * and the others converge on what the server stored.
94
+ */
95
+ export class LayoutRoutes {
96
+ readonly #options: LayoutRoutesOptions;
97
+
98
+ constructor(options: LayoutRoutesOptions) {
99
+ this.#options = options;
100
+ }
101
+
102
+ /** The layout as readers see it: only the rooms that exist. */
103
+ async view(): Promise<LayoutView> {
104
+ const rooms = this.#options.placeable();
105
+ return shown(await this.#options.layout.view(), (key) => rooms.has(key));
106
+ }
107
+
108
+ /** `api/folders` and `api/folders/<id>`; `parts` is the path split after the mount. */
109
+ async folders(
110
+ request: Request,
111
+ parts: string[],
112
+ admission: Authenticated,
113
+ ): Promise<Response> {
114
+ return this.#guard(async () => {
115
+ const { method } = request;
116
+ const [, , id, action] = parts;
117
+ if (parts.length > 4 || (parts.length === 4 && action !== "mute"))
118
+ throw new LayoutRefusal(404, notAnApi);
119
+ if (action === "mute") return this.#muteFolder(request, id, admission);
120
+ if (!id) {
121
+ if (method !== "POST") throw new LayoutRefusal(405, notAllowed);
122
+ await this.#options.owner(admission);
123
+ const { name } = await bodyOf(request);
124
+ if (typeof name !== "string")
125
+ throw new LayoutRefusal(
126
+ 400,
127
+ "Give the folder a name of 1 to 40 characters.",
128
+ );
129
+ const folder = `folder.${crypto.randomUUID()}`;
130
+ return json(
131
+ await this.#write((layout) => createFolder(layout, folder, name)),
132
+ 201,
133
+ );
134
+ }
135
+ if (method !== "PATCH" && method !== "DELETE")
136
+ throw new LayoutRefusal(405, notAllowed);
137
+ await this.#options.owner(admission);
138
+ if (!FOLDER_ID.test(id))
139
+ throw new LayoutRefusal(404, "There is no such folder.");
140
+ if (method === "DELETE")
141
+ return json(await this.#write((layout) => removeFolder(layout, id)));
142
+ const body = await bodyOf(request);
143
+ const { name } = body;
144
+ if (name !== undefined && typeof name !== "string")
145
+ throw bad("name must be text.");
146
+ const placed = "before" in body;
147
+ if (name === undefined && !placed) throw bad("Give a name or a place.");
148
+ const before = beforeOf(body);
149
+ return json(
150
+ await this.#write((layout) => {
151
+ let next = layout;
152
+ if (name !== undefined) next = renameFolder(next, id, name);
153
+ if (placed) next = placeFolder(next, id, before);
154
+ return next;
155
+ }),
156
+ );
157
+ });
158
+ }
159
+
160
+ /**
161
+ * `PUT api/folders/<id>/mute`: mutes or unmutes every room in the folder as one step, through the
162
+ * rooms' own mutes. A room moved into the folder later keeps its own mute.
163
+ */
164
+ async #muteFolder(
165
+ request: Request,
166
+ id: string | undefined,
167
+ admission: Authenticated,
168
+ ): Promise<Response> {
169
+ if (request.method !== "PUT") throw new LayoutRefusal(405, notAllowed);
170
+ const owner = await this.#options.owner(admission);
171
+ const folder = (await this.view()).folders.find((one) => one.id === id);
172
+ if (!folder) throw new LayoutRefusal(404, "There is no such folder.");
173
+ const { muted } = await bodyOf(request);
174
+ if (typeof muted !== "boolean") throw bad("muted must be true or false.");
175
+ await this.#options.mutes.setMany(folder.rooms, muted, owner.principalId);
176
+ this.#options.changed();
177
+ return json({ muted, rooms: folder.rooms });
178
+ }
179
+
180
+ /** `PUT api/rooms/<key>/folder` and `…/pin`, for a room that resolved. */
181
+ async place(
182
+ request: Request,
183
+ room: ResolvedRoom,
184
+ action: "folder" | "pin",
185
+ admission: Authenticated,
186
+ ): Promise<Response> {
187
+ return this.#guard(async () => {
188
+ await this.#options.owner(admission);
189
+ if (room.kind === "thread") throw bad("A thread stays under its room.");
190
+ if (room.key === this.#options.home())
191
+ throw bad("Home stays at the top.");
192
+ const body = await bodyOf(request);
193
+ const before = beforeOf(body);
194
+ if (action === "pin") {
195
+ const { pinned } = body;
196
+ if (typeof pinned !== "boolean")
197
+ throw bad("pinned must be true or false.");
198
+ return json(
199
+ await this.#write((layout) =>
200
+ setPinned(layout, room.key, pinned, before),
201
+ ),
202
+ );
203
+ }
204
+ const { folder } = body;
205
+ if (folder !== null && typeof folder !== "string")
206
+ throw bad("folder must be a folder id or null.");
207
+ return json(
208
+ await this.#write((layout) =>
209
+ placeRoom(layout, room.key, folder, before),
210
+ ),
211
+ );
212
+ });
213
+ }
214
+
215
+ /** Stores an edit, tells the other pages, and answers with the layout as readers see it. */
216
+ async #write(edit: (layout: LayoutView) => LayoutView): Promise<LayoutView> {
217
+ const stored = await this.#options.layout.change(edit);
218
+ this.#options.changed();
219
+ const rooms = this.#options.placeable();
220
+ return shown(stored, (key) => rooms.has(key));
221
+ }
222
+
223
+ async #guard(run: () => Promise<Response>): Promise<Response> {
224
+ try {
225
+ return await run();
226
+ } catch (error) {
227
+ const refusal = error instanceof LayoutError ? refusalOf(error) : error;
228
+ if (refusal instanceof LayoutRefusal)
229
+ return json(
230
+ { error: refusal.message } satisfies ApiError,
231
+ refusal.status,
232
+ );
233
+ throw error;
234
+ }
235
+ }
236
+ }
237
+
238
+ const notAllowed = "Method not allowed.";
239
+ const notAnApi = "There is no such API.";
@@ -0,0 +1,133 @@
1
+ import type { SQL } from "bun";
2
+ import type { Migration } from "pi-roundtable";
3
+ import type { LayoutView } from "../api-types.ts";
4
+ import { forgetRoom } from "../layout-order.ts";
5
+
6
+ /** What the layout routes need of the stored arrangement; `RoomLayout` is the database's. */
7
+ export interface LayoutStore {
8
+ /** Everything stored, including rooms that no longer exist. */
9
+ view(): Promise<LayoutView>;
10
+ /**
11
+ * Applies an edit to the stored layout as one step that no other edit overlaps, and returns the
12
+ * result. An edit that throws changes nothing.
13
+ */
14
+ change(edit: (layout: LayoutView) => LayoutView): Promise<LayoutView>;
15
+ /**
16
+ * Drops a room's pin and its place in a folder, for a room that is deleted for good (an archived
17
+ * agent returns to its place when it comes back, so archiving does not call this).
18
+ */
19
+ forget(room: string): Promise<void>;
20
+ }
21
+
22
+ interface FolderRow {
23
+ id: string;
24
+ name: string;
25
+ }
26
+ interface RoomRow {
27
+ room: string;
28
+ folder: string;
29
+ }
30
+
31
+ /**
32
+ * The arrangement of the room list, shared by every owner and device: the pinned rooms
33
+ * (`web_room_pins`), the folders (`web_room_folders`) and the rooms in them
34
+ * (`web_room_folder_rooms`). Positions are dense (0..n-1) within each list and rewritten on every
35
+ * change under one advisory lock, so two devices editing at once cannot leave a gap or a duplicate.
36
+ * Rows of rooms that no longer exist are kept, so a re-created agent returns to its place.
37
+ */
38
+ export class RoomLayout implements LayoutStore {
39
+ readonly #sql: SQL;
40
+
41
+ constructor(sql: SQL) {
42
+ this.#sql = sql;
43
+ }
44
+
45
+ static readonly migration: Migration = {
46
+ name: "web-room-layout",
47
+ up: async (sql) => {
48
+ await sql`
49
+ CREATE TABLE IF NOT EXISTS web_room_folders (
50
+ id text PRIMARY KEY,
51
+ name text NOT NULL,
52
+ position integer NOT NULL,
53
+ created_at timestamptz NOT NULL DEFAULT now()
54
+ )`;
55
+ await sql`
56
+ CREATE TABLE IF NOT EXISTS web_room_folder_rooms (
57
+ room text PRIMARY KEY,
58
+ folder text NOT NULL REFERENCES web_room_folders (id) ON DELETE CASCADE,
59
+ position integer NOT NULL
60
+ )`;
61
+ await sql`CREATE INDEX IF NOT EXISTS web_room_folder_rooms_folder ON web_room_folder_rooms (folder, position)`;
62
+ await sql`
63
+ CREATE TABLE IF NOT EXISTS web_room_pins (
64
+ room text PRIMARY KEY,
65
+ position integer NOT NULL
66
+ )`;
67
+ },
68
+ };
69
+
70
+ async forget(room: string): Promise<void> {
71
+ await this.change((layout) => forgetRoom(layout, room));
72
+ }
73
+
74
+ async view(): Promise<LayoutView> {
75
+ // The three SELECTs share one snapshot, so an edit that commits between them is seen whole or not at all.
76
+ return this.#sql.begin("isolation level repeatable read, read only", read);
77
+ }
78
+
79
+ async change(edit: (layout: LayoutView) => LayoutView): Promise<LayoutView> {
80
+ return this.#sql.begin(async (tx) => {
81
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended('web-room-layout', 0))`;
82
+ const before = await read(tx);
83
+ const after = edit(before);
84
+ if (JSON.stringify(after) !== JSON.stringify(before))
85
+ await write(tx, after);
86
+ return after;
87
+ });
88
+ }
89
+ }
90
+
91
+ async function read(tx: SQL): Promise<LayoutView> {
92
+ const folders: FolderRow[] = await tx`
93
+ SELECT id, name FROM web_room_folders ORDER BY position, created_at, id`;
94
+ const inside: RoomRow[] = await tx`
95
+ SELECT room, folder FROM web_room_folder_rooms ORDER BY folder, position`;
96
+ const pins: { room: string }[] = await tx`
97
+ SELECT room FROM web_room_pins ORDER BY position`;
98
+ return {
99
+ pins: pins.map((row) => row.room),
100
+ folders: folders.map((folder) => ({
101
+ id: folder.id,
102
+ name: folder.name,
103
+ rooms: inside
104
+ .filter((row) => row.folder === folder.id)
105
+ .map((r) => r.room),
106
+ })),
107
+ };
108
+ }
109
+
110
+ /** Rewrites the three tables to match the layout, positions dense. */
111
+ async function write(tx: SQL, layout: LayoutView): Promise<void> {
112
+ await tx`DELETE FROM web_room_folder_rooms`;
113
+ await tx`DELETE FROM web_room_pins`;
114
+ const ids = layout.folders.map((folder) => folder.id);
115
+ if (ids.length === 0) await tx`DELETE FROM web_room_folders`;
116
+ else await tx`DELETE FROM web_room_folders WHERE id NOT IN ${tx(ids)}`;
117
+ for (const [position, folder] of layout.folders.entries())
118
+ await tx`
119
+ INSERT INTO web_room_folders (id, name, position)
120
+ VALUES (${folder.id}, ${folder.name}, ${position})
121
+ ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, position = EXCLUDED.position`;
122
+ const pins = layout.pins.map((room, position) => ({ room, position }));
123
+ if (pins.length > 0) await tx`INSERT INTO web_room_pins ${tx(pins)}`;
124
+ const placed = layout.folders.flatMap((folder) =>
125
+ folder.rooms.map((room, position) => ({
126
+ room,
127
+ folder: folder.id,
128
+ position,
129
+ })),
130
+ );
131
+ if (placed.length > 0)
132
+ await tx`INSERT INTO web_room_folder_rooms ${tx(placed)}`;
133
+ }
@@ -0,0 +1,167 @@
1
+ import type {
2
+ PromptDocuments,
3
+ PromptDocumentView,
4
+ PromptVersion,
5
+ } from "pi-roundtable";
6
+ import { PromptConflictError } from "pi-roundtable";
7
+ import type {
8
+ PromptConflict,
9
+ PromptHistoryView,
10
+ PromptRevert,
11
+ PromptSave,
12
+ PromptsView,
13
+ PromptVersionView,
14
+ PromptView,
15
+ } from "../api-types.ts";
16
+ import { json } from "../http.ts";
17
+ import { bad, bodyOf, HttpError } from "./api-http.ts";
18
+ import type { OwnerOf } from "./team-routes.ts";
19
+
20
+ /** The most bytes a character takes in a JSON body: a control character is escaped as `\u00XX`. */
21
+ const BYTES_PER_CHAR = 6;
22
+ /** What a body carries besides the texts: the braces, the keys and some room. */
23
+ const BODY_OVERHEAD = 4 * 1024;
24
+
25
+ /**
26
+ * The largest body of a write to a document: the new text, at most `maxChars` characters, and
27
+ * `base`, the text in force when the edit began, which may be longer (a file version has no limit).
28
+ */
29
+ const bodyLimit = (document: PromptDocumentView): number =>
30
+ (Math.max(document.maxChars, document.text.length, document.fileText.length) +
31
+ document.maxChars) *
32
+ BYTES_PER_CHAR +
33
+ BODY_OVERHEAD;
34
+
35
+ export interface PromptApiOptions {
36
+ prompts: Pick<PromptDocuments, "list" | "get" | "set" | "revert" | "history">;
37
+ }
38
+
39
+ const viewOf = (document: PromptDocumentView): PromptView => ({
40
+ id: document.id,
41
+ label: document.label,
42
+ ...(document.description ? { description: document.description } : {}),
43
+ differs: document.differs,
44
+ ...(document.broken ? { broken: document.broken } : {}),
45
+ ...(document.override
46
+ ? {
47
+ override: {
48
+ by: document.override.by,
49
+ at: document.override.at.toISOString(),
50
+ },
51
+ }
52
+ : {}),
53
+ text: document.text,
54
+ fileText: document.fileText,
55
+ maxChars: document.maxChars,
56
+ });
57
+
58
+ /** The answer to a write, or 409 with the document as it is now when it was not the text the writer saw. */
59
+ async function changed(
60
+ write: () => Promise<PromptDocumentView>,
61
+ ): Promise<Response> {
62
+ try {
63
+ return json(viewOf(await write()));
64
+ } catch (error) {
65
+ if (!(error instanceof PromptConflictError)) throw error;
66
+ const refused = new HttpError(
67
+ 409,
68
+ "This prompt changed while you were editing it.",
69
+ );
70
+ return json(
71
+ {
72
+ error: refused.message,
73
+ conflict: "changed",
74
+ current: viewOf(error.current),
75
+ } satisfies PromptConflict,
76
+ refused.status,
77
+ );
78
+ }
79
+ }
80
+
81
+ export const promptVersionView = (
82
+ version: PromptVersion,
83
+ ): PromptVersionView => ({
84
+ id: version.id,
85
+ action: version.action,
86
+ text: version.text,
87
+ previous: version.previous,
88
+ by: version.by,
89
+ at: version.at.toISOString(),
90
+ });
91
+
92
+ /**
93
+ * The app's REST for the prompt documents: the main prompts the owner edits outside the files.
94
+ * Every call, a read too, is an owner's (the tier read again); a write is also checked for the
95
+ * console's `Origin` before it gets here. A save names the text it was edited from, and a document
96
+ * that changed meanwhile answers 409 with what is there now.
97
+ */
98
+ export class PromptApi {
99
+ readonly #options: PromptApiOptions;
100
+
101
+ constructor(options: PromptApiOptions) {
102
+ this.#options = options;
103
+ }
104
+
105
+ async handle(
106
+ request: Request,
107
+ parts: string[],
108
+ owner: OwnerOf,
109
+ ): Promise<Response> {
110
+ const { method } = request;
111
+ const [, , id, action] = parts;
112
+ const { prompts } = this.#options;
113
+ if (!id) {
114
+ if (method !== "GET") throw new HttpError(405, "Method not allowed.");
115
+ await owner();
116
+ return json({
117
+ documents: prompts
118
+ .list()
119
+ .map(
120
+ ({ differs, id: key, label, description, override, broken }) => ({
121
+ id: key,
122
+ label,
123
+ ...(description ? { description } : {}),
124
+ differs,
125
+ ...(broken ? { broken } : {}),
126
+ ...(override
127
+ ? {
128
+ override: {
129
+ by: override.by,
130
+ at: override.at.toISOString(),
131
+ },
132
+ }
133
+ : {}),
134
+ }),
135
+ ),
136
+ } satisfies PromptsView);
137
+ }
138
+ // The tier is read before the id is looked up, so a person below the owner learns of no id.
139
+ const speaker = await owner();
140
+ const document = prompts.get(id);
141
+ if (!document) throw new HttpError(404, "There is no such prompt.");
142
+ if (!action && method === "GET") return json(viewOf(document));
143
+ if (!action && method === "PUT") {
144
+ const body = await bodyOf(request, bodyLimit(document));
145
+ const { text, base } = body;
146
+ if (typeof text !== "string") throw bad("text must be text.");
147
+ if (typeof base !== "string")
148
+ throw bad("base must be the text the prompt was edited from.");
149
+ const save: PromptSave = { text, base };
150
+ // The registry compares `base` where the write waits its turn, so two saves cannot both pass.
151
+ return changed(() => prompts.set(id, save.text, speaker.name, save.base));
152
+ }
153
+ if (action === "revert" && method === "POST" && parts.length === 4) {
154
+ const body = await bodyOf(request, bodyLimit(document));
155
+ if (typeof body.base !== "string")
156
+ throw bad("base must be the text the prompt was reverted from.");
157
+ const revert: PromptRevert = { base: body.base };
158
+ return changed(() => prompts.revert(id, speaker.name, revert.base));
159
+ }
160
+ if (action === "history" && method === "GET" && parts.length === 4) {
161
+ return json({
162
+ versions: (await prompts.history(id)).map(promptVersionView),
163
+ } satisfies PromptHistoryView);
164
+ }
165
+ throw new HttpError(404, "There is no such API.");
166
+ }
167
+ }
@@ -123,6 +123,13 @@ export class RoomLog {
123
123
  };
124
124
  }
125
125
 
126
+ /** Deletes every message of a room that is gone for good; returns how many. */
127
+ async removeRoom(room: string): Promise<number> {
128
+ const rows: unknown[] = await this
129
+ .#sql`DELETE FROM web_room_messages WHERE room = ${room} RETURNING 1`;
130
+ return rows.length;
131
+ }
132
+
126
133
  /** The newest message of each of the rooms that has one. */
127
134
  async lastOf(rooms: readonly string[]): Promise<Map<string, RoomMessage>> {
128
135
  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) =>