@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.
- package/LICENSE +201 -0
- package/README.md +304 -0
- package/bin/dsh-bot-web.js +33 -0
- package/client.js +13691 -0
- package/cordis.patch.yml +17 -0
- package/index.js +57 -0
- package/package.json +80 -0
- package/src/host/agents.js +186 -0
- package/src/host/approvals.js +63 -0
- package/src/host/browser.js +258 -0
- package/src/host/channel.js +342 -0
- package/src/host/chat-index.js +293 -0
- package/src/host/connectors.js +169 -0
- package/src/host/constants.js +65 -0
- package/src/host/delivery.js +71 -0
- package/src/host/diagnostics.js +249 -0
- package/src/host/dsh-compat.js +22 -0
- package/src/host/dsh-update.js +170 -0
- package/src/host/groups.js +592 -0
- package/src/host/home-lock.js +97 -0
- package/src/host/images.js +211 -0
- package/src/host/memory.js +1362 -0
- package/src/host/models.js +231 -0
- package/src/host/parts.js +976 -0
- package/src/host/permissions.js +48 -0
- package/src/host/prompt.js +71 -0
- package/src/host/roster.js +471 -0
- package/src/host/schedules.js +151 -0
- package/src/host/secrets.js +530 -0
- package/src/host/steward.js +164 -0
- package/src/host/store.js +344 -0
- package/src/host/text.js +47 -0
- package/src/host/time-context.js +46 -0
- package/src/host/tools.js +425 -0
- package/src/host/turns.js +388 -0
- package/src/host/update.js +237 -0
- package/src/host/usage.js +591 -0
- package/src/host/waifu.js +58 -0
- package/src/shared/mentions.js +65 -0
- package/src/shared/web-port.js +26 -0
- package/web-search.js +764 -0
|
@@ -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
|
+
}
|