pi-roundtable-web 0.27.1 → 0.29.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 (257) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +25 -9
  3. package/dist/chunk-account-9616x405.js +1 -0
  4. package/dist/chunk-action-menu-q5y7xpry.js +1 -0
  5. package/dist/chunk-agent-x74xezyt.js +1 -0
  6. package/dist/chunk-arranged-rooms-9prq46vh.js +1 -0
  7. package/dist/chunk-bulk-actions-5dc4m9ad.js +1 -0
  8. package/dist/chunk-character-creator-wf02eg0h.js +1 -0
  9. package/dist/chunk-codeeditor-239ss6a2.js +2 -0
  10. package/dist/chunk-confirm-bhrx9195.js +1 -0
  11. package/dist/chunk-connectors-k77pd9by.js +37 -0
  12. package/dist/chunk-conversations-ftcpaqw1.js +1 -0
  13. package/dist/chunk-council-sheet-a5e6tspr.js +2 -0
  14. package/dist/chunk-council-view-6593rqbr.js +1 -0
  15. package/dist/chunk-drag-7873fvzw.js +1 -0
  16. package/dist/chunk-entry-5zmjxadx.js +1 -0
  17. package/dist/chunk-folder-list-h9hmbm8e.js +1 -0
  18. package/dist/chunk-folds-vxq86t1s.js +1 -0
  19. package/dist/chunk-gate-fa6za28z.js +1 -0
  20. package/dist/chunk-host-icons-y6frbzqt.js +1 -0
  21. package/dist/chunk-host-picture-fqg55rbr.js +1 -0
  22. package/dist/chunk-host-state-65tdhkyq.js +1 -0
  23. package/dist/chunk-host-ui-233kz7qp.js +1 -0
  24. package/dist/{chunk-index-j4x9dq30.css → chunk-index-1652v386.css} +1 -1
  25. package/dist/chunk-index-2c00zf3z.js +2 -0
  26. package/dist/chunk-index-2dhrqvba.js +2 -0
  27. package/dist/chunk-index-2s4bkrz3.js +2 -0
  28. package/dist/chunk-index-2ypaqwjy.js +2 -0
  29. package/dist/{chunk-index-k0z507h3.js → chunk-index-35zqk1zr.js} +2 -2
  30. package/dist/chunk-index-39qq37j5.js +2 -0
  31. package/dist/{chunk-index-j7pgphx3.js → chunk-index-3tjmyd1h.js} +2 -2
  32. package/dist/chunk-index-4eag6s50.js +2 -0
  33. package/dist/chunk-index-4jnn2g09.js +2 -0
  34. package/dist/{chunk-index-y60x5jbh.js → chunk-index-4krm48hm.js} +1 -1
  35. package/dist/{chunk-index-w42rcbx1.js → chunk-index-4re5prfp.js} +2 -2
  36. package/dist/chunk-index-57teqgct.js +2 -0
  37. package/dist/{chunk-index-3d8s3565.js → chunk-index-5ssncptg.js} +2 -2
  38. package/dist/{chunk-index-yrmqcay4.js → chunk-index-7c9fn21n.js} +2 -2
  39. package/dist/chunk-index-7k5895sn.js +14 -0
  40. package/dist/chunk-index-93f3e06v.js +2 -0
  41. package/dist/chunk-index-9m9v1p7s.js +2 -0
  42. package/dist/chunk-index-a8c0ap8g.js +2 -0
  43. package/dist/chunk-index-a8nqn3y0.js +3 -0
  44. package/dist/chunk-index-am7ghh75.js +120 -0
  45. package/dist/chunk-index-amspdq2j.js +4 -0
  46. package/dist/chunk-index-amxa2bn3.js +2 -0
  47. package/dist/{chunk-index-nsvwbjfe.js → chunk-index-aycxt14t.js} +2 -2
  48. package/dist/{chunk-index-n05yw5wd.js → chunk-index-bjeg3e2j.js} +2 -2
  49. package/dist/chunk-index-cttrqfm8.js +2 -0
  50. package/dist/chunk-index-dajanzxc.js +1 -0
  51. package/dist/chunk-index-e2eeckhh.js +2 -0
  52. package/dist/chunk-index-e46652wf.js +2 -0
  53. package/dist/chunk-index-ek98d2rn.js +2 -0
  54. package/dist/{chunk-index-f500nw4w.js → chunk-index-en5dm1zv.js} +2 -2
  55. package/dist/chunk-index-evhaaqzk.js +13 -0
  56. package/dist/chunk-index-f4gqzfkv.js +2 -0
  57. package/dist/{chunk-index-ak6ecqy3.js → chunk-index-gj3n4dh5.js} +2 -2
  58. package/dist/chunk-index-gp4chnp3.js +2 -0
  59. package/dist/{chunk-index-af4ebmkr.js → chunk-index-h2a42p6s.js} +3 -3
  60. package/dist/{chunk-index-4z4chg7v.js → chunk-index-haac6nbt.js} +2 -2
  61. package/dist/{chunk-index-3pnvpppa.js → chunk-index-hef4gwtb.js} +2 -2
  62. package/dist/chunk-index-mg96sqpk.js +2 -0
  63. package/dist/chunk-index-nsya88fn.js +1 -0
  64. package/dist/{chunk-index-ben4gv45.js → chunk-index-ntt3em2k.js} +1 -1
  65. package/dist/chunk-index-pcbkp62w.js +2 -0
  66. package/dist/chunk-index-pfs6rm6w.js +2 -0
  67. package/dist/chunk-index-pgxgq61x.js +2 -0
  68. package/dist/{chunk-index-9z13jw6q.js → chunk-index-q33ppq0j.js} +3 -3
  69. package/dist/chunk-index-r0hk9kyd.js +47 -0
  70. package/dist/{chunk-index-0ye10vk3.js → chunk-index-rr73h4kc.js} +3 -3
  71. package/dist/{chunk-index-983dmeaa.js → chunk-index-rtz4936v.js} +3 -3
  72. package/dist/chunk-index-smz90j6p.js +2 -0
  73. package/dist/chunk-index-teyx2hz7.js +2 -0
  74. package/dist/{chunk-index-sm7m4fej.js → chunk-index-v6h43553.js} +2 -2
  75. package/dist/chunk-index-vzqwn3wp.js +2 -0
  76. package/dist/chunk-index-wt2asefx.js +50 -0
  77. package/dist/chunk-index-x4tq9btg.js +2 -0
  78. package/dist/chunk-index-y6p8j3zv.js +2 -0
  79. package/dist/chunk-index-yvcdg5hh.js +2 -0
  80. package/dist/{chunk-index-afrhnrxj.js → chunk-index-zdzzf3fp.js} +2 -2
  81. package/dist/chunk-index-zeqfnkty.js +2 -0
  82. package/dist/chunk-index-zpwb8mkk.js +2 -0
  83. package/dist/chunk-lang-css-avzhvm72.js +1 -0
  84. package/dist/{chunk-lang-dockerfile-cnwqn660.js → chunk-lang-dockerfile-2p7e3ssz.js} +1 -1
  85. package/dist/{chunk-lang-go-yy6kgsxv.js → chunk-lang-go-zxp9k517.js} +3 -3
  86. package/dist/chunk-lang-html-red02gep.js +1 -0
  87. package/dist/chunk-lang-javascript-n33kmjcx.js +1 -0
  88. package/dist/chunk-lang-json-zmym50rg.js +1 -0
  89. package/dist/chunk-lang-markdown-ww2kcs85.js +6 -0
  90. package/dist/chunk-lang-python-ng1zdtd4.js +3 -0
  91. package/dist/chunk-lang-rust-6ynhxjdd.js +1 -0
  92. package/dist/{chunk-lang-shell-1rpgtepw.js → chunk-lang-shell-39t9bz2s.js} +1 -1
  93. package/dist/chunk-lang-sql-w875ejpg.js +2 -0
  94. package/dist/{chunk-lang-toml-tbj7467c.js → chunk-lang-toml-b8ckgkg5.js} +1 -1
  95. package/dist/chunk-lang-typescript-qf8acbb2.js +1 -0
  96. package/dist/chunk-lang-xml-bz91cjp1.js +1 -0
  97. package/dist/chunk-lang-yaml-g9zy0tdv.js +1 -0
  98. package/dist/chunk-layout-actions-fyzzzeyp.js +1 -0
  99. package/dist/chunk-menu-items-yvnk3vds.js +1 -0
  100. package/dist/chunk-mode-toggle-ccv271hm.js +1 -0
  101. package/dist/chunk-note-row-314py1pp.js +204 -0
  102. package/dist/chunk-notes-4rnrddzw.js +1 -0
  103. package/dist/chunk-notice-bell-z9wgamnz.js +1 -0
  104. package/dist/chunk-notices-mtn7pg1z.js +1 -0
  105. package/dist/chunk-onboarding-f3hhxgg4.js +1 -0
  106. package/dist/chunk-overview-qmepntrr.js +1 -0
  107. package/dist/chunk-palette-edkn1ypq.js +1 -0
  108. package/dist/chunk-placement-items-trsa5t23.js +1 -0
  109. package/dist/chunk-profile-pwfq0wns.js +1 -0
  110. package/dist/chunk-prompt-history-xp9hmeqt.js +1 -0
  111. package/dist/chunk-prompts-a56qvytg.js +1 -0
  112. package/dist/chunk-room-confirms-gqpkz4h0.js +1 -0
  113. package/dist/chunk-room-menu-1m2c2ekt.js +1 -0
  114. package/dist/chunk-rooms-menu-button-tcvda1gq.js +1 -0
  115. package/dist/chunk-schedules-kwnw7p8v.js +1 -0
  116. package/dist/{chunk-seat-menu-kwtjnpjj.js → chunk-seat-menu-m2n9nvv0.js} +1 -1
  117. package/dist/chunk-sheet-hosts-pr8xktyq.js +29 -0
  118. package/dist/{chunk-shortcut-help-g5cpqjjj.js → chunk-shortcut-help-sbvrds1m.js} +2 -2
  119. package/dist/chunk-shortcuts-8705p7q3.js +1 -0
  120. package/dist/chunk-skill-editor-7xy09sq1.js +8 -0
  121. package/dist/chunk-skills-cnc25x7q.js +1 -0
  122. package/dist/chunk-start-over-line-97zdd9pb.js +16 -0
  123. package/dist/chunk-switcher-list-ehsn2wsk.js +1 -0
  124. package/dist/{chunk-tab-dot-yse6wcfp.js → chunk-tab-dot-gkpe06q8.js} +1 -1
  125. package/dist/chunk-table-av3kh3tt.js +1 -0
  126. package/dist/chunk-table2d-5x3jptp9.js +1 -0
  127. package/dist/chunk-table3d-rsvajx7r.js +4220 -0
  128. package/dist/chunk-thread-actions-7xtkegwm.js +1 -0
  129. package/dist/{chunk-thread-cards-36rckyg7.js → chunk-thread-cards-grzszfha.js} +1 -1
  130. package/dist/chunk-thread-list-9bjjac3j.js +217 -0
  131. package/dist/chunk-transcript-t5szb8r0.js +1 -0
  132. package/dist/chunk-waiting-chip-d8b6a0p8.js +28 -0
  133. package/dist/chunk-waiting-ts3kxjvv.js +1 -0
  134. package/dist/chunk-where-3d3ncsa5.js +1 -0
  135. package/dist/chunk-workspace-ct6hvn0z.js +578 -0
  136. package/dist/chunk-zh-tw-v9v1eerm.js +1 -0
  137. package/dist/index.html +1 -1
  138. package/dist/sw.js +1 -1
  139. package/package.json +3 -3
  140. package/src/api-types.ts +137 -1
  141. package/src/app/app.ts +4 -7
  142. package/src/app/desk.ts +19 -4
  143. package/src/app/room-log.ts +66 -18
  144. package/src/app/rooms.ts +45 -7
  145. package/src/app/routes.ts +86 -11
  146. package/src/app/skill-doc-routes.ts +12 -2
  147. package/src/app/skill-group-routes.ts +126 -0
  148. package/src/app/surface.ts +26 -2
  149. package/src/app/team-routes.ts +3 -7
  150. package/src/authz.ts +2 -0
  151. package/src/cleanup-api.ts +9 -2
  152. package/src/connector-manage-api.ts +257 -0
  153. package/src/console-api.ts +60 -2
  154. package/src/features.ts +91 -0
  155. package/src/host/reads.ts +10 -1
  156. package/src/i18n/zh-tw.ts +137 -5
  157. package/src/push/channel.ts +1 -0
  158. package/src/web-plugin.ts +8 -1
  159. package/dist/chunk-account-83crfbn4.js +0 -1
  160. package/dist/chunk-action-menu-qw7h03x0.js +0 -1
  161. package/dist/chunk-agent-t1632ktv.js +0 -1
  162. package/dist/chunk-arranged-rooms-sb6tbsxw.js +0 -1
  163. package/dist/chunk-bulk-actions-v1g2bzk7.js +0 -1
  164. package/dist/chunk-character-creator-ye69w0kx.js +0 -1
  165. package/dist/chunk-codeeditor-5j6abj8k.js +0 -2
  166. package/dist/chunk-confirm-x8w877bz.js +0 -1
  167. package/dist/chunk-connectors-t7qbc6nk.js +0 -1
  168. package/dist/chunk-conversations-kmt6qzym.js +0 -1
  169. package/dist/chunk-council-sheet-bxvn7vza.js +0 -2
  170. package/dist/chunk-drag-03zgvd66.js +0 -1
  171. package/dist/chunk-entry-y5vwp5e5.js +0 -1
  172. package/dist/chunk-folder-list-h3vnjzpb.js +0 -1
  173. package/dist/chunk-folds-a3e22m7x.js +0 -1
  174. package/dist/chunk-gate-qzephvdv.js +0 -1
  175. package/dist/chunk-group-scene-w1ycabrm.js +0 -1
  176. package/dist/chunk-group-table-9nvmja8w.js +0 -1
  177. package/dist/chunk-host-icons-fccg3b2d.js +0 -1
  178. package/dist/chunk-host-picture-jaqz127q.js +0 -1
  179. package/dist/chunk-host-state-r01v1ywg.js +0 -1
  180. package/dist/chunk-host-ui-n54mh5af.js +0 -1
  181. package/dist/chunk-index-0a81da8z.js +0 -2
  182. package/dist/chunk-index-1kwp3spy.js +0 -2
  183. package/dist/chunk-index-47vq85ye.js +0 -2
  184. package/dist/chunk-index-5zmswz4s.js +0 -2
  185. package/dist/chunk-index-64rsz6pf.js +0 -2
  186. package/dist/chunk-index-6vcs0h0m.js +0 -2
  187. package/dist/chunk-index-6xqz6mg2.js +0 -2
  188. package/dist/chunk-index-83v0c9x6.js +0 -2
  189. package/dist/chunk-index-8xd6anam.js +0 -2
  190. package/dist/chunk-index-b73y2gc0.js +0 -47
  191. package/dist/chunk-index-bhqczxr3.js +0 -2
  192. package/dist/chunk-index-c44p8q87.js +0 -2
  193. package/dist/chunk-index-cbqhzf40.js +0 -13
  194. package/dist/chunk-index-db8zh8e6.js +0 -2
  195. package/dist/chunk-index-dvfkb699.js +0 -2
  196. package/dist/chunk-index-e02ddp46.js +0 -1
  197. package/dist/chunk-index-ebg48jgy.js +0 -2
  198. package/dist/chunk-index-ek5mek4j.js +0 -14
  199. package/dist/chunk-index-em7vndrb.js +0 -2
  200. package/dist/chunk-index-eq161yvp.js +0 -4
  201. package/dist/chunk-index-f5x2srr0.js +0 -2
  202. package/dist/chunk-index-fg42egv9.js +0 -50
  203. package/dist/chunk-index-h3v1d3nt.js +0 -2
  204. package/dist/chunk-index-kbgykazq.js +0 -2
  205. package/dist/chunk-index-n8ghdvxb.js +0 -2
  206. package/dist/chunk-index-p5jcwjx8.js +0 -2
  207. package/dist/chunk-index-qfgkfq86.js +0 -2
  208. package/dist/chunk-index-rcwdqn50.js +0 -7
  209. package/dist/chunk-index-s72hmse6.js +0 -2
  210. package/dist/chunk-index-sv9qpmhv.js +0 -1
  211. package/dist/chunk-index-x4enmj72.js +0 -2
  212. package/dist/chunk-index-xevnf9ew.js +0 -2
  213. package/dist/chunk-index-xx8jq73q.js +0 -2
  214. package/dist/chunk-index-ypkgw7gq.js +0 -2
  215. package/dist/chunk-lang-css-y9133ktr.js +0 -1
  216. package/dist/chunk-lang-html-r2xmryek.js +0 -1
  217. package/dist/chunk-lang-javascript-1r6wd177.js +0 -1
  218. package/dist/chunk-lang-json-dx56g11y.js +0 -1
  219. package/dist/chunk-lang-markdown-fpcf2dfq.js +0 -6
  220. package/dist/chunk-lang-python-zzp95955.js +0 -3
  221. package/dist/chunk-lang-rust-214fh7sp.js +0 -1
  222. package/dist/chunk-lang-sql-vvp46hpj.js +0 -2
  223. package/dist/chunk-lang-typescript-y5w4tt4g.js +0 -1
  224. package/dist/chunk-lang-xml-tbcqmedk.js +0 -1
  225. package/dist/chunk-lang-yaml-jh56t3sv.js +0 -1
  226. package/dist/chunk-layout-actions-8dzvysym.js +0 -1
  227. package/dist/chunk-menu-items-3jhya205.js +0 -2
  228. package/dist/chunk-mode-toggle-js49gvpd.js +0 -1
  229. package/dist/chunk-note-row-kv061030.js +0 -75
  230. package/dist/chunk-notes-sdkqz5hr.js +0 -1
  231. package/dist/chunk-notice-bell-r012f2zy.js +0 -1
  232. package/dist/chunk-notices-h4rk1zxc.js +0 -1
  233. package/dist/chunk-onboarding-a18cvkph.js +0 -1
  234. package/dist/chunk-overview-ynaqn4fp.js +0 -1
  235. package/dist/chunk-palette-jm79gb5q.js +0 -1
  236. package/dist/chunk-placement-items-mwm9q1zn.js +0 -1
  237. package/dist/chunk-profile-ajw6pp4r.js +0 -1
  238. package/dist/chunk-prompt-history-zcfvr3r8.js +0 -1
  239. package/dist/chunk-prompts-m5rzdk24.js +0 -1
  240. package/dist/chunk-room-confirms-ftsj15ja.js +0 -1
  241. package/dist/chunk-room-menu-b9ktw9pw.js +0 -1
  242. package/dist/chunk-rooms-menu-button-qe62t6nx.js +0 -1
  243. package/dist/chunk-schedules-t8yxnf8q.js +0 -1
  244. package/dist/chunk-sheet-hosts-yj5wszgj.js +0 -1
  245. package/dist/chunk-shortcuts-9ft5sqdw.js +0 -1
  246. package/dist/chunk-skill-editor-xdfq2708.js +0 -8
  247. package/dist/chunk-skills-9wa4mwdc.js +0 -1
  248. package/dist/chunk-switcher-list-vpg6wvtw.js +0 -1
  249. package/dist/chunk-table-1y76m54h.js +0 -1
  250. package/dist/chunk-table2d-g57vr6h0.js +0 -1
  251. package/dist/chunk-table3d-cv6069hy.js +0 -4220
  252. package/dist/chunk-thread-actions-xjhpdjk7.js +0 -1
  253. package/dist/chunk-thread-list-fd6agz77.js +0 -217
  254. package/dist/chunk-transcript-5nfn3r35.js +0 -1
  255. package/dist/chunk-where-ha8pc3cg.js +0 -1
  256. package/dist/chunk-workspace-e85twv32.js +0 -578
  257. package/dist/chunk-zh-tw-z5p5mpph.js +0 -1
