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.
@@ -40,7 +40,7 @@ export function install(rt) {
40
40
  ...bot, sessionId: rt.chatOf(bot.id), parts: (segments ?? []).map(segment => segment.sessionId), ...(kickoff ? { waiting: true } : {}),
41
41
  })),
42
42
  // `parts`: the members' hidden group Sessions and their earlier segments, for usage.
43
- rooms: Object.values(state.rooms).map(({ log, seen, botRounds, sessions, segments, ...room }) => {
43
+ rooms: Object.values(state.rooms).map(({ log, seen, botRounds, lateRounds, sessions, segments, ...room }) => {
44
44
  const parts = [...Object.values(sessions ?? {}), ...Object.values(segments ?? {}).flat().map(segment => segment.sessionId)]
45
45
  const last = (log ?? []).filter(line => line.kind === undefined).at(-1)
46
46
  return { ...room, parts, ...(last ? { preview: last.who === 'User' ? last.text : `${last.who}:${last.text}` } : {}) }
@@ -51,6 +51,7 @@ export function install(rt) {
51
51
  agents: Object.values(state.agents),
52
52
  // Key names and who may use them; values never leave the host.
53
53
  secrets: rt.secretsView(),
54
+ capabilities: rt.capabilityLibraryView(),
54
55
  secretRequests: state.secretRequests ?? {},
55
56
  colors: [...COLORS, ...EXTRA_COLORS],
56
57
  models: rt.cachedCatalog(),
@@ -67,13 +68,15 @@ export function install(rt) {
67
68
  })
68
69
  // What changes without a new revision, sent with every state answer. `memoryNews`:
69
70
  // per Bot, its latest memory change; `memoryReviews`: memory reviews waiting or
70
- // running; `work`: per Bot, the turn it runs and its tool, and the approvals waiting.
71
+ // running; `work`: per Bot, the turn it runs and its tool; per group, the members whose
72
+ // turn there runs; and the approvals waiting.
71
73
  const live = () => ({
72
74
  activity: rt.activityView(),
73
- work: { bots: rt.workView(), approvals: rt.approvalsView() },
75
+ work: { bots: rt.workView(), rooms: rt.roomWorkView(), approvals: rt.approvalsView() },
74
76
  memoryNews: rt.memoryNews(),
75
77
  memoryReviews: rt.memoryReviews(),
76
78
  connectors: rt.connectorsView(),
79
+ capabilityStatuses: rt.capabilityStatusesView(),
77
80
  })
78
81
  // What a host that only reads the team may still answer.
79
82
  // Updates change the dsh profile, not the team, so a reading host may run them too.
@@ -97,7 +100,7 @@ export function install(rt) {
97
100
  return rt.save()
98
101
  }
99
102
 
100
- const handle = async (endpoint, payload) => {
103
+ const handle = async (endpoint, payload, signal) => {
101
104
  try {
102
105
  const body = payload ?? {}
103
106
  await rt.refresh()
@@ -138,7 +141,7 @@ export function install(rt) {
138
141
  return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === body.roomId).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null })))
139
142
  case 'create-bot': {
140
143
  await rt.bootstrap()
141
- const bot = await rt.createBot({ name: body.name, role: body.role, color: body.color, instructions: body.instructions, brief: body.brief, avatar: body.avatar, model: body.model, effort: body.effort })
144
+ const bot = await rt.createBot({ name: body.name, role: body.role, color: body.color, instructions: body.instructions, brief: body.brief, avatar: body.avatar, model: body.model, effort: body.effort, workDir: body.workDir })
142
145
  return ok(bot)
143
146
  }
144
147
  case 'update-bot':
@@ -171,9 +174,15 @@ export function install(rt) {
171
174
  members: Array.isArray(body.members) ? body.members : undefined,
172
175
  add: Array.isArray(body.add) ? body.add : undefined,
173
176
  remove: Array.isArray(body.remove) ? body.remove : undefined,
177
+ workDir: body.workDir,
174
178
  })
175
179
  return ok({ room: view().rooms.find(entry => entry.id === room.id), changes })
176
180
  }
181
+ case 'folders':
182
+ case 'folder-pick':
183
+ case 'folder-list':
184
+ await rt.load()
185
+ return ok(await rt.folderEndpoint(endpoint, body, signal))
177
186
  case 'add-agent':
178
187
  await rt.load()
179
188
  return ok(await rt.addAgent())
@@ -205,6 +214,8 @@ export function install(rt) {
205
214
  case 'approval-answer':
206
215
  rt.answerApproval(body.id, body.choice)
207
216
  return ok(true)
217
+ case 'stop-member-work':
218
+ return ok(await rt.stopMemberWork(String(body.roomId ?? ''), String(body.botId ?? '')))
208
219
  case 'dismiss-question':
209
220
  await rt.load()
210
221
  if (state.questions[body.sessionId] !== undefined) {
@@ -224,6 +235,12 @@ export function install(rt) {
224
235
  case 'connector-retry':
225
236
  await rt.load()
226
237
  return ok(await rt.connectorEndpoint(endpoint, body))
238
+ case 'capability-save':
239
+ case 'capability-delete':
240
+ case 'capability-approve':
241
+ case 'bot-capability':
242
+ await rt.load()
243
+ return ok(await rt.capabilityEndpoint(endpoint, body))
227
244
  case 'schedules':
228
245
  case 'schedule-create':
229
246
  case 'schedule-delete':
@@ -246,6 +263,7 @@ export function install(rt) {
246
263
  // Claim the question before queueing so a second answer is refused.
247
264
  delete state.questions[sessionId]
248
265
  try {
266
+ await rt.ensureCapabilities(sessionId)
249
267
  const agent = await rt.resolveAgent(sessionId)
250
268
  agent.followup(createUserMessage({ content: [{ type: 'text', text: reply.text }], source: reply.source }))
251
269
  await ctx.sessions.flush(agent.session)
@@ -263,6 +281,7 @@ export function install(rt) {
263
281
  // A Bot id or an earlier part of its own chat means the current part.
264
282
  const past = rt.pastOwners.get(body.sessionId)
265
283
  const sessionId = rt.botOf(body.sessionId) ? rt.chatOf(body.sessionId) : past && past.roomId === undefined ? rt.chatOf(past.botId) : body.sessionId
284
+ await rt.ensureCapabilities(sessionId)
266
285
  await ctx.sessionController.prompt({
267
286
  requestId: randomUUID(),
268
287
  sessionId,
@@ -319,7 +338,7 @@ export function install(rt) {
319
338
  fetch: async (request) => {
320
339
  let body
321
340
  try { body = await request.json() } catch { body = {} }
322
- const result = await handle(String(body?.endpoint ?? ''), body?.payload)
341
+ const result = await handle(String(body?.endpoint ?? ''), body?.payload, request.signal)
323
342
  return new Response(JSON.stringify(result), { status: 200, headers: { 'content-type': 'application/json' } })
324
343
  },
325
344
  }), 'ds-bot: api route')
@@ -21,6 +21,9 @@ export const OCR_CACHE_MAX = 500
21
21
  export const MAX_AVATAR_IMAGE = 160_000
22
22
  export const ROUTE = '/api/bot'
23
23
  export const MAX_HOPS = 6
24
+ // Bot requests a Bot's own chat may hold unread; past this the Bot is most likely
25
+ // stuck, and more messages only pile up behind it.
26
+ export const MAX_UNREAD_REQUESTS = 10
24
27
  export const MAX_EXCHANGES = 600
25
28
  export const ROOM_REPLY_TIMEOUT_MS = 240_000
26
29
  export const SOURCE_KIND = 'bot'
@@ -49,9 +52,17 @@ export const TEAM_TOOLS = [
49
52
  'create_group', 'update_group', 'delete_group', 'post_to_group', 'ask_user',
50
53
  'request_secret', 'list_secrets', 'group_relay', 'read_own_chat',
51
54
  'read_group_chat', 'remember', 'forget', 'recall', 'read_browser',
55
+ 'list_capabilities', 'save_capability', 'delete_capability',
56
+ 'attach_capability', 'detach_capability',
52
57
  ]
53
58
  // Rounds Bots may start in one group by posting, before the user speaks there again.
54
59
  export const ROOM_BOT_ROUNDS = 3
60
+ // Answers a group gives to what members post after the round moved on (their finished
61
+ // work, mostly), before the user speaks there again.
62
+ export const ROOM_LATE_ROUNDS = 10
63
+ // A member still at work this long after its first tool call lets the round go on; it
64
+ // posts its reply when the work is done.
65
+ export const ROOM_WORK_GRACE_MS = 15_000
55
66
 
56
67
  export const OWN_CHAT_LINE_MAX = 400
57
68
  // The message an anchor points at is shown whole (up to this), so a long draft takes
@@ -9,11 +9,15 @@ export function install(rt) {
9
9
  const resolveAgent = async (sessionId) => {
10
10
  const resolved = await ctx.sessionController.resolveAgent(sessionId)
11
11
  if ('error' in resolved) throw new Error(resolved.error?.message ?? 'Session unavailable')
12
+ rt.hideBlockingQuestion(resolved.agent)
12
13
  return resolved.agent
13
14
  }
14
15
 
15
- const headerFor = (role, fromName, roomName, audience, note = 'new message', deferred = false) => {
16
+ const headerFor = (role, fromName, roomName, audience, note = 'new message', deferred = false, { resumed = false, retried = false } = {}) => {
16
17
  if (deferred) return `[Message from ${fromName} · you read it during your last task; answer it now]`
18
+ if (retried && role !== 'room') return '[Your last reply wrote a tool call as text, so nothing ran]'
19
+ if (resumed && role === 'resume') return '[DeepSeek Harness restarted while you were working]'
20
+ if (resumed && role !== 'room') return `[Message from ${fromName} · DeepSeek Harness restarted while you were working on it]`
17
21
  if (role === 'reply') return `[Reply from ${fromName}]`
18
22
  if (role === 'stopped') return `[Stopped by ${fromName}]`
19
23
  if (role === 'room') return `[Group chat "${roomName}" · ${note}]`
@@ -29,8 +33,13 @@ export function install(rt) {
29
33
 
30
34
  // `sessionId` defaults to the recipient's own chat; group turns name its group Session.
31
35
  // `deferred`: a request the Bot already read mid-turn, handed back to answer in a
32
- // turn of its own; its exchange was recorded when it first arrived.
33
- async function deliver({ fromId, toId, sessionId, text, role, hop = 0, replyTo, roomId, audience, note, exchangeId = randomUUID(), steer = false, deferred = false }) {
36
+ // turn of its own; its exchange was recorded when it first arrived. `resumed` and
37
+ // `retried` carry on a turn that a restart cut off or that wrote its tool call as
38
+ // text (resume.js, turns.js), and `resent` is a message a shutdown cancelled; their
39
+ // exchange is recorded already too. `askedBy` is the member whose @mention called a
40
+ // group turn (groups.js). `fromRoomId`: the group whose Session the sender wrote
41
+ // from; the reply goes back to that Session (turns.js).
42
+ async function deliver({ fromId, toId, sessionId, text, role, hop = 0, replyTo, roomId, audience, note, exchangeId = randomUUID(), steer = false, deferred = false, resumed = false, retried = false, resent = false, askedBy, fromRoomId }) {
34
43
  const from = rt.botOf(fromId)
35
44
  const to = rt.botOf(toId)
36
45
  if (to === undefined) throw new Error(`Unknown Bot ${toId}`)
@@ -49,16 +58,22 @@ export function install(rt) {
49
58
  ...(roomId ? { roomId } : {}),
50
59
  ...(audience === 'user' ? { audience } : {}),
51
60
  ...(deferred ? { deferred: true } : {}),
61
+ ...(resumed ? { resumed: true } : {}),
62
+ ...(retried ? { retried: true } : {}),
63
+ ...(askedBy ? { askedBy } : {}),
64
+ ...(fromRoomId ? { senderRoomId: fromRoomId } : {}),
52
65
  }
53
- if (EXCHANGE_ROLES.has(role) && !deferred) {
66
+ if (EXCHANGE_ROLES.has(role) && !deferred && !resumed && !retried && !resent) {
54
67
  state.exchanges.push({ id: exchangeId, from: fromId, to: toId, text, role, time: Date.now(), ...(replyTo ? { replyTo } : {}), ...(audience === 'user' ? { audience } : {}) })
55
68
  void rt.save().catch(() => {})
56
69
  }
57
- const agent = await resolveAgent(sessionId ?? rt.chatOf(toId))
70
+ const targetSessionId = sessionId ?? rt.chatOf(toId)
71
+ await rt.ensureCapabilities(targetSessionId)
72
+ const agent = await resolveAgent(targetSessionId)
58
73
  // A steered message cuts into a running turn at its next step; a follow-up
59
74
  // queues for the next turn (see turns.js for how each is routed).
60
75
  const message = createUserMessage({
61
- content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred)}\n${text}` }],
76
+ content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried })}\n${text}` }],
62
77
  source,
63
78
  })
64
79
  if (steer) agent.steer(message)
@@ -0,0 +1,135 @@
1
+ // Working folders: where a Bot's Sessions run. A group's folder holds for every member
2
+ // there, else a Bot works in its own folder, else in the team workspace. A Session's
3
+ // folder is fixed when it is made, so a change moves the conversation on to a fresh
4
+ // part (parts.js).
5
+ import { realpath, stat } from 'node:fs/promises'
6
+ import { homedir } from 'node:os'
7
+ import { basename, isAbsolute, join, resolve, sep } from 'node:path'
8
+ import { unquote } from './text.js'
9
+
10
+ const PICK_TIMEOUT_MS = 10 * 60_000
11
+
12
+ export const within = (inner, outer) => inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep)
13
+
14
+ const expandHome = text => (text === '~' ? homedir() : /^~[\\/]/.test(text) ? join(homedir(), text.slice(2)) : text)
15
+
16
+ // Read as DSH reads it, so the team's data and the guarded folder are where DSH keeps
17
+ // its own: a blank value counts as unset.
18
+ export const dshHomeOf = value => resolve(expandHome(value?.trim() ? value : join(homedir(), '.dsh')))
19
+
20
+ export function install(rt) {
21
+ const { ctx, state } = rt
22
+
23
+ const teamFolder = () => ctx.workspaceRegistry.get?.(state.workspaceId)?.path
24
+ const workDirOf = ({ botId, roomId } = {}) => rt.roomOf(roomId)?.workDir ?? rt.botOf(botId)?.workDir
25
+ const isFolder = path => stat(path).then(info => info.isDirectory(), () => false)
26
+ const real = path => realpath(path).catch(() => resolve(path))
27
+
28
+ // A Bot that could write where DSH or the team keeps its data could change its own
29
+ // permissions or the team, so neither those folders nor one that holds them is allowed.
30
+ async function guarded(path) {
31
+ const team = teamFolder()
32
+ for (const root of [rt.dshHome, rt.home]) {
33
+ const data = await real(root)
34
+ if (within(data, path)) return true
35
+ if (within(path, data) && !(team !== undefined && within(path, team))) return true
36
+ }
37
+ return false
38
+ }
39
+
40
+ // Saved as the real path, which is what a Session's cwd shows. Undefined means the
41
+ // team workspace.
42
+ async function cleanWorkDir(input) {
43
+ const text = unquote(input)
44
+ if (text === '') return undefined
45
+ const expanded = expandHome(text)
46
+ if (!isAbsolute(expanded)) throw new Error(`Give the full path of the folder, not ${text}`)
47
+ let path
48
+ try {
49
+ path = await realpath(expanded)
50
+ } catch {
51
+ throw new Error(`There is no folder at ${text}`)
52
+ }
53
+ if (!(await isFolder(path))) throw new Error(`${text} is a file, not a folder`)
54
+ if (await guarded(path)) throw new Error(`${path} holds DeepSeek Harness data, so Bots cannot work there`)
55
+ return path === teamFolder() ? undefined : path
56
+ }
57
+
58
+ // Every Session of a Bot: its own chat and its conversation in each of its groups.
59
+ const sessionsOfBot = botId => [rt.chatOf(botId), ...Object.values(state.rooms)
60
+ .filter(room => room.members.includes(botId)).map(room => room.sessions?.[botId])].filter(Boolean)
61
+
62
+ // A folder outside the team workspace widens where a Bot may write, so a Bot that
63
+ // sets one for Bots whose writes are bounded by their folder asks the user first.
64
+ // `sessions` are the Sessions that would work there; the default policy counts too,
65
+ // since the Sessions made there later start under it. Throws when it is not allowed.
66
+ async function approveFolder(exec, { path, sessions = [], who }) {
67
+ const team = teamFolder()
68
+ const policy = ctx.get?.('sandboxPolicy')
69
+ if (path === undefined || (team !== undefined && within(path, team)) || policy === undefined) return
70
+ const agents = await Promise.all(sessions.map(id => rt.resolveAgent(id)))
71
+ const modes = [policy.resolve(), ...agents.map(agent => policy.resolve({ session: agent.session }))].map(entry => entry.mode)
72
+ if (!modes.includes('workspace-write')) return
73
+ const approval = ctx.get?.('approval')
74
+ const agent = exec.agent
75
+ if (approval === undefined || agent?.session === undefined) throw new Error(`Working in ${path} needs the user's approval, and there is no way to ask for it here, so nothing changed. The user can make this change in the settings.`)
76
+ const outcome = await approval.request({
77
+ agent,
78
+ toolName: exec.name,
79
+ ...(exec.callId !== undefined ? { callId: exec.callId } : {}),
80
+ reason: `Let ${who.en} work in ${path}? Files there can then be changed.`,
81
+ displayReason: { en: `Let ${who.en} work in ${path}? Files there can then be changed.`, zh: `允许${who.zh}在 ${path} 中工作吗?之后可以修改这个文件夹里的文件。` },
82
+ ...(exec.signal ? { signal: exec.signal } : {}),
83
+ })
84
+ if (outcome === 'allowed-once') return
85
+ // An approval policy that never asks answers 'rejected' as well.
86
+ throw new Error(outcome === 'rejected' ? `Working in ${path} was not allowed, so nothing changed. The user can make this change in the settings.`
87
+ : outcome === 'cancelled' ? 'The approval was cancelled, so nothing changed.'
88
+ : `Working in ${path} needs the user's approval, and no one could answer, so nothing changed. The user can make this change in the settings.`)
89
+ }
90
+
91
+ const picker = () => {
92
+ try {
93
+ return ctx.get?.('directoryPicker')?.capability()
94
+ } catch {
95
+ return undefined
96
+ }
97
+ }
98
+
99
+ async function folderEndpoint(endpoint, body, signal) {
100
+ switch (endpoint) {
101
+ case 'folders': {
102
+ const team = teamFolder() ?? null
103
+ const known = new Map()
104
+ for (const workspace of ctx.workspaceRegistry.list?.() ?? []) {
105
+ if (workspace.path !== team) known.set(workspace.path, workspace.title)
106
+ }
107
+ for (const entry of [...Object.values(state.bots), ...Object.values(state.rooms)]) {
108
+ if (entry.workDir && !known.has(entry.workDir)) known.set(entry.workDir, basename(entry.workDir) || entry.workDir)
109
+ }
110
+ const present = await Promise.all([...known].map(async ([path, title]) => (await isFolder(path) ? { path, title } : undefined)))
111
+ const kind = picker()?.kind
112
+ return {
113
+ team,
114
+ picker: kind === 'native' || kind === 'browse' ? kind : null,
115
+ folders: present.filter(Boolean),
116
+ }
117
+ }
118
+ case 'folder-pick': {
119
+ const native = picker()
120
+ if (native?.kind !== 'native') throw new Error('This DeepSeek Harness has no folder chooser')
121
+ return await native.pick(AbortSignal.any([signal, AbortSignal.timeout(PICK_TIMEOUT_MS)].filter(Boolean)))
122
+ }
123
+ case 'folder-list': {
124
+ const browse = picker()
125
+ if (browse?.kind !== 'browse') throw new Error('This DeepSeek Harness cannot list folders')
126
+ const listing = await browse.list(typeof body.path === 'string' && body.path !== '' ? body.path : undefined, signal)
127
+ return { path: listing.path, home: listing.home, crumbs: listing.crumbs, entries: listing.entries, truncated: listing.truncated }
128
+ }
129
+ default:
130
+ throw new Error(`Unknown endpoint ${endpoint}`)
131
+ }
132
+ }
133
+
134
+ Object.assign(rt, { teamFolder, workDirOf, isFolder, cleanWorkDir, sessionsOfBot, approveFolder, folderEndpoint })
135
+ }