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
@@ -0,0 +1,457 @@
1
+ import type {
2
+ AgentChange,
3
+ AgentDirectory,
4
+ AgentTeam,
5
+ ChannelKey,
6
+ ConversationModes,
7
+ Speaker,
8
+ } from "pi-roundtable";
9
+ import { agentRoomKey, characterOf, groupRoomKey } from "pi-roundtable";
10
+ import { resolveCharacter, suggestCharacter } from "pi-roundtable/characters";
11
+ import type {
12
+ AgentCreate,
13
+ AgentPatch,
14
+ AgentProfileView,
15
+ AgentSkillsChange,
16
+ AgentSkillsView,
17
+ ArchivedEntry,
18
+ ArchiveView,
19
+ CharacterRequest,
20
+ GroupCreate,
21
+ GroupPatch,
22
+ GroupProfileView,
23
+ PromptHistoryView,
24
+ SkillView,
25
+ } from "../api-types.ts";
26
+ import { json } from "../http.ts";
27
+ import type { PushMutes } from "../push/mutes.ts";
28
+ import {
29
+ bad,
30
+ bodyOf,
31
+ HttpError,
32
+ notFound,
33
+ optionalList,
34
+ optionalText,
35
+ requiredText,
36
+ } from "./api-http.ts";
37
+ import type { CardLog } from "./card-log.ts";
38
+ import type { LayoutStore } from "./layout.ts";
39
+ import { promptVersionView } from "./prompt-routes.ts";
40
+ import type { RoomLog } from "./room-log.ts";
41
+ import type { Rooms } from "./rooms.ts";
42
+ import type { WebThreads } from "./threads.ts";
43
+
44
+ /** The largest body of a character write: 4 KiB. */
45
+ const MAX_CHARACTER_BYTES = 4 * 1024;
46
+
47
+ export interface TeamApiOptions {
48
+ /** The key prefix of the app's rooms, such as `app`. */
49
+ surface: string;
50
+ rooms: Pick<Rooms, "avatarOf">;
51
+ directory: Pick<AgentDirectory, "agent" | "agents" | "group" | "groups">;
52
+ team: Pick<
53
+ AgentTeam,
54
+ | "update"
55
+ | "promptHistory"
56
+ | "dress"
57
+ | "modelOf"
58
+ | "createAgent"
59
+ | "archive"
60
+ | "restore"
61
+ | "remove"
62
+ | "createGroup"
63
+ | "updateGroup"
64
+ >;
65
+ skills?:
66
+ | {
67
+ catalog(): SkillView[];
68
+ attach(
69
+ agent: string,
70
+ add: readonly string[],
71
+ remove: readonly string[],
72
+ ): Promise<string[]>;
73
+ }
74
+ | undefined;
75
+ /** What a deleted agent's or group's room leaves behind in the app. */
76
+ log: Pick<RoomLog, "removeRoom">;
77
+ cards: Pick<CardLog, "removeRoom">;
78
+ mutes: Pick<PushMutes, "set">;
79
+ modes: Pick<ConversationModes, "set">;
80
+ /** The dispatch threads under a room, which go with it. */
81
+ threads: Pick<WebThreads, "under" | "removeUnder">;
82
+ /** The owners' shared arrangement, which drops a deleted room's pin and folder place. */
83
+ layout: Pick<LayoutStore, "forget">;
84
+ changed(): void;
85
+ }
86
+
87
+ /** The owner who writes: their tier read again. */
88
+ export type OwnerOf = () => Promise<Speaker>;
89
+
90
+ /**
91
+ * The app's REST for the team: reading and editing an agent, making one, archiving, restoring and
92
+ * deleting agents and groups, an agent's skills, and the groups. Everything goes through the agent
93
+ * server's own operations (`AgentTeam`), so an agent made here is the one `agent_create` makes and
94
+ * an archived one the one `archive` leaves; this class only checks the request and words the answer.
95
+ */
96
+ export class TeamApi {
97
+ readonly #options: TeamApiOptions;
98
+
99
+ constructor(options: TeamApiOptions) {
100
+ this.#options = options;
101
+ }
102
+
103
+ // ── Agents ─────────────────────────────────────────────────────────────
104
+
105
+ async agents(
106
+ request: Request,
107
+ parts: string[],
108
+ owner: OwnerOf,
109
+ ): Promise<Response> {
110
+ const { method } = request;
111
+ const [, , name, action] = parts;
112
+ const { directory } = this.#options;
113
+ // The owner check comes first, so no answer tells a member which names and states exist.
114
+ // An agent's profile is the one read open to a member.
115
+ const speaker =
116
+ method === "GET" && name && !action ? undefined : await owner();
117
+ const asOwner: OwnerOf = async () => speaker ?? owner();
118
+ if (!name) {
119
+ if (method !== "POST") throw new HttpError(405, "Method not allowed.");
120
+ return this.#createAgent(request, asOwner);
121
+ }
122
+ const agent = directory.agent(name);
123
+ if (!agent) throw notFound();
124
+ const active = agent.status === "active";
125
+ if (action === "restore" && method === "POST" && parts.length === 4) {
126
+ if (active) throw bad(`"${name}" is not archived.`);
127
+ await this.#options.team.restore(name);
128
+ this.#options.changed();
129
+ return json(this.#profile(name));
130
+ }
131
+ if (!action && method === "DELETE") {
132
+ const by = await asOwner();
133
+ // The app's part runs inside the delete, after its checks and before the row goes: a
134
+ // cleanup that fails leaves the agent there to delete again.
135
+ const room = agentRoomKey(this.#options.surface, name);
136
+ await this.#options.team.remove(name, () => this.#forget(room, by));
137
+ this.#options.changed();
138
+ return json({ ok: true });
139
+ }
140
+ // Everything else is about an agent that answers.
141
+ if (!active) throw notFound();
142
+ if (!action && method === "GET") return json(this.#profile(name));
143
+ if (!action && method === "PATCH")
144
+ return this.#patch(request, name, asOwner);
145
+ if (action === "prompt-history" && method === "GET" && parts.length === 4) {
146
+ await asOwner();
147
+ return json({
148
+ versions: (await this.#options.team.promptHistory(name)).map(
149
+ promptVersionView,
150
+ ),
151
+ } satisfies PromptHistoryView);
152
+ }
153
+ if (action === "character" && method === "PUT")
154
+ return this.#dress(request, name, asOwner);
155
+ if (action === "archive" && method === "POST" && parts.length === 4) {
156
+ await this.#options.team.archive(undefined, name);
157
+ this.#options.changed();
158
+ return json({ ok: true });
159
+ }
160
+ if (action === "skills" && parts.length === 4) {
161
+ if (method === "GET") return json(this.#skillsOf(name));
162
+ if (method === "PUT") return this.#carry(request, name, asOwner);
163
+ }
164
+ throw new HttpError(404, "There is no such API.");
165
+ }
166
+
167
+ async #createAgent(request: Request, owner: OwnerOf): Promise<Response> {
168
+ const speaker = await owner();
169
+ const body = await bodyOf(request);
170
+ const name = requiredText(body, "name");
171
+ const displayName = requiredText(body, "displayName");
172
+ const prompt = requiredText(body, "prompt");
173
+ const model = optionalText(body, "model");
174
+ const thinking = optionalText(body, "thinking");
175
+ const task = optionalText(body, "task")?.trim();
176
+ const skills = optionalList(body, "skills");
177
+ const { character } = body;
178
+ if (
179
+ character !== undefined &&
180
+ (typeof character !== "object" ||
181
+ character === null ||
182
+ Array.isArray(character))
183
+ )
184
+ throw bad("character must be an object.");
185
+ const made: AgentCreate = {
186
+ name,
187
+ displayName,
188
+ prompt,
189
+ ...(model !== undefined ? { model } : {}),
190
+ ...(thinking !== undefined ? { thinking } : {}),
191
+ ...(task ? { task } : {}),
192
+ ...(skills && skills.length > 0 ? { skills } : {}),
193
+ ...(character !== undefined
194
+ ? { character: character as NonNullable<AgentCreate["character"]> }
195
+ : {}),
196
+ };
197
+ // `createAgent` words a refusal of the look as an agent error; checking here first names the field.
198
+ if (made.character)
199
+ resolveCharacter(
200
+ made.character,
201
+ suggestCharacter({ name, displayName, prompt }),
202
+ );
203
+ // The opening task starts in the app's room for the agent, where the owner is looking.
204
+ await this.#options.team.createAgent(speaker, {
205
+ ...made,
206
+ openIn: this.#options.surface,
207
+ });
208
+ this.#options.changed();
209
+ return json(this.#profile(name), 201);
210
+ }
211
+
212
+ async #patch(
213
+ request: Request,
214
+ name: string,
215
+ owner: OwnerOf,
216
+ ): Promise<Response> {
217
+ const speaker = await owner();
218
+ const body = await bodyOf(request);
219
+ const patch: AgentPatch = {};
220
+ for (const key of ["displayName", "prompt", "model", "thinking"] as const) {
221
+ const value = optionalText(body, key);
222
+ if (value !== undefined) patch[key] = value;
223
+ }
224
+ const change: AgentChange = patch;
225
+ // A changed prompt is kept in the agent's history under the owner's name.
226
+ await this.#options.team.update(name, change, speaker.name);
227
+ this.#options.changed();
228
+ return json(this.#profile(name));
229
+ }
230
+
231
+ async #dress(
232
+ request: Request,
233
+ name: string,
234
+ owner: OwnerOf,
235
+ ): Promise<Response> {
236
+ await owner();
237
+ const body = await bodyOf(request, MAX_CHARACTER_BYTES);
238
+ const { character } = body;
239
+ if (
240
+ typeof character !== "object" ||
241
+ character === null ||
242
+ Array.isArray(character)
243
+ )
244
+ throw bad("character must be an object.");
245
+ const asked: CharacterRequest = {
246
+ character: character as CharacterRequest["character"],
247
+ };
248
+ const agent = this.#options.directory.agent(name);
249
+ if (!agent) throw notFound();
250
+ // `dress` words a refusal as an agent error; checking here first names the field.
251
+ resolveCharacter(asked.character, characterOf(agent));
252
+ await this.#options.team.dress(name, asked.character);
253
+ this.#options.changed();
254
+ return json(this.#profile(name));
255
+ }
256
+
257
+ /** Every skill of the host's catalog, and whether this agent carries it. */
258
+ #skillsOf(name: string): AgentSkillsView {
259
+ const { skills } = this.#options;
260
+ if (!skills) throw new HttpError(404, "This host has no skills.");
261
+ return {
262
+ skills: skills.catalog().map((entry) => {
263
+ const builtin = entry.source.kind === "builtin";
264
+ return {
265
+ name: entry.name,
266
+ ...(entry.description ? { description: entry.description } : {}),
267
+ source: entry.source.kind,
268
+ builtin,
269
+ carried: builtin || entry.carriers.includes(name),
270
+ ...(entry.missing ? { missing: entry.missing } : {}),
271
+ };
272
+ }),
273
+ };
274
+ }
275
+
276
+ async #carry(
277
+ request: Request,
278
+ name: string,
279
+ owner: OwnerOf,
280
+ ): Promise<Response> {
281
+ const { skills } = this.#options;
282
+ if (!skills) throw new HttpError(404, "This host has no skills.");
283
+ await owner();
284
+ const body = await bodyOf(request);
285
+ const add = optionalList(body, "add") ?? [];
286
+ const remove = optionalList(body, "remove") ?? [];
287
+ const change: AgentSkillsChange = { add, remove };
288
+ if (add.length === 0 && remove.length === 0)
289
+ throw bad("Give add or remove.");
290
+ // One call, so an unknown or built-in name refuses the whole change.
291
+ await skills.attach(name, change.add ?? [], change.remove ?? []);
292
+ this.#options.changed();
293
+ return json(this.#skillsOf(name));
294
+ }
295
+
296
+ #profile(name: string): AgentProfileView {
297
+ const { directory, team, rooms, surface } = this.#options;
298
+ const agent = directory.agent(name);
299
+ if (!agent) throw notFound();
300
+ const effective = team.modelOf(name);
301
+ return {
302
+ name: agent.name,
303
+ displayName: agent.displayName,
304
+ prompt: agent.prompt,
305
+ ...(agent.model ? { model: agent.model } : {}),
306
+ ...(agent.thinking ? { thinking: agent.thinking } : {}),
307
+ effective: {
308
+ model: effective.model,
309
+ thinking: String(effective.thinking),
310
+ },
311
+ avatar: rooms.avatarOf(agent),
312
+ character: characterOf(agent),
313
+ room: agentRoomKey(surface, agent.name),
314
+ };
315
+ }
316
+
317
+ // ── Groups ─────────────────────────────────────────────────────────────
318
+
319
+ async groups(
320
+ request: Request,
321
+ parts: string[],
322
+ owner: OwnerOf,
323
+ ): Promise<Response> {
324
+ const { method } = request;
325
+ const [, , name, action] = parts;
326
+ const { directory, team } = this.#options;
327
+ // The owner check comes first, so no answer tells a member which names and states exist.
328
+ const speaker = await owner();
329
+ if (!name) {
330
+ if (method !== "POST") throw new HttpError(405, "Method not allowed.");
331
+ const body = await bodyOf(request);
332
+ const members = optionalList(body, "members");
333
+ if (!members) throw bad("members must be a list of text.");
334
+ const host = optionalText(body, "host");
335
+ const made: GroupCreate = {
336
+ name: requiredText(body, "name"),
337
+ displayName: requiredText(body, "displayName"),
338
+ members,
339
+ ...(host !== undefined ? { host } : {}),
340
+ };
341
+ await team.createGroup(made);
342
+ this.#options.changed();
343
+ return json(this.#group(made.name), 201);
344
+ }
345
+ const group = directory.group(name);
346
+ if (!group) throw notFound();
347
+ const active = group.status === "active";
348
+ if (action === "restore" && method === "POST" && parts.length === 4) {
349
+ if (active) throw bad(`"${name}" is not archived.`);
350
+ await team.restore(name);
351
+ this.#options.changed();
352
+ return json(this.#group(name));
353
+ }
354
+ if (!action && method === "DELETE") {
355
+ const room = groupRoomKey(this.#options.surface, name);
356
+ await team.remove(name, () => this.#forget(room, speaker));
357
+ this.#options.changed();
358
+ return json({ ok: true });
359
+ }
360
+ if (!active) throw notFound();
361
+ if (!action && method === "GET") return json(this.#group(name));
362
+ if (!action && method === "PATCH") {
363
+ const body = await bodyOf(request);
364
+ const displayName = optionalText(body, "displayName");
365
+ const members = optionalList(body, "members");
366
+ const host = optionalText(body, "host");
367
+ const patch: GroupPatch = {
368
+ ...(displayName !== undefined ? { displayName } : {}),
369
+ ...(members !== undefined ? { members } : {}),
370
+ ...(host !== undefined ? { host } : {}),
371
+ };
372
+ if (Object.keys(patch).length === 0)
373
+ throw bad("Give displayName, members or host.");
374
+ await team.updateGroup(name, patch);
375
+ this.#options.changed();
376
+ return json(this.#group(name));
377
+ }
378
+ if (action === "archive" && method === "POST" && parts.length === 4) {
379
+ await team.archive(undefined, name);
380
+ this.#options.changed();
381
+ return json({ ok: true });
382
+ }
383
+ throw new HttpError(404, "There is no such API.");
384
+ }
385
+
386
+ #group(name: string): GroupProfileView {
387
+ const group = this.#options.directory.group(name);
388
+ if (!group) throw notFound();
389
+ return {
390
+ name: group.name,
391
+ displayName: group.displayName,
392
+ members: [...group.members],
393
+ host: group.host,
394
+ room: groupRoomKey(this.#options.surface, group.name),
395
+ };
396
+ }
397
+
398
+ // ── The archive ────────────────────────────────────────────────────────
399
+
400
+ /** Archived agents and groups, which stay reachable here until they are restored or deleted. */
401
+ async archived(owner: OwnerOf): Promise<Response> {
402
+ await owner();
403
+ const { directory, rooms } = this.#options;
404
+ const entries: ArchivedEntry[] = [
405
+ ...directory
406
+ .agents()
407
+ .filter((agent) => agent.status === "archived")
408
+ .map(
409
+ (agent): ArchivedEntry => ({
410
+ kind: "agent",
411
+ name: agent.name,
412
+ displayName: agent.displayName,
413
+ avatar: rooms.avatarOf(agent),
414
+ character: characterOf(agent),
415
+ }),
416
+ ),
417
+ ...directory
418
+ .groups()
419
+ .filter((group) => group.status === "archived")
420
+ .map(
421
+ (group): ArchivedEntry => ({
422
+ kind: "group",
423
+ name: group.name,
424
+ displayName: group.displayName,
425
+ members: [...group.members],
426
+ }),
427
+ ),
428
+ ].sort((a, b) => a.name.localeCompare(b.name));
429
+ return json({ entries } satisfies ArchiveView);
430
+ }
431
+
432
+ /**
433
+ * What the app kept of a room that is gone for good: its messages, cards, mute, and pin or
434
+ * folder place, and its dispatch threads with theirs. It can run again after a failure: the threads go last, so
435
+ * until then the retry still finds them.
436
+ */
437
+ async #forget(room: string, by: Speaker): Promise<void> {
438
+ const { log, cards, mutes, modes, threads, layout, surface } =
439
+ this.#options;
440
+ for (const thread of await threads.under(room)) {
441
+ const threadRoom = `${surface}:${thread.id}`;
442
+ await log.removeRoom(threadRoom);
443
+ await cards.removeRoom(threadRoom);
444
+ }
445
+ await threads.removeUnder(room);
446
+ await log.removeRoom(room);
447
+ await cards.removeRoom(room);
448
+ await mutes.set(room, false, by.principalId);
449
+ await layout.forget(room);
450
+ // A name made again later starts with the modes of a new room.
451
+ await modes.set(
452
+ room as ChannelKey,
453
+ { yolo: false, longRunning: false },
454
+ by,
455
+ );
456
+ }
457
+ }
@@ -82,6 +82,20 @@ export class WebThreads {
82
82
  return row && threadOf(row);
83
83
  }
84
84
 
85
+ /** Every thread under `parent`, open or closed. */
86
+ async under(parent: string): Promise<WebThread[]> {
87
+ const rows: Row[] = await this.#sql`
88
+ SELECT * FROM web_threads WHERE parent = ${parent} ORDER BY created_at, id`;
89
+ return rows.map(threadOf);
90
+ }
91
+
92
+ /** Deletes every thread under a room that is gone for good; returns how many. */
93
+ async removeUnder(parent: string): Promise<number> {
94
+ const rows: unknown[] = await this
95
+ .#sql`DELETE FROM web_threads WHERE parent = ${parent} RETURNING 1`;
96
+ return rows.length;
97
+ }
98
+
85
99
  /**
86
100
  * The threads the room list shows, newest first: every open one, and the `closedLimit` newest
87
101
  * closed ones, which stay readable but are not kept in view for ever.
@@ -1,7 +1,12 @@
1
1
  import { extname } from "node:path";
2
2
  import type { AttachmentFile, AttachmentRef } from "pi-roundtable";
3
3
  import type { RoomFile, UploadView } from "../api-types.ts";
4
- import { FILE_RETENTION_MS, type FileDir, safeName } from "./files.ts";
4
+ import {
5
+ FILE_RETENTION_MS,
6
+ type FileBinding,
7
+ type FileDir,
8
+ safeName,
9
+ } from "./files.ts";
5
10
  import {
6
11
  boundaryOf,
7
12
  MultipartError,
@@ -32,7 +37,7 @@ const startsWith = (bytes: Uint8Array, signature: readonly number[], at = 0) =>
32
37
  const ascii = (text: string) => [...text].map((char) => char.charCodeAt(0));
33
38
 
34
39
  /** The image type the first bytes say, if any; these four are the ones the page shows itself. */
35
- function imageTypeOf(head: Uint8Array): string | undefined {
40
+ export function imageTypeOf(head: Uint8Array): string | undefined {
36
41
  if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
37
42
  return "image/png";
38
43
  if (startsWith(head, [0xff, 0xd8, 0xff])) return "image/jpeg";
@@ -153,6 +158,8 @@ export class Uploads {
153
158
  readonly #now: () => number;
154
159
  readonly maxBytes: number;
155
160
  readonly #pendingBytes: number;
161
+ /** How long a stored file of an agent room is kept, in milliseconds. */
162
+ readonly #keepMs: number;
156
163
 
157
164
  constructor(
158
165
  waiting: FileDir,
@@ -161,6 +168,8 @@ export class Uploads {
161
168
  maxBytes?: number;
162
169
  /** The most bytes one person may have waiting; default 400 MiB. */
163
170
  pendingBytes?: number;
171
+ /** How many days a stored file of an agent room is kept; default 30. */
172
+ keepDays?: number;
164
173
  now?: () => number;
165
174
  } = {},
166
175
  ) {
@@ -169,6 +178,10 @@ export class Uploads {
169
178
  this.#now = options.now ?? Date.now;
170
179
  this.maxBytes = options.maxBytes ?? DEFAULT_UPLOAD_MAX_BYTES;
171
180
  this.#pendingBytes = options.pendingBytes ?? PENDING_BYTES_PER_PRINCIPAL;
181
+ this.#keepMs =
182
+ options.keepDays === undefined
183
+ ? FILE_RETENTION_MS
184
+ : options.keepDays * 24 * 3_600_000;
172
185
  }
173
186
 
174
187
  #waitingOf(principalId: string): { files: number; bytes: number } {
@@ -303,11 +316,15 @@ export class Uploads {
303
316
  return files;
304
317
  }
305
318
 
306
- /** Moves claimed uploads into the stored files, once their message is accepted. */
307
- use(ids: readonly string[]): void {
319
+ /**
320
+ * Moves claimed uploads into the stored files, once their message is accepted. `binding` ties
321
+ * them to the host room the message is in, so only that room's readers are served them.
322
+ */
323
+ use(ids: readonly string[], binding?: FileBinding): void {
308
324
  for (const id of ids) {
309
325
  this.#pending.delete(id);
310
- this.#waiting.moveTo(id, this.#stored);
326
+ if (this.#waiting.moveTo(id, this.#stored) && binding)
327
+ this.#stored.bind(id, binding);
311
328
  }
312
329
  }
313
330
 
@@ -334,7 +351,11 @@ export class Uploads {
334
351
  return { path };
335
352
  }
336
353
 
337
- /** Deletes the waiting uploads older than a day and the stored files older than 30 days; returns how many. */
354
+ /**
355
+ * Deletes the waiting uploads older than a day and the stored files that are due; returns how
356
+ * many. A file of an agent room is due after `keepDays` (default 30); a file bound to a host
357
+ * room is due when its own expiry passes, and kept when it has none.
358
+ */
338
359
  sweep(): number {
339
360
  const now = this.#now();
340
361
  let dropped = 0;
@@ -343,7 +364,12 @@ export class Uploads {
343
364
  this.#pending.delete(id);
344
365
  dropped += 1;
345
366
  }
346
- for (const id of this.#stored.older(FILE_RETENTION_MS, now)) {
367
+ for (const id of this.#stored.ids()) {
368
+ const binding = this.#stored.binding(id);
369
+ const due = binding
370
+ ? binding.expiresAt !== undefined && binding.expiresAt <= now
371
+ : (this.#stored.ageOf(id, now) ?? 0) > this.#keepMs;
372
+ if (!due) continue;
347
373
  this.#stored.remove(id);
348
374
  dropped += 1;
349
375
  }