@@ -1,10 +1,12 @@
1
1
  import type { SQL } from "bun";
2
2
  import type { Migration } from "pi-roundtable";
3
- import type {
4
- HostAuthorKind,
5
- PictureRef,
6
- RoomFile,
7
- RoomMessage,
3
+ import {
4
+ type HostAuthorKind,
5
+ type PictureRef,
6
+ type RoomFile,
7
+ type RoomIntercom,
8
+ type RoomMessage,
9
+ START_OVER_TEXT,
8
10
  } from "../api-types.ts";
9
11
  import { storedJson } from "../host/stored-json.ts";
10
12
 
@@ -27,6 +29,8 @@ export interface NewRoomMessage {
27
29
  at?: Date;
28
30
  /** An internal note: shown to the room's readers, never handed to the agent. */
29
31
  note?: true;
32
+ /** A message between agents, drawn as a small card. */
33
+ intercom?: RoomIntercom;
30
34
  }
31
35
 
32
36
  interface Row {
@@ -44,6 +48,7 @@ interface Row {
44
48
  picture: PictureRef | string | null;
45
49
  edited_at: Date | null;
46
50
  note: boolean | null;
51
+ intercom?: RoomIntercom | string | null;
47
52
  }
48
53
 
49
54
  /** The most messages one page of history holds. */
@@ -70,6 +75,9 @@ function messageOf(row: Row): RoomMessage {
70
75
  }),
71
76
  ...(row.edited_at === null ? {} : { edited: true as const }),
72
77
  ...(row.note ? { note: true as const } : {}),
78
+ ...(row.intercom == null
79
+ ? {}
80
+ : { intercom: storedJson(row.intercom, "intercom card") }),
73
81
  };
