@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.
- package/.dsh-plugin/bot-product.mjs +81 -5
- package/.dsh-plugin/client.js +17490 -1791
- package/README.i18n.yaml +4 -4
- package/README.md +409 -364
- package/README.zh.md +409 -364
- package/apps/integration-client/README.md +31 -7
- package/apps/integration-client/api.mjs +14 -3
- package/apps/integration-client/binding-editor.jsx +308 -109
- package/apps/integration-client/binding-model.mjs +262 -0
- package/apps/integration-client/binding.css +144 -0
- package/apps/integration-client/bot-model-status.mjs +18 -0
- package/apps/integration-client/bot-profile-identity.jsx +197 -0
- package/apps/integration-client/bot-profile.css +57 -0
- package/apps/integration-client/bot-profile.mjs +97 -0
- package/apps/integration-client/bot-settings-dialog.css +18 -0
- package/apps/integration-client/bot-settings-dialog.jsx +111 -0
- package/apps/integration-client/bot-thread-header-bridge.css +20 -0
- package/apps/integration-client/bot-thread-header.jsx +278 -0
- package/apps/integration-client/bot-threads-panel.jsx +378 -0
- package/apps/integration-client/bot-threads.css +169 -0
- package/apps/integration-client/chat-design.mjs +250 -0
- package/apps/integration-client/chat-ui.jsx +175 -0
- package/apps/integration-client/chat.css +477 -0
- package/apps/integration-client/composer-draft-binding.mjs +50 -0
- package/apps/integration-client/composer-draft-bound.jsx +14 -0
- package/apps/integration-client/composer-draft-host.jsx +142 -0
- package/apps/integration-client/composer-draft-session.jsx +287 -0
- package/apps/integration-client/composer-draft-witness.mjs +115 -0
- package/apps/integration-client/composer-draft.css +4 -0
- package/apps/integration-client/composer-draft.mjs +322 -0
- package/apps/integration-client/contact-attention.jsx +54 -0
- package/apps/integration-client/data-panel.jsx +439 -0
- package/apps/integration-client/data.css +69 -0
- package/apps/integration-client/desktop-bridge.mjs +1 -0
- package/apps/integration-client/desktop-http-socket.mjs +49 -0
- package/apps/integration-client/desktop-keyboard.mjs +32 -0
- package/apps/integration-client/desktop-pane.jsx +99 -30
- package/apps/integration-client/desktop-stream.mjs +13 -3
- package/apps/integration-client/desktop-tools.jsx +19 -0
- package/apps/integration-client/desktop-touch.mjs +27 -0
- package/apps/integration-client/desktop.css +41 -0
- package/apps/integration-client/independent-chat-controller.mjs +224 -0
- package/apps/integration-client/independent-chat-entry.jsx +61 -0
- package/apps/integration-client/independent-chat-navigation.mjs +21 -0
- package/apps/integration-client/independent-chat-page.jsx +148 -0
- package/apps/integration-client/independent-chat-rich.jsx +64 -0
- package/apps/integration-client/independent-chat-rich.mjs +30 -0
- package/apps/integration-client/independent-chat.css +73 -0
- package/apps/integration-client/index.jsx +24 -16
- package/apps/integration-client/library-panel.jsx +4 -1
- package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
- package/apps/integration-client/main-route-editor.jsx +94 -0
- package/apps/integration-client/main-route-model.mjs +100 -0
- package/apps/integration-client/model-picker.css +37 -0
- package/apps/integration-client/model-picker.jsx +83 -0
- package/apps/integration-client/native-activity.css +141 -0
- package/apps/integration-client/native-activity.jsx +276 -0
- package/apps/integration-client/native-activity.mjs +384 -0
- package/apps/integration-client/notifications-panel.jsx +186 -0
- package/apps/integration-client/notifications.css +126 -0
- package/apps/integration-client/pairing-qr.jsx +79 -0
- package/apps/integration-client/panels.jsx +126 -115
- package/apps/integration-client/product-slots.jsx +46 -0
- package/apps/integration-client/routine-recorder.mjs +23 -2
- package/apps/integration-client/secure-pairing.jsx +84 -0
- package/apps/integration-client/tasks-panel.jsx +32 -10
- package/apps/integration-client/ui-icons.jsx +17 -0
- package/apps/integration-client/usage-explorer.jsx +43 -0
- package/apps/integration-client/usage-report.mjs +71 -0
- package/apps/integration-client/usage.css +34 -0
- package/apps/integration-client/web-panel-model.mjs +140 -0
- package/apps/integration-client/web-panel-page.jsx +221 -0
- package/apps/integration-client/web-panel-pins-live.jsx +28 -0
- package/apps/integration-client/web-panel.css +139 -0
- package/apps/integration-client/workspace-model.mjs +67 -0
- package/apps/integration-client/workspace-navigation.mjs +56 -0
- package/apps/integration-client/workspace-ui.jsx +173 -0
- package/apps/integration-client/workspace.css +332 -0
- package/modules/bot-runtime/SOURCE.json +176 -50
- package/modules/bot-runtime/index.mjs +169 -13
- package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
- package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
- package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
- package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
- package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
- package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
- package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
- package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
- package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
- package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
- package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
- package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
- package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
- package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
- package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
- package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
- package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
- package/modules/bot-runtime/read-model.mjs +19 -4
- package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
- package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
- package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
- package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
- package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
- package/modules/independent-runtime/index.mjs +152 -62
- package/modules/independent-runtime/remote-tools.mjs +8 -5
- package/modules/openmaus-protocol/NOTICE +10 -0
- package/modules/openmaus-protocol/README.md +15 -0
- package/modules/openmaus-protocol/SOURCE.json +68 -0
- package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
- package/modules/openmaus-protocol/index.mjs +1 -0
- package/modules/openmaus-protocol/transport.mjs +77 -0
- package/modules/openmaus-protocol/vendor/LICENSE +202 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
- package/package.json +20 -6
- package/packages/bot-acp/README.md +156 -0
- package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-acp/licenses/NOTICE.md +27 -0
- package/packages/bot-acp/src/acp-adapter.mjs +795 -0
- package/packages/bot-acp/src/acp-host.mjs +148 -0
- package/packages/bot-acp/src/native-launch.mjs +121 -0
- package/packages/bot-acp/src/permission-policy.mjs +283 -0
- package/packages/bot-acp/src/rpc-codec.mjs +265 -0
- package/packages/bot-acp/src/session-core.mjs +581 -0
- package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
- package/packages/bot-engines/README.md +281 -0
- package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-engines/licenses/NOTICE.md +32 -0
- package/packages/bot-engines/src/claude-engine.mjs +772 -0
- package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
- package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
- package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
- package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
- package/packages/bot-engines/src/claude-protocol.mjs +177 -0
- package/packages/bot-engines/src/driver-catalog.mjs +334 -0
- package/packages/bot-engines/src/host-loop-request.mjs +95 -0
- package/packages/bot-engines/src/host-registrations.mjs +105 -0
- package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
- package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
- package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
- package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
- package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
- package/packages/cli-runtime/src/native-peer.ps1 +51 -3
- package/packages/cli-runtime/src/native-process.mjs +77 -14
- package/packages/model-router-plugin/README.md +3 -3
- package/packages/model-router-plugin/package.json +1 -1
- package/packages/orchestration/src/binding-product.mjs +19 -0
- package/packages/orchestration/src/bot-data-actions.mjs +45 -0
- package/packages/orchestration/src/bot-data-chart.mjs +109 -0
- package/packages/orchestration/src/bot-data-context.mjs +44 -0
- package/packages/orchestration/src/bot-data-engine.mjs +809 -0
- package/packages/orchestration/src/bot-data-io.mjs +186 -0
- package/packages/orchestration/src/bot-data-sql.mjs +195 -0
- package/packages/orchestration/src/bot-data-surface.mjs +144 -0
- package/packages/orchestration/src/bot-data-tools.mjs +68 -0
- package/packages/orchestration/src/bot-data-worker.mjs +228 -0
- package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
- package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
- package/packages/orchestration/src/bot-loop.mjs +152 -19
- package/packages/orchestration/src/bot-notifications.mjs +159 -0
- package/packages/orchestration/src/bot-policy.mjs +43 -4
- package/packages/orchestration/src/bot-profile.mjs +171 -0
- package/packages/orchestration/src/bot-routine.mjs +4 -4
- package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
- package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
- package/packages/orchestration/src/bot-serving.mjs +164 -0
- package/packages/orchestration/src/bot-session-store.mjs +105 -9
- package/packages/orchestration/src/bot-settings.mjs +50 -11
- package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
- package/packages/orchestration/src/bot-thread-host.mjs +187 -0
- package/packages/orchestration/src/bot-threads.mjs +790 -0
- package/packages/orchestration/src/data-product-access.mjs +37 -0
- package/packages/orchestration/src/data-tool-contract.mjs +291 -0
- package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
- package/packages/orchestration/src/desktop-proxy.mjs +311 -0
- package/packages/orchestration/src/desktop-sessions.mjs +43 -9
- package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
- package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
- package/packages/orchestration/src/independent-chat-page.mjs +39 -0
- package/packages/orchestration/src/independent-chat-service.mjs +308 -0
- package/packages/orchestration/src/independent-context-state.mjs +44 -0
- package/packages/orchestration/src/independent-message-wire.mjs +84 -0
- package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
- package/packages/orchestration/src/independent-tool-families.mjs +73 -0
- package/packages/orchestration/src/independent-tool-host.mjs +344 -0
- package/packages/orchestration/src/main-bot-grant.mjs +15 -0
- package/packages/orchestration/src/main-model-routes.mjs +208 -0
- package/packages/orchestration/src/model-accounting.mjs +99 -35
- package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
- package/packages/orchestration/src/model-loop-frame.mjs +179 -0
- package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
- package/packages/orchestration/src/model-request-bounds.mjs +111 -0
- package/packages/orchestration/src/native-activity-contract.mjs +491 -0
- package/packages/orchestration/src/native-activity-service.mjs +102 -0
- package/packages/orchestration/src/native-activity-store.mjs +249 -0
- package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
- package/packages/orchestration/src/native-data-tools.mjs +100 -0
- package/packages/orchestration/src/native-remote-actions.mjs +742 -0
- package/packages/orchestration/src/native-team-tools.mjs +73 -0
- package/packages/orchestration/src/pairing-invite.mjs +37 -0
- package/packages/orchestration/src/product-composition.mjs +1559 -1001
- package/packages/orchestration/src/remote-machines.mjs +82 -14
- package/packages/orchestration/src/routine-execution.mjs +5 -3
- package/packages/orchestration/src/routine-session.mjs +19 -13
- package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
- package/packages/orchestration/src/task-journal.mjs +29 -0
- package/packages/orchestration/src/task-product.mjs +17 -0
- package/packages/orchestration/src/team-assignment-store.mjs +120 -0
- package/packages/orchestration/src/team-bridge.mjs +282 -0
- package/packages/orchestration/src/team-scheduler.mjs +83 -0
- package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
- package/packages/orchestration/src/team-tool-contract.mjs +284 -0
- package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
- package/packages/orchestration/src/web-panel-actions.mjs +103 -0
- package/packages/orchestration/src/web-panel-framing.mjs +227 -0
- package/packages/orchestration/src/web-panel-store.mjs +206 -0
- package/remote-computer-agent/README.md +39 -29
- package/remote-computer-agent/build.mjs +3 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
- package/remote-computer-agent/dist/cli.mjs +1688 -168
- package/remote-computer-agent/dist/cli.mjs.map +4 -4
- package/remote-computer-agent/dist/index.mjs +1579 -122
- package/remote-computer-agent/dist/index.mjs.map +4 -4
- package/remote-computer-agent/dist/native-peer.ps1 +51 -3
- package/remote-computer-agent/package-lock.json +308 -2
- package/remote-computer-agent/package.json +2 -1
- package/remote-computer-agent/pair-device.ps1 +20 -0
- package/remote-computer-agent/setup.ps1 +38 -16
- package/remote-computer-agent/src/cli.ts +24 -3
- package/remote-computer-agent/src/desktop-session.ts +11 -3
- package/remote-computer-agent/src/e2e-server.ts +81 -0
- package/remote-computer-agent/src/pairing-page.ts +35 -0
- package/remote-computer-agent/src/protocol.ts +25 -25
- package/remote-computer-agent/src/secure-pairing.ts +105 -0
- package/remote-computer-agent/src/store.ts +20 -5
- 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
|
+
}
|