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
package/src/app/routes.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type {
2
- AgentChange,
3
2
  AgentDirectory,
4
3
  AgentTeam,
5
4
  AvatarStudio,
@@ -9,23 +8,22 @@ import type {
9
8
  Logger,
10
9
  ModeChange,
11
10
  ScheduleStore,
11
+ SkillRegistry,
12
12
  Speaker,
13
13
  } from "pi-roundtable";
14
- import { characterOf, PluginError } from "pi-roundtable";
15
- import { CharacterError, resolveCharacter } from "pi-roundtable/characters";
14
+ import { PluginError } from "pi-roundtable";
15
+ import { CharacterError } from "pi-roundtable/characters";
16
16
  import { AgentError } from "pi-roundtable/kit";
17
17
  import type {
18
- AgentPatch,
19
- AgentProfileView,
20
18
  ApiError,
21
19
  CharacterRefusal,
22
- CharacterRequest,
23
20
  MessagesView,
24
21
  ModelsView,
25
22
  RoomsView,
26
23
  SchedulesView,
27
24
  ScheduleView,
28
25
  SessionView,
26
+ SkillView,
29
27
  WorkspaceConflict,
30
28
  WorkspaceNew,
31
29
  WorkspaceRefusalBody,
@@ -34,14 +32,27 @@ import type {
34
32
  WorkspaceSave,
35
33
  } from "../api-types.ts";
36
34
  import type { Authenticated } from "../auth/authenticator.ts";
35
+ import { type ConsolePresentation, message } from "../features.ts";
36
+ import type { Gate } from "../gate.ts";
37
+ import type { HostApp } from "../host/host-app.ts";
38
+ import type { RoomReads } from "../host/reads.ts";
39
+ import { readsOf, withExpiredFiles } from "../host/room-views.ts";
37
40
  import { json } from "../http.ts";
41
+ import type { ResolvedOptions } from "../options.ts";
38
42
  import type { PushMutes } from "../push/mutes.ts";
39
- import { readLimited } from "../request-body.ts";
43
+ import { viewerOf, type WebAppRegistry } from "../web-app.ts";
44
+ import { bad, bodyOf, HttpError, notFound } from "./api-http.ts";
40
45
  import type { CardLog } from "./card-log.ts";
41
46
  import { type FileDir, fileResponse, isFileId } from "./files.ts";
47
+ import type { LayoutStore } from "./layout.ts";
48
+ import { LayoutRoutes } from "./layout-routes.ts";
49
+ import { PromptApi, type PromptApiOptions } from "./prompt-routes.ts";
42
50
  import { PAGE_LIMIT, type RoomLog } from "./room-log.ts";
43
51
  import type { Rooms } from "./rooms.ts";
52
+ import { SkillDocApi } from "./skill-doc-routes.ts";
44
53
  import type { AppSurface } from "./surface.ts";
54
+ import { TeamApi } from "./team-routes.ts";
55
+ import type { WebThreads } from "./threads.ts";
45
56
  import { UploadRefusal, type Uploads } from "./uploads.ts";
46
57
  import {
47
58
  EDIT_MAX_BYTES,
@@ -50,38 +61,82 @@ import {
50
61
  WorkspaceRefusal,
51
62
  } from "./workspace-files.ts";
52
63
 
53
- /** The largest JSON body a write reads: 64 KiB. */
54
- const MAX_BODY_BYTES = 64 * 1024;
55
- /** The largest body of a character write: 4 KiB. */
56
- const MAX_CHARACTER_BYTES = 4 * 1024;
57
-
58
64
  export interface AppApiOptions {
65
+ /** The host's wording: a refusal reaches a client of the API in this locale when the catalog has it. */
66
+ presentation?: ConsolePresentation;
59
67
  surface: AppSurface;
60
68
  rooms: Rooms;
61
- log: RoomLog;
62
- cards: CardLog;
69
+ log: Pick<RoomLog, "page" | "lastOf" | "removeRoom">;
70
+ cards: Pick<CardLog, "during" | "removeRoom">;
63
71
  uploads: Uploads;
64
72
  /** Where stored files are read from. */
65
73
  files: FileDir;
66
74
  modes: Pick<ConversationModes, "set" | "peek">;
67
75
  /** The rooms whose push the owners muted. */
68
- mutes: Pick<PushMutes, "set">;
76
+ mutes: Pick<PushMutes, "set" | "setMany">;
77
+ /** The dispatch threads, which go with the room they hang under. */
78
+ threads: Pick<WebThreads, "under" | "removeUnder">;
79
+ /** The owners' shared arrangement of the room list: pins and folders. */
80
+ layout: LayoutStore;
69
81
  conversations: Pick<ConversationPort, "startFresh">;
70
- identity: Pick<IdentityService, "tierOf">;
82
+ identity: Pick<IdentityService, "principal">;
71
83
  /** Present when the schedules pane is served. */
72
- schedules?: Pick<ScheduleStore, "all" | "remove">;
73
- directory: Pick<AgentDirectory, "agent">;
84
+ schedules?: Pick<ScheduleStore, "all" | "get" | "remove">;
85
+ directory: Pick<AgentDirectory, "agent" | "agents" | "group" | "groups">;
74
86
  team: Pick<
75
87
  AgentTeam,
76
- "update" | "dress" | "usableModels" | "modelOf" | "defaultModel"
88
+ | "update"
89
+ | "promptHistory"
90
+ | "dress"
91
+ | "usableModels"
92
+ | "modelOf"
93
+ | "defaultModel"
94
+ | "createAgent"
95
+ | "archive"
96
+ | "restore"
97
+ | "remove"
98
+ | "createGroup"
99
+ | "updateGroup"
77
100
  >;
101
+ /** Present when the host has the skills addon: the catalog an agent's skills are chosen from, and the change of what it carries. */
102
+ skills?: {
103
+ catalog(): SkillView[];
104
+ attach(
105
+ agent: string,
106
+ add: readonly string[],
107
+ remove: readonly string[],
108
+ ): Promise<string[]>;
109
+ /** The registry's documents: a skill's SKILL.md read, saved and made, and its versions. */
110
+ documents: Pick<
111
+ SkillRegistry,
112
+ "document" | "saveDocument" | "createDocument" | "documentHistory"
113
+ >;
114
+ };
115
+ /** Present when the host has prompt documents (the prompts plugin): the main prompts the owner edits. */
116
+ prompts?: PromptApiOptions["prompts"];
78
117
  avatars: Pick<AvatarStudio, "serve">;
118
+ /** The read cursors, for each room's unread count and who has read what. */
119
+ reads?: Pick<RoomReads, "unread" | "cursorOf" | "marks">;
120
+ /** Who may be served a file bound to a host room or a page. */
121
+ host?: Pick<HostApp, "mayRead">;
122
+ /** The name the app shows for a person. */
123
+ nameOf?(principalId: string): Promise<string>;
79
124
  /** Present when the workspace pane is served. */
80
125
  workspace?: WorkspaceFiles;
81
126
  /** The mount, which a room's agent picture hangs under. */
82
127
  mount: string;
83
128
  /** How the console signs the person in, as the session reports it. */
84
129
  authMode: SessionView["auth"];
130
+ /** Who the app admits; the session reports the person's tier only where it admits more than owners. */
131
+ admission?: ResolvedOptions["admission"];
132
+ /** The area handlers the pieces of the app plug in; without it the API is the owners' as in 0.25. */
133
+ registry?: Pick<WebAppRegistry, "areaFor">;
134
+ /** The onboarding gate; without it nobody is gated. */
135
+ gate?: Pick<Gate, "required">;
136
+ /** The tiers whose people may use push; default owners. */
137
+ pushTiers?: ResolvedOptions["push"]["tiers"];
138
+ /** Whether the yolo and long-running switches are served; `PUT rooms/<key>/mode` answers 404 without them. Default true. */
139
+ modeSwitches?: boolean;
85
140
  /** The quiet hours of push, as the settings page shows them. */
86
141
  quietHours?: NonNullable<SessionView["quietHours"]>;
87
142
  /** Called after a change the other pages should refetch. */
@@ -89,19 +144,6 @@ export interface AppApiOptions {
89
144
  logger: Logger;
90
145
  }
91
146
 
92
- class HttpError extends Error {
93
- constructor(
94
- readonly status: number,
95
- message: string,
96
- ) {
97
- super(message);
98
- }
99
- }
100
-
101
- const bad = (message: string) => new HttpError(400, message);
102
- const notFound = () =>
103
- new HttpError(404, "There is no such room, agent or file.");
104
-
105
147
  /** A refusal of the workspace pane as the HTTP error the owner reads. */
106
148
  function refusalOf(refusal: WorkspaceRefusal): HttpError {
107
149
  const { status } = refusal;
@@ -183,33 +225,6 @@ export function partsOf(path: string): string[] | undefined {
183
225
  }
184
226
  }
185
227
 
186
- async function bodyOf(
187
- request: Request,
188
- limit = MAX_BODY_BYTES,
189
- ): Promise<Record<string, unknown>> {
190
- const raw = await readLimited(request, limit);
191
- if (!raw) throw new HttpError(413, "The body is too large.");
192
- let value: unknown;
193
- try {
194
- value = JSON.parse(raw.toString("utf8"));
195
- } catch {
196
- throw bad("The body is not JSON.");
197
- }
198
- if (typeof value !== "object" || value === null || Array.isArray(value))
199
- throw bad("The body is not an object.");
200
- return value as Record<string, unknown>;
201
- }
202
-
203
- const optionalText = (
204
- body: Record<string, unknown>,
205
- key: string,
206
- ): string | undefined => {
207
- const value = body[key];
208
- if (value === undefined) return undefined;
209
- if (typeof value !== "string") throw bad(`${key} must be text.`);
210
- return value;
211
- };
212
-
213
228
  /**
214
229
  * The owner app's REST under `<mount>/api/` and `<mount>/avatars/`. Every route is behind the
215
230
  * authenticator, which also checks the `Origin` of a write; a write also reads the person's tier
@@ -218,9 +233,44 @@ const optionalText = (
218
233
  */
219
234
  export class AppApi {
220
235
  readonly #options: AppApiOptions;
236
+ readonly #team: TeamApi;
237
+ readonly #layout: LayoutRoutes;
238
+ readonly #prompts: PromptApi | undefined;
239
+ readonly #skillDocs: SkillDocApi | undefined;
221
240
 
222
241
  constructor(options: AppApiOptions) {
223
242
  this.#options = options;
243
+ this.#layout = new LayoutRoutes({
244
+ layout: options.layout,
245
+ mutes: options.mutes,
246
+ placeable: () => options.rooms.placeable(),
247
+ home: () => options.rooms.homeKey(),
248
+ owner: (admission) => this.#owner(admission),
249
+ changed: () => options.changed(),
250
+ });
251
+ this.#prompts = options.prompts
252
+ ? new PromptApi({ prompts: options.prompts })
253
+ : undefined;
254
+ this.#skillDocs = options.skills
255
+ ? new SkillDocApi({
256
+ skills: options.skills.documents,
257
+ catalog: () => options.skills?.catalog() ?? [],
258
+ })
259
+ : undefined;
260
+ this.#team = new TeamApi({
261
+ surface: options.surface.surface,
262
+ rooms: options.rooms,
263
+ directory: options.directory,
264
+ team: options.team,
265
+ skills: options.skills,
266
+ log: options.log,
267
+ cards: options.cards,
268
+ mutes: options.mutes,
269
+ modes: options.modes,
270
+ threads: options.threads,
271
+ layout: options.layout,
272
+ changed: () => options.changed(),
273
+ });
224
274
  }
225
275
 
226
276
  /** Answers a path of the app's, or undefined for any other. `rest` is what follows the mount. */
@@ -228,25 +278,76 @@ export class AppApi {
228
278
  request: Request,
229
279
  rest: string,
230
280
  admission: Authenticated,
281
+ ): Promise<Response | undefined> {
282
+ const response = await this.#answer(request, rest, admission);
283
+ return response && this.#options.presentation
284
+ ? this.#worded(response, this.#options.presentation)
285
+ : response;
286
+ }
287
+
288
+ /** A refusal's `error` in the host's wording; the page words it again from the same catalog and finds the wording already there. */
289
+ async #worded(
290
+ response: Response,
291
+ presentation: ConsolePresentation,
292
+ ): Promise<Response> {
293
+ if (response.status < 400) return response;
294
+ const body: unknown = await response
295
+ .clone()
296
+ .json()
297
+ .catch(() => undefined);
298
+ const error = (body as { error?: unknown } | undefined)?.error;
299
+ if (typeof error !== "string") return response;
300
+ const worded = message(presentation, error);
301
+ if (worded === error) return response;
302
+ const headers = new Headers(response.headers);
303
+ headers.delete("content-length");
304
+ return new Response(
305
+ JSON.stringify({ ...(body as object), error: worded }),
306
+ {
307
+ status: response.status,
308
+ headers,
309
+ },
310
+ );
311
+ }
312
+
313
+ async #answer(
314
+ request: Request,
315
+ rest: string,
316
+ admission: Authenticated,
231
317
  ): Promise<Response | undefined> {
232
318
  const parts = partsOf(rest);
233
319
  if (!parts) return undefined;
234
320
  const [first, second] = parts;
321
+ const area =
322
+ first === "api" && second !== undefined
323
+ ? this.#options.registry?.areaFor(second)
324
+ : undefined;
235
325
  const mine =
326
+ area !== undefined ||
236
327
  (first === "avatars" && parts.length === 2) ||
237
328
  (first === "api" &&
238
329
  [
239
330
  "session",
240
331
  "rooms",
332
+ "folders",
241
333
  "uploads",
242
334
  "files",
243
335
  "schedules",
244
336
  "agents",
337
+ "groups",
338
+ "archive",
245
339
  "models",
246
340
  "workspace",
341
+ "prompts",
342
+ "skill-docs",
247
343
  ].includes(second ?? ""));
248
344
  if (!mine) return undefined;
249
345
  try {
346
+ if (area)
347
+ return await area.handle(request, parts.slice(2), {
348
+ visitor: admission.visitor,
349
+ viewer: viewerOf(admission.visitor),
350
+ });
250
351
  return await this.#route(request, parts, admission);
251
352
  } catch (error) {
252
353
  if (error instanceof HttpError || error instanceof UploadRefusal)
@@ -269,16 +370,13 @@ export class AppApi {
269
370
  }
270
371
  }
271
372
 
272
- /** The owner who writes: their tier read again, as a speaker for the calls that need one. */
373
+ /** The owner who writes: the tier the request was admitted at, as a speaker for the calls that need one. */
273
374
  async #owner(admission: Authenticated): Promise<Speaker> {
274
- const { principal } = admission.visitor;
275
- const tier = await this.#options.identity.tierOf(principal.id);
375
+ const { principal, tier, speakerId } = admission.visitor;
276
376
  if (tier !== "owner")
277
377
  throw new HttpError(403, "Only an owner may do this.");
278
378
  return {
279
- id: admission.actor
280
- ? `${admission.actor.provider}:${admission.actor.subject}`
281
- : principal.id,
379
+ id: speakerId,
282
380
  name: principal.displayName,
283
381
  tier,
284
382
  principalId: principal.id,
@@ -299,40 +397,67 @@ export class AppApi {
299
397
  new Response("Not found", { status: 404 })
300
398
  );
301
399
  }
302
- if (area === "session" && method === "GET") return this.#session(admission);
400
+ if (area === "session" && method === "GET")
401
+ return await this.#session(admission);
303
402
  if (area === "models" && method === "GET")
304
403
  return json({
305
404
  models: await this.#options.team.usableModels(),
306
405
  } satisfies ModelsView);
307
406
  if (area === "rooms") return this.#rooms(request, parts, admission);
308
- if (area === "uploads" && method === "POST" && !id) {
309
- const owner = await this.#owner(admission);
407
+ if (area === "folders")
408
+ return this.#layout.folders(request, parts, admission);
409
+ // An upload is the sender's own, whoever they are; a file answers by the room it is bound to.
410
+ if (area === "uploads" && method === "POST" && !id)
310
411
  return json(
311
- await this.#options.uploads.receive(request, owner.principalId),
412
+ await this.#options.uploads.receive(
413
+ request,
414
+ admission.visitor.principal.id,
415
+ ),
312
416
  );
313
- }
314
417
  if (area === "uploads" && method === "DELETE" && id && !action) {
315
- const owner = await this.#owner(admission);
316
- if (!this.#options.uploads.discard(id, owner.principalId))
418
+ if (!this.#options.uploads.discard(id, admission.visitor.principal.id))
317
419
  throw notFound();
318
420
  return json({ ok: true });
319
421
  }
320
422
  if (area === "files" && method === "GET" && id && !action)
321
- return this.#file(id);
423
+ return await this.#file(id, admission);
322
424
  if (area === "workspace") return this.#workspace(request, parts, admission);
323
425
  if (area === "schedules") return this.#schedules(method, id, admission);
324
- if (area === "agents" && id)
325
- return this.#agent(request, id, action, admission);
426
+ const owner = () => this.#owner(admission);
427
+ if (area === "agents") return this.#team.agents(request, parts, owner);
428
+ if (area === "groups") return this.#team.groups(request, parts, owner);
429
+ if (area === "archive" && method === "GET" && !id)
430
+ return this.#team.archived(owner);
431
+ if (area === "prompts") {
432
+ if (!this.#prompts)
433
+ throw new HttpError(404, "This host has no prompts to edit.");
434
+ return this.#prompts.handle(request, parts, owner);
435
+ }
436
+ if (area === "skill-docs") {
437
+ if (!this.#skillDocs)
438
+ throw new HttpError(404, "This host has no skills.");
439
+ return this.#skillDocs.handle(request, parts, owner);
440
+ }
326
441
  throw new HttpError(404, "There is no such API.");
327
442
  }
328
443
 
329
- #session(admission: Authenticated): Response {
330
- const { principal } = admission.visitor;
444
+ async #session(admission: Authenticated): Promise<Response> {
445
+ const { principal, tier } = admission.visitor;
446
+ const { admission: admits = "owners", gate } = this.#options;
447
+ const pushTiers = this.#options.pushTiers ?? ["owner"];
448
+ const owed =
449
+ admits === "access" && gate
450
+ ? await gate.required(viewerOf(admission.visitor))
451
+ : [];
331
452
  return json({
332
453
  principal: { id: principal.id, name: principal.displayName },
333
454
  surface: this.#options.surface.surface,
334
455
  auth: this.#options.authMode,
335
- push: true,
456
+ ...(admits === "access" ? { tier } : {}),
457
+ ...(owed.length > 0 ? { gate: owed } : {}),
458
+ ...(pushTiers.includes(tier) ? { push: true as const } : {}),
459
+ ...(this.#options.prompts ? { prompts: true } : {}),
460
+ ...(this.#options.skills ? { skillEdit: true } : {}),
336
461
  ...(this.#options.quietHours
337
462
  ? { quietHours: this.#options.quietHours }
338
463
  : {}),
@@ -350,9 +475,24 @@ export class AppApi {
350
475
  const { method } = request;
351
476
  const [, , key, action] = parts;
352
477
  const { rooms, log, cards, modes, mutes, conversations } = this.#options;
478
+ // The agent rooms are the owners'; to anyone else this API does not exist.
479
+ if (admission.visitor.tier !== "owner") throw notFound();
353
480
  if (!key) {
354
481
  if (method !== "GET") throw new HttpError(405, "Method not allowed.");
355
- return json({ rooms: await rooms.list() } satisfies RoomsView);
482
+ const listed = await rooms.list();
483
+ // What each room holds past this person's cursor; a room they never read starts from what it has now.
484
+ const unread = await this.#options.reads?.unread(
485
+ admission.visitor.principal.id,
486
+ listed.map((one) => one.key),
487
+ new Set(listed.map((one) => one.key)),
488
+ );
489
+ return json({
490
+ rooms: listed.map((one) => {
491
+ const count = unread?.get(one.key);
492
+ return count ? { ...one, unread: count } : one;
493
+ }),
494
+ ...(await this.#layout.view()),
495
+ } satisfies RoomsView);
356
496
  }
357
497
  const room = await rooms.resolve(key);
358
498
  if (!room) throw notFound();
@@ -372,17 +512,26 @@ export class AppApi {
372
512
  // The cards stand among the messages by the time they opened; a page that has older
373
513
  // messages leaves the cards before its first one to the older page.
374
514
  const first = page.messages[0];
515
+ const { reads, nameOf } = this.#options;
516
+ const self = admission.visitor.principal.id;
517
+ const cursor = await reads?.cursorOf(room.key, self);
375
518
  return json({
376
519
  ...page,
520
+ messages: withExpiredFiles(page.messages, this.#options.files),
377
521
  cards: await cards.during(room.key, {
378
522
  ...(page.more && first ? { from: first.at } : {}),
379
523
  ...(beforeId === undefined ? {} : { before: beforeId }),
380
524
  }),
525
+ ...(reads && nameOf
526
+ ? { reads: await readsOf(reads, room.key, self, nameOf) }
527
+ : {}),
528
+ ...(cursor === undefined ? {} : { cursor }),
381
529
  } satisfies MessagesView);
382
530
  }
383
531
  if (action === "mode" && method === "PUT" && parts.length === 4) {
384
532
  // Only an agent's or group's room has modes; a thread runs no turn of the owner's.
385
- if (room.readOnly) throw notFound();
533
+ if (room.readOnly || this.#options.modeSwitches === false)
534
+ throw notFound();
386
535
  const owner = await this.#owner(admission);
387
536
  const body = await bodyOf(request);
388
537
  const change: ModeChange = {};
@@ -404,6 +553,12 @@ export class AppApi {
404
553
  await conversations.startFresh(room.key);
405
554
  return json({ ok: true });
406
555
  }
556
+ if (
557
+ (action === "folder" || action === "pin") &&
558
+ method === "PUT" &&
559
+ parts.length === 4
560
+ )
561
+ return this.#layout.place(request, room, action, admission);
407
562
  if (action === "mute" && method === "PUT" && parts.length === 4) {
408
563
  // Only an agent's or group's room has a mute of its own; a thread's posts are its parent's business.
409
564
  if (room.readOnly) throw notFound();
@@ -418,10 +573,20 @@ export class AppApi {
418
573
  throw new HttpError(404, "There is no such API.");
419
574
  }
420
575
 
421
- #file(id: string): Response {
576
+ async #file(id: string, admission: Authenticated): Promise<Response> {
422
577
  if (!isFileId(id)) throw notFound();
423
- const meta = this.#options.files.meta(id);
424
- const blob = this.#options.files.blob(id);
578
+ const { files, host } = this.#options;
579
+ // A file bound to a host room or page is for the people it is open to, owners too; every other stored file belongs to an agent room, which only owners read.
580
+ const bound = files.binding(id)?.room;
581
+ if (bound === undefined) {
582
+ if (admission.visitor.tier !== "owner") throw notFound();
583
+ } else if (
584
+ !host ||
585
+ !(await host.mayRead(viewerOf(admission.visitor), bound))
586
+ )
587
+ throw notFound();
588
+ const meta = files.meta(id);
589
+ const blob = files.blob(id);
425
590
  if (!meta || !blob) throw notFound();
426
591
  return fileResponse(meta, blob);
427
592
  }
@@ -541,6 +706,18 @@ export class AppApi {
541
706
  throw new HttpError(404, "There is no such API.");
542
707
  }
543
708
 
709
+ /** Whether a schedule is the visitor's own: its creator id names their principal. */
710
+ async #ownSchedule(
711
+ createdById: string,
712
+ principalId: string,
713
+ ): Promise<boolean> {
714
+ if (createdById === principalId) return true;
715
+ // A schedule from 0.8 names its creator by a user id that the backfill made a principal's id.
716
+ return (
717
+ (await this.#options.identity.principal(createdById))?.id === principalId
718
+ );
719
+ }
720
+
544
721
  async #schedules(
545
722
  method: string,
546
723
  id: string | undefined,
@@ -548,11 +725,19 @@ export class AppApi {
548
725
  ): Promise<Response> {
549
726
  const { schedules, rooms } = this.#options;
550
727
  if (!schedules) throw new HttpError(404, "This pane is not served.");
728
+ const { tier, principal } = admission.visitor;
729
+ const owner = tier === "owner";
551
730
  if (method === "GET" && !id) {
552
731
  const all = await schedules.all();
553
732
  const views: ScheduleView[] = [];
554
733
  for (const schedule of all) {
555
- const room = await rooms.resolve(schedule.channel);
734
+ // The owners see every schedule; anyone else sees their own, and no room of the owners.
735
+ if (
736
+ !owner &&
737
+ !(await this.#ownSchedule(schedule.createdById, principal.id))
738
+ )
739
+ continue;
740
+ const room = owner ? await rooms.resolve(schedule.channel) : undefined;
556
741
  views.push({
557
742
  id: schedule.id,
558
743
  ...(room ? { room: room.key } : {}),
@@ -572,9 +757,17 @@ export class AppApi {
572
757
  return json({ schedules: views } satisfies SchedulesView);
573
758
  }
574
759
  if (method === "DELETE" && id) {
575
- await this.#owner(admission);
576
760
  const number = Number(id);
577
761
  if (!Number.isInteger(number)) throw bad("The schedule id is a number.");
762
+ // Another person's schedule is not there to remove, for anyone but the owners.
763
+ if (!owner) {
764
+ const found = await schedules.get(number);
765
+ if (
766
+ !found ||
767
+ !(await this.#ownSchedule(found.createdById, principal.id))
768
+ )
769
+ throw new HttpError(404, "There is no such schedule.");
770
+ }
578
771
  const removed = await schedules.remove(number);
579
772
  if (!removed) throw new HttpError(404, "There is no such schedule.");
580
773
  this.#options.changed();
@@ -582,75 +775,4 @@ export class AppApi {
582
775
  }
583
776
  throw new HttpError(405, "Method not allowed.");
584
777
  }
585
-
586
- async #agent(
587
- request: Request,
588
- name: string,
589
- action: string | undefined,
590
- admission: Authenticated,
591
- ): Promise<Response> {
592
- const { directory, team } = this.#options;
593
- const agent = directory.agent(name);
594
- if (agent?.status !== "active") throw notFound();
595
- if (!action && request.method === "GET") return json(this.#profile(name));
596
- if (!action && request.method === "PATCH") {
597
- await this.#owner(admission);
598
- const body = await bodyOf(request);
599
- const patch: AgentPatch = {};
600
- for (const key of [
601
- "displayName",
602
- "prompt",
603
- "model",
604
- "thinking",
605
- ] as const) {
606
- const value = optionalText(body, key);
607
- if (value !== undefined) patch[key] = value;
608
- }
609
- const change: AgentChange = patch;
610
- await team.update(name, change);
611
- this.#options.changed();
612
- return json(this.#profile(name));
613
- }
614
- if (action === "character" && request.method === "PUT") {
615
- await this.#owner(admission);
616
- const body = await bodyOf(request, MAX_CHARACTER_BYTES);
617
- const { character } = body;
618
- if (
619
- typeof character !== "object" ||
620
- character === null ||
621
- Array.isArray(character)
622
- )
623
- throw bad("character must be an object.");
624
- const asked: CharacterRequest = {
625
- character: character as CharacterRequest["character"],
626
- };
627
- // `dress` words a refusal as an agent error; checking here first names the field.
628
- resolveCharacter(asked.character, characterOf(agent));
629
- await team.dress(name, asked.character);
630
- this.#options.changed();
631
- return json(this.#profile(name));
632
- }
633
- throw new HttpError(404, "There is no such API.");
634
- }
635
-
636
- #profile(name: string): AgentProfileView {
637
- const { directory, team, rooms, surface } = this.#options;
638
- const agent = directory.agent(name);
639
- if (!agent) throw notFound();
640
- const effective = team.modelOf(name);
641
- return {
642
- name: agent.name,
643
- displayName: agent.displayName,
644
- prompt: agent.prompt,
645
- ...(agent.model ? { model: agent.model } : {}),
646
- ...(agent.thinking ? { thinking: agent.thinking } : {}),
647
- effective: {
648
- model: effective.model,
649
- thinking: String(effective.thinking),
650
- },
651
- avatar: rooms.avatarOf(agent),
652
- character: characterOf(agent),
653
- room: `${surface.surface}:agent.${agent.name}`,
654
- };
655
- }
656
778
  }