74
82
  }
75
83
 
@@ -128,6 +136,22 @@ export class RoomLog {
128
136
  },
129
137
  };
130
138
 
139
+ /** The column that marks a message between agents, with which way it went and the other agent. */
140
+ static readonly intercomMigration: Migration = {
141
+ name: "web-room-messages-intercom",
142
+ up: async (sql) => {
143
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS intercom jsonb`;
144
+ },
145
+ };
146
+
147
+ /** Finds a room's start-over lines without reading the whole room. */
148
+ static readonly startOverMigration: Migration = {
149
+ name: "web-room-messages-start-over",
150
+ up: async (sql) => {
151
+ await sql`CREATE INDEX IF NOT EXISTS web_room_messages_start_over ON web_room_messages (room, id) WHERE kind = 'system' AND text = 'Conversation started over'`;
152
+ },
153
+ };
154
+
131
155
  async append(message: NewRoomMessage): Promise<RoomMessage> {
132
156
  const [row] = await this.#insert(message, false);
133
157
  if (!row) throw new Error("the room log stored nothing");
@@ -153,24 +177,25 @@ export class RoomLog {
153
177
  async #insert(message: NewRoomMessage, once: boolean): Promise<Row[]> {
154
178
  const files = JSON.stringify(message.files ?? []);
155
179
  const picture = message.picture ? JSON.stringify(message.picture) : null;
180
+ const intercom = message.intercom ? JSON.stringify(message.intercom) : null;
156
181
  const at = message.at ?? new Date();
157
182
  return once
158
183
  ? await this.#sql`
159
184
  INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
160
- author_kind, author_label, picture, reference, created_at, note)
185
+ author_kind, author_label, picture, reference, created_at, note, intercom)
161
186
  VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
