@ljwei-stak/dsh-model-route-bot 0.66.3 → 0.67.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 (272) hide show
  1. package/.dsh-plugin/bot-product.mjs +81 -5
  2. package/.dsh-plugin/client.js +17490 -1791
  3. package/README.i18n.yaml +4 -4
  4. package/README.md +409 -364
  5. package/README.zh.md +409 -364
  6. package/apps/integration-client/README.md +31 -7
  7. package/apps/integration-client/api.mjs +14 -3
  8. package/apps/integration-client/binding-editor.jsx +308 -109
  9. package/apps/integration-client/binding-model.mjs +262 -0
  10. package/apps/integration-client/binding.css +144 -0
  11. package/apps/integration-client/bot-model-status.mjs +18 -0
  12. package/apps/integration-client/bot-profile-identity.jsx +197 -0
  13. package/apps/integration-client/bot-profile.css +57 -0
  14. package/apps/integration-client/bot-profile.mjs +97 -0
  15. package/apps/integration-client/bot-settings-dialog.css +18 -0
  16. package/apps/integration-client/bot-settings-dialog.jsx +111 -0
  17. package/apps/integration-client/bot-thread-header-bridge.css +20 -0
  18. package/apps/integration-client/bot-thread-header.jsx +278 -0
  19. package/apps/integration-client/bot-threads-panel.jsx +378 -0
  20. package/apps/integration-client/bot-threads.css +169 -0
  21. package/apps/integration-client/chat-design.mjs +250 -0
  22. package/apps/integration-client/chat-ui.jsx +175 -0
  23. package/apps/integration-client/chat.css +477 -0
  24. package/apps/integration-client/composer-draft-binding.mjs +50 -0
  25. package/apps/integration-client/composer-draft-bound.jsx +14 -0
  26. package/apps/integration-client/composer-draft-host.jsx +142 -0
  27. package/apps/integration-client/composer-draft-session.jsx +287 -0
  28. package/apps/integration-client/composer-draft-witness.mjs +115 -0
  29. package/apps/integration-client/composer-draft.css +4 -0
  30. package/apps/integration-client/composer-draft.mjs +322 -0
  31. package/apps/integration-client/contact-attention.jsx +54 -0
  32. package/apps/integration-client/data-panel.jsx +439 -0
  33. package/apps/integration-client/data.css +69 -0
  34. package/apps/integration-client/desktop-bridge.mjs +1 -0
  35. package/apps/integration-client/desktop-http-socket.mjs +49 -0
  36. package/apps/integration-client/desktop-keyboard.mjs +32 -0
  37. package/apps/integration-client/desktop-pane.jsx +99 -30
  38. package/apps/integration-client/desktop-stream.mjs +13 -3
  39. package/apps/integration-client/desktop-tools.jsx +19 -0
  40. package/apps/integration-client/desktop-touch.mjs +27 -0
  41. package/apps/integration-client/desktop.css +41 -0
  42. package/apps/integration-client/independent-chat-controller.mjs +224 -0
  43. package/apps/integration-client/independent-chat-entry.jsx +61 -0
  44. package/apps/integration-client/independent-chat-navigation.mjs +21 -0
  45. package/apps/integration-client/independent-chat-page.jsx +148 -0
  46. package/apps/integration-client/independent-chat-rich.jsx +64 -0
  47. package/apps/integration-client/independent-chat-rich.mjs +30 -0
  48. package/apps/integration-client/independent-chat.css +73 -0
  49. package/apps/integration-client/index.jsx +24 -16
  50. package/apps/integration-client/library-panel.jsx +4 -1
  51. package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
  52. package/apps/integration-client/main-route-editor.jsx +94 -0
  53. package/apps/integration-client/main-route-model.mjs +100 -0
  54. package/apps/integration-client/model-picker.css +37 -0
  55. package/apps/integration-client/model-picker.jsx +83 -0
  56. package/apps/integration-client/native-activity.css +141 -0
  57. package/apps/integration-client/native-activity.jsx +276 -0
  58. package/apps/integration-client/native-activity.mjs +384 -0
  59. package/apps/integration-client/notifications-panel.jsx +186 -0
  60. package/apps/integration-client/notifications.css +126 -0
  61. package/apps/integration-client/pairing-qr.jsx +79 -0
  62. package/apps/integration-client/panels.jsx +126 -115
  63. package/apps/integration-client/product-slots.jsx +46 -0
  64. package/apps/integration-client/routine-recorder.mjs +23 -2
  65. package/apps/integration-client/secure-pairing.jsx +84 -0
  66. package/apps/integration-client/tasks-panel.jsx +32 -10
  67. package/apps/integration-client/ui-icons.jsx +17 -0
  68. package/apps/integration-client/usage-explorer.jsx +43 -0
  69. package/apps/integration-client/usage-report.mjs +71 -0
  70. package/apps/integration-client/usage.css +34 -0
  71. package/apps/integration-client/web-panel-model.mjs +140 -0
  72. package/apps/integration-client/web-panel-page.jsx +221 -0
  73. package/apps/integration-client/web-panel-pins-live.jsx +28 -0
  74. package/apps/integration-client/web-panel.css +139 -0
  75. package/apps/integration-client/workspace-model.mjs +67 -0
  76. package/apps/integration-client/workspace-navigation.mjs +56 -0
  77. package/apps/integration-client/workspace-ui.jsx +173 -0
  78. package/apps/integration-client/workspace.css +332 -0
  79. package/modules/bot-runtime/SOURCE.json +176 -50
  80. package/modules/bot-runtime/index.mjs +169 -13
  81. package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
  82. package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
  83. package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
  84. package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
  85. package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
  86. package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
  87. package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
  88. package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
  89. package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
  90. package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
  91. package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
  92. package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
  93. package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
  94. package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
  95. package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
  96. package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
  97. package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
  98. package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
  99. package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
  100. package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
  101. package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
  102. package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
  103. package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
  104. package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
  105. package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
  106. package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
  107. package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
  108. package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
  109. package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
  110. package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
  111. package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
  112. package/modules/bot-runtime/read-model.mjs +19 -4
  113. package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
  114. package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
  115. package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
  116. package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
  117. package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
  118. package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
  119. package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
  120. package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
  121. package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
  122. package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
  123. package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
  124. package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
  125. package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
  126. package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
  127. package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
  128. package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
  129. package/modules/independent-runtime/index.mjs +152 -62
  130. package/modules/independent-runtime/remote-tools.mjs +8 -5
  131. package/modules/openmaus-protocol/NOTICE +10 -0
  132. package/modules/openmaus-protocol/README.md +15 -0
  133. package/modules/openmaus-protocol/SOURCE.json +68 -0
  134. package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
  135. package/modules/openmaus-protocol/index.mjs +1 -0
  136. package/modules/openmaus-protocol/transport.mjs +77 -0
  137. package/modules/openmaus-protocol/vendor/LICENSE +202 -0
  138. package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
  139. package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
  140. package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
  141. package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
  142. package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
  143. package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
  144. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
  145. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
  146. package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
  147. package/package.json +20 -6
  148. package/packages/bot-acp/README.md +156 -0
  149. package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
  150. package/packages/bot-acp/licenses/NOTICE.md +27 -0
  151. package/packages/bot-acp/src/acp-adapter.mjs +795 -0
  152. package/packages/bot-acp/src/acp-host.mjs +148 -0
  153. package/packages/bot-acp/src/native-launch.mjs +121 -0
  154. package/packages/bot-acp/src/permission-policy.mjs +283 -0
  155. package/packages/bot-acp/src/rpc-codec.mjs +265 -0
  156. package/packages/bot-acp/src/session-core.mjs +581 -0
  157. package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
  158. package/packages/bot-engines/README.md +281 -0
  159. package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
  160. package/packages/bot-engines/licenses/NOTICE.md +32 -0
  161. package/packages/bot-engines/src/claude-engine.mjs +772 -0
  162. package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
  163. package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
  164. package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
  165. package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
  166. package/packages/bot-engines/src/claude-protocol.mjs +177 -0
  167. package/packages/bot-engines/src/driver-catalog.mjs +334 -0
  168. package/packages/bot-engines/src/host-loop-request.mjs +95 -0
  169. package/packages/bot-engines/src/host-registrations.mjs +105 -0
  170. package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
  171. package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
  172. package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
  173. package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
  174. package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
  175. package/packages/cli-runtime/src/native-peer.ps1 +51 -3
  176. package/packages/cli-runtime/src/native-process.mjs +77 -14
  177. package/packages/model-router-plugin/README.md +3 -3
  178. package/packages/model-router-plugin/package.json +1 -1
  179. package/packages/orchestration/src/binding-product.mjs +19 -0
  180. package/packages/orchestration/src/bot-data-actions.mjs +45 -0
  181. package/packages/orchestration/src/bot-data-chart.mjs +109 -0
  182. package/packages/orchestration/src/bot-data-context.mjs +44 -0
  183. package/packages/orchestration/src/bot-data-engine.mjs +809 -0
  184. package/packages/orchestration/src/bot-data-io.mjs +186 -0
  185. package/packages/orchestration/src/bot-data-sql.mjs +195 -0
  186. package/packages/orchestration/src/bot-data-surface.mjs +144 -0
  187. package/packages/orchestration/src/bot-data-tools.mjs +68 -0
  188. package/packages/orchestration/src/bot-data-worker.mjs +228 -0
  189. package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
  190. package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
  191. package/packages/orchestration/src/bot-loop.mjs +152 -19
  192. package/packages/orchestration/src/bot-notifications.mjs +159 -0
  193. package/packages/orchestration/src/bot-policy.mjs +43 -4
  194. package/packages/orchestration/src/bot-profile.mjs +171 -0
  195. package/packages/orchestration/src/bot-routine.mjs +4 -4
  196. package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
  197. package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
  198. package/packages/orchestration/src/bot-serving.mjs +164 -0
  199. package/packages/orchestration/src/bot-session-store.mjs +105 -9
  200. package/packages/orchestration/src/bot-settings.mjs +50 -11
  201. package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
  202. package/packages/orchestration/src/bot-thread-host.mjs +187 -0
  203. package/packages/orchestration/src/bot-threads.mjs +790 -0
  204. package/packages/orchestration/src/data-product-access.mjs +37 -0
  205. package/packages/orchestration/src/data-tool-contract.mjs +291 -0
  206. package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
  207. package/packages/orchestration/src/desktop-proxy.mjs +311 -0
  208. package/packages/orchestration/src/desktop-sessions.mjs +43 -9
  209. package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
  210. package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
  211. package/packages/orchestration/src/independent-chat-page.mjs +39 -0
  212. package/packages/orchestration/src/independent-chat-service.mjs +308 -0
  213. package/packages/orchestration/src/independent-context-state.mjs +44 -0
  214. package/packages/orchestration/src/independent-message-wire.mjs +84 -0
  215. package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
  216. package/packages/orchestration/src/independent-tool-families.mjs +73 -0
  217. package/packages/orchestration/src/independent-tool-host.mjs +344 -0
  218. package/packages/orchestration/src/main-bot-grant.mjs +15 -0
  219. package/packages/orchestration/src/main-model-routes.mjs +208 -0
  220. package/packages/orchestration/src/model-accounting.mjs +99 -35
  221. package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
  222. package/packages/orchestration/src/model-loop-frame.mjs +179 -0
  223. package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
  224. package/packages/orchestration/src/model-request-bounds.mjs +111 -0
  225. package/packages/orchestration/src/native-activity-contract.mjs +491 -0
  226. package/packages/orchestration/src/native-activity-service.mjs +102 -0
  227. package/packages/orchestration/src/native-activity-store.mjs +249 -0
  228. package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
  229. package/packages/orchestration/src/native-data-tools.mjs +100 -0
  230. package/packages/orchestration/src/native-remote-actions.mjs +742 -0
  231. package/packages/orchestration/src/native-team-tools.mjs +73 -0
  232. package/packages/orchestration/src/pairing-invite.mjs +37 -0
  233. package/packages/orchestration/src/product-composition.mjs +1559 -1001
  234. package/packages/orchestration/src/remote-machines.mjs +82 -14
  235. package/packages/orchestration/src/routine-execution.mjs +5 -3
  236. package/packages/orchestration/src/routine-session.mjs +19 -13
  237. package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
  238. package/packages/orchestration/src/task-journal.mjs +29 -0
  239. package/packages/orchestration/src/task-product.mjs +17 -0
  240. package/packages/orchestration/src/team-assignment-store.mjs +120 -0
  241. package/packages/orchestration/src/team-bridge.mjs +282 -0
  242. package/packages/orchestration/src/team-scheduler.mjs +83 -0
  243. package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
  244. package/packages/orchestration/src/team-tool-contract.mjs +284 -0
  245. package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
  246. package/packages/orchestration/src/web-panel-actions.mjs +103 -0
  247. package/packages/orchestration/src/web-panel-framing.mjs +227 -0
  248. package/packages/orchestration/src/web-panel-store.mjs +206 -0
  249. package/remote-computer-agent/README.md +39 -29
  250. package/remote-computer-agent/build.mjs +3 -0
  251. package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
  252. package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
  253. package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
  254. package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
  255. package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
  256. package/remote-computer-agent/dist/cli.mjs +1688 -168
  257. package/remote-computer-agent/dist/cli.mjs.map +4 -4
  258. package/remote-computer-agent/dist/index.mjs +1579 -122
  259. package/remote-computer-agent/dist/index.mjs.map +4 -4
  260. package/remote-computer-agent/dist/native-peer.ps1 +51 -3
  261. package/remote-computer-agent/package-lock.json +308 -2
  262. package/remote-computer-agent/package.json +2 -1
  263. package/remote-computer-agent/pair-device.ps1 +20 -0
  264. package/remote-computer-agent/setup.ps1 +38 -16
  265. package/remote-computer-agent/src/cli.ts +24 -3
  266. package/remote-computer-agent/src/desktop-session.ts +11 -3
  267. package/remote-computer-agent/src/e2e-server.ts +81 -0
  268. package/remote-computer-agent/src/pairing-page.ts +35 -0
  269. package/remote-computer-agent/src/protocol.ts +25 -25
  270. package/remote-computer-agent/src/secure-pairing.ts +105 -0
  271. package/remote-computer-agent/src/store.ts +20 -5
  272. package/remote-computer-agent/src/transport.ts +39 -15
