ds-bot 0.1.4 → 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.
package/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // DS Bot: a team of long-lived Bots on top of ordinary dsh root Sessions.
2
2
  // Every Bot is one root Session; the roster, rooms, and the bot-to-bot exchange log live
3
3
  // in one JSON file under DSH_HOME so they survive restarts without new Session event types.
4
- import { homedir } from 'node:os'
5
4
  import { join } from 'node:path'
6
5
  import * as diagnostics from './src/host/diagnostics.js'
7
6
  import * as store from './src/host/store.js'
@@ -10,6 +9,7 @@ import * as permissions from './src/host/permissions.js'
10
9
  import * as turns from './src/host/turns.js'
11
10
  import * as delivery from './src/host/delivery.js'
12
11
  import * as roster from './src/host/roster.js'
12
+ import * as folders from './src/host/folders.js'
13
13
  import * as agents from './src/host/agents.js'
14
14
  import * as prompt from './src/host/prompt.js'
15
15
  import * as timeContext from './src/host/time-context.js'
@@ -40,17 +40,19 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses
40
40
  // the functions other modules call; they call them through `rt` at run time, never during
41
41
  // install. Listeners run and tools are listed in registration order, so this order holds:
42
42
  // usage meters model requests after every listener that rewrites them.
43
- const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel]
43
+ const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel]
44
44
 
45
45
  export function apply(ctx, config = {}) {
46
46
  createRuntime(ctx, config)
47
47
  }
48
48
 
49
49
  export function createRuntime(ctx, config = {}) {
50
- const home = config.home ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'bot')
50
+ const dshHome = folders.dshHomeOf(process.env.DSH_HOME)
51
+ const home = config.home ?? join(dshHome, 'bot')
51
52
  const rt = {
52
53
  ctx,
53
54
  config,
55
+ dshHome,
54
56
  home,
55
57
  statePath: join(home, 'state.json'),
56
58
  defaults: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ds-bot",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "DS Bot: a team of long-lived Bots for DeepSeek Harness, with Main Bots, Bot-to-Bot messages and group chats",
5
5
  "keywords": [
6
6
  "dsh-plugin",
@@ -100,7 +100,7 @@ export function install(rt) {
100
100
  return rt.save()
101
101
  }
102
102
 
103
- const handle = async (endpoint, payload) => {
103
+ const handle = async (endpoint, payload, signal) => {
104
104
  try {
105
105
  const body = payload ?? {}
106
106
  await rt.refresh()
@@ -141,7 +141,7 @@ export function install(rt) {
141
141
  return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === body.roomId).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null })))
142
142
  case 'create-bot': {
143
143
  await rt.bootstrap()
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 })
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 })
145
145
  return ok(bot)
146
146
  }
147
147
  case 'update-bot':
@@ -174,9 +174,15 @@ export function install(rt) {
174
174
  members: Array.isArray(body.members) ? body.members : undefined,
175
175
  add: Array.isArray(body.add) ? body.add : undefined,
176
176
  remove: Array.isArray(body.remove) ? body.remove : undefined,
177
+ workDir: body.workDir,
177
178
  })
178
179
  return ok({ room: view().rooms.find(entry => entry.id === room.id), changes })
179
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))
180
186
  case 'add-agent':
181
187
  await rt.load()
182
188
  return ok(await rt.addAgent())
@@ -332,7 +338,7 @@ export function install(rt) {
332
338
  fetch: async (request) => {
333
339
  let body
334
340
  try { body = await request.json() } catch { body = {} }
335
- const result = await handle(String(body?.endpoint ?? ''), body?.payload)
341
+ const result = await handle(String(body?.endpoint ?? ''), body?.payload, request.signal)
336
342
  return new Response(JSON.stringify(result), { status: 200, headers: { 'content-type': 'application/json' } })
337
343
  },
338
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'
@@ -9,6 +9,7 @@ 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
 
@@ -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
+ }
@@ -124,7 +124,7 @@ export function install(rt) {
124
124
  await rt.ensureCapabilities(known)
125
125
  return { id: known, brief: undefined }
126
126
  }
127
- const id = await rt.createSession(`${member.name} · ${room.name}`)
127
+ const id = await rt.createSession(`${member.name} · ${room.name}`, { dir: rt.workDirOf({ botId: member.id, roomId: room.id }) })
128
128
  room.sessions[member.id] = id
129
129
  rt.groupOwners.set(id, { botId: member.id, roomId: room.id })
130
130
  delete room.seen[member.id]
