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/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,21 @@ 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";
37
36
  import { json } from "../http.ts";
38
37
  import type { PushMutes } from "../push/mutes.ts";
39
- import { readLimited } from "../request-body.ts";
38
+ import { bad, bodyOf, HttpError, notFound } from "./api-http.ts";
40
39
  import type { CardLog } from "./card-log.ts";
41
40
  import { type FileDir, fileResponse, isFileId } from "./files.ts";
41
+ import type { LayoutStore } from "./layout.ts";
42
+ import { LayoutRoutes } from "./layout-routes.ts";
43
+ import { PromptApi, type PromptApiOptions } from "./prompt-routes.ts";
42
44
  import { PAGE_LIMIT, type RoomLog } from "./room-log.ts";
43
45
  import type { Rooms } from "./rooms.ts";
46
+ import { SkillDocApi } from "./skill-doc-routes.ts";
44
47
  import type { AppSurface } from "./surface.ts";
48
+ import { TeamApi } from "./team-routes.ts";
49
+ import type { WebThreads } from "./threads.ts";
45
50
  import { UploadRefusal, type Uploads } from "./uploads.ts";
46
51
  import {
47
52
  EDIT_MAX_BYTES,
@@ -50,31 +55,59 @@ import {
50
55
  WorkspaceRefusal,
51
56
  } from "./workspace-files.ts";
52
57
 
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
58
  export interface AppApiOptions {
59
+ /** The host's wording: a refusal reaches a client of the API in this locale when the catalog has it. */
60
+ presentation?: ConsolePresentation;
59
61
  surface: AppSurface;
60
62
  rooms: Rooms;
61
- log: RoomLog;
62
- cards: CardLog;
63
+ log: Pick<RoomLog, "page" | "lastOf" | "removeRoom">;
64
+ cards: Pick<CardLog, "during" | "removeRoom">;
63
65
  uploads: Uploads;
64
66
  /** Where stored files are read from. */
65
67
  files: FileDir;
66
68
  modes: Pick<ConversationModes, "set" | "peek">;
67
69
  /** The rooms whose push the owners muted. */
68
- mutes: Pick<PushMutes, "set">;
70
+ mutes: Pick<PushMutes, "set" | "setMany">;
71
+ /** The dispatch threads, which go with the room they hang under. */
72
+ threads: Pick<WebThreads, "under" | "removeUnder">;
73
+ /** The owners' shared arrangement of the room list: pins and folders. */
74
+ layout: LayoutStore;
69
75
  conversations: Pick<ConversationPort, "startFresh">;
70
76
  identity: Pick<IdentityService, "tierOf">;
71
77
  /** Present when the schedules pane is served. */
72
78
  schedules?: Pick<ScheduleStore, "all" | "remove">;
73
- directory: Pick<AgentDirectory, "agent">;
79
+ directory: Pick<AgentDirectory, "agent" | "agents" | "group" | "groups">;
74
80
  team: Pick<
75
81
  AgentTeam,
76
- "update" | "dress" | "usableModels" | "modelOf" | "defaultModel"
82
+ | "update"
83
+ | "promptHistory"
84
+ | "dress"
85
+ | "usableModels"
86
+ | "modelOf"
87
+ | "defaultModel"
88
+ | "createAgent"
89
+ | "archive"
90
+ | "restore"
91
+ | "remove"
92
+ | "createGroup"
93
+ | "updateGroup"
77
94
  >;
95
+ /** Present when the host has the skills addon: the catalog an agent's skills are chosen from, and the change of what it carries. */
96
+ skills?: {
97
+ catalog(): SkillView[];
98
+ attach(
99
+ agent: string,
100
+ add: readonly string[],
101
+ remove: readonly string[],
102
+ ): Promise<string[]>;
103
+ /** The registry's documents: a skill's SKILL.md read, saved and made, and its versions. */
104
+ documents: Pick<
105
+ SkillRegistry,
106
+ "document" | "saveDocument" | "createDocument" | "documentHistory"
107
+ >;
108
+ };
109
+ /** Present when the host has prompt documents (the prompts plugin): the main prompts the owner edits. */
110
+ prompts?: PromptApiOptions["prompts"];
78
111
  avatars: Pick<AvatarStudio, "serve">;
79
112
  /** Present when the workspace pane is served. */
80
113
  workspace?: WorkspaceFiles;
@@ -89,19 +122,6 @@ export interface AppApiOptions {
89
122
  logger: Logger;
90
123
  }
91
124
 
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
125
  /** A refusal of the workspace pane as the HTTP error the owner reads. */
106
126
  function refusalOf(refusal: WorkspaceRefusal): HttpError {
107
127
  const { status } = refusal;
@@ -183,33 +203,6 @@ export function partsOf(path: string): string[] | undefined {
183
203
  }
184
204
  }
185
205
 
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
206
  /**
214
207
  * The owner app's REST under `<mount>/api/` and `<mount>/avatars/`. Every route is behind the
215
208
  * authenticator, which also checks the `Origin` of a write; a write also reads the person's tier
@@ -218,9 +211,44 @@ const optionalText = (
218
211
  */
219
212
  export class AppApi {
220
213
  readonly #options: AppApiOptions;
214
+ readonly #team: TeamApi;
215
+ readonly #layout: LayoutRoutes;
216
+ readonly #prompts: PromptApi | undefined;
217
+ readonly #skillDocs: SkillDocApi | undefined;
221
218
 
222
219
  constructor(options: AppApiOptions) {
223
220
  this.#options = options;
221
+ this.#layout = new LayoutRoutes({
222
+ layout: options.layout,
223
+ mutes: options.mutes,
224
+ placeable: () => options.rooms.placeable(),
225
+ home: () => options.rooms.homeKey(),
226
+ owner: (admission) => this.#owner(admission),
227
+ changed: () => options.changed(),
228
+ });
229
+ this.#prompts = options.prompts
230
+ ? new PromptApi({ prompts: options.prompts })
231
+ : undefined;
232
+ this.#skillDocs = options.skills
233
+ ? new SkillDocApi({
234
+ skills: options.skills.documents,
235
+ catalog: () => options.skills?.catalog() ?? [],
236
+ })
237
+ : undefined;
238
+ this.#team = new TeamApi({
239
+ surface: options.surface.surface,
240
+ rooms: options.rooms,
241
+ directory: options.directory,
242
+ team: options.team,
243
+ skills: options.skills,
244
+ log: options.log,
245
+ cards: options.cards,
246
+ mutes: options.mutes,
247
+ modes: options.modes,
248
+ threads: options.threads,
249
+ layout: options.layout,
250
+ changed: () => options.changed(),
251
+ });
224
252
  }