@@ -0,0 +1,776 @@
1
+ // Roster: creating, changing, and deleting Bots and group chats, the Main Bot role, and
2
+ // the first Main Bot of a new team.
3
+ import { mkdir } from 'node:fs/promises'
4
+ import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOTICE_MAX } from './constants.js'
5
+ import { currentColor, isColor, normalize, unquote } from './text.js'
6
+ import { nameProblem } from '../shared/mentions.js'
7
+
8
+ const BROWSER_GRANT_KINDS = new Set(['post', 'upload', 'download', 'delete', '*'])
9
+ const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/
10
+
11
+ const OWN_RULES = 'Where your instructions say otherwise, follow them.'
12
+ const writeIn = language => `Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
13
+ // The first Main Bot's greeting when the user wrote before it had a model that works.
14
+ const AFTER_USER = 'You were just set up as the user\'s Main Bot. The user wrote to you before your model could answer; their message above has no answer yet. Answer it now, and say in one short sentence that you run their Bot team.'
15
+
16
+ function cleanBrowserGrants(input) {
17
+ if (input === null) return null
18
+ if (!Array.isArray(input) || input.length > 50) throw new Error('Browser grants must be an array of at most 50 entries')
19
+ const grants = []
20
+ const seen = new Set()
21
+ for (const entry of input) {
22
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
23
+ throw new Error('Each browser grant must have a site and kinds')
24
+ }
25
+ const until = entry.until
26
+ if (until !== undefined) {
27
+ if (typeof until !== 'number' || !Number.isFinite(until)) {
28
+ throw new Error('Browser grant until must be a future epoch-millisecond timestamp')
29
+ }
30
+ if (until <= Date.now()) continue
31
+ }
32
+ const site = typeof entry.site === 'string' ? entry.site.trim().toLowerCase() : ''
33
+ if (site !== '*' && !HOSTNAME.test(site)) throw new Error('Browser grant site must be * or a hostname')
34
+ if (!Array.isArray(entry.kinds) || entry.kinds.length === 0
35
+ || entry.kinds.some(kind => typeof kind !== 'string' || !BROWSER_GRANT_KINDS.has(kind))) {
36
+ throw new Error('Browser grant kinds must be a non-empty subset of post, upload, download, delete, *')
37
+ }
38
+ const kinds = [...new Set(entry.kinds)].sort()
39
+ const key = `${site}\0${kinds.join('\0')}\0${until ?? ''}`
40
+ if (seen.has(key)) continue
41
+ seen.add(key)
42
+ grants.push({ site, kinds, ...(until !== undefined ? { until } : {}) })
43
+ }
44
+ return grants
45
+ }
46
+
47
+ export function install(rt) {
48
+ const { ctx, defaults, state } = rt
49
+
50
+ let bootstrapping = null
51
+
52
+ // The team folder is DS Bot's own: deleted, it is made again before a Session needs it.
53
+ async function keepTeamFolder() {
54
+ const team = rt.teamFolder()
55
+ if (team !== undefined) await mkdir(team, { recursive: true }).catch(error => rt.warn('ds-bot: team folder %s not made again: %s', team, error))
56
+ return team
57
+ }
58
+
59
+ async function ensureWorkspace() {
60
+ if (state.workspaceId && ctx.workspaceRegistry.get(state.workspaceId)) {
61
+ await keepTeamFolder()
62
+ return state.workspaceId
63
+ }
64
+ const path = rt.defaultTeamFolder()
65
+ await mkdir(path, { recursive: true })
66
+ const workspace = await ctx.workspaceRegistry.resolveByPath(path) ?? await ctx.workspaceRegistry.create(path, 'DS Bot')
67
+ state.workspaceId = workspace.id
68
+ await rt.save()
69
+ return workspace.id
70
+ }
71
+
72
+ // Under the PTC preset (DSH Desktop's default) a model may call only run_code, which
73
+ // turns down the group_relay call that a group chat's Session is given. A Session
74
+ // keeps the preset it is made with.
75
+ async function botPreset() {
76
+ const presets = ctx.get?.('agentPresets')
77
+ if (presets === undefined) return {}
78
+ const standard = (await presets.list()).some(preset => preset.id === 'standard' && preset.broken === undefined)
79
+ return standard ? { agentPreset: 'standard' } : {}
80
+ }
81
+
82
+ // `dir` is a working folder outside the team workspace. Such a Session is made by cwd
83
+ // alone, so it joins no DSH workspace and the user's own project lists stay as they are —
84
+ // unless the folder is already registered as a workspace, which it then joins (#710).
85
+ async function createSession(title, { dir } = {}) {
86
+ // A Bot's tools run here too; while another dsh holds the team, a new Session would
87
+ // belong to no saved Bot.
88
+ const readOnly = rt.readOnly()
89
+ if (readOnly !== undefined) throw new Error(readOnly)
90
+ const usable = dir !== undefined && await rt.isFolder(dir)
91
+ if (dir !== undefined && !usable) rt.warn('ds-bot: the working folder %s is gone; %s starts in the team workspace', dir, title)
92
+ const known = usable ? await ctx.workspaceRegistry.resolveByPath(dir) : undefined
93
+ const place = known !== undefined ? { workspaceId: known.id } : usable ? { cwd: dir } : { workspaceId: await ensureWorkspace() }
94
+ const { sessionId } = await ctx.sessionController.create({ ...place, ...await botPreset() })
95
+ const agent = await rt.resolveAgent(sessionId)
96
+ await ctx.sessionTitle.rename(agent.session, title)
97
+ return sessionId
98
+ }
99
+
100
+ function cleanAvatar(input) {
101
+ if (input === null || typeof input !== 'object') return undefined
102
+ const avatar = {}
103
+ if (typeof input.shape === 'string' && /^[a-z][\w-]{0,31}$/.test(input.shape)) avatar.shape = input.shape
104
+ if (typeof input.image === 'string') {
105
+ if (!/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(input.image)) throw new Error('Avatar images must be PNG, JPEG, WebP, or GIF')
106
+ if (input.image.length > MAX_AVATAR_IMAGE) throw new Error('Avatar image is too large')
107
+ avatar.image = input.image
108
+ }
109
+ return Object.keys(avatar).length > 0 ? avatar : undefined
110
+ }
111
+
112
+ // A new Bot or group is written only after its Session is made, several awaits after
113
+ // its name was checked. The name is held from the check until the create ends, so a
114
+ // create or rename that runs meanwhile cannot take it too.
115
+ const held = { bot: new Set(), room: new Set() }
116
+ const isHeld = (kind, name) => held[kind].has(normalize(name))
117
+ async function holdName(kind, name, work) {
118
+ const key = normalize(name)
119
+ if (key === '') return work()
120
+ held[kind].add(key)
121
+ try {
122
+ return await work()
123
+ } finally {
124
+ held[kind].delete(key)
125
+ }
126
+ }
127
+ const botNameTaken = name => rt.findBot(name) !== undefined || isHeld('bot', name)
128
+
129
+ const freshName = (base) => {
130
+ const stem = base.slice(0, 34)
131
+ if (!botNameTaken(stem)) return stem
132
+ let index = 2
133
+ while (botNameTaken(`${stem} ${index}`)) index += 1
134
+ return `${stem} ${index}`
135
+ }
136
+
137
+ // `pickModel` leaves the model to the user: the chat opens on a model card, and the
138
+ // greeting waits until a model is chosen. So does a Bot made when no model it can
139
+ // run on is served.
140
+ async function createBot(options) {
141
+ const cleanName = String(options.name ?? '').trim().slice(0, 40)
142
+ const problem = nameProblem(cleanName)
143
+ if (problem !== undefined) throw new Error(problem)
144
+ if (botNameTaken(cleanName)) throw new Error(`A Bot named ${cleanName} already exists`)
145
+ return holdName('bot', cleanName, () => buildBot(cleanName, options))
146
+ }
147
+
148
+ async function buildBot(cleanName, { role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, workDir, copyOf, pickModel = false }) {
149
+ const cleanAvatarValue = cleanAvatar(avatar)
150
+ // The model is fixed at creation, so a later change of the DSH default leaves it alone.
151
+ const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultBotModel()
152
+ // A thinking level needs a model to resolve against; with none it would be dropped
153
+ // silently, so the create fails before a Session is made.
154
+ if (effort && modelRef === undefined) throw new Error('Pick a model before choosing a thinking level')
155
+ // An explicit level wins; an explicit Default (null) picks the model's own
156
+ // level; an untouched pick inherits the profile default's level.
157
+ const effortId = modelRef === undefined ? undefined
158
+ : effort === null || effort === '' ? undefined
159
+ : (await rt.resolveEffort(modelRef, effort)) ?? await rt.defaultEffort(modelRef)
160
+ const dir = workDir === undefined || workDir === null ? undefined : await rt.cleanWorkDir(workDir)
161
+ const clash = dir === undefined ? undefined : await rt.privateClash(dir)
162
+ if (clash) throw new Error(`${dir} is in or around the folder of ${clash.name}, a private Bot, so ${cleanName} cannot work there`)
163
+ const id = await createSession(cleanName, { dir })
164
+ try {
165
+ state.bots[id] = {
166
+ id,
167
+ name: cleanName,
168
+ role: String(role).trim().slice(0, 24),
169
+ // A color is only stored when the user picks one; otherwise the id picks it.
170
+ ...(isColor(currentColor(color)) ? { color: currentColor(color) } : {}),
171
+ ...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}),
172
+ ...(modelRef ? { model: modelRef } : {}),
173
+ ...(effortId ? { effort: effortId } : {}),
174
+ ...(dir ? { workDir: dir } : {}),
175
+ instructions: String(instructions).trim(),
176
+ createdAt: Date.now(),
177
+ createdBy: createdBy ?? null,
178
+ capabilities: [],
179
+ }
180
+ if (main) state.mainBotIds.push(id)
181
+ const creator = rt.botOf(createdBy)
182
+ const original = rt.botOf(copyOf)
183
+ const language = rt.uiLanguage?.()
184
+ // The kickoff arrives as a user message, so it outranks the Bot's instructions
185
+ // unless it gives way to them, and it names no tool those instructions may rule out.
186
+ const kickoff = main
187
+ ? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then ask in one short question what they want the team to help with first. ${writeIn(language)}`
188
+ : original
189
+ ? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then ask in one short question what this copy should focus on.`
190
+ : creator
191
+ ? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then ask in one short question where to start. Keep it brief.`
192
+ : brief
193
+ ? `The user just created you and sent this first message:\n\n${brief}\n\nIntroduce yourself in one short line, then help with it.`
194
+ : 'The user just created you. Greet the user in one short message, then ask in one short question what you should focus on.'
195
+ const opener = `${kickoff}\n${OWN_RULES}`
196
+ const text = !main && language ? `${opener}\nWrite in ${language}.` : opener
197
+ const bot = state.bots[id]
198
+ const greeting = { text, from: createdBy ?? null, ...(main ? { main: true } : {}) }
199
+ // The Bot is saved by now; a failure here must not archive it in the catch below.
200
+ const scopeTools = () => rt.resolveAgent(id)
201
+ .catch(error => rt.warn('ds-bot: scoped tools for %s not applied: %s', cleanName, error))
202
+ if (modelRef === undefined) {
203
+ bot.kickoff = greeting
204
+ await rt.save()
205
+ // The Session was resolved before the Bot existed, so its scoped tools land now.
206
+ await scopeTools()
207
+ return bot
208
+ }
209
+ await rt.save()
210
+ await scopeTools()
211
+ // The greeting runs on the Bot's own model, once its provider is registered.
212
+ void rt.waitForModel(bot)
213
+ .then(() => rt.applyModel(bot).catch(error => rt.warn('ds-bot: model for %s not applied: %s', cleanName, error)))
214
+ .then(() => sendKickoff(bot, greeting))
215
+ return bot
216
+ } catch (error) {
217
+ // The Bot's Session was already made: archive it instead of leaving an orphan.
218
+ delete state.bots[id]
219
+ state.mainBotIds = state.mainBotIds.filter(other => other !== id)
220
+ rt.chatOwners.delete(id)
221
+ // A save that another change queued meanwhile still carries this Bot; a later
222
+ // one writes the team without it.
223
+ void rt.save().catch(() => {})
224
+ await ctx.workspaceRegistry.archiveSession(id)
225
+ .catch(archiveError => rt.warn('ds-bot: archiving the Session for %s failed: %s', cleanName, archiveError))
226
+ throw error
227
+ }
228
+ }
229
+
230
+ // Greetings sent, by Bot: one whose turn a key, a sign-in or a balance stops is held
231
+ // again (turns.js), to go out once the user has fixed that.
232
+ const sentKickoffs = new Map()
233
+ function sendKickoff(bot, greeting) {
234
+ sentKickoffs.set(bot.id, greeting)
235
+ const text = greeting.afterUser ? `${AFTER_USER} ${writeIn(rt.uiLanguage?.())}\n${OWN_RULES}` : greeting.text
236
+ void rt.deliver({ fromId: greeting.from ?? undefined, toId: bot.id, text, role: 'kickoff' })
237
+ .catch(error => rt.warn('ds-bot: kickoff failed: %s', error))
238
+ }
239
+
240
+ // A held greeting goes out once its Bot has a model, on that model once its provider
241
+ // is registered. False while the Bot has none.
242
+ const releasing = new Set()
243
+ async function releaseKickoff(bot) {
244
+ const greeting = bot.kickoff
245
+ if (greeting === undefined || !bot.model) return false
246
+ delete bot.kickoff
247
+ releasing.add(bot.id)
248
+ await rt.save().catch((error) => { releasing.delete(bot.id); throw error })
249
+ const send = () => { releasing.delete(bot.id); sendKickoff(bot, greeting) }
250
+ void rt.waitForModel(bot).then(send, send)
251
+ return true
252
+ }
253
+
254
+ // A greeting on its way to a Bot: released and waiting for its model, or sent to its
255
+ // chat and not yet taken up by a turn there (resume.js notes what no turn took up).
256
+ const greetingOut = botId => releasing.has(botId)
257
+ || (rt.unreadOf?.(rt.chatOf(botId)) ?? []).some(args => args.role === 'kickoff')
258
+
259
+ function holdKickoffAgain(botId) {
260
+ const bot = rt.botOf(botId)
261
+ const greeting = sentKickoffs.get(botId)
262
+ sentKickoffs.delete(botId)
263
+ if (bot === undefined || greeting === undefined || bot.kickoff !== undefined) return
264
+ // "You are the Main Bot" must not go out after a demotion.
265
+ if (greeting.main && !rt.isMain(botId)) return
266
+ bot.kickoff = greeting
267
+ void rt.save().catch(() => {})
268
+ }
269
+
270
+ // The user wrote in a Bot's own chat, so a held greeting would only interrupt. The
271
+ // first Main Bot's stays while that Bot has no model it can run on, and a Bot that a
272
+ // key, a sign-in or a balance blocks has none: once it has one, it answers the message
273
+ // first, unless a turn answered it meanwhile (userAnswered). Read from the catalog as
274
+ // last listed, so it is marked before that turn can end.
275
+ function userWrote(botId) {
276
+ const bot = rt.botOf(botId)
277
+ const greeting = bot?.kickoff
278
+ if (greeting === undefined || greeting.afterUser) return
279
+ if (greeting.main && (bot.blocked !== undefined || !rt.isUsable(bot.model))) greeting.afterUser = true
280
+ else delete bot.kickoff
281
+ void rt.save().catch(() => {})
282
+ }
283
+
284
+ // A turn that answered the user in a Bot's own chat leaves nothing for a greeting held
285
+ // for that message to answer.
286
+ function userAnswered(botId) {
287
+ const bot = rt.botOf(botId)
288
+ if (bot?.kickoff?.afterUser !== true) return
289
+ delete bot.kickoff
290
+ void rt.save().catch(() => {})
291
+ }
292
+
293
+ async function duplicateBot(id) {
294
+ const bot = rt.botOf(id)
295
+ if (bot === undefined) throw new Error('Unknown Bot')
296
+ if (bot.private === true) throw new Error(`${bot.name} is private, so it cannot be copied`)
297
+ const served = (await rt.catalog()).some(entry => entry.ref === bot.model)
298
+ // A folder that went away since leaves the copy in the team workspace.
299
+ const workDir = bot.workDir !== undefined && await rt.isFolder(bot.workDir) ? bot.workDir : undefined
300
+ // No await from here to createBot's check, so two copies at once get different names.
301
+ return createBot({
302
+ name: freshName(`${bot.name} copy`),
303
+ role: bot.role,
304
+ instructions: bot.instructions,
305
+ color: bot.color,
306
+ avatar: bot.avatar,
307
+ model: served ? bot.model : undefined,
308
+ // A copy of a Bot on the model's own default stays on it (null marks the
309
+ // explicit Default pick); else the create would inherit the profile level.
310
+ effort: served ? (bot.effort ?? null) : undefined,
311
+ workDir,
312
+ copyOf: bot.id,
313
+ })
314
+ }
315
+
316
+ async function updateBot(id, patch, actorId, fromSessionId) {
317
+ const bot = rt.botOf(id)
318
+ if (bot === undefined) throw new Error('Unknown Bot')
319
+ if (patch.browserGrants !== undefined && actorId !== undefined) {
320
+ throw new Error(`Only the user can set what ${bot.name} may do in the browser`)
321
+ }
322
+ if (patch.private !== undefined && actorId !== undefined) {
323
+ throw new Error(`Only the user can make ${bot.name} private or open`)
324
+ }
325
+ const browserGrants = patch.browserGrants === undefined ? undefined : cleanBrowserGrants(patch.browserGrants)
326
+ // Checked first, so a folder, model or level that does not pass leaves the rest
327
+ // unchanged too.
328
+ const moving = patch.workDir !== undefined
329
+ const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined
330
+ const flag = patch.private === undefined ? undefined : patch.private === true
331
+ await rt.checkPrivacyChange(bot, { flag, moving, workDir })
332
+ const modelRef = patch.model === undefined ? undefined
333
+ : patch.model === null || patch.model === '' ? null
334
+ : await rt.resolveModel(patch.model)
335
+ // A new level resolves against the model the Bot will run on: the new one, the
336
+ // model it keeps, or — the model cleared — the one it last ran on.
337
+ const effortRef = modelRef === undefined ? bot.model ?? bot.appliedModel : modelRef ?? bot.appliedModel
338
+ let effortId
339
+ if (patch.effort !== undefined) {
340
+ if (patch.effort === null || patch.effort === '') effortId = null
341
+ else {
342
+ if (effortRef === undefined) throw new Error('Pick a model before choosing a thinking level')
343
+ effortId = await rt.resolveEffort(effortRef, patch.effort)
344
+ }
345
+ }
346
+ if (patch.name !== undefined) {
347
+ const next = String(patch.name).trim().slice(0, 40)
348
+ const clash = rt.findBot(next)
349
+ const pending = isHeld('bot', next) && normalize(next) !== normalize(bot.name)
350
+ if (next === '' || (clash && clash.id !== id) || pending) throw new Error(`Name ${next} is not available`)
351
+ // A name saved before the rule stays usable until it changes.
352
+ const problem = next === bot.name ? undefined : nameProblem(next)
353
+ if (problem !== undefined) throw new Error(problem)
354
+ bot.name = next
355
+ const agent = await rt.resolveAgent(rt.chatOf(id))
356
+ await ctx.sessionTitle.rename(agent.session, next)
357
+ await followMemberNames(Object.values(state.rooms).filter(room => room.members.includes(id)))
358
+ }
359
+ const roleBefore = bot.role
360
+ const instructionsBefore = bot.instructions
361
+ if (patch.role !== undefined) bot.role = String(patch.role).trim().slice(0, 24)
362
+ if (patch.instructions !== undefined) bot.instructions = String(patch.instructions).trim()
363
+ let modelChanged = false
364
+ if (patch.color !== undefined) {
365
+ if (patch.color === null) delete bot.color
366
+ else if (isColor(currentColor(patch.color))) bot.color = currentColor(patch.color)
367
+ }
368
+ if (patch.avatar !== undefined) {
369
+ const avatar = cleanAvatar(patch.avatar)
370
+ if (avatar) bot.avatar = avatar
371
+ else delete bot.avatar
372
+ }
373
+ if (patch.model !== undefined) {
374
+ // A failure that blocked the Bot was its old model's (turns.js).
375
+ if ((modelRef ?? undefined) !== bot.model) delete bot.blocked
376
+ if (modelRef === null) delete bot.model
377
+ else bot.model = modelRef
378
+ modelChanged = true
379
+ }
380
+ if (patch.effort !== undefined) {
381
+ if (effortId === null) delete bot.effort
382
+ else bot.effort = effortId
383
+ modelChanged = true
384
+ }
385
+ if (patch.permission !== undefined) await rt.setPermission(bot, patch.permission)
386
+ if (browserGrants !== undefined) {
387
+ if (browserGrants === null) delete bot.browserGrants
388
+ else bot.browserGrants = browserGrants
389
+ }
390
+ if (flag === true) bot.private = true
391
+ else if (flag === false) delete bot.private
392
+ const moved = moving && workDir !== bot.workDir
393
+ if (moving) {
394
+ if (workDir === undefined) delete bot.workDir
395
+ else bot.workDir = workDir
396
+ }
397
+ await rt.save()
398
+ // The system prompt reads the live profile every turn, but a Session with
399
+ // several rounds behind it follows the old kickoff's pull until a message
400
+ // lands; notify the Bot's chat so the next turn honors the new profile.
401
+ const notes = []
402
+ if (bot.role !== roleBefore) notes.push(`Your role is now "${bot.role || '(none)'}"`)
403
+ if (bot.instructions !== instructionsBefore) notes.push(`Your instructions now read:\n${bot.instructions || '(none)'}`)
404
+ // A held greeting means the model pick is still open: the kickoff it later sends
405
+ // already reads the new profile, and a notice would start a turn before that.
406
+ // A Bot editing itself in its own chat just got the tool result in the turn
407
+ // that made the edit; a notice there would only buy it a second, unsolicited
408
+ // reply. The same edit made from a group Session reaches no own-chat turn, so
409
+ // the notice still goes out.
410
+ const selfEditInChat = actorId === bot.id && fromSessionId === rt.chatOf(bot.id)
411
+ if (notes.length > 0 && bot.kickoff === undefined && !selfEditInChat) {
412
+ void rt.deliver({ toId: bot.id, text: notes.join('\n'), role: 'update' })
413
+ .catch(error => rt.warn('ds-bot: profile update for %s not delivered: %s', bot.name, error?.message ?? error))
414
+ }
415
+ if (modelChanged) await rt.applyModel(bot, { force: true })
416
+ if (moved) void rt.followFolder({ botId: bot.id })
417
+ // A new model lifted what blocked the Bot; a new thinking level alone fixes nothing.
418
+ // releaseKickoff itself leaves when the Bot still lacks a model or holds no greeting.
419
+ if (modelChanged && bot.blocked === undefined) await releaseKickoff(bot)
420
+ return bot
421
+ }
422
+
423
+ async function setFlags(id, { pinned, hidden }) {
424
+ const entry = rt.botOf(id) ?? rt.roomOf(id) ?? rt.own(state.agents, id)
425
+ if (entry === undefined) throw new Error('Unknown conversation')
426
+ if (typeof pinned === 'boolean') entry.pinned = pinned
427
+ if (typeof hidden === 'boolean') entry.hidden = hidden
428
+ await rt.save()
429
+ return entry
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // Group chat settings. The user and every Main Bot may change any group; a group's
434
+ // admin may change its own group but not delete it. Only a member can be admin.
435
+
436
+ const roomBusy = id => [...rt.roomProgress.values()].some(round => round.roomId === id)
437
+ const autoRoomName = members => ['You', ...members.map(id => rt.botOf(id)?.name).filter(Boolean)].join(', ')
438
+ // A notice past the limit is refused rather than silently cut (#710).
439
+ const cleanNotice = (text) => {
440
+ const notice = String(text ?? '').trim()
441
+ if (notice.length > ROOM_NOTICE_MAX) throw new Error(`A group's notice can hold at most ${ROOM_NOTICE_MAX} characters; this one has ${notice.length}`)
442
+ return notice
443
+ }
444
+
445
+ function cleanRoomName(text, selfId) {
446
+ const next = unquote(text).replace(/\s+/g, ' ').slice(0, ROOM_NAME_MAX)
447
+ if (next !== '' && (rt.findRooms(next).some(room => room.id !== selfId) || isHeld('room', next))) throw new Error(`Another group chat is already called "${next}"`)
448
+ return next
449
+ }
450
+
451
+ async function retitleRoom(room) {
452
+ try {
453
+ const agent = await rt.resolveAgent(rt.sessionOfRoom(room))
454
+ await ctx.sessionTitle.rename(agent.session, room.name)
455
+ } catch (error) {
456
+ rt.warn('ds-bot: group title for %s not updated: %s', room.name, error)
457
+ }
458
+ }
459
+
460
+ // Groups without a chosen name are named after their members, so they follow renames.
461
+ async function followMemberNames(rooms = Object.values(state.rooms)) {
462
+ for (const room of rooms) {
463
+ if (room.named) continue
464
+ const next = autoRoomName(room.members)
465
+ if (next === room.name) continue
466
+ room.name = next
467
+ await retitleRoom(room)
468
+ }
469
+ }
470
+
471
+ function checkMembers(members) {
472
+ if (members.length < 2) throw new Error('A group chat needs at least two Bots')
473
+ if (members.length > ROOM_MAX_MEMBERS) throw new Error(`A group chat holds at most ${ROOM_MAX_MEMBERS} Bots`)
474
+ rt.checkNoPrivate(members)
475
+ }
476
+
477
+ // A Main Bot leads the groups it makes; a group the user makes is led by a Main Bot
478
+ // member when it has one.
479
+ const defaultAdmin = (members, createdBy) => (members.includes(createdBy) ? createdBy : members.find(rt.isMain) ?? null)
480
+
481
+ async function createRoom({ members: memberIds = [], name: roomName, notice, mode, admin, createdBy } = {}) {
482
+ const members = [...new Set(memberIds)].filter(id => rt.botOf(id))
483
+ checkMembers(members)
484
+ const custom = cleanRoomName(roomName ?? '')
485
+ const adminId = admin !== undefined ? admin : defaultAdmin(members, createdBy)
486
+ if (adminId !== null && !members.includes(adminId)) throw new Error('The admin must be a member of the group')
487
+ if (mode !== undefined && !ROOM_MODES.includes(mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
488
+ return holdName('room', custom, () => buildRoom({ members, custom, notice, mode, adminId, createdBy }))
489
+ }
490
+
491
+ async function buildRoom({ members, custom, notice, mode, adminId, createdBy }) {
492
+ const title = custom || autoRoomName(members)
493
+ // The group's own Session only relays; like a member's it moves to a fresh part
494
+ // in the group's folder once the group has one (parts.js).
495
+ const id = await createSession(title)
496
+ try {
497
+ // A member deleted while the Session was made stays out, and the group is checked
498
+ // again as it is written.
499
+ const live = members.filter(member => rt.botOf(member))
500
+ checkMembers(live)
501
+ const lead = adminId === null || live.includes(adminId) ? adminId : defaultAdmin(live, createdBy)
502
+ const name = custom || autoRoomName(live)
503
+ const room = {
504
+ id,
505
+ name,
506
+ ...(custom ? { named: true } : {}),
507
+ members: live,
508
+ admin: lead,
509
+ mode: mode ?? 'admin',
510
+ notice: cleanNotice(notice),
511
+ createdAt: Date.now(),
512
+ createdBy: createdBy ?? null,
513
+ }
514
+ state.rooms[id] = room
515
+ await rt.save()
516
+ // The Session was resolved before the group owned it, so its relay tool lands now.
517
+ await rt.resolveAgent(id)
518
+ if (name !== title) await retitleRoom(room)
519
+ return room
520
+ } catch (error) {
521
+ // The group's Session was already made: archive it instead of leaving an orphan.
522
+ delete state.rooms[id]
523
+ void rt.save().catch(() => {})
524
+ await ctx.workspaceRegistry.archiveSession(id)
525
+ .catch(archiveError => rt.warn('ds-bot: archiving the Session for %s failed: %s', title, archiveError))
526
+ throw error
527
+ }
528
+ }
529
+
530
+ // What a change would write to the group as it is now, or why it cannot be made. It
531
+ // reads only the team, so updateRoom runs it to fail fast and again after its last
532
+ // await: another change or a deleted Bot may have come in meanwhile, and `add` and
533
+ // `remove` apply to the members the group has then.
534
+ function planRoomChange(room, patch, actorId) {
535
+ const byMain = actorId === undefined || rt.isMain(actorId)
536
+ const byAdmin = !byMain && rt.isAdminOf(room, actorId)
537
+ if (!byMain && !byAdmin) throw new Error(`Only the user, a Main Bot, or the admin of "${room.name}" can change it`)
538
+ let members = Array.isArray(patch.members) ? [...new Set(patch.members)] : [...room.members]
539
+ for (const member of patch.add ?? []) if (!members.includes(member)) members.push(member)
540
+ members = members.filter(member => !(patch.remove ?? []).includes(member) && rt.botOf(member))
541
+ // A group left with one Bot by a deletion can still change its other settings.
542
+ const current = room.members.filter(member => rt.botOf(member))
543
+ if (members.length !== current.length || members.some(member => !current.includes(member))) checkMembers(members)
544
+ let admin = room.admin ?? null
545
+ if (patch.admin !== undefined) {
546
+ admin = patch.admin || null
547
+ if (admin === null && byAdmin) throw new Error('Hand the admin role to another member instead of leaving the group without one')
548
+ if (admin !== null && !members.includes(admin)) throw new Error(`${rt.botOf(admin)?.name ?? 'That Bot'} must be a member to be the admin`)
549
+ }
550
+ if (admin !== null && !members.includes(admin)) {
551
+ if (byAdmin && admin === actorId) throw new Error('Hand the admin role to another member before leaving the group')
552
+ admin = null
553
+ }
554
+ if (patch.mode !== undefined && !ROOM_MODES.includes(patch.mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
555
+ const notice = patch.notice === undefined ? undefined : cleanNotice(patch.notice)
556
+ if (patch.workDir !== undefined && byAdmin) throw new Error(`Only the user or a Main Bot can set the folder of "${room.name}"`)
557
+ // A Bot's own permissions are the user's to set, so a group's are too.
558
+ if (patch.permission !== undefined && actorId !== undefined) throw new Error(`Only the user can set the permissions of "${room.name}"`)
559
+ const permission = patch.permission === undefined ? undefined : rt.cleanRoomPermission(patch.permission)
560
+ // The second run is the last check before the change is written, so no other change
561
+ // can take the name in between.
562
+ const name = patch.name === undefined ? undefined : cleanRoomName(patch.name, room.id)
563
+ return { members, admin, name, notice, permission }
564
+ }
565
+
566
+ // `actorId` is the Bot asking, or undefined for the user. Returns what changed, in words.
567
+ async function updateRoom(id, patch, actorId) {
568
+ const asked = rt.roomOf(id)
569
+ if (asked === undefined) throw new Error('Unknown group chat')
570
+ planRoomChange(asked, patch, actorId)
571
+ const moving = patch.workDir !== undefined
572
+ const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined
573
+ const clash = workDir === undefined ? undefined : await rt.privateClash(workDir)
574
+ if (clash) throw new Error(`${workDir} is in or around the folder of ${clash.name}, a private Bot, so a group cannot work there`)
575
+ // No await from here until every change is written.
576
+ const room = rt.roomOf(id)
577
+ if (room === undefined) throw new Error('Unknown group chat')
578
+ const { members, admin, name, notice, permission } = planRoomChange(room, patch, actorId)
579
+
580
+ const changes = []
581
+ const names = ids => ids.map(member => rt.botOf(member)?.name).filter(Boolean).join(', ')
582
+ const added = members.filter(member => !room.members.includes(member))
583
+ const removed = room.members.filter(member => !members.includes(member))
584
+ if (added.length > 0) changes.push(`added ${names(added)}`)
585
+ // A deleted Bot still listed goes without a word.
586
+ if (names(removed) !== '') changes.push(`removed ${names(removed)}`)
587
+ // A member who leaves and comes back starts from a fresh look at the log.
588
+ for (const member of removed) {
589
+ if (room.seen) delete room.seen[member]
590
+ rt.dropGroupSession(room, member)
591
+ }
592
+ room.members = members
593
+ if (admin !== (room.admin ?? null)) changes.push(admin ? `made ${rt.botOf(admin).name} the admin` : 'removed the admin')
594
+ room.admin = admin
595
+ if (patch.mode !== undefined && patch.mode !== room.mode) {
596
+ room.mode = patch.mode
597
+ changes.push(patch.mode === 'admin' ? 'plain messages now go to the admin first' : 'every member now replies in turn')
598
+ }
599
+ if (notice !== undefined && notice !== (room.notice ?? '')) {
600
+ room.notice = notice
601
+ changes.push(notice ? 'updated the notice' : 'cleared the notice')
602
+ }
603
+ // Members' group conversations move on to the folder at their next turn there;
604
+ // the group's own Session moves the same way, now when it is idle.
605
+ if (moving && workDir !== room.workDir) {
606
+ delete room.folderNotice
607
+ if (workDir === undefined) delete room.workDir
608
+ else room.workDir = workDir
609
+ // A member's own pick for this group gives way to the group's folder, and a
610
+ // cleared one does not bring it back.
611
+ delete room.memberDirs
612
+ changes.push(workDir ? `every member now works in ${workDir} here` : 'every member works in its own folder again')
613
+ void rt.followFolder({ roomId: room.id })
614
+ }
615
+ const before = room.name
616
+ if (name !== undefined) {
617
+ if (name !== '') {
618
+ room.named = true
619
+ room.name = name
620
+ } else {
621
+ delete room.named
622
+ }
623
+ }
624
+ if (!room.named) room.name = autoRoomName(room.members)
625
+ // The preset is written before its first await; the members' Sessions follow it.
626
+ if (permission !== undefined && await rt.setRoomPermission(room, permission)) {
627
+ changes.push(permission ? `every member now works with the ${permission} permissions here` : 'every member uses its own permissions again')
628
+ }
629
+ if (room.name !== before) {
630
+ changes.push(`renamed it "${room.name}"`)
631
+ await retitleRoom(room)
632
+ }
633
+ await rt.save()
634
+ return { room, changes }
635
+ }
636
+
637
+ async function deleteRoom(id) {
638
+ const room = rt.roomOf(id)
639
+ if (room === undefined) throw new Error('Unknown group chat')
640
+ // Before the group's Sessions leave the state, which the archive reads.
641
+ const archived = rt.archiveGroupMemory(room)
642
+ for (const botId of Object.keys(room.sessions ?? {})) rt.dropGroupSession(room, botId)
643
+ for (const segment of room.parts ?? []) delete state.switching[segment.sessionId]
644
+ delete state.switching[rt.sessionOfRoom(room)]
645
+ delete state.rooms[id]
646
+ rt.roomInbox.delete(id)
647
+ rt.dropGroupLines(id)
648
+ await archived
649
+ const failed = await saveForDelete()
650
+ // Armed scheduled tasks refuse a plain archive and would keep delivering into
651
+ // the deleted group; the session-stop that stopActivity runs removes them.
652
+ await ctx.workspaceRegistry.archiveSession(rt.sessionOfRoom(room), { stopActivity: true })
653
+ .catch(error => rt.warn('ds-bot: group chat %s not archived: %s', room.name, error))
654
+ if (failed !== undefined) throw failed
655
+ }
656
+
657
+ // The first Main Bot is the sidebar tile. `replace` gives a Main Bot's place to
658
+ // another Bot in one step, so the team never has none; `primary` moves a Bot to the
659
+ // front and keeps the others.
660
+ async function setMain(id, main, { primary = false, replace } = {}) {
661
+ const bot = rt.botOf(id)
662
+ if (bot === undefined) throw new Error('Unknown Bot')
663
+ if (main && bot.private === true) throw new Error(`${bot.name} is private, so it cannot be a Main Bot`)
664
+ const others = state.mainBotIds.filter(other => other !== id)
665
+ let next
666
+ if (!main) {
667
+ if (!rt.isMain(id)) return
668
+ if (others.length === 0) throw new Error('The team needs at least one Main Bot')
669
+ next = others
670
+ } else if (replace !== undefined && replace !== id) {
671
+ const at = others.indexOf(replace)
672
+ if (at === -1) throw new Error('Only a Main Bot can be replaced')
673
+ next = others.with(at, id)
674
+ } else if (primary) {
675
+ next = [id, ...others]
676
+ } else {
677
+ next = rt.isMain(id) ? state.mainBotIds : [...others, id]
678
+ }
679
+ // The sidebar shows every Main Bot, so a hidden Bot would vanish again once demoted.
680
+ if (main) delete bot.hidden
681
+ // A held setup greeting says "you are the Main Bot"; it must not go out after demotion.
682
+ for (const other of state.mainBotIds.filter(other => !next.includes(other))) {
683
+ if (rt.botOf(other)?.kickoff?.main) delete rt.botOf(other).kickoff
684
+ }
685
+ state.mainBotIds = next
686
+ await rt.save()
687
+ }
688
+
689
+ async function deleteBot(id) {
690
+ const bot = rt.botOf(id)
691
+ if (bot === undefined) throw new Error('Unknown Bot')
692
+ if (rt.isMain(id)) throw new Error('A Main Bot cannot be deleted; remove its Main Bot role first')
693
+ // Before the Bot and its group Sessions leave the state, which the archive reads.
694
+ const archived = rt.archiveBotMemory(id)
695
+ const current = rt.chatOf(id)
696
+ // Questions and exchange turns are kept per Session, so every part of the chat is cleared.
697
+ const parts = new Set([id, current, ...(bot.segments ?? []).map(segment => segment.sessionId)])
698
+ // `archiving` stands until the archive's path is known: the memory may already be
699
+ // there, and no region of its own closes it yet.
700
+ const closedArchive = bot.private === true ? {
701
+ id: bot.id,
702
+ name: bot.name,
703
+ ...(bot.workDir === undefined ? {} : { workDir: bot.workDir }),
704
+ sessionIds: [...parts, ...rt.groupSessionsOf(id)],
705
+ archiving: true,
706
+ } : undefined
707
+ await rt.releaseBot(id, [...parts, ...rt.groupSessionsOf(id)])
708
+ if (closedArchive !== undefined) state.privateArchives = [...(state.privateArchives ?? []).filter(entry => entry.id !== id), closedArchive]
709
+ delete state.bots[id]
710
+ // The shared capability list is the user's and keeps its items; only a
711
+ // still-unapproved local MCP this Bot alone asked for leaves with it.
712
+ rt.dropOrphanCapabilities?.(bot)
713
+ for (const part of parts) {
714
+ delete state.questions[part]
715
+ if (state.views !== undefined) delete state.views[part]
716
+ delete state.exchangeTurns[part]
717
+ delete state.switching[part]
718
+ }
719
+ const touched = []
720
+ for (const room of Object.values(state.rooms)) {
721
+ if (!room.members.includes(id)) continue
722
+ room.members = room.members.filter(member => member !== id)
723
+ if (room.admin === id) room.admin = null
724
+ rt.dropGroupSession(room, id)
725
+ touched.push(room)
726
+ }
727
+ await followMemberNames(touched)
728
+ await rt.forgetBotSecrets(id)
729
+ const archivePath = await archived
730
+ if (closedArchive !== undefined) {
731
+ // No path means nothing was moved: a memory that stayed in place is a region already.
732
+ if (archivePath !== undefined) closedArchive.archivePath = archivePath
733
+ delete closedArchive.archiving
734
+ }
735
+ const failed = await saveForDelete()
736
+ // Same as deleteRoom: stopActivity removes the Session's armed scheduled tasks.
737
+ // Past parts are archived too: a part move can leave tasks on the old Session,
738
+ // which otherwise stays armed and invisible. Already-archived ids are a no-op.
739
+ for (const part of parts) {
740
+ await ctx.workspaceRegistry.archiveSession(part, { stopActivity: true })
741
+ .catch(error => rt.warn('ds-bot: Bot %s not archived: %s', bot.name, error))
742
+ }
743
+ if (failed !== undefined) throw failed
744
+ }
745
+
746
+ // A deletion whose save fails stays in memory, and the next save writes it, so its
747
+ // Sessions are stopped anyway: left armed, their scheduled tasks would keep running
748
+ // with nothing in the team to show or delete them. Only a host that lost the team
749
+ // to another dsh keeps them, since the other host's copy still has them.
750
+ async function saveForDelete() {
751
+ try {
752
+ await rt.save()
753
+ return undefined
754
+ } catch (error) {
755
+ if (error?.code === 'ELOCKLOST' || rt.readOnly() !== undefined) throw error
756
+ return error
757
+ }
758
+ }
759
+
760
+ async function bootstrap() {
761
+ await rt.load()
762
+ void rt.syncModels()
763
+ if (state.mainBotIds.some(id => rt.botOf(id))) return
764
+ // Only the team's first Main Bot is stored as the DeepSeek whale; other Bots get a
765
+ // look from their id, and a Bot made Main Bot later keeps its own.
766
+ bootstrapping ??= createBot({ name: defaults.mainName, role: defaults.mainRole, main: true, color: 'deepseek', avatar: { shape: 'whale' }, pickModel: true })
767
+ .finally(() => { bootstrapping = null })
768
+ await bootstrapping
769
+ }
770
+
771
+ Object.assign(rt, {
772
+ ensureWorkspace, keepTeamFolder, createSession, createBot, duplicateBot, updateBot, setFlags,
773
+ roomBusy, autoRoomName, retitleRoom, createRoom, updateRoom, deleteRoom,
774
+ setMain, deleteBot, bootstrap, releaseKickoff, greetingOut, holdKickoffAgain, userWrote, userAnswered,
775
+ })
776
+ }