ds-bot 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,374 @@
1
+ // Team tools: the roster, bot-to-bot messages, group chat settings and posts, and the
2
+ // question card. Group rounds and reading chats are in groups.js.
3
+ import { randomUUID } from 'node:crypto'
4
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
5
+ import { defineTool } from '@deepseek-ai/dsh-tools'
6
+ import { COLORS, EXTRA_COLORS, MAX_HOPS, ROOM_BOT_ROUNDS, ROOM_MODES, SOURCE_KIND } from './constants.js'
7
+ import { unquote } from './text.js'
8
+
9
+ export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }
10
+ export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' }
11
+ const MODE_PARAM = {
12
+ type: 'string',
13
+ enum: ROOM_MODES,
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.',
15
+ }
16
+
17
+ export function install(rt) {
18
+ const { ctx, state } = rt
19
+
20
+ const callerBot = (exec) => rt.botOf(rt.selfOf(exec.agent?.id))
21
+
22
+ const roomLine = (room, self) => {
23
+ const members = room.members.map(rt.botOf).filter(Boolean)
24
+ .map(bot => `${bot.name}${bot.id === room.admin ? ' (admin)' : ''}${bot.id === self ? ' (you)' : ''}`)
25
+ const parts = [
26
+ `members: ${members.join(', ')}`,
27
+ room.mode === 'admin' && room.admin ? 'plain messages go to the admin first' : 'every member replies in turn',
28
+ room.notice ? `notice: ${room.notice.split('\n')[0].slice(0, 100)}` : '',
29
+ ].filter(Boolean)
30
+ return `- "${room.name}" — ${parts.join('; ')}`
31
+ }
32
+ // Main Bots see every group; other Bots see the groups they are in.
33
+ const visibleRooms = self => Object.values(state.rooms).filter(room => rt.isMain(self) || room.members.includes(self))
34
+
35
+ ctx.tools.register(defineTool({
36
+ name: 'list_bots',
37
+ description: 'List every Bot on the user\'s team with its role and what it does, and the group chats you can see.',
38
+ parameters: {},
39
+ output,
40
+ async execute(_args, exec) {
41
+ await rt.load()
42
+ const self = rt.selfOf(exec.agent?.id)
43
+ const lines = Object.values(state.bots).map(bot => {
44
+ const model = bot.appliedModel ?? bot.model
45
+ const tags = [bot.role, model ? `model ${model}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ')
46
+ const job = bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : ''
47
+ return `- ${bot.name}${tags ? ` (${tags})` : ''}${job}`
48
+ })
49
+ if (lines.length === 0) return 'No Bots yet.'
50
+ const rooms = visibleRooms(self)
51
+ return [
52
+ 'Bots:',
53
+ ...lines,
54
+ '',
55
+ rooms.length === 0 ? 'Group chats: none you can see.' : 'Group chats:',
56
+ ...rooms.map(room => roomLine(room, self)),
57
+ ].join('\n')
58
+ },
59
+ }))
60
+
61
+ ctx.tools.register(defineTool({
62
+ 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.',
64
+ parameters: {
65
+ to: { type: 'string', required: true, description: 'Exact Bot name from list_bots' },
66
+ message: { type: 'string', required: true, description: 'Self-contained message; the other Bot cannot see this conversation' },
67
+ reply_to: {
68
+ type: 'string',
69
+ enum: ['me', 'user'],
70
+ 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
+ },
72
+ },
73
+ output,
74
+ async execute(args, exec) {
75
+ await rt.load()
76
+ const sender = callerBot(exec)
77
+ if (sender === undefined) return 'Only Bots on the team can use message_bot.'
78
+ const target = rt.findBot(args.to)
79
+ if (target === undefined) return `No Bot named ${args.to}. Team: ${Object.values(state.bots).map(bot => bot.name).join(', ')}`
80
+ if (target.id === sender.id) return 'You cannot message yourself.'
81
+ const hop = (rt.live.get(exec.agent?.id)?.info?.hop ?? 0)
82
+ if (hop >= MAX_HOPS) return 'This chain of Bot messages is already long. Stop here and report to the user instead.'
83
+ 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
86
+ ? `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.`
88
+ },
89
+ }))
90
+
91
+ ctx.tools.register(defineTool({
92
+ name: 'create_bot',
93
+ description: 'Main Bot only. Create a new Bot on the user\'s team. It appears in the sidebar and greets the user.',
94
+ parameters: {
95
+ name: { type: 'string', required: true, description: 'Short display name, e.g. "Researcher" or "Editor"' },
96
+ role: { type: 'string', required: true, description: 'Two-to-four character role label shown under the name' },
97
+ instructions: { type: 'string', required: true, description: 'What this Bot is responsible for and how it should work' },
98
+ brief: { type: 'string', description: 'Why the user wants it, passed to the new Bot as its first context' },
99
+ 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.' },
100
+ },
101
+ output,
102
+ async execute(args, exec) {
103
+ await rt.load()
104
+ if (!rt.isMain(rt.selfOf(exec.agent?.id))) return 'Only the Main Bot can create Bots. Ask the Main Bot instead.'
105
+ try {
106
+ const bot = await rt.createBot({ ...args, createdBy: rt.selfOf(exec.agent.id) })
107
+ return `Created ${bot.name}. It is greeting the user now.`
108
+ } catch (error) {
109
+ return `Could not create the Bot: ${error.message}`
110
+ }
111
+ },
112
+ }))
113
+
114
+ ctx.tools.register(defineTool({
115
+ name: 'update_bot',
116
+ description: 'Main Bot only. Rename a Bot or change its role, color, model, or instructions.',
117
+ parameters: {
118
+ bot: { type: 'string', required: true, description: 'Current Bot name' },
119
+ name: { type: 'string', description: 'New name' },
120
+ role: { type: 'string', description: 'New role label' },
121
+ instructions: { type: 'string', description: 'Replacement instructions' },
122
+ color: { type: 'string', description: `One of ${[...COLORS, ...EXTRA_COLORS].join(', ')}` },
123
+ model: { type: 'string', description: 'Move the Bot to another model, as provider/model or a model id' },
124
+ },
125
+ output,
126
+ async execute(args, exec) {
127
+ 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)
130
+ if (bot === undefined) return `No Bot named ${args.bot}.`
131
+ try {
132
+ const { name: newName, role, instructions, color, model } = args
133
+ const next = await rt.updateBot(bot.id, { name: newName, role, instructions, color, ...(model ? { model } : {}) })
134
+ return `Updated ${next.name}.`
135
+ } catch (error) {
136
+ return `Could not update the Bot: ${error.message}`
137
+ }
138
+ },
139
+ }))
140
+
141
+ ctx.tools.register(defineTool({
142
+ name: 'list_models',
143
+ description: 'List the models a Bot can run on, as provider/model refs for create_bot and update_bot.',
144
+ parameters: {},
145
+ output,
146
+ async execute() {
147
+ const models = await rt.catalog()
148
+ if (models.length === 0) return 'No models are configured. Ask the user to add one in Settings → Models.'
149
+ const fallback = rt.defaultRef()
150
+ return models.map(entry => `- ${entry.ref}${entry.name !== entry.id ? ` (${entry.name})` : ''}${entry.input?.includes('image') ? ', reads images' : ''}${entry.ref === fallback ? ', default' : ''}`).join('\n')
151
+ },
152
+ }))
153
+
154
+ // Group tools take Bot names as an array, or as one comma-separated string from
155
+ // models that flatten it.
156
+ const nameList = value => (Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[,,、]/) : [])
157
+ .map(item => String(item ?? '').trim()).filter(Boolean)
158
+ const resolveBots = (refs) => {
159
+ const bots = []
160
+ const unknown = []
161
+ for (const ref of nameList(refs)) {
162
+ const bot = rt.findBot(ref)
163
+ if (bot === undefined) unknown.push(ref)
164
+ else if (!bots.includes(bot)) bots.push(bot)
165
+ }
166
+ return { bots, unknown }
167
+ }
168
+ const teamNames = () => Object.values(state.bots).map(bot => bot.name).join(', ')
169
+ const groupNames = self => visibleRooms(self).map(room => `"${room.name}"`).join(', ') || 'none'
170
+ const pickRoom = (ref, self) => {
171
+ const matches = rt.findRooms(ref).filter(room => visibleRooms(self).includes(room))
172
+ if (matches.length === 1) return { room: matches[0] }
173
+ if (matches.length > 1) return { error: `Several group chats are called "${unquote(ref)}". Rename one first.` }
174
+ return { error: `No group chat called "${unquote(ref)}" that you can see. Groups: ${groupNames(self)}.` }
175
+ }
176
+
177
+ ctx.tools.register(defineTool({
178
+ name: 'create_group',
179
+ description: 'Main Bot only. Create a group chat with the user and some Bots. You join it as its admin unless you name another member.',
180
+ parameters: {
181
+ members: { type: 'array', required: true, items: { type: 'string' }, description: 'Exact names of the other Bots to add; you are added automatically' },
182
+ name: { type: 'string', description: 'Group name; defaults to the member names' },
183
+ notice: { type: 'string', description: 'Shared instructions every member reads before speaking in this group' },
184
+ admin: { type: 'string', description: 'Member who leads the group; defaults to you' },
185
+ mode: MODE_PARAM,
186
+ },
187
+ output,
188
+ async execute(args, exec) {
189
+ await rt.load()
190
+ const self = rt.selfOf(exec.agent?.id)
191
+ if (!rt.isMain(self)) return 'Only a Main Bot can create group chats. Ask a Main Bot instead.'
192
+ const { bots, unknown } = resolveBots(args.members)
193
+ if (unknown.length > 0) return `No Bot named ${unknown.join(', ')}. Team: ${teamNames()}`
194
+ const admin = args.admin ? rt.findBot(args.admin) : rt.botOf(self)
195
+ if (admin === undefined) return `No Bot named ${args.admin}. Team: ${teamNames()}`
196
+ const members = [...new Set([self, ...bots.map(bot => bot.id), admin.id])]
197
+ try {
198
+ const room = await rt.createRoom({ members, name: args.name, notice: args.notice, mode: args.mode, admin: admin.id, createdBy: self })
199
+ return `Created group "${room.name}" with ${room.members.map(id => rt.botOf(id).name).join(', ')}. ${admin.id === self ? 'You are' : `${admin.name} is`} its admin. It is in the user's sidebar; use post_to_group to start a discussion there.`
200
+ } catch (error) {
201
+ return `Could not create the group: ${error.message}`
202
+ }
203
+ },
204
+ }))
205
+
206
+ ctx.tools.register(defineTool({
207
+ name: 'update_group',
208
+ description: 'Change a group chat: rename it, set its notice, add or remove members, change its admin or reply mode. Main Bots may change any group; a group\'s admin may change its own group.',
209
+ parameters: {
210
+ group: GROUP_REF,
211
+ name: { type: 'string', description: 'New group name; an empty string names it after its members again' },
212
+ notice: { type: 'string', description: 'Replacement notice, the shared instructions every member reads; an empty string clears it' },
213
+ add_members: { type: 'array', items: { type: 'string' }, description: 'Exact names of Bots to add' },
214
+ remove_members: { type: 'array', items: { type: 'string' }, description: 'Exact names of Bots to remove' },
215
+ admin: { type: 'string', description: 'Member to make the admin; "none" leaves the group without one (Main Bots only)' },
216
+ mode: MODE_PARAM,
217
+ },
218
+ output,
219
+ async execute(args, exec) {
220
+ await rt.load()
221
+ const self = rt.selfOf(exec.agent?.id)
222
+ if (rt.botOf(self) === undefined) return 'Only Bots on the team can use update_group.'
223
+ const { room, error } = pickRoom(args.group, self)
224
+ if (error) return error
225
+ const add = resolveBots(args.add_members)
226
+ const remove = resolveBots(args.remove_members)
227
+ const unknown = [...add.unknown, ...remove.unknown]
228
+ if (unknown.length > 0) return `No Bot named ${unknown.join(', ')}. Team: ${teamNames()}`
229
+ let admin
230
+ if (args.admin !== undefined && args.admin !== '') {
231
+ if (/^(none|nobody|null|无|没有)$/i.test(String(args.admin).trim())) admin = null
232
+ else {
233
+ admin = rt.findBot(args.admin)?.id
234
+ if (admin === undefined) return `No Bot named ${args.admin}. Team: ${teamNames()}`
235
+ }
236
+ }
237
+ try {
238
+ const { room: next, changes } = await rt.updateRoom(room.id, {
239
+ name: args.name,
240
+ notice: args.notice,
241
+ mode: args.mode,
242
+ admin,
243
+ add: add.bots.map(bot => bot.id),
244
+ remove: remove.bots.map(bot => bot.id),
245
+ }, self)
246
+ return changes.length === 0 ? `Nothing changed in "${next.name}".` : `Updated "${next.name}": ${changes.join('; ')}.`
247
+ } catch (failure) {
248
+ return `Could not update "${room.name}": ${failure.message}`
249
+ }
250
+ },
251
+ }))
252
+
253
+ ctx.tools.register(defineTool({
254
+ name: 'delete_group',
255
+ description: 'Main Bot only. Delete a group chat; its history is archived. Only when the user asks.',
256
+ parameters: { group: GROUP_REF },
257
+ output,
258
+ async execute(args, exec) {
259
+ await rt.load()
260
+ const self = rt.selfOf(exec.agent?.id)
261
+ if (!rt.isMain(self)) return 'Only a Main Bot can delete group chats.'
262
+ const { room, error } = pickRoom(args.group, self)
263
+ if (error) return error
264
+ if (rt.roomBusy(room.id)) return `"${room.name}" is in the middle of a round. Delete it after the round ends.`
265
+ await rt.deleteRoom(room.id)
266
+ return `Deleted "${room.name}". Its history is archived.`
267
+ },
268
+ }))
269
+
270
+ ctx.tools.register(defineTool({
271
+ name: 'post_to_group',
272
+ 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.',
273
+ parameters: {
274
+ group: GROUP_REF,
275
+ message: { type: 'string', required: true, description: 'What to say; write "@Name" to choose who answers' },
276
+ report_back: { type: 'boolean', description: 'Also send the members\' replies back to you as a new message when the round ends' },
277
+ },
278
+ output,
279
+ async execute(args, exec) {
280
+ await rt.load()
281
+ const self = rt.selfOf(exec.agent?.id)
282
+ const sender = rt.botOf(self)
283
+ if (sender === undefined) return 'Only Bots on the team can use post_to_group.'
284
+ const { room, error } = pickRoom(args.group, self)
285
+ if (error) return error
286
+ if (!rt.isMain(self) && !rt.isAdminOf(room, self)) return `Only a Main Bot or the admin of "${room.name}" can post there. Use message_bot to reach its members.`
287
+ const info = rt.live.get(exec.agent?.id)?.info
288
+ if (info?.role === 'room' && info.roomId === room.id) return `You are speaking in "${room.name}" right now. Write your message as your reply instead.`
289
+ if ((info?.hop ?? 0) >= MAX_HOPS) return 'This chain of Bot messages is already long. Stop here and report to the user instead.'
290
+ const text = String(args.message ?? '').trim()
291
+ if (text === '') return 'Write a message to post.'
292
+ if ((room.botRounds ?? 0) >= ROOM_BOT_ROUNDS) return `Bots already started ${ROOM_BOT_ROUNDS} rounds in "${room.name}" since the user last spoke there. Wait for the user.`
293
+ room.botRounds = (room.botRounds ?? 0) + 1
294
+ await rt.save()
295
+ const reportBack = args.report_back === true
296
+ 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)
313
+ } catch (failure) {
314
+ return `Could not post in "${room.name}": ${failure.message}`
315
+ }
316
+ return `Posted in "${room.name}". Members reply there, where the user reads them${reportBack ? '; their replies also come back to you as a new message when the round ends' : ', and nothing comes back to you'}.`
317
+ },
318
+ }))
319
+
320
+ ctx.tools.register(defineTool({
321
+ name: 'ask_user',
322
+ description: 'Show the user a short multiple-choice question card in the chat (the user can also type their own answer). Calling it ends your turn; the chosen answer arrives later as the user\'s next message.',
323
+ parameters: {
324
+ question: { type: 'string', required: true, description: 'One short question' },
325
+ options: {
326
+ type: 'array',
327
+ required: true,
328
+ description: 'Two to four answers',
329
+ items: {
330
+ type: 'object',
331
+ additionalProperties: false,
332
+ properties: {
333
+ label: { type: 'string', required: true, description: 'Short answer shown on the option' },
334
+ description: { type: 'string', description: 'Optional one-line explanation under the label' },
335
+ },
336
+ },
337
+ },
338
+ detail: { type: 'string', description: 'Optional one-line context shown under the question' },
339
+ multiSelect: { type: 'boolean', description: 'Allow choosing several options' },
340
+ allowCustom: { type: 'boolean', description: 'Also offer a field for an answer in the user\'s own words' },
341
+ },
342
+ output,
343
+ async execute(args, exec) {
344
+ await rt.load()
345
+ const id = exec.agent?.id
346
+ if (rt.groupOwners.has(id)) return 'Question cards only work in your own chat. In a group, ask in your message instead.'
347
+ if (!rt.chatOwners.has(id)) return 'Only Bots on the team can use ask_user.'
348
+ const options = (Array.isArray(args.options) ? args.options : [])
349
+ .map(option => (typeof option === 'string' ? { label: option } : option ?? {}))
350
+ .map(option => ({
351
+ label: String(option.label ?? '').trim(),
352
+ ...(option.description ? { description: String(option.description).trim() } : {}),
353
+ }))
354
+ .filter(option => option.label !== '')
355
+ .slice(0, 6)
356
+ if (options.length === 0) return 'Give at least one option.'
357
+ state.questions[id] = {
358
+ id: randomUUID(),
359
+ ...(exec.callId ? { callId: exec.callId } : {}),
360
+ question: String(args.question ?? '').trim(),
361
+ ...(args.detail ? { detail: String(args.detail).trim() } : {}),
362
+ options,
363
+ multiSelect: args.multiSelect === true,
364
+ ...(args.allowCustom === true ? { allowCustom: true } : {}),
365
+ askedAt: Date.now(),
366
+ }
367
+ await rt.save()
368
+ exec.concludeTurn?.()
369
+ return 'The card is on screen; the user\'s answer arrives as their next message.'
370
+ },
371
+ }))
372
+
373
+ Object.assign(rt, { pickRoom })
374
+ }
@@ -0,0 +1,250 @@
1
+ // Turns: what each Bot Session is doing now (for its mascot), which turns another Bot
2
+ // triggered, the waiters and inboxes of group rounds, and routing a finished turn's reply.
3
+ //
4
+ // Turn tracking: which turns were triggered by another Bot, and what they said.
5
+ import { REPLY_KIND, SOURCE_KIND } from './constants.js'
6
+ import { textOf } from './text.js'
7
+ import { reminderText } from './schedules.js'
8
+
9
+ // A failed turn's `LlmFailure`, kept to what can be shown and logged. Like DSH's own
10
+ // error row, an AUTH failure loses its message: providers may echo part of the key.
11
+ export function turnFailure(error) {
12
+ const code = typeof error?.code === 'string' && error.code !== '' ? error.code : 'UNKNOWN'
13
+ const message = code === 'AUTH' || typeof error?.message !== 'string' ? '' : error.message.trim().slice(0, 500)
14
+ return {
15
+ code,
16
+ message,
17
+ ...(typeof error?.status === 'number' ? { status: error.status } : {}),
18
+ ...(typeof error?.requestId === 'string' ? { requestId: error.requestId } : {}),
19
+ }
20
+ }
21
+
22
+ export const failureLine = failure => `${failure.code}${failure.status ? ` ${failure.status}` : ''}${failure.message ? `: ${failure.message.slice(0, 200)}` : ''}`
23
+
24
+ export function install(rt) {
25
+ const { ctx, state } = rt
26
+
27
+ /** @type {Map<string, {turn?:number, pending?:any, info?:any, texts:string[]}>} */
28
+ const live = new Map()
29
+ /** @type {Map<string, {resolve:(text:string)=>void}>} member turn waiters keyed by exchange id */
30
+ const roomWaiters = new Map()
31
+ /** @type {Map<string, {roomId:string, replies:any[], members:string[], speaking:string|null}>} */
32
+ const roomProgress = new Map()
33
+ /** @type {Map<string, {text:string, botId?:string, name?:string, reportBack?:boolean, hop?:number, kickoff?:boolean}[]>} messages a room's next relay posts */
34
+ const roomInbox = new Map()
35
+ const queueRoomMessage = (roomId, item) => roomInbox.set(roomId, [...(roomInbox.get(roomId) ?? []), item])
36
+
37
+ const liveOf = id => {
38
+ let entry = live.get(id)
39
+ if (entry === undefined) live.set(id, entry = { texts: [] })
40
+ return entry
41
+ }
42
+
43
+ // What each running Bot is doing right now, for its mascot. Process-local: it is
44
+ // rebuilt from live events and never saved.
45
+ /** @type {Record<string, string>} */
46
+ let activity = {}
47
+ const activityOf = (tool) => {
48
+ if (/^(message_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(tool)) return 'sending'
49
+ if (tool === 'group_relay') return 'orbit'
50
+ if (/search|fetch|browse|browser|read|grep|glob|list/i.test(tool)) return 'searching'
51
+ return 'working'
52
+ }
53
+ const setActivity = (id, value) => {
54
+ if (activity[id] === value) return
55
+ const next = { ...activity }
56
+ if (value === undefined) delete next[id]
57
+ else next[id] = value
58
+ activity = next
59
+ }
60
+
61
+ const markExchangeTurn = (sessionId, turn) => {
62
+ const turns = state.exchangeTurns[sessionId] ??= []
63
+ if (!turns.includes(turn)) {
64
+ turns.push(turn)
65
+ if (turns.length > 400) turns.splice(0, turns.length - 400)
66
+ void rt.save()
67
+ }
68
+ }
69
+ const unmarkExchangeTurn = (sessionId, turn) => {
70
+ const turns = state.exchangeTurns[sessionId] ?? []
71
+ if (!turns.includes(turn)) return
72
+ state.exchangeTurns[sessionId] = turns.filter(other => other !== turn)
73
+ void rt.save()
74
+ }
75
+
76
+ // A request whose audience is the user is answered in the recipient's own chat: that
77
+ // turn stays visible there and its reply is not routed back to the sender.
78
+ const answersUser = info => info.role === 'request' && info.audience === 'user'
79
+
80
+ // A group Session is hidden as a whole, so its turns need no marking. A handoff
81
+ // package that only opens a part is acknowledged with a fixed line, also hidden.
82
+ const claimTrigger = (sessionId, entry, info) => {
83
+ entry.info = info
84
+ if (rt.groupOwners.has(sessionId)) return
85
+ if ((info.role === 'request' && !answersUser(info)) || info.role === 'room' || (info.role === 'handoff' && info.resume !== true)) {
86
+ markExchangeTurn(sessionId, entry.turn)
87
+ }
88
+ }
89
+
90
+ // Activity is tracked per Session; a Bot's mascot shows its own chat's, else a group's.
91
+ const activityView = () => {
92
+ const merged = {}
93
+ for (const [id, value] of Object.entries(activity)) {
94
+ if (!rt.groupOwners.has(id)) merged[rt.chatOwners.get(id) ?? id] = value
95
+ }
96
+ for (const [id, value] of Object.entries(activity)) {
97
+ const owner = rt.groupOwners.get(id)
98
+ if (owner !== undefined) merged[owner.botId] ??= value
99
+ }
100
+ // A Bot writing its handoff note is thinking.
101
+ for (const job of rt.switchJobs()) {
102
+ if (job.roomId === undefined) merged[job.botId] = 'thinking'
103
+ else merged[job.botId] ??= 'thinking'
104
+ }
105
+ return merged
106
+ }
107
+
108
+ // Where a Session speaks, for the diagnostics log.
109
+ const placeOf = (id) => {
110
+ const room = rt.roomOf(rt.groupOwners.get(id)?.roomId) ?? rt.roomOf(id)
111
+ return room ? `group "${room.name}"` : 'own chat'
112
+ }
113
+ const noteTurnFailure = (id, failure) => {
114
+ rt.noteFailure({
115
+ source: 'turn',
116
+ text: failure.message || 'the model call failed',
117
+ code: failure.code,
118
+ status: failure.status,
119
+ requestId: failure.requestId,
120
+ bot: rt.botOf(rt.selfOf(id))?.name ?? (rt.roomOf(id) ? undefined : 'unknown Bot'),
121
+ where: placeOf(id),
122
+ sessionId: id,
123
+ })
124
+ }
125
+
126
+ ctx.on('session/event', (session, event) => {
127
+ const id = session?.id
128
+ if (id === undefined) return
129
+ if (!rt.isBotSession(id) && !rt.roomOf(id)) return
130
+ const entry = liveOf(id)
131
+ switch (event.type) {
132
+ case 'tool/call':
133
+ setActivity(id, event.data?.name === 'ask_user' ? undefined : activityOf(String(event.data?.name ?? '')))
134
+ break
135
+ case 'tool/result':
136
+ if (entry.turn !== undefined) setActivity(id, 'thinking')
137
+ break
138
+ case 'turn/start':
139
+ setActivity(id, 'thinking')
140
+ entry.turn = event.data.turn
141
+ entry.texts = []
142
+ entry.info = undefined
143
+ // A turn turned away before its first step may end without a turn/end.
144
+ entry.switched = false
145
+ entry.answers = false
146
+ if (entry.pending !== undefined) {
147
+ claimTrigger(id, entry, entry.pending)
148
+ entry.pending = undefined
149
+ }
150
+ break
151
+ case 'user/message': {
152
+ const source = event.data?.source
153
+ // A message that shares a step with a bare handoff package is what the turn
154
+ // answers, so the turn is not a hidden acknowledgement.
155
+ const bare = info => info?.handoff === true && info.resume !== true
156
+ if (entry.turn !== undefined && (source?.kind === 'user' || source?.kind === REPLY_KIND || source?.kind === SOURCE_KIND)) {
157
+ if (bare(source)) {
158
+ if (entry.answers) break
159
+ } else {
160
+ entry.answers = true
161
+ if (bare(entry.info)) {
162
+ unmarkExchangeTurn(id, entry.turn)
163
+ entry.info = undefined
164
+ }
165
+ }
166
+ }
167
+ if (source?.kind === SOURCE_KIND && source.role === 'post' && rt.roomOf(id)) {
168
+ queueRoomMessage(id, {
169
+ text: textOf(event.data.content).replace(/^\[Group post from [^\]]*\]\n?/, ''),
170
+ botId: source.senderSessionId,
171
+ name: source.senderName,
172
+ reportBack: source.reportBack === true,
173
+ hop: source.hop ?? 0,
174
+ })
175
+ } else if (source?.kind === SOURCE_KIND && source.role === 'kickoff' && rt.roomOf(id)) {
176
+ queueRoomMessage(id, { text: '', kickoff: true })
177
+ } else if (source?.kind === SOURCE_KIND) {
178
+ if (entry.turn !== undefined && entry.info === undefined) claimTrigger(id, entry, source)
179
+ else entry.pending = source
180
+ } else if (source?.kind === 'schedule' && rt.roomOf(id)) {
181
+ // A group's scheduled task reaches the group as the user's message.
182
+ queueRoomMessage(id, { text: reminderText(textOf(event.data.content)) })
183
+ } else if (source?.kind === 'user' || source?.kind === REPLY_KIND) {
184
+ if (rt.roomOf(id) && source.kind === 'user') {
185
+ const images = (Array.isArray(event.data.content) ? event.data.content : [])
186
+ .filter(block => block?.type === 'image' && !block.offloaded).map(block => block.attachment)
187
+ queueRoomMessage(id, { text: textOf(event.data.content), ...(images.length > 0 ? { images } : {}) })
188
+ }
189
+ const own = rt.chatOwners.get(id)
190
+ if (own !== undefined) rt.dropKickoff(own)
191
+ // Any user message answers or supersedes the open question card.
192
+ if (state.questions[id] !== undefined) {
193
+ delete state.questions[id]
194
+ void rt.save()
195
+ }
196
+ }
197
+ break
198
+ }
199
+ case 'assistant/message': {
200
+ const text = textOf(event.data?.message?.content)
201
+ if (text !== '') entry.texts.push(text)
202
+ break
203
+ }
204
+ case 'turn/end': {
205
+ setActivity(id, undefined)
206
+ const info = entry.info
207
+ const reply = entry.texts.join('\n\n').trim()
208
+ // A turn stopped for a switch goes on in the next part, which answers instead.
209
+ const switched = entry.switched === true
210
+ entry.turn = undefined
211
+ entry.info = undefined
212
+ entry.texts = []
213
+ entry.switched = false
214
+ const reason = event.data?.reason
215
+ const failure = reason?.kind === 'error' && !switched ? turnFailure(reason.error) : undefined
216
+ if (failure !== undefined) noteTurnFailure(id, failure)
217
+ if (info === undefined || switched) break
218
+ if (info.role === 'room') {
219
+ const waiter = roomWaiters.get(info.exchangeId)
220
+ roomWaiters.delete(info.exchangeId)
221
+ if (failure !== undefined) waiter?.fail(failure)
222
+ else waiter?.resolve(reason?.kind === 'aborted' ? null : reply)
223
+ } else if (info.role === 'request' && !answersUser(info) && failure !== undefined) {
224
+ // The sender was told a reply comes later; without this it would wait forever.
225
+ const name = rt.botOf(rt.selfOf(id))?.name ?? 'The other Bot'
226
+ void rt.deliver({
227
+ fromId: rt.selfOf(id),
228
+ toId: info.senderSessionId,
229
+ text: `${name} could not answer your message: its model call failed (${failureLine(failure)}). Tell the user if it matters; do not retry right away.`,
230
+ role: 'reply',
231
+ hop: (info.hop ?? 0) + 1,
232
+ replyTo: info.exchangeId,
233
+ }).catch(error => rt.warn('ds-bot: failure notice delivery failed: %s', error))
234
+ } else if (info.role === 'request' && !answersUser(info) && reply !== '') {
235
+ void rt.deliver({
236
+ fromId: rt.selfOf(id),
237
+ toId: info.senderSessionId,
238
+ text: reply,
239
+ role: 'reply',
240
+ hop: (info.hop ?? 0) + 1,
241
+ replyTo: info.exchangeId,
242
+ }).catch(error => rt.warn('ds-bot: reply delivery failed: %s', error))
243
+ }
244
+ break
245
+ }
246
+ }
247
+ })
248
+
249
+ Object.assign(rt, { live, liveOf, roomWaiters, roomProgress, roomInbox, activityView })
250
+ }