ds-bot 0.1.3 → 0.1.5

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.
@@ -21,13 +21,16 @@ export function install(rt) {
21
21
  return workspace.id
22
22
  }
23
23
 
24
- async function createSession(title) {
24
+ // `dir` is a working folder outside the team workspace. Such a Session is made by cwd
25
+ // alone, so it joins no DSH workspace and the user's own project lists stay as they are.
26
+ async function createSession(title, { dir } = {}) {
25
27
  // A Bot's tools run here too; while another dsh holds the team, a new Session would
26
28
  // belong to no saved Bot.
27
29
  const readOnly = rt.readOnly()
28
30
  if (readOnly !== undefined) throw new Error(readOnly)
29
- const workspaceId = await ensureWorkspace()
30
- const { sessionId } = await ctx.sessionController.create({ workspaceId })
31
+ const usable = dir !== undefined && await rt.isFolder(dir)
32
+ if (dir !== undefined && !usable) rt.warn('ds-bot: the working folder %s is gone; %s starts in the team workspace', dir, title)
33
+ const { sessionId } = await ctx.sessionController.create(usable ? { cwd: dir } : { workspaceId: await ensureWorkspace() })
31
34
  const agent = await rt.resolveAgent(sessionId)
32
35
  await ctx.sessionTitle.rename(agent.session, title)
33
36
  return sessionId
@@ -55,7 +58,7 @@ export function install(rt) {
55
58
 
56
59
  // `pickModel` leaves the model to the user: the chat opens on a model card, and the
57
60
  // greeting waits until a model is chosen. So does a Bot made when no model is served.
58
- async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, copyOf, pickModel = false }) {
61
+ async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, workDir, copyOf, pickModel = false }) {
59
62
  const cleanName = String(botName ?? '').trim().slice(0, 40)
60
63
  const problem = nameProblem(cleanName)
61
64
  if (problem !== undefined) throw new Error(problem)
@@ -64,7 +67,8 @@ export function install(rt) {
64
67
  // The model is fixed at creation, so a later change of the DSH default leaves it alone.
65
68
  const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultModel()
66
69
  const effortId = modelRef !== undefined && effort !== undefined ? await rt.resolveEffort(modelRef, effort) : undefined
67
- const id = await createSession(cleanName)
70
+ const dir = workDir === undefined || workDir === null ? undefined : await rt.cleanWorkDir(workDir)
71
+ const id = await createSession(cleanName, { dir })
68
72
  try {
69
73
  state.bots[id] = {
70
74
  id,
@@ -75,24 +79,29 @@ export function install(rt) {
75
79
  ...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}),
76
80
  ...(modelRef ? { model: modelRef } : {}),
77
81
  ...(effortId ? { effort: effortId } : {}),
82
+ ...(dir ? { workDir: dir } : {}),
78
83
  instructions: String(instructions).trim(),
79
84
  createdAt: Date.now(),
80
85
  createdBy: createdBy ?? null,
86
+ capabilities: [],
81
87
  }
82
88
  if (main) state.mainBotIds.push(id)
83
89
  const creator = rt.botOf(createdBy)
84
90
  const original = rt.botOf(copyOf)
85
91
  const language = rt.uiLanguage?.()
92
+ // The kickoff arrives as a user message, so it outranks the Bot's instructions
93
+ // unless it gives way to them, and it names no tool those instructions may rule out.
86
94
  const kickoff = main
87
- ? `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 use ask_user to ask what they want the team to help with first (3-4 short options). Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
95
+ ? `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. Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
88
96
  : original
89
- ? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then use ask_user to ask what this copy should focus on (3-4 short options).`
97
+ ? `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.`
90
98
  : creator
91
- ? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then use ask_user to ask where to start (3-4 short options relevant to your role, with allowCustom). Keep it brief.`
99
+ ? `${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.`
92
100
  : brief
93
101
  ? `The user just created you and sent this first message:\n\n${brief}\n\nIntroduce yourself in one short line, then help with it.`
94
- : 'The user just created you. Greet the user in one short message, then use ask_user to ask what you should focus on (3-4 short options, with allowCustom).'
95
- const text = !main && language ? `${kickoff}\nWrite in ${language}.` : kickoff
102
+ : 'The user just created you. Greet the user in one short message, then ask in one short question what you should focus on.'
103
+ const opener = `${kickoff}\nWhere your instructions say otherwise, follow them.`
104
+ const text = !main && language ? `${opener}\nWrite in ${language}.` : opener
96
105
  const bot = state.bots[id]
97
106
  if (modelRef === undefined) {
98
107
  bot.kickoff = { text, from: createdBy ?? null, ...(main ? { main: true } : {}) }
@@ -144,6 +153,8 @@ export function install(rt) {
144
153
  avatar: bot.avatar,
145
154
  model: served ? bot.model : undefined,
146
155
  effort: served ? bot.effort : undefined,
156
+ // A folder that went away since leaves the copy in the team workspace.
157
+ workDir: bot.workDir !== undefined && await rt.isFolder(bot.workDir) ? bot.workDir : undefined,
147
158
  copyOf: bot.id,
148
159
  })
149
160
  }
@@ -151,6 +162,9 @@ export function install(rt) {
151
162
  async function updateBot(id, patch) {
152
163
  const bot = rt.botOf(id)
153
164
  if (bot === undefined) throw new Error('Unknown Bot')
165
+ // Checked first, so a folder that does not pass leaves the rest unchanged too.
166
+ const moving = patch.workDir !== undefined
167
+ const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined
154
168
  if (patch.name !== undefined) {
155
169
  const next = String(patch.name).trim().slice(0, 40)
156
170
  const clash = rt.findBot(next)
@@ -190,8 +204,14 @@ export function install(rt) {
190
204
  modelChanged = true
191
205
  }
192
206
  if (patch.permission !== undefined) await rt.setPermission(bot, patch.permission)
207
+ const moved = moving && workDir !== bot.workDir
208
+ if (moving) {
209
+ if (workDir === undefined) delete bot.workDir
210
+ else bot.workDir = workDir
211
+ }
193
212
  await rt.save()
194
213
  if (modelChanged) await rt.applyModel(bot, { force: true })
214
+ if (moved) void rt.followFolder(bot.id)
195
215
  if (bot.model && bot.kickoff !== undefined) {
196
216
  const held = bot.kickoff
197
217
  delete bot.kickoff
@@ -259,6 +279,8 @@ export function install(rt) {
259
279
  if (adminId !== null && !members.includes(adminId)) throw new Error('The admin must be a member of the group')
260
280
  if (mode !== undefined && !ROOM_MODES.includes(mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
261
281
  const title = custom || autoRoomName(members)
282
+ // The group's own Session only relays, so it stays in the team workspace; the
283
+ // group's folder is for its members' Sessions.
262
284
  const id = await createSession(title)
263
285
  try {
264
286
  state.rooms[id] = {
@@ -308,6 +330,9 @@ export function install(rt) {
308
330
  if (patch.mode !== undefined && !ROOM_MODES.includes(patch.mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
309
331
  const name = patch.name === undefined ? undefined : cleanRoomName(patch.name, room.id)
310
332
  const notice = patch.notice === undefined ? undefined : cleanNotice(patch.notice)
333
+ const moving = patch.workDir !== undefined
334
+ if (moving && byAdmin) throw new Error(`Only the user or a Main Bot can set the folder of "${room.name}"`)
335
+ const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined
311
336
 
312
337
  const changes = []
313
338
  const names = ids => ids.map(member => rt.botOf(member)?.name).filter(Boolean).join(', ')
@@ -331,6 +356,12 @@ export function install(rt) {
331
356
  room.notice = notice
332
357
  changes.push(notice ? 'updated the notice' : 'cleared the notice')
333
358
  }
359
+ // Members' group conversations move on to the folder at their next turn there.
360
+ if (moving && workDir !== room.workDir) {
361
+ if (workDir === undefined) delete room.workDir
362
+ else room.workDir = workDir
363
+ changes.push(workDir ? `every member now works in ${workDir} here` : 'every member works in its own folder again')
364
+ }
334
365
  const before = room.name
335
366
  if (name !== undefined) {
336
367
  if (name !== '') {
@@ -526,5 +526,13 @@ export function install(rt) {
526
526
  return () => { if (usedTimer !== null) clearTimeout(usedTimer) }
527
527
  }, 'ds-bot: load secrets')
528
528
 
529
- Object.assign(rt, { secretsView, secretValue: name => secrets.get(name)?.value, secretEndpoint, forgetBotSecrets, loadSecrets, redactSecrets: text => redact(text) })
529
+ Object.assign(rt, {
530
+ secretsView,
531
+ secretValue: name => secrets.get(name)?.value,
532
+ canUseSecret: (name, botId) => canUse(secrets.get(name), botId),
533
+ secretEndpoint,
534
+ forgetBotSecrets,
535
+ loadSecrets,
536
+ redactSecrets: text => redact(text),
537
+ })
530
538
  }
package/src/host/store.js CHANGED
@@ -11,8 +11,8 @@ import { currentColor, normalize, unquote } from './text.js'
11
11
  export function install(rt) {
12
12
  const { ctx, home, statePath } = rt
13
13
 
14
- /** @type {{version:1, workspaceId?:string, mainBotIds:string[], bots:Record<string,any>, rooms:Record<string,any>, agents:Record<string,any>, exchanges:any[], exchangeTurns:Record<string,number[]>, revision:number}} */
15
- const state = { version: 1, mainBotIds: [], bots: {}, rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0 }
14
+ /** @type {{version:1, workspaceId?:string, mainBotIds:string[], bots:Record<string,any>, rooms:Record<string,any>, agents:Record<string,any>, capabilities:{items:Record<string,any>}, exchanges:any[], exchangeTurns:Record<string,number[]>, revision:number}} */
15
+ const state = { version: 1, mainBotIds: [], bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0 }
16
16
  let loaded = null
17
17
  let writing = Promise.resolve()
18
18
  const { warn } = rt
@@ -97,13 +97,25 @@ export function install(rt) {
97
97
  // Fill `state` from the saved file and bring an older team up to date. Returns the
98
98
  // groups whose Session titles still need the new name.
99
99
  const adopt = (parsed) => {
100
+ const hadCapabilities = parsed !== null && parsed !== undefined && Object.hasOwn(parsed, 'capabilities')
100
101
  for (const key of Object.keys(state)) delete state[key]
101
102
  Object.assign(state, JSON.parse(EMPTY), parsed ?? {})
102
103
  state.prefs ??= {}
103
104
  // Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset.
104
105
  if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {}
105
- let migrated = false
106
+ let migrated = !hadCapabilities
107
+ if (state.capabilities === null || typeof state.capabilities !== 'object' || Array.isArray(state.capabilities)) {
108
+ state.capabilities = { items: {} }
109
+ migrated = true
110
+ } else if (state.capabilities.items === null || typeof state.capabilities.items !== 'object' || Array.isArray(state.capabilities.items)) {
111
+ state.capabilities.items = {}
112
+ migrated = true
113
+ }
106
114
  for (const bot of Object.values(state.bots)) {
115
+ if (!Array.isArray(bot.capabilities)) {
116
+ bot.capabilities = []
117
+ migrated = true
118
+ }
107
119
  if (bot.color !== currentColor(bot.color)) {
108
120
  bot.color = currentColor(bot.color)
109
121
  migrated = true
@@ -248,6 +260,7 @@ export function install(rt) {
248
260
  ensureLock()
249
261
  await rename(temp, statePath)
250
262
  seen = stamp()
263
+ rt.capabilitiesStateSaved?.()
251
264
  } catch (error) {
252
265
  await unlink(temp).catch(() => {})
253
266
  throw error
package/src/host/tools.js CHANGED
@@ -2,21 +2,27 @@
2
2
  // question card. Group rounds and reading chats are in groups.js.
3
3
  import { randomUUID } from 'node:crypto'
4
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
5
- import { COLORS, EXTRA_COLORS, MAX_HOPS, ROOM_BOT_ROUNDS, ROOM_MODES } from './constants.js'
5
+ import { COLORS, EXTRA_COLORS, MAX_HOPS, MAX_UNREAD_REQUESTS, ROOM_BOT_ROUNDS, ROOM_MODES, SOURCE_KIND } from './constants.js'
6
6
  import { unquote } from './text.js'
7
7
 
8
8
  export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }
9
9
  export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' }
10
+ const BLOCKING_QUESTION_TOOL = 'ask_user_question'
10
11
  const MODE_PARAM = {
11
12
  type: 'string',
12
13
  enum: ROOM_MODES,
13
14
  description: '"everyone": every member answers a plain message in turn, admin first. "admin" (the default): a plain message goes to the admin, who calls others with @Name. @mentions always choose who answers.',
14
15
  }
16
+ const WORK_DIR_PARAM = { type: 'string', description: 'Full path of the folder to work in, where commands and file tools start. Only when the user asks for it' }
15
17
 
16
18
  export function install(rt) {
17
19
  const { ctx, state } = rt
18
20
 
19
21
  const callerBot = (exec) => rt.botOf(rt.selfOf(exec.agent?.id))
22
+ const callerRoom = (exec) => {
23
+ const roomId = rt.groupOwners.get(exec.agent?.id)?.roomId
24
+ return roomId ? { fromRoomId: roomId } : {}
25
+ }
20
26
 
21
27
  const roomLine = (room, self) => {
22
28
  const members = room.members.map(rt.botOf).filter(Boolean)
@@ -25,6 +31,7 @@ export function install(rt) {
25
31
  `members: ${members.join(', ')}`,
26
32
  room.mode === 'admin' && room.admin ? 'plain messages go to the admin first' : 'every member replies in turn',
27
33
  room.notice ? `notice: ${room.notice.split('\n')[0].slice(0, 100)}` : '',
34
+ room.workDir ? `members work in ${room.workDir} here` : '',
28
35
  ].filter(Boolean)
29
36
  return `- "${room.name}" — ${parts.join('; ')}`
30
37
  }
@@ -41,7 +48,7 @@ export function install(rt) {
41
48
  const self = rt.selfOf(exec.agent?.id)
42
49
  const lines = Object.values(state.bots).map(bot => {
43
50
  const model = bot.appliedModel ?? bot.model
44
- const tags = [bot.role, model ? `model ${model}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ')
51
+ const tags = [bot.role, model ? `model ${model}` : '', bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ')
45
52
  const job = bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : ''
46
53
  return `- ${bot.name}${tags ? ` (${tags})` : ''}${job}`
47
54
  })
@@ -68,6 +75,12 @@ export function install(rt) {
68
75
  const room = rt.roomOf(rt.groupOwners.get(sessionId)?.roomId)
69
76
  return room ? `group "${room.name}"` : 'its own chat'
70
77
  }
78
+ // Delivered but not read yet: no step or turn has taken them out of the inbox.
79
+ const unreadRequests = agent => [...(agent.inbox?.nextStep ?? []), ...(agent.inbox?.nextTurn ?? [])]
80
+ .filter(message => message.source?.kind === SOURCE_KIND && message.source.role === 'request').length
81
+
82
+ // A tool that throws shows as failed in the chat, instead of as sent or stopped.
83
+ const refuse = (message) => { throw new Error(message) }
71
84
 
72
85
  ctx.tools.register(defineTool({
73
86
  name: 'message_bot',
@@ -97,10 +110,15 @@ export function install(rt) {
97
110
  const turn = rt.live.get(exec.agent?.id)
98
111
  const hop = Math.max(turn?.info?.hop ?? 0, ...(turn?.joined ?? []).map(joined => joined.info?.hop ?? 0))
99
112
  if (hop >= MAX_HOPS) return 'This chain of Bot messages is already long. Stop here and report to the user instead.'
113
+ const chat = rt.chatOf(target.id)
114
+ const unread = unreadRequests(await rt.resolveAgent(chat))
115
+ if (unread >= MAX_UNREAD_REQUESTS) {
116
+ refuse(`Not sent: ${target.name} has not read the last ${unread} messages from Bots yet, so it is most likely stuck, for example waiting for the user. Do not send it more; tell the user that ${target.name} is not responding.`)
117
+ }
100
118
  const toUser = args.reply_to === 'user'
101
- const working = rt.live.get(rt.chatOf(target.id))?.turn !== undefined
119
+ const working = rt.live.get(chat)?.turn !== undefined
102
120
  const steer = args.when !== 'queue' && !toUser
103
- await rt.deliver({ fromId: sender.id, toId: target.id, text: args.message, role: 'request', hop, steer, ...(toUser ? { audience: 'user' } : {}) })
121
+ await rt.deliver({ fromId: sender.id, toId: target.id, text: args.message, role: 'request', hop, steer, ...(toUser ? { audience: 'user' } : callerRoom(exec)) })
104
122
  const sent = toUser
105
123
  ? `Delivered to ${target.name}. It will answer the user in its own chat; nothing comes back to you, so point the user to ${target.name}'s chat instead of relaying.`
106
124
  : working && steer
@@ -108,18 +126,20 @@ export function install(rt) {
108
126
  : working
109
127
  ? `Queued for ${target.name}: it reads your message after its current work. Its reply will arrive later as a new message; do not wait for it.`
110
128
  : `Delivered to ${target.name}. Its reply will arrive later as a new message; do not wait for it.`
129
+ // "Delivered" only means it is in the inbox; say so when it may sit there a while.
130
+ const notes = [
131
+ rt.waitsForApproval?.(chat) ? `${target.name} is waiting for the user's approval, so it reads your message only after the user answers.` : '',
132
+ unread > 0 ? `${target.name} has not read ${unread === 1 ? 'an earlier message' : `${unread} earlier messages`} from Bots yet.` : '',
133
+ ].filter(Boolean)
134
+ const status = notes.length === 0 ? sent : `${sent} ${notes.join(' ')}`
111
135
  // Messages reach a Bot's own chat only; work in a group goes on without them.
112
- const groups = activeSessions(target.id).running.filter(sessionId => sessionId !== rt.chatOf(target.id))
113
- if (groups.length === 0) return sent
136
+ const groups = activeSessions(target.id).running.filter(sessionId => sessionId !== chat)
137
+ if (groups.length === 0) return status
114
138
  const stop = rt.isMain(sender.id) ? ' If it must not go on there, use stop_bot.' : ''
115
- return `${sent} ${target.name} is also working in ${groups.map(whereOf).join(' and ')}; your message does not reach that work.${stop}`
139
+ return `${status} ${target.name} is also working in ${groups.map(whereOf).join(' and ')}; your message does not reach that work.${stop}`
116
140
  },
117
141
  }))
118
142
 
119
- // stop_bot throws when it does nothing, so the chat shows the call as failed
120
- // instead of "Stopped".
121
- const refuse = (message) => { throw new Error(message) }
122
-
123
143
  ctx.tools.register(defineTool({
124
144
  name: 'stop_bot',
125
145
  description: 'Main Bot only. Stop another Bot\'s running turn immediately — when it must not finish a dangerous action and a message_bot warning is not enough. Its queued messages are dropped with the turn. It is then told why in a new turn, whose reply comes back to you.',
@@ -142,7 +162,7 @@ export function install(rt) {
142
162
  agent.cancel({ kind: 'hook', reason: 'ds-bot/stopped' })
143
163
  }
144
164
  const why = String(args.reason ?? '').trim() || 'Stop what you are doing; the previous turn was ended on purpose. Acknowledge briefly.'
145
- await rt.deliver({ fromId: sender.id, toId: target.id, text: why, role: 'stopped' })
165
+ await rt.deliver({ fromId: sender.id, toId: target.id, text: why, role: 'stopped', ...callerRoom(exec) })
146
166
  return `Stopped ${target.name} in ${running.map(whereOf).join(' and ')}. It is told why in a new turn, whose reply comes back to you.`
147
167
  },
148
168
  }))
@@ -156,14 +176,18 @@ export function install(rt) {
156
176
  instructions: { type: 'string', required: true, description: 'What this Bot is responsible for and how it should work' },
157
177
  brief: { type: 'string', description: 'Why the user wants it, passed to the new Bot as its first context' },
158
178
  model: { type: 'string', description: 'Model the Bot runs on, as provider/model or a model id. Only when the user names one; otherwise the DSH default model.' },
179
+ work_dir: WORK_DIR_PARAM,
159
180
  },
160
181
  output,
161
182
  async execute(args, exec) {
162
183
  await rt.load()
163
184
  if (!rt.isMain(rt.selfOf(exec.agent?.id))) return 'Only the Main Bot can create Bots. Ask the Main Bot instead.'
164
185
  try {
165
- const bot = await rt.createBot({ ...args, createdBy: rt.selfOf(exec.agent.id) })
166
- return `Created ${bot.name}. It is greeting the user now.`
186
+ const workDir = args.work_dir ? await rt.cleanWorkDir(args.work_dir) : undefined
187
+ const name = String(args.name ?? '').trim()
188
+ await rt.approveFolder(exec, { path: workDir, who: { en: `the new Bot ${name}`, zh: `新 Bot ${name} ` } })
189
+ const bot = await rt.createBot({ ...args, workDir, createdBy: rt.selfOf(exec.agent.id) })
190
+ return `Created ${bot.name}${workDir ? `, working in ${workDir}` : ''}. It is greeting the user now.`
167
191
  } catch (error) {
168
192
  return `Could not create the Bot: ${error.message}`
169
193
  }
@@ -172,7 +196,7 @@ export function install(rt) {
172
196
 
173
197
  ctx.tools.register(defineTool({
174
198
  name: 'update_bot',
175
- description: 'Change a Bot\'s name, role, color, model, or instructions. Every Bot may change itself; only a Main Bot may change another Bot.',
199
+ description: 'Change a Bot\'s name, role, color, model, working folder, or instructions. Every Bot may change itself; only a Main Bot may change another Bot.',
176
200
  parameters: {
177
201
  bot: { type: 'string', description: 'Current name of the Bot to change; leave out to change yourself' },
178
202
  name: { type: 'string', description: 'New name' },
@@ -180,6 +204,7 @@ export function install(rt) {
180
204
  instructions: { type: 'string', description: 'Replacement instructions' },
181
205
  color: { type: 'string', description: `One of ${[...COLORS, ...EXTRA_COLORS].join(', ')}` },
182
206
  model: { type: 'string', description: 'Move the Bot to another model, as provider/model or a model id' },
207
+ work_dir: { ...WORK_DIR_PARAM, description: `${WORK_DIR_PARAM.description}; an empty string returns it to the team workspace` },
183
208
  },
184
209
  output,
185
210
  async execute(args, exec) {
@@ -192,8 +217,13 @@ export function install(rt) {
192
217
  if (bot.id !== caller.id && !rt.isMain(self)) return 'Only a Main Bot can change another Bot. Leave out bot to change yourself, or ask a Main Bot with message_bot.'
193
218
  try {
194
219
  const { name: newName, role, instructions, color, model } = args
195
- const next = await rt.updateBot(bot.id, { name: newName, role, instructions, color, ...(model ? { model } : {}) })
196
- return `Updated ${next.name}.`
220
+ const moving = typeof args.work_dir === 'string'
221
+ const workDir = moving ? await rt.cleanWorkDir(args.work_dir) : undefined
222
+ if (moving && workDir !== bot.workDir) await rt.approveFolder(exec, { path: workDir, sessions: rt.sessionsOfBot(bot.id), who: { en: bot.name, zh: ` ${bot.name} ` } })
223
+ const next = await rt.updateBot(bot.id, { name: newName, role, instructions, color, ...(model ? { model } : {}), ...(moving ? { workDir: workDir ?? '' } : {}) })
224
+ if (!moving) return `Updated ${next.name}.`
225
+ const folder = workDir ?? 'the team workspace'
226
+ return bot.id === caller.id ? `Updated ${next.name}. From your next step on, you work in ${folder}.` : `Updated ${next.name}. It now works in ${folder}.`
197
227
  } catch (error) {
198
228
  return `Could not update the Bot: ${error.message}`
199
229
  }
@@ -276,6 +306,7 @@ export function install(rt) {
276
306
  remove_members: { type: 'array', items: { type: 'string' }, description: 'Exact names of Bots to remove' },
277
307
  admin: { type: 'string', description: 'Member to make the admin; "none" leaves the group without one (Main Bots only)' },
278
308
  mode: MODE_PARAM,
309
+ work_dir: { ...WORK_DIR_PARAM, description: `${WORK_DIR_PARAM.description}. Every member works there in this group; an empty string lets each member work in its own folder again (Main Bots only)` },
279
310
  },
280
311
  output,
281
312
  async execute(args, exec) {
@@ -284,6 +315,8 @@ export function install(rt) {
284
315
  if (rt.botOf(self) === undefined) return 'Only Bots on the team can use update_group.'
285
316
  const { room, error } = pickRoom(args.group, self)
286
317
  if (error) return error
318
+ const moving = typeof args.work_dir === 'string'
319
+ if (moving && !rt.isMain(self)) return `Only a Main Bot can set the folder of "${room.name}".`
287
320
  const add = resolveBots(args.add_members)
288
321
  const remove = resolveBots(args.remove_members)
289
322
  const unknown = [...add.unknown, ...remove.unknown]
@@ -297,6 +330,16 @@ export function install(rt) {
297
330
  }
298
331
  }
299
332
  try {
333
+ const workDir = moving ? await rt.cleanWorkDir(args.work_dir) : undefined
334
+ // A Bot that joins a group with a folder works there from its first turn.
335
+ const joining = add.bots.filter(bot => !room.members.includes(bot.id))
336
+ if (moving && workDir !== room.workDir) {
337
+ const members = [...room.members, ...joining.map(bot => bot.id)]
338
+ await rt.approveFolder(exec, { path: workDir, sessions: members.map(id => room.sessions?.[id]).filter(Boolean), who: { en: `every member of "${room.name}"`, zh: `群聊「${room.name}」的成员` } })
339
+ } else if (joining.length > 0) {
340
+ const names = joining.map(bot => bot.name)
341
+ await rt.approveFolder(exec, { path: room.workDir, sessions: joining.map(bot => room.sessions?.[bot.id]).filter(Boolean), who: { en: names.join(', '), zh: ` ${names.join('、')} ` } })
342
+ }
300
343
  const { room: next, changes } = await rt.updateRoom(room.id, {
301
344
  name: args.name,
302
345
  notice: args.notice,
@@ -304,6 +347,7 @@ export function install(rt) {
304
347
  admin,
305
348
  add: add.bots.map(bot => bot.id),
306
349
  remove: remove.bots.map(bot => bot.id),
350
+ ...(moving ? { workDir: workDir ?? '' } : {}),
307
351
  }, self)
308
352
  return changes.length === 0 ? `Nothing changed in "${next.name}".` : `Updated "${next.name}": ${changes.join('; ')}.`
309
353
  } catch (failure) {
@@ -334,7 +378,7 @@ export function install(rt) {
334
378
  description: 'Post a message into a group chat as yourself. It starts a round: members reply in the group, where the user reads them. Main Bots may post into any group; an admin into its own group.',
335
379
  parameters: {
336
380
  group: GROUP_REF,
337
- message: { type: 'string', required: true, description: 'What to say; write "@Name" to choose who answers' },
381
+ message: { type: 'string', required: true, description: 'What to say; write "@Name" to choose who answers, and a name without "@" to only mention a member' },
338
382
  report_back: { type: 'boolean', description: 'Also send the members\' replies back to you as a new message when the round ends' },
339
383
  },
340
384
  output,
@@ -421,5 +465,135 @@ export function install(rt) {
421
465
  },
422
466
  }))
423
467
 
424
- Object.assign(rt, { pickRoom })
468
+ // dsh's own question tool holds the turn open until the user answers, and every
469
+ // message from another Bot or a group round queues behind it. Bot Sessions ask
470
+ // through ask_user instead, so the tool is taken out of their tool list.
471
+ const hiddenQuestion = new WeakSet()
472
+ const hideBlockingQuestion = (agent) => {
473
+ if (agent === undefined || hiddenQuestion.has(agent) || !rt.isBotSession(agent.session?.id)) return
474
+ if (typeof agent.ctx?.tools?.restrict !== 'function') return
475
+ try {
476
+ // restrict() throws on a name this profile does not serve.
477
+ if (!ctx.tools.schemas(agent).some(schema => schema.name === BLOCKING_QUESTION_TOOL)) return
478
+ agent.ctx.tools.restrict({ deny: [BLOCKING_QUESTION_TOOL] })
479
+ hiddenQuestion.add(agent)
480
+ } catch (error) {
481
+ rt.warn('ds-bot: %s stays in the tool list of %s: %s', BLOCKING_QUESTION_TOOL, agent.session?.id, error)
482
+ }
483
+ }
484
+ // A new Bot, part, or group Session gets its Agent before ds-bot records it, so the
485
+ // list is only trimmed once ds-bot resolves that Agent again; the guard refuses
486
+ // the tool in any Bot Session meanwhile.
487
+ ctx.tools.guard(exec => (exec.name === BLOCKING_QUESTION_TOOL && rt.isBotSession(exec.agent?.id)
488
+ ? `Bots cannot use ${BLOCKING_QUESTION_TOOL}. Ask with ask_user in your own chat, or ask in your reply.`
489
+ : undefined))
490
+ // Creation waits for this listener, so the Agent's first step already lacks the tool.
491
+ ctx.on('agent/created', async ({ agent }) => {
492
+ try {
493
+ await rt.load()
494
+ hideBlockingQuestion(agent)
495
+ } catch (error) {
496
+ rt.warn('ds-bot: hiding %s on agent/created failed: %s', BLOCKING_QUESTION_TOOL, error)
497
+ }
498
+ })
499
+
500
+ ctx.tools.register(defineTool({
501
+ name: 'list_capabilities',
502
+ description: 'List shared MCP servers, skills, and packs, plus the capabilities attached to you.',
503
+ parameters: {},
504
+ output,
505
+ async execute(_args, exec) {
506
+ await rt.load()
507
+ const self = rt.selfOf(exec.agent?.id)
508
+ const bot = rt.botOf(self)
509
+ if (!bot) return 'Only Bots on the team can use list_capabilities.'
510
+ const items = Object.values(state.capabilities?.items ?? {})
511
+ if (items.length === 0) return 'No shared capabilities are saved yet.'
512
+ const statuses = rt.capabilityStatusesView()[bot.id] ?? {}
513
+ const lines = items.map(item => {
514
+ const extra = item.kind === 'pack' ? `; includes ${(item.items ?? []).join(', ') || 'nothing'}`
515
+ : item.kind === 'mcp' && item.transport === 'stdio' ? `; ${item.approved ? 'allowed' : 'needs user approval'}` : ''
516
+ const itemStatus = statuses[item.name]
517
+ const state = itemStatus ? ` (${itemStatus.state}${itemStatus.error ? `: ${itemStatus.error}` : ''})` : ''
518
+ const status = bot.capabilities?.includes(item.name) ? `; attached to you${state}` : ''
519
+ return `- ${item.name} (${item.kind})${item.description ? ` — ${item.description}` : ''}${extra}${status}`
520
+ })
521
+ return ['Shared capabilities:', ...lines].join('\n')
522
+ },
523
+ }))
524
+ ctx.tools.register(defineTool({
525
+ name: 'save_capability',
526
+ description: 'Create or update a shared MCP server, skill, or pack. Changes apply from your next message.',
527
+ parameters: {
528
+ name: { type: 'string', required: true, description: 'Lowercase letters, digits, and single hyphens, up to 32 characters; "github" is reserved' },
529
+ kind: { type: 'string', required: true, enum: ['mcp', 'skill', 'pack'] },
530
+ description: { type: 'string', description: 'What this capability provides' },
531
+ transport: { type: 'string', enum: ['stdio', 'streamable-http'], description: 'How to reach an MCP server: a local command or an HTTP URL' },
532
+ command: { type: 'string', description: 'Program that starts a local MCP server' },
533
+ args: { type: 'array', items: { type: 'string' }, description: 'Arguments for command' },
534
+ env: { type: 'object', additionalProperties: true, description: 'Environment variables for command; write $DSH_SECRET_<NAME> to insert a saved secret instead of the value' },
535
+ url: { type: 'string', description: 'MCP server URL' },
536
+ headers: { type: 'object', additionalProperties: true, description: 'HTTP headers; write $DSH_SECRET_<NAME> to insert a saved secret instead of the value' },
537
+ content: { type: 'string', description: 'Skill instructions' },
538
+ items: { type: 'array', items: { type: 'string' }, description: 'MCP server and skill names in the pack' },
539
+ },
540
+ output,
541
+ async execute(args, exec) {
542
+ await rt.load()
543
+ const self = rt.selfOf(exec.agent?.id)
544
+ if (!rt.botOf(self)) return 'Only Bots on the team can use save_capability.'
545
+ try {
546
+ const item = await rt.capabilitySave(args, { botId: self })
547
+ const approval = item.kind === 'mcp' && item.transport === 'stdio' && !item.approved
548
+ ? ' The user must allow this MCP server in DS Bot settings before it can work.'
549
+ : ''
550
+ return `Saved ${item.name}. Bots with it attached use the change from their next message.${approval}`
551
+ } catch (error) { return error.message }
552
+ },
553
+ }))
554
+ ctx.tools.register(defineTool({
555
+ name: 'delete_capability',
556
+ description: 'Remove a shared capability and detach it from every Bot and pack.',
557
+ parameters: { name: { type: 'string', required: true } },
558
+ output,
559
+ async execute(args, exec) {
560
+ await rt.load()
561
+ const self = rt.selfOf(exec.agent?.id)
562
+ if (!rt.botOf(self)) return 'Only Bots on the team can use delete_capability.'
563
+ try {
564
+ await rt.capabilityDelete(args.name, { botId: self })
565
+ return `Deleted ${args.name}; it was removed from every Bot and pack. The change applies from their next message.`
566
+ } catch (error) { return error.message }
567
+ },
568
+ }))
569
+ for (const [name, endpoint, verb] of [['attach_capability', 'attach', 'Attached'], ['detach_capability', 'detach', 'Detached']]) {
570
+ ctx.tools.register(defineTool({
571
+ name,
572
+ description: `${verb} a shared capability to yourself, or to another Bot if you are a Main Bot.`,
573
+ parameters: {
574
+ name: { type: 'string', required: true, description: 'Capability name from list_capabilities' },
575
+ bot: { type: 'string', description: 'Optional Bot name; only a Main Bot may change another Bot' },
576
+ },
577
+ output,
578
+ async execute(args, exec) {
579
+ await rt.load()
580
+ const self = rt.selfOf(exec.agent?.id)
581
+ if (!rt.botOf(self)) return `Only Bots on the team can use ${name}.`
582
+ try {
583
+ const result = await rt.changeCapabilityAttachment(endpoint, args, self)
584
+ const attached = state.capabilities.items[args.name]
585
+ const members = attached?.kind === 'pack'
586
+ ? (attached.items ?? []).map(child => state.capabilities.items[child]).filter(Boolean)
587
+ : attached ? [attached] : []
588
+ const approvals = members
589
+ .filter(item => item.kind === 'mcp' && item.transport === 'stdio' && item.approved !== true)
590
+ .map(item => ` The user must allow ${item.name} in DS Bot settings before it can work.`)
591
+ .join('')
592
+ return `${verb} ${args.name} ${args.bot ? `to ${result.bot}` : 'to yourself'}. Changes apply from ${result.bot}'s next message.${endpoint === 'attach' ? approvals : ''}`
593
+ } catch (error) { return error.message }
594
+ },
595
+ }))
596
+ }
597
+
598
+ Object.assign(rt, { pickRoom, hideBlockingQuestion })
425
599
  }