@thewaifu/dsh-bot 1.1.1

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,592 @@
1
+ // Group chats: the room log, members' group Sessions, rounds and @mentions, round
2
+ // reports, and the group tools (group_relay, read_own_chat, read_group_chat).
3
+ //
4
+ // Group chat rounds. A group chat is one shared, ordered conversation, so members
5
+ // speak one at a time: a turn starts only after the previous speaker has posted or
6
+ // passed, and it shows everything that arrived since that member last looked, plus
7
+ // the speaking order and the member's place in it. Nobody answers a room that has
8
+ // moved on, and nobody has to count lines to know who went before. A plain message
9
+ // goes to every member in roster order, admin first, or to the admin alone in admin
10
+ // mode; "@Name" picks members. A reply that @mentions a member calls that member
11
+ // next, bounded per member and per round; the admin's calls reach further.
12
+ import { randomUUID } from 'node:crypto'
13
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
14
+ import { defineTool } from '@deepseek-ai/dsh-tools'
15
+ import { ROOM_BOT_ROUNDS, ROOM_REPLY_TIMEOUT_MS, SOURCE_KIND } from './constants.js'
16
+ import { escapeRegExp, splitRef, textOf } from './text.js'
17
+ import { mentionSpans } from '../shared/mentions.js'
18
+ import { GROUP_REF, output } from './tools.js'
19
+ import { failureLine } from './turns.js'
20
+
21
+ const ROOM_LOG_MAX = 60
22
+ const ROOM_FIRST_LOOK = 12
23
+ const ROOM_DELTA_MAX = 24
24
+ const ROOM_EARLIER_MAX = 8
25
+ const ROOM_EXTRA_TURNS = 4
26
+ const ROOM_ADMIN_EXTRA_TURNS = 4
27
+ const ROOM_TURNS_EACH = 2
28
+ const ROOM_ADMIN_TURNS_EACH = 4
29
+
30
+ // Logs written before rooms were versioned get sequence numbers in place.
31
+ const ensureRoomLog = (room) => {
32
+ if (typeof room.seq !== 'number') {
33
+ room.log = (room.log ?? []).map((line, index) => ({ ...line, seq: index + 1 }))
34
+ room.seq = room.log.length
35
+ }
36
+ room.seen ??= {}
37
+ }
38
+
39
+ const appendRoom = (room, line) => {
40
+ room.seq += 1
41
+ room.log = [...room.log, { ...line, seq: room.seq }].slice(-ROOM_LOG_MAX)
42
+ }
43
+
44
+ // A member's own lines are already in its Session, so only the others' are news.
45
+ const unseenLines = (room, memberId) => {
46
+ const since = room.seen[memberId]
47
+ if (since === undefined) return room.log.slice(-ROOM_FIRST_LOOK)
48
+ return room.log.filter(line => line.seq > since && line.botId !== memberId).slice(-ROOM_DELTA_MAX)
49
+ }
50
+
51
+ const lineText = line => (line.kind === 'lookup'
52
+ ? `[${line.who} looked up its own chat${line.text ? ` for "${line.text}"` : ''}]`
53
+ : `${line.who}: ${line.text}`)
54
+ const transcript = lines => lines.map(lineText).join('\n')
55
+
56
+ // Models often echo the transcript format ("Name: …") despite the instruction.
57
+ export const stripOwnName = (name, text) => text.replace(new RegExp(`^\\s*\\**${escapeRegExp(name)}\\**\\s*[::]\\s*`), '').trim()
58
+ export const isPass = text => text === '' || /^\s*[[【((]\s*pass\s*[\]】))]|^\s*pass\s*[.。!!]?\s*$/i.test(text)
59
+
60
+ // Who "@" calls, in order, by the same rule the bubbles draw. `references` are the
61
+ // round's referenced conversations: each one's "@label" is a reference, not a call.
62
+ // Pass every member, the speaker too, so "@新 Bot 2" said by 新 Bot 2 is never read as
63
+ // a call to 新 Bot; the caller drops the speaker.
64
+ export const mentionsIn = (text, members, references = []) => {
65
+ const found = []
66
+ let all = false
67
+ for (const span of mentionSpans(text, members, { references: references.flatMap(reference => reference.labels) })) {
68
+ if (span.all) all = true
69
+ else if (!found.includes(span.bot)) found.push(span.bot)
70
+ }
71
+ return { all, found }
72
+ }
73
+
74
+ // What the user referenced in this round, for a member that has not been shown it yet.
75
+ export const referenceNote = references => (references?.length
76
+ ? `Conversations the user referenced in this round:\n\n${references.map(reference => reference.text).join('\n\n')}`
77
+ : '')
78
+
79
+ const ROOM_REPLY = 'Reply with your message for the group: one or two short messages, no name prefix. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.'
80
+
81
+ const slotLabel = (slot) => {
82
+ if (slot.status === 'speaking') return `${slot.name} (you, now)`
83
+ if (slot.status === 'posted') return `${slot.name} (posted)`
84
+ if (slot.status === 'passed') return `${slot.name} (passed)`
85
+ if (slot.status === 'missed') return `${slot.name} (no answer)`
86
+ if (slot.status === 'waiting-approval') return `${slot.name} (waiting for the user's approval; posts when done)`
87
+ if (slot.status === 'failed') return `${slot.name} (could not answer: error)`
88
+ if (slot.status === 'left') return `${slot.name} (left the group)`
89
+ return slot.name
90
+ }
91
+
92
+ const adminNote = room => [
93
+ 'You are this group\'s admin.',
94
+ room.mode === 'admin' ? 'Plain messages come to you first: answer them, or call the members who should answer with @Name.' : '',
95
+ 'Writing "@Name" calls that member to speak right after you, and "@all" calls every member. When the user asks, change the group with update_group.',
96
+ ].filter(Boolean).join(' ')
97
+
98
+ // The opening turn of a group the user just made without a first message.
99
+ const roomKickoff = language => `The user just created this group and has not written yet. Greet the user in one short message and ask what they want this group to work on. Do not call members with @ yet, and do not reply [PASS]. No name prefix. Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
100
+
101
+ const ROOM_READ_MAX = 30
102
+
103
+ export function install(rt) {
104
+ const { ctx } = rt
105
+
106
+ // A member's group Session, created on its first turn there. A new one starts with
107
+ // a fresh look at the log and a brief of the member's own chat; the brief is the
108
+ // first message, so it stays fixed and the prefix stays cacheable.
109
+ async function groupSessionFor(room, member) {
110
+ room.sessions ??= {}
111
+ const known = room.sessions[member.id]
112
+ if (known !== undefined) return { id: known, brief: undefined }
113
+ const id = await rt.createSession(`${member.name} · ${room.name}`)
114
+ room.sessions[member.id] = id
115
+ rt.groupOwners.set(id, { botId: member.id, roomId: room.id })
116
+ delete room.seen[member.id]
117
+ const ref = await rt.desiredModel(member)
118
+ if (ref !== undefined) {
119
+ await rt.selectSessionModel(id, splitRef(ref))
120
+ .catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error))
121
+ }
122
+ await rt.applyPermission(member, id)
123
+ .catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error))
124
+ await rt.save()
125
+ return { id, brief: await rt.ownChatBrief(member) }
126
+ }
127
+
128
+ // Lines from before the round's opening message are kept apart from this round, so
129
+ // an earlier round (an old count-off, say) never reads as part of the current one.
130
+ function roomPrompt(room, member, round) {
131
+ const peers = room.members.filter(id => id !== member.id).map(rt.botOf).filter(Boolean)
132
+ .map(bot => `${bot.name}${rt.isAdminOf(room, bot.id) ? ' (admin)' : ''}`)
133
+ const leads = rt.isAdminOf(room, member.id)
134
+ const lines = unseenLines(room, member.id)
135
+ const earlier = lines.filter(line => line.seq < round.startSeq).slice(-ROOM_EARLIER_MAX)
136
+ const current = lines.filter(line => line.seq >= round.startSeq)
137
+ const spokeThisRound = (room.seen[member.id] ?? 0) >= round.startSeq
138
+ const index = round.slots.findIndex(slot => slot.status === 'speaking')
139
+ const before = index
140
+ const after = round.slots.length - index - 1
141
+ const members = `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. Members speak one at a time; each message is posted before the next speaker starts.`
142
+ const notice = room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : ''
143
+ if (round.kickoff) return [members, notice, leads ? adminNote(room) : '', roomKickoff(rt.uiLanguage?.())].filter(Boolean).join('\n\n')
144
+ return [
145
+ members,
146
+ notice,
147
+ leads ? adminNote(room) : '',
148
+ earlier.length ? `Earlier in the group, before this round (context only):\n${transcript(earlier)}` : '',
149
+ current.length
150
+ ? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}`
151
+ : 'Nothing new in this round since your last message.',
152
+ round.shown?.includes(member.id) ? '' : referenceNote(round.references),
153
+ `Speaking order for this round: ${round.slots.map(slotLabel).join(' → ')}.`,
154
+ `You are number ${index + 1} of ${round.slots.length}. ${before === 0 ? 'Nobody spoke before you' : `${before} went before you`}${after === 0 ? ', and you are the last.' : `; ${after} after you will see your message.`}`,
155
+ ROOM_REPLY,
156
+ ].filter(Boolean).join('\n\n')
157
+ }
158
+
159
+ // One member turn inside the room; resolves to its final text, `{ failure }` when its
160
+ // model call failed, `{ missed }` when it went quiet for too long, `{ approval }` when
161
+ // it waits for the user's approval, or null when the round was stopped. The wait
162
+ // restarts on each event of the member's Session, so a member at work is never cut off.
163
+ async function roomTurn(room, member, sessionId, text, note, signal, started) {
164
+ if (signal.aborted) return null
165
+ const exchangeId = randomUUID()
166
+ const turn = { botId: member.id, sessionId, exchangeId }
167
+ started.push(turn)
168
+ room.seen[member.id] = room.seq
169
+ const done = new Promise((resolve) => {
170
+ let timer
171
+ const finish = (value) => {
172
+ clearTimeout(timer)
173
+ rt.roomWaiters.delete(exchangeId)
174
+ resolve(value)
175
+ }
176
+ const touch = () => {
177
+ clearTimeout(timer)
178
+ timer = setTimeout(() => finish({ missed: true }), ROOM_REPLY_TIMEOUT_MS)
179
+ }
180
+ touch()
181
+ rt.roomWaiters.set(exchangeId, { sessionId, botId: member.id, roomId: room.id, resolve: finish, fail: failure => finish({ failure }), touch, approval: () => finish({ approval: true }) })
182
+ signal.addEventListener('abort', () => finish(null), { once: true })
183
+ })
184
+ try {
185
+ await rt.deliver({ fromId: undefined, toId: member.id, sessionId, role: 'room', roomId: room.id, exchangeId, note, text })
186
+ // Still held by an earlier request: this turn cannot start until the user answers.
187
+ if (rt.waitsForApproval?.(sessionId)) rt.roomWaiters.get(exchangeId)?.approval()
188
+ } catch (error) {
189
+ rt.warn('ds-bot: group delivery to %s failed: %s', member.name, error)
190
+ rt.roomWaiters.get(exchangeId)?.resolve(null)
191
+ }
192
+ const reply = await done
193
+ // A turn that ended leaves nothing for a stop to cancel.
194
+ if (typeof reply === 'string' || reply?.failure !== undefined) started.splice(started.indexOf(turn), 1)
195
+ if (reply === null || typeof reply === 'object') return reply
196
+ return stripOwnName(member.name, String(reply ?? '').trim())
197
+ }
198
+
199
+ // A member that reaches its context limit mid-turn goes on in a fresh Session, so a
200
+ // waiter is also found by the member and group that Session now speaks for.
201
+ const waitersOf = (sessionId) => {
202
+ const owner = rt.groupOwners.get(sessionId)
203
+ return [...rt.roomWaiters.values()].filter(waiter => waiter.sessionId === sessionId
204
+ || (owner !== undefined && waiter.botId === owner.botId && waiter.roomId === owner.roomId))
205
+ }
206
+ rt.touchRoomTurn = (sessionId) => { for (const waiter of waitersOf(sessionId)) waiter.touch() }
207
+ rt.yieldRoomTurn = (sessionId) => { for (const waiter of waitersOf(sessionId)) waiter.approval() }
208
+
209
+ // A called member moves up if it is still waiting, or gets one more turn if it
210
+ // already spoke; either way it goes right after slot `index`. `cap` is the caller's
211
+ // per-member turn bound for the round.
212
+ function callMembers(round, turns, index, callees, note, cap) {
213
+ let next = index + 1
214
+ for (const peer of callees) {
215
+ if ((turns.get(peer.id) ?? 0) >= cap) continue
216
+ const waiting = round.slots.findIndex((other, at) => at > index && other.id === peer.id && other.status === 'waiting')
217
+ const called = waiting === -1 ? { id: peer.id, name: peer.name, status: 'waiting' } : round.slots.splice(waiting, 1)[0]
218
+ called.note = note
219
+ round.slots.splice(next, 0, called)
220
+ next += 1
221
+ }
222
+ }
223
+
224
+ // The user's messages waiting in the group Session's inbox while a round runs, from
225
+ // the composer whether it queued or steered them. Images and chat references are
226
+ // only read when the Session takes the message itself, so a message with either
227
+ // stays for the next round, and so does everything after it in that list.
228
+ const waitingLines = agent => ['nextStep', 'nextTurn'].flatMap((list) => {
229
+ const lines = []
230
+ for (const message of agent?.inbox?.[list] ?? []) {
231
+ if (message.source?.kind !== 'user') continue
232
+ const content = Array.isArray(message.content) ? message.content : []
233
+ const text = textOf(content)
234
+ if (text === '' || content.some(block => block?.type !== 'text') || text.includes('dsh-session:')) break
235
+ lines.push({ id: message.id, text })
236
+ }
237
+ return lines
238
+ })
239
+
240
+ // Messages the user sent while this round is running post into the log before the
241
+ // next speaker, so everyone still to speak sees them. The members the message's own
242
+ // round would open with answer it next, inside this round. Each message leaves the
243
+ // Session's inbox, so it does not open another round when this one ends. Returns
244
+ // the number of turns added, which the round's limit allows for.
245
+ function drainRoomInbox(room, round, turns, index, agent) {
246
+ // The greeting's prompt tells the admin the user has not written yet.
247
+ if (round.kickoff) return 0
248
+ const lines = waitingLines(agent).filter(line => agent.inbox.remove(line.id))
249
+ if (lines.length === 0) return 0
250
+ const before = round.slots.length
251
+ const members = room.members.map(rt.botOf).filter(Boolean)
252
+ for (const line of lines) {
253
+ appendRoom(room, { who: 'User', text: line.text })
254
+ round.replies.push({ kind: 'user', text: line.text, time: Date.now() })
255
+ const { all, found } = mentionsIn(line.text, members)
256
+ const note = all || found.length > 0 ? 'the user mentioned you' : 'new message from the user'
257
+ callMembers(round, turns, index - 1, openingSpeakers(room, { text: line.text }), note, ROOM_ADMIN_TURNS_EACH)
258
+ }
259
+ // The user spoke in this room, so Bots may start their own rounds again.
260
+ room.botRounds = 0
261
+ void rt.save().catch(() => {})
262
+ return round.slots.length - before
263
+ }
264
+
265
+ async function runRound(room, round, signal, agent) {
266
+ let limit = round.slots.length + ROOM_EXTRA_TURNS + (room.admin ? ROOM_ADMIN_EXTRA_TURNS : 0)
267
+ const turns = new Map()
268
+ // The inbox check keeps a message sent during the last speaker's turn inside
269
+ // this round: it drains at the loop's head even once the slots ran out.
270
+ for (let index = 0; (index < round.slots.length || waitingLines(agent).length > 0) && round.turns < limit; index += 1) {
271
+ if (signal.aborted) break
272
+ limit += drainRoomInbox(room, round, turns, index, agent)
273
+ if (index >= round.slots.length) break
274
+ const slot = round.slots[index]
275
+ const member = rt.botOf(slot.id)
276
+ // Settings may change mid-round, for example when the admin removes someone.
277
+ if (member === undefined || !room.members.includes(slot.id)) {
278
+ slot.status = 'left'
279
+ continue
280
+ }
281
+ slot.status = 'speaking'
282
+ round.speaking = member.id
283
+ round.turns += 1
284
+ turns.set(member.id, (turns.get(member.id) ?? 0) + 1)
285
+ let session
286
+ try {
287
+ session = await groupSessionFor(room, member)
288
+ } catch (error) {
289
+ rt.warn('ds-bot: group Session for %s in %s failed: %s', member.name, room.name, error)
290
+ round.speaking = null
291
+ slot.status = 'failed'
292
+ round.failed.push(member.name)
293
+ round.replies.push({ kind: 'failed', botId: member.id, name: member.name, color: member.color, code: 'SESSION', text: String(error?.message ?? error).slice(0, 300), time: Date.now() })
294
+ continue
295
+ }
296
+ const prompt = roomPrompt(room, member, round)
297
+ round.shown?.push(member.id)
298
+ const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}`
299
+ const reply = await roomTurn(room, member, session.id, text, slot.note ?? 'your turn', signal, round.started)
300
+ round.speaking = null
301
+ if (reply?.failure !== undefined) {
302
+ // Not a pass: the user sees why the member is silent, and the round goes on.
303
+ slot.status = 'failed'
304
+ round.failed.push(member.name)
305
+ const { code, status, message } = reply.failure
306
+ round.replies.push({ kind: 'failed', botId: member.id, name: member.name, color: member.color, code, ...(status ? { status } : {}), text: message, time: Date.now() })
307
+ continue
308
+ }
309
+ // The member's turn goes on in the background and posts when it ends.
310
+ if (reply?.approval) {
311
+ slot.status = 'waiting-approval'
312
+ round.replies.push({ kind: 'approval', botId: member.id, name: member.name, color: member.color, time: Date.now() })
313
+ continue
314
+ }
315
+ if (reply === null || reply?.missed || isPass(reply)) {
316
+ slot.status = typeof reply === 'string' ? 'passed' : 'missed'
317
+ round.passed.push(member.name)
318
+ if (reply?.missed) round.replies.push({ kind: 'missed', botId: member.id, name: member.name, color: member.color, time: Date.now() })
319
+ continue
320
+ }
321
+ slot.status = 'posted'
322
+ appendRoom(room, { who: member.name, botId: member.id, text: reply })
323
+ round.replies.push({ botId: member.id, name: member.name, color: member.color, text: reply, time: Date.now() })
324
+ void rt.save().catch(() => {})
325
+ // Only the admin's "@all" calls everyone, so a member cannot set off a storm.
326
+ const leads = rt.isAdminOf(room, member.id)
327
+ const everyone = room.members.map(rt.botOf).filter(Boolean)
328
+ const others = everyone.filter(other => other.id !== member.id)
329
+ const { all, found: named } = mentionsIn(reply, everyone)
330
+ const found = named.filter(other => other.id !== member.id)
331
+ const callees = leads && all ? [...found, ...others.filter(other => !found.includes(other))] : found
332
+ callMembers(round, turns, index, callees, `${member.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH)
333
+ }
334
+ }
335
+
336
+ ctx.tools.register(defineTool({
337
+ name: 'group_relay',
338
+ description: 'Group chat relay only. Deliver the latest user message to the Bots in this group chat, one at a time, and collect their replies.',
339
+ parameters: {},
340
+ output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] },
341
+ async execute(_args, exec) {
342
+ await rt.load()
343
+ const room = rt.roomOf(exec.agent?.id)
344
+ if (room === undefined) return JSON.stringify({ error: 'Not a group chat' })
345
+ ensureRoomLog(room)
346
+ // Everything queued since the last relay is posted in order, so messages that the
347
+ // Session folds into one turn are not lost; the newest one picks the speakers.
348
+ const queued = rt.roomInbox.get(room.id) ?? []
349
+ rt.roomInbox.delete(room.id)
350
+ const posts = queued.length > 0 ? queued : [{ text: '' }]
351
+ const startSeq = room.seq + 1
352
+ // The opening greeting is a cue for the admin, not a line of the group; a member's
353
+ // late failure is an event of the round.
354
+ for (const post of posts.filter(item => !item.kickoff && !item.failed)) {
355
+ // Image readings go into the log only; `@` names are picked from what the user typed.
356
+ const text = post.images === undefined ? post.text : [post.text, ...await rt.imagesForGroup(post.images, room.id)].filter(Boolean).join('\n')
357
+ appendRoom(room, post.botId
358
+ ? { who: rt.botOf(post.botId)?.name ?? post.name ?? 'Bot', botId: post.botId, text }
359
+ : { who: 'User', text })
360
+ }
361
+ if (posts.some(post => !post.botId && !post.kickoff)) room.botRounds = 0
362
+ await rt.save()
363
+ const opener = posts.at(-1)
364
+ const references = posts.flatMap(post => post.references ?? [])
365
+ const round = {
366
+ roomId: room.id,
367
+ members: room.members,
368
+ startSeq,
369
+ ...(opener.kickoff ? { kickoff: true } : {}),
370
+ ...(references.length > 0 ? { references, shown: [] } : {}),
371
+ slots: openingSpeakers(room, opener).map(member => ({ id: member.id, name: member.name, status: 'waiting', ...(opener.kickoff ? { note: 'the group was just created' } : {}) })),
372
+ speaking: null,
373
+ turns: 0,
374
+ replies: [],
375
+ passed: [],
376
+ failed: [],
377
+ started: [],
378
+ }
379
+ for (const post of posts.filter(item => item.failed)) {
380
+ const bot = rt.botOf(post.botId)
381
+ const { code, status, message } = post.failed
382
+ round.failed.push(bot?.name ?? post.name)
383
+ round.replies.push({ kind: 'failed', botId: post.botId, name: bot?.name ?? post.name, color: bot?.color, code, ...(status ? { status } : {}), text: message, time: Date.now() })
384
+ }
385
+ rt.roomProgress.set(exec.callId, round)
386
+ try {
387
+ await runRound(room, round, exec.signal, exec.agent)
388
+ } catch (error) {
389
+ rt.warn('ds-bot: group round in %s stopped: %s', room.name, error)
390
+ } finally {
391
+ rt.roomProgress.delete(exec.callId)
392
+ }
393
+ if (exec.signal?.aborted) {
394
+ for (const turn of round.started) void rt.stopRoomTurn?.(room.sessions?.[turn.botId] ?? turn.sessionId, turn.exchangeId)
395
+ }
396
+ await rt.save()
397
+ for (const post of posts) {
398
+ if (post.reportBack && rt.botOf(post.botId)) void reportRound(room, post, round)
399
+ }
400
+ exec.concludeTurn?.()
401
+ return JSON.stringify({ replies: round.replies, passed: round.passed, ...(round.failed.length > 0 ? { failed: round.failed } : {}) })
402
+ },
403
+ }))
404
+
405
+ // Private context enters a group only through this tool, and the group sees each use.
406
+ ctx.tools.register(defineTool({
407
+ name: 'read_own_chat',
408
+ description: 'Read or search your own chat with the user, across all its parts, including earlier parts that left your working context. Each line starts with its anchor, such as [part 2 #1234 · 10-08 14:22]; long messages are cut. Pass the anchor as around to read that message in full, with what was said just before and after it. In a group chat the group sees that you looked, so share only what the group needs.',
409
+ parameters: {
410
+ query: { type: 'string', description: 'Words to look for in every part, separated by spaces. A message must have all of them; when none has, those with the most are shown. Omit for the latest messages' },
411
+ around: { type: 'string', description: 'An anchor from an earlier result, such as "part 2 #1234": shows that message in full and the messages just before and after it' },
412
+ part: { type: 'integer', description: 'Number of an earlier part: shows the handoff note you wrote when it ended' },
413
+ },
414
+ output,
415
+ async execute(args, exec) {
416
+ await rt.load()
417
+ const id = exec.agent?.id
418
+ const owner = rt.groupOwners.get(id)
419
+ const botId = owner?.botId ?? rt.chatOwners.get(id)
420
+ if (botId === undefined) return 'Only Bots on the team can use read_own_chat.'
421
+ const member = rt.botOf(botId)
422
+ const room = rt.roomOf(owner?.roomId)
423
+ const query = String(args.query ?? '').trim().slice(0, 80)
424
+ const around = String(args.around ?? '').trim().slice(0, 40)
425
+ const parts = rt.partsOf(botId)
426
+ const number = Number.isInteger(args.part) ? args.part : undefined
427
+ if (room !== undefined && member !== undefined) {
428
+ ensureRoomLog(room)
429
+ const text = query !== '' ? query : around !== '' ? `around ${around}` : number !== undefined ? `part ${number}` : ''
430
+ appendRoom(room, { who: member.name, botId: member.id, kind: 'lookup', text })
431
+ for (const round of rt.roomProgress.values()) {
432
+ if (round.roomId === room.id) round.replies.push({ kind: 'lookup', botId: member.id, name: member.name, color: member.color, text, time: Date.now() })
433
+ }
434
+ void rt.save().catch(() => {})
435
+ }
436
+ const partsNote = parts.length > 1 ? `\n\nYour own chat has ${parts.length} parts: ${rt.partIndex(parts)}. Call read_own_chat with part N for the note that closed an earlier part, or with words to search all parts.` : ''
437
+ if (query === '' && around !== '') {
438
+ const found = await rt.aroundOwnChat(botId, around)
439
+ if (found.error) return `${found.error}${partsNote}`
440
+ if (found.lines.length === 0) return `Nothing was said around ${around}.${partsNote}`
441
+ const heading = found.exact ? `around ${around}` : `with no message at ${around}; the nearest ones`
442
+ return `Your own chat ${heading} (oldest first):\n${found.lines.join('\n')}${partsNote}`
443
+ }
444
+ if (query === '' && number !== undefined) {
445
+ const part = parts[number - 1]
446
+ if (part === undefined) return `Your own chat has no part ${number}.${partsNote}`
447
+ if (part.endedAt === undefined) return `Part ${number} is the current one; it is in your context.${partsNote}`
448
+ return `Part ${number} of your own chat (${rt.clock(part.startedAt)} – ${rt.clock(part.endedAt)}). The handoff note that closed it:\n${rt.noteText(part) ?? '(none: the part ended on a context overflow)'}${partsNote}`
449
+ }
450
+ if (query === '') {
451
+ const lines = await rt.latestOwnChat(botId, { askingId: id })
452
+ return lines.length === 0 ? `Your own chat is empty.${partsNote}` : `Your own chat (oldest first):\n${lines.join('\n')}${partsNote}`
453
+ }
454
+ const { lines, every } = await rt.searchOwnChat(botId, query, { askingId: id })
455
+ if (lines.length === 0) return `Nothing in your own chat mentions "${query}".${partsNote}`
456
+ const heading = every ? `messages mentioning "${query}"` : `no message has all of "${query}"; messages with the most of these words`
457
+ return `Your own chat, ${heading} (oldest first):\n${lines.join('\n')}${partsNote}`
458
+ },
459
+ }))
460
+
461
+ ctx.tools.register(defineTool({
462
+ name: 'read_group_chat',
463
+ description: 'Read the latest messages of a group chat, including what you said there. You speak in groups from separate conversations, so your own chat does not show them. Main Bots may read any group; others the groups they are in.',
464
+ parameters: { group: GROUP_REF },
465
+ output,
466
+ async execute(args, exec) {
467
+ await rt.load()
468
+ const self = rt.selfOf(exec.agent?.id)
469
+ if (rt.botOf(self) === undefined) return 'Only Bots on the team can read group chats.'
470
+ const { room, error } = rt.pickRoom(args.group, self)
471
+ if (error) return error
472
+ ensureRoomLog(room)
473
+ const lines = room.log.slice(-ROOM_READ_MAX)
474
+ return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}`
475
+ },
476
+ }))
477
+
478
+ // A Bot that posts never answers its own post. @mentions choose the speakers; else
479
+ // the admin answers alone in admin mode, or first of everyone.
480
+ function openingSpeakers(room, opener) {
481
+ const everyone = room.members.map(rt.botOf).filter(Boolean)
482
+ const members = everyone.filter(bot => bot.id !== opener.botId)
483
+ const admin = members.find(bot => rt.isAdminOf(room, bot.id))
484
+ if (opener.kickoff) return admin ? [admin] : []
485
+ if (opener.quiet) return []
486
+ const { all, found: named } = mentionsIn(opener.text, everyone, opener.references)
487
+ const found = named.filter(bot => bot.id !== opener.botId)
488
+ if (opener.late) return found.length > 0 ? found : admin ? [admin] : []
489
+ if (found.length > 0 && !all) return found
490
+ if (!all && room.mode === 'admin' && admin) return [admin]
491
+ return admin ? [admin, ...members.filter(bot => bot !== admin)] : members
492
+ }
493
+
494
+ // A Bot's message into the group's own Session, which relays it as a new round.
495
+ async function postToRoom(room, sender, text, extra = {}) {
496
+ const agent = await rt.resolveAgent(room.id)
497
+ agent.followup(createUserMessage({
498
+ content: [{ type: 'text', text: `[Group post from ${sender.name}]\n${text}` }],
499
+ source: { kind: SOURCE_KIND, form: 'relay', role: 'post', exchangeId: randomUUID(), senderName: sender.name, senderSessionId: sender.id, roomId: room.id, hop: 0, ...extra },
500
+ }))
501
+ await ctx.sessions.flush(agent.session)
502
+ }
503
+ rt.postToRoom = postToRoom
504
+
505
+ // A member the round moved on from (it went quiet or waited for an approval) posts
506
+ // what its turn ended with. Only the admin and anyone it names answer, and past the
507
+ // Bot round limit nobody does, so late posts cannot keep a group talking.
508
+ async function lateRoomReply(sessionId, info, reply) {
509
+ const owner = rt.groupOwners.get(sessionId)
510
+ const room = rt.roomOf(owner?.roomId ?? info.roomId)
511
+ const member = rt.botOf(owner?.botId)
512
+ if (room === undefined || member === undefined || !room.members.includes(member.id)) return
513
+ const text = stripOwnName(member.name, reply)
514
+ if (isPass(text)) return
515
+ const quiet = (room.botRounds ?? 0) >= ROOM_BOT_ROUNDS
516
+ if (!quiet) room.botRounds = (room.botRounds ?? 0) + 1
517
+ await rt.save()
518
+ await postToRoom(room, member, text, { late: true, ...(quiet ? { quiet: true } : {}) })
519
+ .catch(error => rt.warn('ds-bot: late group reply from %s failed: %s', member.name, error))
520
+ }
521
+ rt.lateRoomReply = lateRoomReply
522
+
523
+ // The round said such a member posts when done; a failed model call is how it ended.
524
+ // Nobody answers it.
525
+ async function lateRoomFailure(sessionId, info, failure) {
526
+ const owner = rt.groupOwners.get(sessionId)
527
+ const room = rt.roomOf(owner?.roomId ?? info.roomId)
528
+ const member = rt.botOf(owner?.botId)
529
+ if (room === undefined || member === undefined || !room.members.includes(member.id)) return
530
+ const { code, status, message } = failure
531
+ await postToRoom(room, member, `Could not finish: its model call failed (${failureLine(failure)}).`, {
532
+ late: true, quiet: true, failed: { code, ...(status ? { status } : {}), message },
533
+ }).catch(error => rt.warn('ds-bot: late group failure from %s failed: %s', member.name, error))
534
+ }
535
+ rt.lateRoomFailure = lateRoomFailure
536
+
537
+ // A group the user just made opens with its admin asking what the group is for.
538
+ async function greetRoom(roomId) {
539
+ const room = rt.roomOf(roomId)
540
+ if (!room?.admin) return
541
+ const agent = await rt.resolveAgent(room.id)
542
+ agent.followup(createUserMessage({
543
+ content: [{ type: 'text', text: '[Group chat created]' }],
544
+ source: { kind: SOURCE_KIND, form: 'relay', role: 'kickoff', exchangeId: randomUUID(), senderName: 'You', hop: 0, roomId: room.id },
545
+ }))
546
+ await ctx.sessions.flush(agent.session)
547
+ }
548
+ rt.greetRoom = greetRoom
549
+
550
+ async function reportRound(room, post, round) {
551
+ const lines = round.replies.filter(reply => reply.kind === undefined).map(reply => `${reply.name}: ${reply.text}`)
552
+ const waiting = round.replies.filter(reply => reply.kind === 'approval').map(reply => reply.name)
553
+ const text = [
554
+ lines.length > 0 ? `Replies to your post (oldest first):\n${lines.join('\n')}` : 'Nobody replied to your post.',
555
+ waiting.length > 0 ? `Waiting for the user's approval: ${waiting.join(', ')}. What they say afterwards is posted in the group, not sent to you; read_group_chat shows it.` : '',
556
+ round.passed.length > 0 ? `Stayed silent: ${round.passed.join(', ')}.` : '',
557
+ round.failed.length > 0 ? `Could not answer because their model call failed: ${round.replies.filter(reply => reply.kind === 'failed').map(reply => `${reply.name} (${failureLine({ code: reply.code, status: reply.status, message: reply.text })})`).join(', ')}.` : '',
558
+ ].filter(Boolean).join('\n\n')
559
+ await rt.deliver({ toId: post.botId, role: 'report', roomId: room.id, text, hop: (post.hop ?? 0) + 1 })
560
+ .catch(error => rt.warn('ds-bot: group report to %s failed: %s', post.name, error))
561
+ }
562
+
563
+ // A group chat's own Session never consults the model: models drift from "only call
564
+ // group_relay" (they message one member directly when the user names it), so the
565
+ // relay step is synthesized here. Auxiliary calls such as titles still reach the model.
566
+ ctx.on('llm/stream', (options, next) => {
567
+ if (options.sessionId === undefined || options.purpose !== undefined) return next()
568
+ return (async function* relayOrPass() {
569
+ await rt.load()
570
+ if (rt.roomOf(options.sessionId) === undefined) {
571
+ yield* next()
572
+ return
573
+ }
574
+ // Plugins append context as user-role messages (time, attachments), so only a
575
+ // message from the user, a scheduled task, or a Bot's post after the last model or
576
+ // tool step starts a relay.
577
+ const opens = message => message.role === 'user'
578
+ && (message.source?.kind === 'user' || message.source?.kind === 'schedule' || (message.source?.kind === SOURCE_KIND && (message.source.role === 'post' || message.source.role === 'kickoff')))
579
+ const latest = [...options.messages].reverse()
580
+ .find(message => message.role === 'assistant' || message.role === 'tool' || opens(message))
581
+ if (latest?.role !== 'user') {
582
+ yield { type: 'finish', reason: { kind: 'stop' } }
583
+ return
584
+ }
585
+ const id = `toolu_relay_${randomUUID().replace(/-/g, '')}`
586
+ yield { type: 'block-start', index: 0, blockType: 'tool-call' }
587
+ yield { type: 'tool-call-delta', index: 0, id, name: 'group_relay', argumentsDelta: '{}' }
588
+ yield { type: 'block-end', index: 0, block: { type: 'tool-call', id, name: 'group_relay', arguments: '{}' } }
589
+ yield { type: 'finish', reason: { kind: 'tool-calls' } }
590
+ })()
591
+ })
592
+ }