162
187
  ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
163
188
  ${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
164
- ${message.reference ?? null}, ${at}, ${message.note === true})
189
+ ${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::jsonb)
165
190
  ON CONFLICT (room, reference) WHERE reference IS NOT NULL DO NOTHING
166
191
  RETURNING *`
167
192
  : await this.#sql`
168
193
  INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
169
- author_kind, author_label, picture, reference, created_at, note)
194
+ author_kind, author_label, picture, reference, created_at, note, intercom)
170
195
  VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
171
196
  ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
172
197
  ${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
173
- ${message.reference ?? null}, ${at}, ${message.note === true})
198
+ ${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::jsonb)
174
199
  RETURNING *`;
175
200
  }
176
201
 
@@ -241,27 +266,50 @@ export class RoomLog {
241
266
  return found;
242
267
  }
243
268
 
244
- /** The newest `limit` messages older than `before` (all of them when absent), oldest first. */
269
+ /**
270
+ * The newest `limit` messages older than `before` (all of them when absent), oldest first, of
271
+ * the room's conversation: from its latest start-over line, which the page includes, on. With
272
+ * `until`, the id of one of its start-over lines, those of the conversation that line ended
273
+ * instead: older than the line, back to the start-over line before it.
274
+ */
245
275
  async page(
246
276
  room: string,
247
277
  before: number | undefined,
248
278
  limit: number,
279
+ until?: number,
249
280
  ): Promise<{ messages: RoomMessage[]; more: boolean }> {
250
281
  const count = Math.min(Math.max(Math.trunc(limit), 1), PAGE_LIMIT);
251
- const rows: Row[] =
252
- before === undefined
253
- ? await this.#sql`
254
- SELECT * FROM web_room_messages WHERE room = ${room}
255
- ORDER BY id DESC LIMIT ${count + 1}`
256
- : await this.#sql`
257
- SELECT * FROM web_room_messages WHERE room = ${room} AND id < ${before}
258
- ORDER BY id DESC LIMIT ${count + 1}`;
282
+ const start = await this.startOver(room, until);
283
+ // The live conversation includes its own start line; an archived one ends before the next.
284
+ const above = until === undefined ? (start ?? 1) - 1 : (start ?? 0);
285
+ const below =
286
+ until === undefined
287
+ ? before
288
+ : before === undefined
289
+ ? until
290
+ : Math.min(before, until);
291
+ const rows: Row[] = await this.#sql`
292
+ SELECT * FROM web_room_messages
293
+ WHERE room = ${room} AND id > ${above}
294
+ AND (${below ?? null}::bigint IS NULL OR id < ${below ?? null}::bigint)
295
+ ORDER BY id DESC LIMIT ${count + 1}`;
259
296
  return {
260
297
  messages: rows.slice(0, count).map(messageOf).reverse(),
261
298
  more: rows.length > count,
262
299
  };
263
300
  }
264
301
 
302
+ /** The id of the room's latest start-over line, or of the latest one older than `before`. */
303
+ async startOver(room: string, before?: number): Promise<number | undefined> {
304
+ const [row]: { id: string | number | bigint | null }[] = await this.#sql`
305
+ SELECT max(id) AS id FROM web_room_messages
306
+ WHERE room = ${room} AND kind = 'system' AND text = ${START_OVER_TEXT}
307
+ AND (${before ?? null}::bigint IS NULL OR id < ${before ?? null}::bigint)`;
308
+ return row?.id === null || row?.id === undefined
309
+ ? undefined
310
+ : Number(row.id);
311
+ }
312
+
265
313
  /** Deletes every message of a room that is gone for good; returns how many. */
266
314
  async removeRoom(room: string): Promise<number> {
267
315
  const rows: unknown[] = await this
package/src/app/rooms.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  AgentTeam,
6
6
  AvatarStudio,
7
7
  ChannelKey,
8
+ ConversationMode,
8
9
  ConversationModes,
9
10
  } from "pi-roundtable";
10
11
  import {
@@ -12,10 +13,22 @@ import {
12
13
  channelKey,
13
14
  characterOf,
14
15
  groupRoomKey,
16
+ helperSurface,
15
17
  isHelper,
16
18
  roomOf,
17
19
  } from "pi-roundtable";
18
- import type { RoomKind, RoomView } from "../api-types.ts";
20
+ import type { RoomKind, RoomMode, RoomView } from "../api-types.ts";
21
+
22
+ /** A conversation's modes as a room shows them: an inherited one names where it comes from. */
23
+ export function roomModeOf(mode: ConversationMode): RoomMode {
24
+ const from = mode.inheritedFrom;
25
+ return {
26
+ yolo: mode.yolo,
27
+ longRunning: mode.longRunning,
28
+ ...(from ? { inheritedFrom: from.name ?? from.channel } : {}),
29
+ };
30
+ }
31
+
19
32
  import type { PushMutes } from "../push/mutes.ts";
20
33
  import type { RoomLog } from "./room-log.ts";
21
34
  import type { WebThreads } from "./threads.ts";
@@ -62,6 +75,8 @@ const THREAD_ID = /^thread\.[0-9a-f-]{36}$/;
62
75
  */
63
76
  export class Rooms {
64
77
  readonly #options: RoomsOptions;
78
+ /** The rooms of temp knights made on Discord that someone opened by their address here. */
79
+ readonly #opened = new Set<string>();
65
80
 
66
81
  constructor(options: RoomsOptions) {
67
82
  this.#options = options;
@@ -176,12 +191,11 @@ export class Rooms {
176
191
  const thread = await this.#options.threads.get(room.name);
177
192
  if (!thread) return undefined;
178
193
  const { modes, activity, log } = this.#options;
179
- const { yolo, longRunning } = modes.peek(key);
180
194
  const last = (await log.lastOf([key])).get(key);
181
195
  return {
182
196
  home: false,
183
197
  readOnly: true,
184
- mode: { yolo, longRunning },
198
+ mode: roomModeOf(modes.peek(key)),
185
199
  ...activity(key),
186
200
  kind: "thread",
187
201
  name: thread.id,
@@ -193,6 +207,19 @@ export class Rooms {
193
207
  };
194
208
  }
195
209
 
210
+ /**
211
+ * The view of a temp knight's room that the list leaves out, opened by its address: from then on
212
+ * the list shows it. Undefined for any other room.
213
+ */
214
+ async openHelper(key: ChannelKey): Promise<RoomView | undefined> {
215
+ const room = await this.resolve(key);
216
+ if (room?.kind !== "agent") return undefined;
217
+ const agent = this.#options.directory.agent(room.name);
218
+ if (helperSurface(agent) !== "discord") return undefined;
219
+ this.#opened.add(room.name);
220
+ return (await this.list()).find((one) => one.key === key);
221
+ }
222
+
196
223
  async list(): Promise<RoomView[]> {
197
224
  const { surface, directory, modes, mutes, activity, log, threads } =
198
225
  this.#options;
@@ -207,11 +234,10 @@ export class Rooms {
207
234
  key: ChannelKey,
208
235
  rest: Pick<RoomView, "kind" | "name" | "displayName"> & Partial<RoomView>,
209
236
  ): RoomView => {
210
- const { yolo, longRunning } = modes.peek(key);
211
237
  return {
212
238
  home: key === home,
213
239
  readOnly: false,
214
- mode: { yolo, longRunning },
240
+ mode: roomModeOf(modes.peek(key)),
215
241
  ...(muted.has(key) ? { muted: true as const } : {}),
216
242
  ...(notes && rest.kind !== "thread" ? { notes: true as const } : {}),
217
243
  ...activity(key),
@@ -264,9 +290,21 @@ export class Rooms {
264
290
  // Home first; the others keep the team's order.
265
291
  rooms.sort((a, b) => Number(b.home) - Number(a.home));
266
292
  const last = await log.lastOf(rooms.map((room) => room.key));
267
- return rooms.map((room) => {
293
+ // A temp knight made on Discord works there: its room here shows once it has a message or
294
+ // someone opened it by its address.
295
+ const quiet = new Set<string>(
296
+ agents
297
+ .filter(
298
+ (agent) =>
299
+ helperSurface(agent) === "discord" && !this.#opened.has(agent.name),
300
+ )
301
+ .map((agent) => agentRoomKey(surface, agent.name))
302
+ .filter((key) => !last.has(key)),
303
+ );
304
+ return rooms.flatMap((room) => {
305
+ if (quiet.has(room.key)) return [];
268
306
  const message = last.get(room.key);
269
- return message ? { ...room, last: message } : room;
307
+ return [message ? { ...room, last: message } : room];
270
308
  });
271
309
  }
272
310
  }
package/src/app/routes.ts CHANGED
@@ -2,6 +2,7 @@ import type {
2
2
  AgentDirectory,
3
3
  AgentTeam,
4
4
  AvatarStudio,
5
+ ChannelKey,
5
6
  ConversationModes,
6
7
  ConversationPort,
7
8
  IdentityService,
@@ -37,6 +38,7 @@ import type {
37
38
  WorkspaceRootsView,
38
39
  WorkspaceSave,
39
40
  } from "../api-types.ts";
41
+ import { isStartOver } from "../api-types.ts";
40
42
  import type { Authenticated } from "../auth/authenticator.ts";
41
43
  import { type ConsolePresentation, message } from "../features.ts";
42
44
  import type { Gate } from "../gate.ts";
@@ -56,8 +58,9 @@ import type { LayoutStore } from "./layout.ts";
56
58
  import { LayoutRoutes } from "./layout-routes.ts";
57
59
  import { PromptApi, type PromptApiOptions } from "./prompt-routes.ts";
58
60
  import { PAGE_LIMIT, type RoomLog } from "./room-log.ts";
59
- import type { ResolvedRoom, Rooms } from "./rooms.ts";
61
+ import { type ResolvedRoom, type Rooms, roomModeOf } from "./rooms.ts";
60
62
  import { SkillDocApi } from "./skill-doc-routes.ts";
63
+ import { SkillGroupApi } from "./skill-group-routes.ts";
61
64
  import type { AppSurface } from "./surface.ts";
62
65
  import { TeamApi } from "./team-routes.ts";
63
66
  import type { WebThreads } from "./threads.ts";
@@ -79,12 +82,12 @@ export interface AppApiOptions {
79
82
  presentation?: ConsolePresentation;
80
83
  surface: AppSurface;
81
84
  rooms: Rooms;
82
- log: Pick<RoomLog, "page" | "lastOf" | "removeRoom">;
85
+ log: Pick<RoomLog, "page" | "lastOf" | "removeRoom" | "get" | "startOver">;
83
86
  cards: Pick<CardLog, "during" | "removeRoom">;
84
87
  uploads: Uploads;
85
88
  /** Where stored files are read from. */
86
89
  files: FileDir;
87
- modes: Pick<ConversationModes, "set" | "peek">;
90
+ modes: Pick<ConversationModes, "set" | "peek" | "forget">;
88
91
  /** The rooms whose push the owners muted. */
89
92
  mutes: Pick<PushMutes, "set" | "setMany">;
90
93
  /** The dispatch threads, which go with the room they hang under. */
@@ -131,11 +134,17 @@ export interface AppApiOptions {
131
134
  add: readonly string[],
132
135
  remove: readonly string[],
133
136
  ): Promise<string[]>;
134
- /** The registry's documents: a skill's SKILL.md read, saved and made, and its versions. */
137
+ /** The registry's documents: a skill's SKILL.md read, saved, made and deleted, and its versions. */
135
138
  documents: Pick<
136
139
  SkillRegistry,
137
- "document" | "saveDocument" | "createDocument" | "documentHistory"
140
+ | "document"
141
+ | "saveDocument"
142
+ | "createDocument"
143
+ | "documentHistory"
144
+ | "delete"
138
145
  >;
146
+ /** The registry's skill groups: listed, saved and deleted. */
147
+ groups: Pick<SkillRegistry, "groups" | "setGroup" | "deleteGroup">;
139
148
  };
140
149
  /** Present when the host has prompt documents (the prompts plugin): the main prompts the owner edits. */
141
150
  prompts?: PromptApiOptions["prompts"];
@@ -279,6 +288,7 @@ export class AppApi {
279
288
  readonly #helpers: HelperRoutes;
280
289
  readonly #prompts: PromptApi | undefined;
281
290
  readonly #skillDocs: SkillDocApi | undefined;
291
+ readonly #skillGroups: SkillGroupApi | undefined;
282
292
 
283
293
  constructor(options: AppApiOptions) {
284
294
  this.#options = options;
@@ -305,6 +315,17 @@ export class AppApi {
305
315
  catalog: () => options.skills?.catalog() ?? [],
306
316
  })
307
317
  : undefined;
318
+ const skills = options.skills;
319
+ this.#skillGroups = skills
320
+ ? new SkillGroupApi({
321
+ groups: skills.groups,
322
+ catalog: () => skills.catalog(),
323
+ attach: (agent, add, remove) => skills.attach(agent, add, remove),
324
+ active: (agent) =>
325
+ options.directory.agent(agent)?.status === "active",
326
+ changed: () => options.changed(),
327
+ })
328
+ : undefined;
308
329
  this.#team = new TeamApi({
309
330
  surface: options.surface.surface,
310
331
  rooms: options.rooms,
@@ -388,6 +409,7 @@ export class AppApi {
388
409
  "workspace",
389
410
  "prompts",
390
411
  "skill-docs",
412
+ "skill-groups",
391
413
  ].includes(second ?? ""));
392
414
  if (!mine) return undefined;
393
415
  try {
@@ -486,6 +508,11 @@ export class AppApi {
486
508
  throw new HttpError(404, "This host has no skills.");
487
509
  return this.#skillDocs.handle(request, parts, owner);
488
510
  }
511
+ if (area === "skill-groups") {
512
+ if (!this.#skillGroups)
513
+ throw new HttpError(404, "This host has no skills.");
514
+ return this.#skillGroups.handle(request, parts, owner);
515
+ }
489
516
  throw new HttpError(404, "There is no such API.");
490
517
  }
491
518
 
@@ -559,8 +586,11 @@ export class AppApi {
559
586
  const room = await rooms.resolve(key);
560
587
  if (!room) throw notFound();
561
588
  if (!action && method === "GET") {
562
- // A thread dismissed from the list still opens by its address; the page asks for its view.
563
- const view = await rooms.threadView(room.key);
589
+ // A thread dismissed from the list, or a temp knight's room the list leaves out, still opens
590
+ // by its address; the page asks for its view.
591
+ const view =
592
+ (await rooms.threadView(room.key)) ??
593
+ (await rooms.openHelper(room.key));
564
594
  if (!view) throw notFound();
565
595
  return json({ room: view } satisfies RoomOneView);
566
596
  }
@@ -580,17 +610,23 @@ export class AppApi {
580
610
  const params = new URL(request.url).searchParams;
581
611
  const before = params.get("before");
582
612
  const limit = params.get("limit");
613
+ const archive = params.get("archive");
583
614
  const beforeId = before === null ? undefined : Number(before);
615
+ const archiveId = archive === null ? undefined : Number(archive);
584
616
  const count = limit === null ? PAGE_LIMIT : Number(limit);
585
617
  if (
586
618
  (beforeId !== undefined && !Number.isInteger(beforeId)) ||
619
+ (archiveId !== undefined && !Number.isInteger(archiveId)) ||
587
620
  !Number.isInteger(count) ||
588
621
  count < 1
589
622
  )
590
- throw bad("before and limit must be whole numbers.");
623
+ throw bad("before, limit and archive must be whole numbers.");
624
+ if (archiveId !== undefined)
625
+ return json(await this.#archived(room.key, archiveId, beforeId, count));
591
626
  const page = await log.page(room.key, beforeId, count);
592
627
  // The cards stand among the messages by the time they opened; a page that has older
593
- // messages leaves the cards before its first one to the older page.
628
+ // messages leaves the cards before its first one to the older page, and a conversation
629
+ // started over leaves the archived one's cards to it.
594
630
  const first = page.messages[0];
595
631
  const { reads, nameOf } = this.#options;
596
632
  const self = admission.visitor.principal.id;
@@ -599,7 +635,9 @@ export class AppApi {
599
635
  ...page,
600
636
  messages: withExpiredFiles(page.messages, this.#options.files),
601
637
  cards: await cards.during(room.key, {
602
- ...(page.more && first ? { from: first.at } : {}),
638
+ ...(first && (page.more || isStartOver(first))
639
+ ? { from: first.at }
640
+ : {}),
603
641
  ...(beforeId === undefined ? {} : { before: beforeId }),
604
642
  }),
605
643
  ...(reads && nameOf
@@ -624,13 +662,19 @@ export class AppApi {
624
662
  }
625
663
  if (Object.keys(change).length === 0)
626
664
  throw bad("Give yolo or longRunning.");
665
+ if (modes.peek(room.key).inheritedFrom)
666
+ throw bad(
667
+ "This room follows the modes of the conversation that made it; switch them there.",
668
+ );
627
669
  const mode = await modes.set(room.key, change, owner);
628
- return json({ mode: { yolo: mode.yolo, longRunning: mode.longRunning } });
670
+ return json({ mode: roomModeOf(mode) });
629
671
  }
630
672
  if (action === "start-over" && method === "POST" && parts.length === 4) {
631
673
  if (room.readOnly) throw notFound();
632
674
  await this.#owner(admission);
633
675
  await conversations.startFresh(room.key);
676
+ // The other pages read the room list again: its unread count and last line changed.
677
+ this.#options.changed();
634
678
  return json({ ok: true });
635
679
  }
636
680
  if (
@@ -653,6 +697,37 @@ export class AppApi {
653
697
  throw new HttpError(404, "There is no such API.");
654
698
  }
655
699
 
700
+ /**
701
+ * A page of the conversation a start-over line of the room ended, read only: its messages and
702
+ * the cards that opened within it. A line that is not one of the room's start-overs names none.
703
+ */
704
+ async #archived(
705
+ room: ChannelKey,
706
+ until: number,
707
+ before: number | undefined,
708
+ count: number,
709
+ ): Promise<MessagesView> {
710
+ const { log, cards } = this.#options;
711
+ const line = await log.get(room, until);
712
+ if (!line || !isStartOver(line)) throw notFound();
713
+ const page = await log.page(room, before, count, until);
714
+ const first = page.messages[0];
715
+ const previous = page.more ? undefined : await log.startOver(room, until);
716
+ const from = page.more
717
+ ? first?.at
718
+ : previous === undefined
719
+ ? undefined
720
+ : (await log.get(room, previous))?.at;
721
+ return {
722
+ ...page,
723
+ messages: withExpiredFiles(page.messages, this.#options.files),
724
+ cards: await cards.during(room, {
725
+ ...(from === undefined ? {} : { from }),
726
+ before: before ?? until,
727
+ }),
728
+ };
729
+ }
730
+
656
731
  /**
657
732
  * Reads the person's rooms up to their newest message in one call: the named ones, or every room
658
733
  * the list shows. A room they may not read is passed over as if it were not named. With `restore`
@@ -2,6 +2,7 @@ import type { SkillDocument, SkillRegistry } from "pi-roundtable";
2
2
  import { SkillConflictError } from "pi-roundtable";
3
3
  import type {
4
4
  SkillConflict,
5
+ SkillDeleted,
5
6
  SkillDocCreate,
6
7
  SkillDocSave,
7
8
  SkillDocView,
@@ -18,7 +19,11 @@ const MAX_SKILL_BYTES = 64 * 1024;
18
19
  export interface SkillDocApiOptions {
19
20
  skills: Pick<
20
21
  SkillRegistry,
21
- "document" | "saveDocument" | "createDocument" | "documentHistory"
22
+ | "document"
23
+ | "saveDocument"
24
+ | "createDocument"
25
+ | "documentHistory"
26
+ | "delete"
22
27
  >;
23
28
  /** The catalog the page lists, so a name it does not list is a 404 before the registry is asked. */
24
29
  catalog(): SkillView[];
@@ -34,7 +39,7 @@ const viewOf = (document: SkillDocument): SkillDocView => ({
34
39
 
35
40
  /**
36
41
  * 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
42
+ * skill, deleting an agent-written one and listing the versions kept. The frontmatter rules are the skill tools': the registry
38
43
  * checks them, and a refusal is a 400 with its words. Every call is an owner's; a save names the
39
44
  * hash it was edited from, and a skill that changed meanwhile answers 409 with what is there now.
40
45
  */
@@ -106,6 +111,11 @@ export class SkillDocApi {
106
111
  throw error;
107
112
  }
108
113
  }
114
+ // The registry refuses a skill it did not write, in its own words; deleting detaches it from every carrier.
115
+ if (!action && method === "DELETE")
116
+ return json({
117
+ message: await skills.delete(name),
118
+ } satisfies SkillDeleted);
109
119
  if (action === "history" && method === "GET" && parts.length === 4) {
110
120
  return json({
111
121
  versions: (await skills.documentHistory(name)).map((version) => ({
@@ -0,0 +1,126 @@
1
+ import type { SkillRegistry } from "pi-roundtable";
2
+ import type {
3
+ SkillGroupApplied,
4
+ SkillGroupApply,
5
+ SkillGroupChanged,
6
+ SkillGroupSave,
7
+ SkillGroupsView,
8
+ SkillView,
9
+ } from "../api-types.ts";
10
+ import { json } from "../http.ts";
11
+ import {
12
+ bad,
13
+ bodyOf,
14
+ HttpError,
15
+ optionalList,
16
+ optionalText,
17
+ requiredText,
18
+ } from "./api-http.ts";
19
+ import type { OwnerOf } from "./team-routes.ts";
20
+
21
+ export interface SkillGroupApiOptions {
22
+ groups: Pick<SkillRegistry, "groups" | "setGroup" | "deleteGroup">;
23
+ /** The catalog the page lists: who carries each skill, and which are built in. */
24
+ catalog(): SkillView[];
25
+ /** The same change the per-skill toggle makes. */
26
+ attach(
27
+ agent: string,
28
+ add: readonly string[],
29
+ remove: readonly string[],
30
+ ): Promise<string[]>;
31
+ /** Whether a knight answers; an archived or unknown one is a 404. */
32
+ active(agent: string): boolean;
33
+ changed(): void;
34
+ }
35
+
36
+ /**
37
+ * The app's REST for skill groups: listing them, creating or replacing one, deleting one, and
38
+ * applying one to a knight. Applying switches on the group's skills the knight lacks through the
39
+ * per-skill toggle's path; applying it off switches off the group's skills the knight carries, built-in
40
+ * ones excepted. The knight carries skills, not the group.
41
+ * Every call is an owner's; the registry's refusals are a 400 in its words.
42
+ */
43
+ export class SkillGroupApi {
44
+ readonly #options: SkillGroupApiOptions;
45
+
46
+ constructor(options: SkillGroupApiOptions) {
47
+ this.#options = options;
48
+ }
49
+
50
+ async handle(
51
+ request: Request,
52
+ parts: string[],
53
+ owner: OwnerOf,
54
+ ): Promise<Response> {
55
+ const { method } = request;
56
+ const [, , name, action] = parts;
57
+ const { groups } = this.#options;
58
+ // The tier is read first, so a person below the owner learns of no group.
59
+ await owner();
60
+ if (!name) {
61
+ if (method !== "GET") throw new HttpError(405, "Method not allowed.");
62
+ return json({
63
+ groups: groups.groups().map(({ name, description, skills }) => ({
64
+ name,
65
+ description,
66
+ skills: [...skills],
67
+ })),
68
+ } satisfies SkillGroupsView);
69
+ }
70
+ const group = groups.groups().find((one) => one.name === name);
71
+ if (!action && method === "PUT") {
72
+ const body = await bodyOf(request);
73
+ const from = optionalText(body, "from");
74
+ const save: SkillGroupSave = {
75
+ description: requiredText(body, "description"),
76
+ skills: optionalList(body, "skills") ?? [],
77
+ ...(from ? { from } : {}),
78
+ };
79
+ const renamed = save.from !== undefined && save.from !== name;
80
+ if (renamed) {
81
+ if (!groups.groups().some((one) => one.name === save.from))
82
+ throw new HttpError(404, "There is no such skill group.");
83
+ if (group) throw bad(`There is already a skill group "${name}".`);
84
+ }
85
+ let message = await groups.setGroup(name, save.description, save.skills);
86
+ if (renamed && save.from)
87
+ message = `${message} ${await groups.deleteGroup(save.from)}`;
88
+ this.#options.changed();
89
+ return json({ message } satisfies SkillGroupChanged, group ? 200 : 201);
90
+ }
91
+ if (!group) throw new HttpError(404, "There is no such skill group.");
92
+ if (!action && method === "DELETE") {
93
+ const message = await groups.deleteGroup(name);
94
+ this.#options.changed();
95
+ return json({ message } satisfies SkillGroupChanged);
96
+ }
97
+ if (action === "apply" && method === "POST" && parts.length === 4) {
98
+ const body = await bodyOf(request);
99
+ const apply: SkillGroupApply = {
100
+ agent: requiredText(body, "agent"),
101
+ on: body.on !== false,
102
+ };
103
+ if (!this.#options.active(apply.agent))
104
+ throw new HttpError(404, "There is no such room, knight or file.");
105
+ const catalog = new Map(
106
+ this.#options.catalog().map((entry) => [entry.name, entry]),
107
+ );
108
+ // A built-in skill is carried always: it is never added nor switched off.
109
+ const switchable = group.skills.filter(
110
+ (skill) => catalog.get(skill)?.source.kind !== "builtin",
111
+ );
112
+ const carries = (skill: string) =>
113
+ catalog.get(skill)?.carriers.includes(apply.agent) ?? false;
114
+ const added = apply.on
115
+ ? switchable.filter((skill) => !carries(skill))
116
+ : [];
117
+ const removed = apply.on ? [] : switchable.filter(carries);
118
+ if (added.length > 0 || removed.length > 0) {
119
+ await this.#options.attach(apply.agent, added, removed);
120
+ this.#options.changed();
121
+ }
122
+ return json({ added, removed } satisfies SkillGroupApplied);
123
+ }
124
+ throw new HttpError(404, "There is no such API.");
125
+ }
126
+ }