ds-bot 0.1.1 → 0.1.3

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/src/host/store.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // host on the same data from writing it, and the indexes that map a Session to the Bot
3
3
  // or group chat it belongs to.
4
4
  import { mkdirSync, statSync } from 'node:fs'
5
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
5
+ import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
6
6
  import { join } from 'node:path'
7
7
  import { LEGACY_THEMES, MAX_EXCHANGES, ROOM_MODES } from './constants.js'
8
8
  import { homeLock } from './home-lock.js'
@@ -22,7 +22,9 @@ export function install(rt) {
22
22
  // undefined while this host may write.
23
23
  let holder
24
24
  let lockBroken = false
25
+ let stuck
25
26
  let warnedReadOnly = false
27
+ let stopped = false
26
28
  // mtime and size of the state.json this host last read or wrote.
27
29
  let seen = ''
28
30
  const stamp = () => {
@@ -40,27 +42,56 @@ export function install(rt) {
40
42
  mkdirSync(home, { recursive: true })
41
43
  holder = lock.claim()
42
44
  } catch (error) {
43
- // An unusable lock (a read-only or odd file system) must not stop the team.
45
+ // An unusable lock (a read-only or odd file system) must not stop the team; a
46
+ // save that then cannot write still rejects.
44
47
  lockBroken = true
45
48
  holder = undefined
46
49
  warn('ds-bot: cannot lock %s, so another dsh on the same data could undo changes: %s', home, error)
47
50
  }
48
51
  }
49
52
  const readOnly = () => {
53
+ if (stuck !== undefined) return `The Bot team file ${statePath} could not be read (${stuck}). DS Bot does not change it; fix or move the file and reload.`
50
54
  if (holder === undefined) return undefined
51
55
  const where = holder.pid ? `pid ${holder.pid}${holder.host ? ` on ${holder.host}` : ''}` : 'unknown process'
52
56
  return `The Bot team in ${home} is open in another dsh (Web or Desktop, ${where}). This window shows the team but cannot change it; close the other one and reload.`
53
57
  }
54
58
 
55
59
  const readFileState = async () => {
60
+ let contents
56
61
  try {
62
+ contents = await readFile(statePath, 'utf8')
63
+ } catch (error) {
57
64
  seen = stamp()
58
- const parsed = JSON.parse(await readFile(statePath, 'utf8'))
59
- return parsed?.version === 1 ? parsed : undefined
65
+ if (error?.code === 'ENOENT') {
66
+ stuck = undefined
67
+ return undefined
68
+ }
69
+ stuck = error?.code ?? error?.message ?? String(error)
70
+ warn('ds-bot: unreadable state %s: %s', statePath, error)
71
+ return undefined
72
+ }
73
+ seen = stamp()
74
+ let parsed
75
+ try {
76
+ parsed = JSON.parse(contents)
60
77
  } catch (error) {
61
- if (error?.code !== 'ENOENT') warn('ds-bot: unreadable state %s: %s', statePath, error)
78
+ const backup = `${statePath}.broken-${Date.now()}`
79
+ try {
80
+ await copyFile(statePath, backup)
81
+ stuck = undefined
82
+ warn('ds-bot: unreadable state %s: %s; backed up as %s', statePath, error, backup)
83
+ } catch (copyError) {
84
+ stuck = copyError?.code ?? copyError?.message ?? error?.message ?? String(error)
85
+ warn('ds-bot: unreadable state %s: %s; backup failed: %s', statePath, error, copyError)
86
+ }
62
87
  return undefined
63
88
  }
89
+ if (parsed?.version === 1) {
90
+ stuck = undefined
91
+ return parsed
92
+ }
93
+ stuck = 'saved by a newer DS Bot; update DS Bot'
94
+ return undefined
64
95
  }
65
96
 
66
97
  // Fill `state` from the saved file and bring an older team up to date. Returns the
@@ -129,7 +160,15 @@ export function install(rt) {
129
160
  if (holder !== undefined) warn('ds-bot: %s', readOnly())
130
161
  const { migrated, stale } = adopt(await readFileState())
131
162
  if (holder !== undefined) return
132
- if (migrated) await save()
163
+ if (migrated && stuck === undefined) {
164
+ // A failed write must not poison `loaded`: the migrated state stays in
165
+ // memory and the next save() writes it.
166
+ try {
167
+ await save()
168
+ } catch (error) {
169
+ warn('ds-bot: saving the migrated Bot team failed: %s', error)
170
+ }
171
+ }
133
172
  // Not awaited: renaming a session must not hold up the first roster read.
134
173
  for (const room of stale) void rt.retitleRoom(room)
135
174
  })()