@@ -651,9 +651,10 @@ export function install(rt) {
651
651
 
652
652
  // A group chat's own Session never consults the model: models drift from "only call
653
653
  // group_relay" (they message one member directly when the user names it), so the
654
- // relay step is synthesized here. Auxiliary calls such as titles still reach the model.
654
+ // relay step is synthesized here. Auxiliary calls such as titles, and DS Bot's own
655
+ // calls for the room such as image readings, still reach the model.
655
656
  ctx.on('llm/stream', (options, next) => {
656
- if (options.sessionId === undefined || options.purpose !== undefined) return next()
657
+ if (options.sessionId === undefined || options.purpose !== undefined || rt.ownRequest(options)) return next()
657
658
  return (async function* relayOrPass() {
658
659
  await rt.load()
659
660
  if (rt.roomOf(options.sessionId) === undefined) {
@@ -91,10 +91,10 @@ export function install(rt) {
91
91
  const route = await ocrRoute()
92
92
  if (route === undefined) throw new Error('no vision model is available')
93
93
  const assembler = new BlockAssembler()
94
- // No sessionId: this call is not a Session request, so the hook below lets it through.
95
94
  for await (const chunk of ctx.llm.stream({
96
95
  provider: route.provider,
97
96
  model: route.model,
97
+ sessionId,
98
98
  messages: rt.tagUsage([{ role: 'user', content: [{ type: 'image', attachment }, { type: 'text', text: OCR_PROMPT }] }], { sessionId, kind: 'image' }),
99
99
  maxTokens: 8192,
100
100
  signal: AbortSignal.timeout(OCR_TIMEOUT_MS),
@@ -176,7 +176,7 @@ export function install(rt) {
176
176
  }
177
177
 
178
178
  ctx.on('llm/stream', (options, next) => {
179
- if (options.sessionId === undefined || !(options.messages ?? []).some(liveImages)) return next()
179
+ if (options.sessionId === undefined || rt.ownRequest(options) || !(options.messages ?? []).some(liveImages)) return next()
180
180
  return (async function* imagesAsTextOrPass() {
181
181
  await rt.load()
182
182
  if (rt.roomOf(options.sessionId) !== undefined || await readsImages(options.provider, options.model)) {
@@ -919,6 +919,7 @@ export function install(rt) {
919
919
  for await (const chunk of ctx.llm.stream({
920
920
  provider: route.provider,
921
921
  model: route.model,
922
+ ...(tag?.sessionId === undefined ? {} : { sessionId: tag.sessionId }),
922
923
  ...(effort === undefined ? {} : { reasoningEffort: effort }),
923
924
  system,
924
925
  messages,
@@ -936,6 +937,25 @@ export function install(rt) {
936
937
  throw new Error(`The model's reasoning used up the output budget (${SUMMARY_MAX_TOKENS} and ${SUMMARY_RETRY_MAX_TOKENS} tokens); no complete answer was written`)
937
938
  }
938
939
 
940
+ // A served model can still refuse the call (a key without access, say), and
941
+ // desiredModel only falls back for models that are not served; so a failed call
942
+ // is tried once more on the profile default.
943
+ async function botShot(bot, system, text, tag) {
944
+ const own = await modelFor(bot)
945
+ try {
946
+ return await oneShot(own, system, text, tag)
947
+ } catch (error) {
948
+ const fallback = splitRef(await rt.defaultModel())
949
+ if (fallback === undefined || (fallback.provider === own.provider && fallback.model === own.model)) throw error
950
+ try {
951
+ return await oneShot(fallback, system, text, tag)
952
+ } catch (again) {
953
+ const said = failure => failure?.message ?? String(failure)
954
+ throw new Error(`${own.provider}/${own.model}: ${said(error)}; ${fallback.provider}/${fallback.model}: ${said(again)}`)
955
+ }
956
+ }
957
+ }
958
+
939
959
  /** @type {Map<string, {hash:string, text:string, at:number}>} */
940
960
  const summaries = new Map()
941
961
  const summarizing = new Map()
@@ -955,21 +975,23 @@ export function install(rt) {
955
975
  const linesFor = files => allEntries(files).map(entry => `- ${entry.file.topic ? `[[${entry.file.topic}]] ` : ''}${entry.text}`)
956
976
 
957
977
  function startSummary(bot, own, team, language, hash) {
978
+ const model = bot.model
958
979
  const job = (async () => {
959
- const text = await oneShot(await modelFor(bot), SUMMARY_SYSTEM, summaryRequest({ name: bot.name, own: linesFor(own), team: linesFor(team), language }), { sessionId: rt.chatOf(bot.id), kind: 'memory' })
980
+ const text = await botShot(bot, SUMMARY_SYSTEM, summaryRequest({ name: bot.name, own: linesFor(own), team: linesFor(team), language }), { sessionId: rt.chatOf(bot.id), kind: 'memory' })
960
981
  const value = { hash, text: text.slice(0, SUMMARY_TEXT_MAX), at: Date.now() }
961
982
  summaries.set(bot.id, value)
962
983
  summaryFailures.delete(bot.id)
963
984
  if (rt.readOnly() === undefined) await writeText(summaryPath(bot.id), JSON.stringify(value, null, 2))
964
985
  })().catch((error) => {
965
- summaryFailures.set(bot.id, { hash, message: error?.message ?? String(error) })
986
+ summaryFailures.set(bot.id, { hash, model, message: error?.message ?? String(error) })
966
987
  rt.warn('ds-bot: memory summary for %s failed: %s', bot.name, error)
967
988
  }).finally(() => summarizing.delete(bot.id))
968
989
  summarizing.set(bot.id, job)
969
990
  }
970
991
 
971
992
  // The summary is written again when the entries or the interface language change;
972
- // a failed one waits for `regenerate` instead of retrying on every poll.
993
+ // a failed one waits for `regenerate`, or a new model for the Bot, instead of
994
+ // retrying on every poll.
973
995
  function memoryView(body) {
974
996
  const bot = rt.botOf(String(body.botId ?? ''))
975
997
  if (bot === undefined) throw new Error('Unknown Bot')
@@ -979,8 +1001,9 @@ export function install(rt) {
979
1001
  const language = languageNow()
980
1002
  const hash = createHash('sha256').update(JSON.stringify([language, linesFor(own), linesFor(team)])).digest('hex')
981
1003
  const saved = savedSummary(bot.id)
982
- const failed = summaryFailures.get(bot.id)
983
- const wanted = body.regenerate === true || (body.summarize === true && saved?.hash !== hash && failed?.hash !== hash)
1004
+ const prior = summaryFailures.get(bot.id)
1005
+ const failed = prior?.hash === hash && prior.model === bot.model ? prior : undefined
1006
+ const wanted = body.regenerate === true || (body.summarize === true && saved?.hash !== hash && failed === undefined)
984
1007
  if (wanted && entries.length > 0 && !summarizing.has(bot.id)) startSummary(bot, own, team, language, hash)
985
1008
  return {
986
1009
  botId: bot.id,
@@ -990,7 +1013,7 @@ export function install(rt) {
990
1013
  summary: entries.length > 0 && saved !== undefined ? { text: saved.text, at: saved.at } : null,
991
1014
  fresh: entries.length === 0 || saved?.hash === hash,
992
1015
  summarizing: summarizing.has(bot.id),
993
- error: failed?.hash === hash && !summarizing.has(bot.id) ? failed.message : null,
1016
+ error: failed !== undefined && !summarizing.has(bot.id) ? failed.message : null,
994
1017
  }
995
1018
  }
996
1019
 
@@ -1003,7 +1026,7 @@ export function install(rt) {
1003
1026
  ...allEntries(loadScope(ownScope(bot.id))).map(entry => ({ ...entry, scope: 'own' })),
1004
1027
  ...allEntries(loadScope(TEAM)).map(entry => ({ ...entry, scope: 'team' })),
1005
1028
  ]
1006
- const answer = parseAsk(await oneShot(await modelFor(bot), ASK_SYSTEM, askRequest({ name: bot.name, numbered, language: languageNow(), text }), { sessionId: rt.chatOf(bot.id), kind: 'memory' }))
1029
+ const answer = parseAsk(await botShot(bot, ASK_SYSTEM, askRequest({ name: bot.name, numbered, language: languageNow(), text }), { sessionId: rt.chatOf(bot.id), kind: 'memory' }))
1007
1030
  const meta = { source: 'memory panel', by: 'user', added: today() }
1008
1031
  const changes = []
1009
1032
  for (const change of answer.changes.slice(0, ASK_CHANGES_MAX)) {
@@ -1236,7 +1259,7 @@ export function install(rt) {
1236
1259
  const sizes = { own: ownFiles[0].content.length, team: teamFiles[0].content.length }
1237
1260
  const earlier = batch.earlier.map(line => reviewLine(line.who, line.text, bot))
1238
1261
  const language = writtenIn(read.filter(line => line.who === 'user').map(line => line.text.replace(/^User: /, '')), languageNow())
1239
- const reply = await oneShot(await modelFor(bot), reviewSystem(writesTeam(bot.id)), reviewRequest({ bot, numbered, left, sizes, where: batch.where, earlier, fresh: fresh.texts, more, language }), { sessionId: rt.chatOf(bot.id), kind: 'memory' })
1262
+ const reply = await botShot(bot, reviewSystem(writesTeam(bot.id)), reviewRequest({ bot, numbered, left, sizes, where: batch.where, earlier, fresh: fresh.texts, more, language }), { sessionId: rt.chatOf(bot.id), kind: 'memory' })
1240
1263
  const changes = parseReview(reply)
1241
1264
  if (changes === undefined) throw new Error(`the model's answer is not a list of changes: ${clip(reply.replace(/\s+/g, ' '), 120)}`)
1242
1265
  const unknown = changes.find(change => change.op !== 'add' && numbered[Number(change.entry) - 1] === undefined)