225
253
 
226
254
  /** Answers a path of the app's, or undefined for any other. `rest` is what follows the mount. */
@@ -228,6 +256,42 @@ export class AppApi {
228
256
  request: Request,
229
257
  rest: string,
230
258
  admission: Authenticated,
259
+ ): Promise<Response | undefined> {
260
+ const response = await this.#answer(request, rest, admission);
261
+ return response && this.#options.presentation
262
+ ? this.#worded(response, this.#options.presentation)
263
+ : response;
264
+ }
265
+
266
+ /** A refusal's `error` in the host's wording; the page words it again from the same catalog and finds the wording already there. */
267
+ async #worded(
268
+ response: Response,
269
+ presentation: ConsolePresentation,
270
+ ): Promise<Response> {
271
+ if (response.status < 400) return response;
272
+ const body: unknown = await response
273
+ .clone()
274
+ .json()
275
+ .catch(() => undefined);
276
+ const error = (body as { error?: unknown } | undefined)?.error;
277
+ if (typeof error !== "string") return response;
278
+ const worded = message(presentation, error);
279
+ if (worded === error) return response;
280
+ const headers = new Headers(response.headers);
281
+ headers.delete("content-length");
282
+ return new Response(
283
+ JSON.stringify({ ...(body as object), error: worded }),
284
+ {
285
+ status: response.status,
286
+ headers,
287
+ },
288
+ );
289
+ }
290
+
291
+ async #answer(
292
+ request: Request,
293
+ rest: string,
294
+ admission: Authenticated,
231
295
  ): Promise<Response | undefined> {
232
296
  const parts = partsOf(rest);
233
297
  if (!parts) return undefined;
@@ -238,12 +302,17 @@ export class AppApi {
238
302
  [
239
303
  "session",
240
304
  "rooms",
305
+ "folders",
241
306
  "uploads",
242
307
  "files",
243
308
  "schedules",
244
309
  "agents",
310
+ "groups",
311
+ "archive",
245
312
  "models",
246
313
  "workspace",
314
+ "prompts",
315
+ "skill-docs",
247
316
  ].includes(second ?? ""));
248
317
  if (!mine) return undefined;
249
318
  try {
@@ -305,6 +374,8 @@ export class AppApi {
305
374
  models: await this.#options.team.usableModels(),
306
375
  } satisfies ModelsView);
307
376
  if (area === "rooms") return this.#rooms(request, parts, admission);
377
+ if (area === "folders")
378
+ return this.#layout.folders(request, parts, admission);
308
379
  if (area === "uploads" && method === "POST" && !id) {
309
380
  const owner = await this.#owner(admission);
310
381
  return json(
@@ -321,8 +392,21 @@ export class AppApi {
321
392
  return this.#file(id);
322
393
  if (area === "workspace") return this.#workspace(request, parts, admission);
323
394
  if (area === "schedules") return this.#schedules(method, id, admission);
324
- if (area === "agents" && id)
325
- return this.#agent(request, id, action, admission);
395
+ const owner = () => this.#owner(admission);
396
+ if (area === "agents") return this.#team.agents(request, parts, owner);
397
+ if (area === "groups") return this.#team.groups(request, parts, owner);
398
+ if (area === "archive" && method === "GET" && !id)
399
+ return this.#team.archived(owner);
400
+ if (area === "prompts") {
401
+ if (!this.#prompts)
402
+ throw new HttpError(404, "This host has no prompts to edit.");
403
+ return this.#prompts.handle(request, parts, owner);
404
+ }
405
+ if (area === "skill-docs") {
406
+ if (!this.#skillDocs)
407
+ throw new HttpError(404, "This host has no skills.");
408
+ return this.#skillDocs.handle(request, parts, owner);
409
+ }
326
410
  throw new HttpError(404, "There is no such API.");
327
411
  }
328
412
 
@@ -333,6 +417,8 @@ export class AppApi {
333
417
  surface: this.#options.surface.surface,
334
418
  auth: this.#options.authMode,
335
419
  push: true,
420
+ ...(this.#options.prompts ? { prompts: true } : {}),
421
+ ...(this.#options.skills ? { skillEdit: true } : {}),
336
422
  ...(this.#options.quietHours
337
423
  ? { quietHours: this.#options.quietHours }
338
424
  : {}),
@@ -352,7 +438,10 @@ export class AppApi {
352
438
  const { rooms, log, cards, modes, mutes, conversations } = this.#options;
353
439
  if (!key) {
354
440
  if (method !== "GET") throw new HttpError(405, "Method not allowed.");
355
- return json({ rooms: await rooms.list() } satisfies RoomsView);
441
+ return json({
442
+ rooms: await rooms.list(),
443
+ ...(await this.#layout.view()),
444
+ } satisfies RoomsView);
356
445
  }
357
446
  const room = await rooms.resolve(key);
358
447
  if (!room) throw notFound();
@@ -404,6 +493,12 @@ export class AppApi {
404
493
  await conversations.startFresh(room.key);
405
494
  return json({ ok: true });
406
495
  }
496
+ if (
497
+ (action === "folder" || action === "pin") &&
498
+ method === "PUT" &&
499
+ parts.length === 4
500
+ )
501
+ return this.#layout.place(request, room, action, admission);
407
502
  if (action === "mute" && method === "PUT" && parts.length === 4) {
408
503
  // Only an agent's or group's room has a mute of its own; a thread's posts are its parent's business.
409
504
  if (room.readOnly) throw notFound();
@@ -582,75 +677,4 @@ export class AppApi {
582
677
  }
583
678
  throw new HttpError(405, "Method not allowed.");
584
679
  }
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
680
  }
@@ -0,0 +1,122 @@
1
+ import type { SkillDocument, SkillRegistry } from "pi-roundtable";
2
+ import { SkillConflictError } from "pi-roundtable";
3
+ import type {
4
+ SkillConflict,
5
+ SkillDocCreate,
6
+ SkillDocSave,
7
+ SkillDocView,
8
+ SkillHistoryView,
9
+ SkillView,
10
+ } from "../api-types.ts";
11
+ import { json } from "../http.ts";
12
+ import { bad, bodyOf, HttpError } from "./api-http.ts";
13
+ import type { OwnerOf } from "./team-routes.ts";
14
+
15
+ /** The largest body of a save: a SKILL.md of 12,000 characters may be four bytes each, with its frontmatter. */
16
+ const MAX_SKILL_BYTES = 64 * 1024;
17
+
18
+ export interface SkillDocApiOptions {
19
+ skills: Pick<
20
+ SkillRegistry,
21
+ "document" | "saveDocument" | "createDocument" | "documentHistory"
22
+ >;
23
+ /** The catalog the page lists, so a name it does not list is a 404 before the registry is asked. */
24
+ catalog(): SkillView[];
25
+ }
26
+
27
+ const viewOf = (document: SkillDocument): SkillDocView => ({
28
+ name: document.name,
29
+ source: document.source,
30
+ text: document.text,
31
+ hash: document.hash,
32
+ writable: document.writable,
33
+ });
34
+
35
+ /**
36
+ * The app's REST for a skill's SKILL.md: reading it whole, saving an agent-written skill's, making a
37
+ * skill and listing the versions kept. The frontmatter rules are the skill tools': the registry
38
+ * checks them, and a refusal is a 400 with its words. Every call is an owner's; a save names the
39
+ * hash it was edited from, and a skill that changed meanwhile answers 409 with what is there now.
40
+ */
41
+ export class SkillDocApi {
42
+ readonly #options: SkillDocApiOptions;
43
+
44
+ constructor(options: SkillDocApiOptions) {
45
+ this.#options = options;
46
+ }
47
+
48
+ async handle(
49
+ request: Request,
50
+ parts: string[],
51
+ owner: OwnerOf,
52
+ ): Promise<Response> {
53
+ const { method } = request;
54
+ const [, , name, action] = parts;
55
+ const { skills } = this.#options;
56
+ if (!name) {
57
+ if (method !== "POST") throw new HttpError(405, "Method not allowed.");
58
+ const speaker = await owner();
59
+ const body = await bodyOf(request, MAX_SKILL_BYTES);
60
+ if (typeof body.text !== "string") throw bad("text must be text.");
61
+ const made: SkillDocCreate = { text: body.text };
62
+ return json(
63
+ viewOf(await skills.createDocument(made.text, speaker.name)),
64
+ 201,
65
+ );
66
+ }
67
+ // The tier is read before the name is looked up, so a person below the owner learns of no skill.
68
+ const speaker = await owner();
69
+ if (!this.#options.catalog().some((skill) => skill.name === name))
70
+ throw new HttpError(404, "There is no such skill.");
71
+ if (!action && method === "GET")
72
+ return json(viewOf(await skills.document(name)));
73
+ if (!action && method === "PUT") {
74
+ const body = await bodyOf(request, MAX_SKILL_BYTES);
75
+ const { text, baseHash } = body;
76
+ if (typeof text !== "string") throw bad("text must be text.");
77
+ if (typeof baseHash !== "string" || !/^[0-9a-f]{64}$/.test(baseHash))
78
+ throw bad("baseHash must be the hash the skill was read with.");
79
+ const save: SkillDocSave = { text, baseHash };
80
+ try {
81
+ return json(
82
+ viewOf(
83
+ await skills.saveDocument(
84
+ name,
85
+ save.text,
86
+ save.baseHash,
87
+ speaker.name,
88
+ ),
89
+ ),
90
+ );
91
+ } catch (error) {
92
+ if (error instanceof SkillConflictError) {
93
+ const refused = new HttpError(
94
+ 409,
95
+ "This skill changed while you were editing it.",
96
+ );
97
+ return json(
98
+ {
99
+ error: refused.message,
100
+ conflict: "changed",
101
+ current: viewOf(error.current),
102
+ } satisfies SkillConflict,
103
+ refused.status,
104
+ );
105
+ }
106
+ throw error;
107
+ }
108
+ }
109
+ if (action === "history" && method === "GET" && parts.length === 4) {
110
+ return json({
111
+ versions: (await skills.documentHistory(name)).map((version) => ({
112
+ id: version.id,
113
+ text: version.text,
114
+ hash: version.hash,
115
+ by: version.by,
116
+ at: version.at.toISOString(),
117
+ })),
118
+ } satisfies SkillHistoryView);
119
+ }
120
+ throw new HttpError(404, "There is no such API.");
121
+ }
122
+ }