@@ -137,18 +176,42 @@ export function install(rt) {
137
176
  // A reading host follows the file and takes over once the other host has gone.
138
177
  const refresh = async () => {
139
178
  await load()
140
- if (holder === undefined) return
141
- claimLock()
142
- if (holder === undefined) {
143
- warn('ds-bot: the other dsh has closed; this one now saves the Bot team')
144
- warnedReadOnly = false
145
- adopt(await readFileState())
146
- return
179
+ const hadHolder = holder !== undefined
180
+ if (hadHolder) {
181
+ claimLock()
182
+ if (holder === undefined) {
183
+ warn('ds-bot: the other dsh has closed; this one now saves the Bot team')
184
+ warnedReadOnly = false
185
+ adopt(await readFileState())
186
+ return
187
+ }
188
+ } else if (!lockBroken && !lock.owns()) {
189
+ // A cached "writer" is stale once the lock file no longer names this host:
190
+ // another dsh may have taken it over, or the file may just be gone. Re-claim
191
+ // and re-check instead of trusting the flag.
192
+ claimLock()
193
+ if (lockBroken) return
194
+ if (holder === undefined) {
195
+ // The lock was free: still the writer, but the file may carry changes
196
+ // written while no host held it.
197
+ warnedReadOnly = false
198
+ adopt(await readFileState())
199
+ return
200
+ }
201
+ warn('ds-bot: %s', readOnly())
147
202
  }
148
- if (stamp() !== seen) adopt(await readFileState())
203
+ if ((stuck !== undefined || holder !== undefined) && stamp() !== seen) adopt(await readFileState())
149
204
  }
150
205
 
206
+ const warnReadOnly = () => {
207
+ if (!warnedReadOnly) {
208
+ warnedReadOnly = true
209
+ warn('ds-bot: not saving the Bot team: %s', readOnly())
210
+ }
211
+ return false
212
+ }
151
213
  const writable = () => {
214
+ if (stuck !== undefined) return warnReadOnly()
152
215
  if (lockBroken) return true
153
216
  if (holder === undefined && lock.owns()) return true
154
217
  // Lost the lock: retake it when nobody else holds it (its file was deleted), else
@@ -157,31 +220,50 @@ export function install(rt) {
157
220
  claimLock()
158
221
  if (holder === undefined) return true
159
222
  }
160
- if (!warnedReadOnly) {
161
- warnedReadOnly = true
162
- warn('ds-bot: not saving the Bot team: %s', readOnly())
163
- }
164
- return false
223
+ return warnReadOnly()
165
224
  }
166
225
 
167
226
  const save = () => {
227
+ if (stopped) return writing
168
228
  indexSessions()
169
229
  // A reading host keeps the file's revision, so its window does not skip the next change.
170
230
  if (!writable()) return writing
171
231
  state.revision += 1
172
232
  state.exchanges = state.exchanges.slice(-MAX_EXCHANGES)
173
233
  const snapshot = JSON.stringify(state, null, 2)
174
- writing = writing.then(async () => {
175
- await mkdir(home, { recursive: true })
234
+ const run = writing.then(async () => {
176
235
  const temp = `${statePath}.${process.pid}.tmp`
177
- await writeFile(temp, snapshot, 'utf8')
178
- await rename(temp, statePath)
179
- seen = stamp()
180
- }).catch(error => { warn('ds-bot: state write failed: %s', error) })
181
- return writing
236
+ try {
237
+ await mkdir(home, { recursive: true })
238
+ const ensureLock = () => {
239
+ if (lockBroken) return
240
+ if (!lock.owns()) {
241
+ const error = new Error('ds-bot lost the state lock to another dsh')
242
+ error.code = 'ELOCKLOST'
243
+ throw error
244
+ }
245
+ }
246
+ ensureLock()
247
+ await writeFile(temp, snapshot, 'utf8')
248
+ ensureLock()
249
+ await rename(temp, statePath)
250
+ seen = stamp()
251
+ } catch (error) {
252
+ await unlink(temp).catch(() => {})
253
+ throw error
254
+ }
255
+ })
256
+ writing = run.catch(() => {})
257
+ return run.catch(error => {
258
+ warn('ds-bot: state write failed: %s', error)
259
+ throw error
260
+ })
182
261
  }
183
262
 
184
- ctx.effect(() => () => lock.release(), 'ds-bot: state lock')
263
+ ctx.effect(() => () => {
264
+ stopped = true
265
+ return writing.finally(() => lock.release())
266
+ }, 'ds-bot: state lock')
185
267
 
186
268
  const botOf = id => (id === undefined ? undefined : state.bots[id])
187
269
  const roomOf = id => (id === undefined ? undefined : state.rooms[id])
@@ -233,7 +315,10 @@ export function install(rt) {
233
315
  delete room.sessions[botId]
234
316
  if (room.segments !== undefined) delete room.segments[botId]
235
317
  groupOwners.delete(sessionId)
236
- void ctx.workspaceRegistry.archiveSession(sessionId).catch(() => {})
318
+ rt.cancelApprovals?.(sessionId)
319
+ // A plain archive refuses a running Session, which would go on working for a group
320
+ // that no longer has it.
321
+ void ctx.workspaceRegistry.archiveSession(sessionId, { stopActivity: true }).catch(() => {})
237
322
  }
238
323
  const findBot = (ref) => {
239
324
  if (ref === undefined) return undefined
@@ -0,0 +1,46 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
2
+
3
+ const SOURCE = 'ds-bot-time-context'
4
+ const isClock = source => source?.kind === SOURCE || source?.kind === 'time-context'
5
+
6
+ export function install(rt) {
7
+ const { ctx, config } = rt
8
+ const interval = config.timeRefreshIntervalMs ?? 600_000
9
+ if (!Number.isSafeInteger(interval) || interval < 0) throw new TypeError('timeRefreshIntervalMs must be a non-negative safe integer')
10
+ const formatter = new Intl.DateTimeFormat('en-CA', {
11
+ ...(config.timeZone ? { timeZone: config.timeZone } : {}),
12
+ year: 'numeric', month: '2-digit', day: '2-digit',
13
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
14
+ timeZoneName: 'longOffset',
15
+ })
16
+ const zone = formatter.resolvedOptions().timeZone
17
+
18
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
19
+ const decision = await next()
20
+ if (decision.kind === 'reject' || signal.aborted) return decision
21
+ await rt.load()
22
+ if (!rt.isBotSession(agent.id) || signal.aborted) return decision
23
+ // A loaded DSH clock covers every step itself. Its listener may run before or
24
+ // after this one (a reload reorders them), so its presence is read from its
25
+ // projection, not from the messages added so far.
26
+ if (ctx.get?.('sessionProjections')?.stateOf(agent.session, 'timeContext') !== undefined) return decision
27
+ if (decision.messages.some(message => isClock(message.source))) return decision
28
+ const now = Date.now()
29
+ for await (const event of rt.eventsBackwards(agent.id, signal, Infinity)) {
30
+ if (event.type === 'user/message' && isClock(event.data.source)) {
31
+ if (now >= event.time && now - event.time < interval) return decision
32
+ break
33
+ }
34
+ if (event.time <= now - interval) break
35
+ }
36
+ if (signal.aborted) return decision
37
+ const text = `Current time: ${formatter.format(now)} (${zone}).`
38
+ return {
39
+ ...decision,
40
+ messages: [...decision.messages, createUserMessage({
41
+ content: [{ type: 'text', text }],
42
+ source: { kind: SOURCE, form: 'snapshot', sections: [{ name: SOURCE, text }] },
43
+ })],
44
+ }
45
+ }, { prepend: true })
46
+ }
package/src/host/tools.js CHANGED
@@ -1,9 +1,8 @@
1
1
  // Team tools: the roster, bot-to-bot messages, group chat settings and posts, and the
2
2
  // question card. Group rounds and reading chats are in groups.js.
3
3
  import { randomUUID } from 'node:crypto'
4
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
5
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
6
- import { COLORS, EXTRA_COLORS, MAX_HOPS, ROOM_BOT_ROUNDS, ROOM_MODES, SOURCE_KIND } from './constants.js'
5
+ import { COLORS, EXTRA_COLORS, MAX_HOPS, ROOM_BOT_ROUNDS, ROOM_MODES } from './constants.js'
7
6
  import { unquote } from './text.js'
8
7
 
9
8
  export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }
@@ -58,9 +57,21 @@ export function install(rt) {
58
57
  },
59
58
  }))
60
59
 
60
+ // Sessions a Bot is running in right now: its own chat plus each group Session
61
+ // with a live turn. An idle Bot reports just its own chat.
62
+ const activeSessions = (botId) => {
63
+ const running = [...new Set([rt.chatOf(botId), ...rt.groupSessionsOf(botId)])]
64
+ .filter(sessionId => rt.live.get(sessionId)?.turn !== undefined)
65
+ return { running, idle: running.length === 0 }
66
+ }
67
+ const whereOf = (sessionId) => {
68
+ const room = rt.roomOf(rt.groupOwners.get(sessionId)?.roomId)
69
+ return room ? `group "${room.name}"` : 'its own chat'
70
+ }
71
+
61
72
  ctx.tools.register(defineTool({
62
73
  name: 'message_bot',
63
- description: 'Send an asynchronous message to another Bot on the team. By default its reply arrives later as a new message to you; with reply_to "user" it answers the user in its own chat instead.',
74
+ description: 'Send a message to another Bot on the team. If that Bot is working, it reads your message at its next step and can change course, then answers once that work is done; if it is idle, it starts on it right away. Its reply arrives later as a new message to you; with reply_to "user" it answers the user in its own chat instead.',
64
75
  parameters: {
65
76
  to: { type: 'string', required: true, description: 'Exact Bot name from list_bots' },
66
77
  message: { type: 'string', required: true, description: 'Self-contained message; the other Bot cannot see this conversation' },
@@ -69,6 +80,11 @@ export function install(rt) {
69
80
  enum: ['me', 'user'],
70
81
  description: '"me" (default): the reply comes back to you and the user does not see it. "user": the Bot answers the user in its own chat, where the user reads it, and nothing comes back to you. Use "user" whenever the user wants to hear from that Bot directly or asks Bots to say something in their own chats.',
71
82
  },
83
+ when: {
84
+ type: 'string',
85
+ enum: ['now', 'queue'],
86
+ description: '"now" (default): a working Bot reads your message at its next step, between its current actions, and answers once its current work is done; use it for questions, information it needs, corrections and warnings. "queue": it reads your message only after its current work is done, as a separate task; use it only for a new task unrelated to what it is doing now. With reply_to "user" the message always queues.',
87
+ },
72
88
  },
73
89
  output,
74
90
  async execute(args, exec) {
@@ -78,13 +94,56 @@ export function install(rt) {
78
94
  const target = rt.findBot(args.to)
79
95
  if (target === undefined) return `No Bot named ${args.to}. Team: ${Object.values(state.bots).map(bot => bot.name).join(', ')}`
80
96
  if (target.id === sender.id) return 'You cannot message yourself.'
81
- const hop = (rt.live.get(exec.agent?.id)?.info?.hop ?? 0)
97
+ const turn = rt.live.get(exec.agent?.id)
98
+ const hop = Math.max(turn?.info?.hop ?? 0, ...(turn?.joined ?? []).map(joined => joined.info?.hop ?? 0))
82
99
  if (hop >= MAX_HOPS) return 'This chain of Bot messages is already long. Stop here and report to the user instead.'
83
100
  const toUser = args.reply_to === 'user'
84
- await rt.deliver({ fromId: sender.id, toId: target.id, text: args.message, role: 'request', hop, ...(toUser ? { audience: 'user' } : {}) })
85
- return toUser
101
+ const working = rt.live.get(rt.chatOf(target.id))?.turn !== undefined
102
+ 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' } : {}) })
104
+ const sent = toUser
86
105
  ? `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.`
87
- : `Delivered to ${target.name}. Its reply will arrive later as a new message; do not wait for it.`
106
+ : working && steer
107
+ ? `Delivered to ${target.name} while it is working: it reads your message at its next step and answers once that work is done. Its reply will arrive later as a new message; do not wait for it.`
108
+ : working
109
+ ? `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
+ : `Delivered to ${target.name}. Its reply will arrive later as a new message; do not wait for it.`
111
+ // 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
114
+ 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}`
116
+ },
117
+ }))
118
+
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
+ ctx.tools.register(defineTool({
124
+ name: 'stop_bot',
125
+ 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.',
126
+ parameters: {
127
+ bot: { type: 'string', required: true, description: 'Exact Bot name from list_bots' },
128
+ reason: { type: 'string', description: 'Why it was stopped; it reads this afterwards' },
129
+ },
130
+ output,
131
+ async execute(args, exec) {
132
+ await rt.load()
133
+ const sender = callerBot(exec)
134
+ if (sender === undefined || !rt.isMain(sender.id)) refuse('Only a Main Bot can stop other Bots.')
135
+ const target = rt.findBot(args.bot)
136
+ if (target === undefined) refuse(`No Bot named ${args.bot}. Team: ${Object.values(state.bots).map(bot => bot.name).join(', ')}`)
137
+ if (target.id === sender.id) refuse('You cannot stop yourself.')
138
+ const { running } = activeSessions(target.id)
139
+ if (running.length === 0) refuse(`${target.name} is not running right now, so there is nothing to stop. Use message_bot to reach it.`)
140
+ for (const sessionId of running) {
141
+ const agent = await rt.resolveAgent(sessionId)
142
+ agent.cancel({ kind: 'hook', reason: 'ds-bot/stopped' })
143
+ }
144
+ 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' })
146
+ return `Stopped ${target.name} in ${running.map(whereOf).join(' and ')}. It is told why in a new turn, whose reply comes back to you.`
88
147
  },
89
148
  }))
90
149
 
@@ -113,9 +172,9 @@ export function install(rt) {
113
172
 
114
173
  ctx.tools.register(defineTool({
115
174
  name: 'update_bot',
116
- description: 'Main Bot only. Rename a Bot or change its role, color, model, or instructions.',
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.',
117
176
  parameters: {
118
- bot: { type: 'string', required: true, description: 'Current Bot name' },
177
+ bot: { type: 'string', description: 'Current name of the Bot to change; leave out to change yourself' },
119
178
  name: { type: 'string', description: 'New name' },
120
179
  role: { type: 'string', description: 'New role label' },
121
180
  instructions: { type: 'string', description: 'Replacement instructions' },
@@ -125,9 +184,12 @@ export function install(rt) {
125
184
  output,
126
185
  async execute(args, exec) {
127
186
  await rt.load()
128
- if (!rt.isMain(rt.selfOf(exec.agent?.id))) return 'Only the Main Bot can update Bots.'
129
- const bot = rt.findBot(args.bot)
187
+ const self = rt.selfOf(exec.agent?.id)
188
+ const caller = rt.botOf(self)
189
+ if (caller === undefined) return 'Only Bots on the team can use update_bot.'
190
+ const bot = args.bot ? rt.findBot(args.bot) : caller
130
191
  if (bot === undefined) return `No Bot named ${args.bot}.`
192
+ 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.'
131
193
  try {
132
194
  const { name: newName, role, instructions, color, model } = args
133
195
  const next = await rt.updateBot(bot.id, { name: newName, role, instructions, color, ...(model ? { model } : {}) })
@@ -294,22 +356,7 @@ export function install(rt) {
294
356
  await rt.save()
295
357
  const reportBack = args.report_back === true
296
358
  try {
297
- const agent = await rt.resolveAgent(room.id)
298
- agent.followup(createUserMessage({
299
- content: [{ type: 'text', text: `[Group post from ${sender.name}]\n${text}` }],
300
- source: {
301
- kind: SOURCE_KIND,
302
- form: 'relay',
303
- role: 'post',
304
- exchangeId: randomUUID(),
305
- senderName: sender.name,
306
- senderSessionId: sender.id,
307
- roomId: room.id,
308
- hop: info?.hop ?? 0,
309
- ...(reportBack ? { reportBack: true } : {}),
310
- },
311
- }))
312
- await ctx.sessions.flush(agent.session)
359
+ await rt.postToRoom(room, sender, text, { hop: info?.hop ?? 0, ...(reportBack ? { reportBack: true } : {}) })
313
360
  } catch (failure) {
314
361
  return `Could not post in "${room.name}": ${failure.message}`
315
362
  }
@@ -354,9 +401,13 @@ export function install(rt) {
354
401
  .filter(option => option.label !== '')
355
402
  .slice(0, 6)
356
403
  if (options.length === 0) return 'Give at least one option.'
404
+ // A hidden Bot-to-Bot turn shows none of its calls, so its card goes to the
405
+ // composer dock, which takes questions without a call.
406
+ const turn = rt.live.get(id)?.turn
407
+ const hidden = turn !== undefined && (state.exchangeTurns[id] ?? []).includes(turn)
357
408
  state.questions[id] = {
358
409
  id: randomUUID(),
359
- ...(exec.callId ? { callId: exec.callId } : {}),
410
+ ...(exec.callId && !hidden ? { callId: exec.callId } : {}),
360
411
  question: String(args.question ?? '').trim(),
361
412
  ...(args.detail ? { detail: String(args.detail).trim() } : {}),
362
413
  options,