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.
- package/LICENSE +201 -0
- package/README.md +234 -3
- package/client.js +12176 -0
- package/cordis.patch.yml +17 -0
- package/index.js +52 -0
- package/package.json +64 -6
- package/src/host/agents.js +186 -0
- package/src/host/browser.js +258 -0
- package/src/host/channel.js +313 -0
- package/src/host/chat-index.js +293 -0
- package/src/host/connectors.js +169 -0
- package/src/host/constants.js +62 -0
- package/src/host/delivery.js +59 -0
- package/src/host/diagnostics.js +247 -0
- package/src/host/groups.js +442 -0
- package/src/host/home-lock.js +91 -0
- package/src/host/images.js +174 -0
- package/src/host/memory.js +1351 -0
- package/src/host/models.js +145 -0
- package/src/host/parts.js +886 -0
- package/src/host/prompt.js +67 -0
- package/src/host/roster.js +397 -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 +259 -0
- package/src/host/text.js +47 -0
- package/src/host/tools.js +374 -0
- package/src/host/turns.js +250 -0
- package/src/host/update.js +205 -0
- package/src/host/usage.js +572 -0
- package/web-search.js +748 -0
|
@@ -0,0 +1,442 @@
|
|
|
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_REPLY_TIMEOUT_MS, SOURCE_KIND } from './constants.js'
|
|
16
|
+
import { escapeRegExp, splitRef } from './text.js'
|
|
17
|
+
import { GROUP_REF, output } from './tools.js'
|
|
18
|
+
import { failureLine } from './turns.js'
|
|
19
|
+
|
|
20
|
+
const ROOM_LOG_MAX = 60
|
|
21
|
+
const ROOM_FIRST_LOOK = 12
|
|
22
|
+
const ROOM_DELTA_MAX = 24
|
|
23
|
+
const ROOM_EARLIER_MAX = 8
|
|
24
|
+
const ROOM_EXTRA_TURNS = 4
|
|
25
|
+
const ROOM_ADMIN_EXTRA_TURNS = 4
|
|
26
|
+
const ROOM_TURNS_EACH = 2
|
|
27
|
+
const ROOM_ADMIN_TURNS_EACH = 4
|
|
28
|
+
|
|
29
|
+
// Logs written before rooms were versioned get sequence numbers in place.
|
|
30
|
+
const ensureRoomLog = (room) => {
|
|
31
|
+
if (typeof room.seq !== 'number') {
|
|
32
|
+
room.log = (room.log ?? []).map((line, index) => ({ ...line, seq: index + 1 }))
|
|
33
|
+
room.seq = room.log.length
|
|
34
|
+
}
|
|
35
|
+
room.seen ??= {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const appendRoom = (room, line) => {
|
|
39
|
+
room.seq += 1
|
|
40
|
+
room.log = [...room.log, { ...line, seq: room.seq }].slice(-ROOM_LOG_MAX)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A member's own lines are already in its Session, so only the others' are news.
|
|
44
|
+
const unseenLines = (room, memberId) => {
|
|
45
|
+
const since = room.seen[memberId]
|
|
46
|
+
if (since === undefined) return room.log.slice(-ROOM_FIRST_LOOK)
|
|
47
|
+
return room.log.filter(line => line.seq > since && line.botId !== memberId).slice(-ROOM_DELTA_MAX)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lineText = line => (line.kind === 'lookup'
|
|
51
|
+
? `[${line.who} looked up its own chat${line.text ? ` for "${line.text}"` : ''}]`
|
|
52
|
+
: `${line.who}: ${line.text}`)
|
|
53
|
+
const transcript = lines => lines.map(lineText).join('\n')
|
|
54
|
+
|
|
55
|
+
// Models often echo the transcript format ("Name: …") despite the instruction.
|
|
56
|
+
export const stripOwnName = (name, text) => text.replace(new RegExp(`^\\s*\\**${escapeRegExp(name)}\\**\\s*[::]\\s*`), '').trim()
|
|
57
|
+
export const isPass = text => text === '' || /^\s*[[【((]\s*pass\s*[\]】))]|^\s*pass\s*[.。!!]?\s*$/i.test(text)
|
|
58
|
+
|
|
59
|
+
// "@Name" mentions in order of appearance. Longest names match first so "@Mx" never
|
|
60
|
+
// reads as "@M"; only ASCII letters and digits continue a name, so "@K你好" is K.
|
|
61
|
+
const ROOM_ALL = ['all', 'everyone', '所有人', '全体']
|
|
62
|
+
const NAME_GOES_ON = /^[a-z0-9_-]/i
|
|
63
|
+
export const mentionsIn = (text, members) => {
|
|
64
|
+
const byLength = [...members].sort((a, b) => b.name.length - a.name.length)
|
|
65
|
+
const found = []
|
|
66
|
+
let all = false
|
|
67
|
+
for (let at = text.indexOf('@'); at !== -1; at = text.indexOf('@', at + 1)) {
|
|
68
|
+
if (at > 0 && /[a-z0-9_.]/i.test(text[at - 1])) continue
|
|
69
|
+
const rest = text.slice(at + 1)
|
|
70
|
+
const starts = word => rest.toLowerCase().startsWith(word.toLowerCase()) && !NAME_GOES_ON.test(rest.slice(word.length))
|
|
71
|
+
if (ROOM_ALL.some(starts)) all = true
|
|
72
|
+
const hit = byLength.find(member => starts(member.name))
|
|
73
|
+
if (hit && !found.includes(hit)) found.push(hit)
|
|
74
|
+
}
|
|
75
|
+
return { all, found }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
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.'
|
|
79
|
+
|
|
80
|
+
const slotLabel = (slot) => {
|
|
81
|
+
if (slot.status === 'speaking') return `${slot.name} (you, now)`
|
|
82
|
+
if (slot.status === 'posted') return `${slot.name} (posted)`
|
|
83
|
+
if (slot.status === 'passed') return `${slot.name} (passed)`
|
|
84
|
+
if (slot.status === 'missed') return `${slot.name} (no answer)`
|
|
85
|
+
if (slot.status === 'failed') return `${slot.name} (could not answer: error)`
|
|
86
|
+
if (slot.status === 'left') return `${slot.name} (left the group)`
|
|
87
|
+
return slot.name
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const adminNote = room => [
|
|
91
|
+
'You are this group\'s admin.',
|
|
92
|
+
room.mode === 'admin' ? 'Plain messages come to you first: answer them, or call the members who should answer with @Name.' : '',
|
|
93
|
+
'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.',
|
|
94
|
+
].filter(Boolean).join(' ')
|
|
95
|
+
|
|
96
|
+
// The opening turn of a group the user just made without a first message.
|
|
97
|
+
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'}.`
|
|
98
|
+
|
|
99
|
+
const ROOM_READ_MAX = 30
|
|
100
|
+
|
|
101
|
+
export function install(rt) {
|
|
102
|
+
const { ctx } = rt
|
|
103
|
+
|
|
104
|
+
// A member's group Session, created on its first turn there. A new one starts with
|
|
105
|
+
// a fresh look at the log and a brief of the member's own chat; the brief is the
|
|
106
|
+
// first message, so it stays fixed and the prefix stays cacheable.
|
|
107
|
+
async function groupSessionFor(room, member) {
|
|
108
|
+
room.sessions ??= {}
|
|
109
|
+
const known = room.sessions[member.id]
|
|
110
|
+
if (known !== undefined) return { id: known, brief: undefined }
|
|
111
|
+
const id = await rt.createSession(`${member.name} · ${room.name}`)
|
|
112
|
+
room.sessions[member.id] = id
|
|
113
|
+
rt.groupOwners.set(id, { botId: member.id, roomId: room.id })
|
|
114
|
+
delete room.seen[member.id]
|
|
115
|
+
const ref = await rt.desiredModel(member)
|
|
116
|
+
if (ref !== undefined) {
|
|
117
|
+
await rt.selectSessionModel(id, splitRef(ref))
|
|
118
|
+
.catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error))
|
|
119
|
+
}
|
|
120
|
+
await rt.save()
|
|
121
|
+
return { id, brief: await rt.ownChatBrief(member) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Lines from before the round's opening message are kept apart from this round, so
|
|
125
|
+
// an earlier round (an old count-off, say) never reads as part of the current one.
|
|
126
|
+
function roomPrompt(room, member, round) {
|
|
127
|
+
const peers = room.members.filter(id => id !== member.id).map(rt.botOf).filter(Boolean)
|
|
128
|
+
.map(bot => `${bot.name}${rt.isAdminOf(room, bot.id) ? ' (admin)' : ''}`)
|
|
129
|
+
const leads = rt.isAdminOf(room, member.id)
|
|
130
|
+
const lines = unseenLines(room, member.id)
|
|
131
|
+
const earlier = lines.filter(line => line.seq < round.startSeq).slice(-ROOM_EARLIER_MAX)
|
|
132
|
+
const current = lines.filter(line => line.seq >= round.startSeq)
|
|
133
|
+
const spokeThisRound = (room.seen[member.id] ?? 0) >= round.startSeq
|
|
134
|
+
const index = round.slots.findIndex(slot => slot.status === 'speaking')
|
|
135
|
+
const before = index
|
|
136
|
+
const after = round.slots.length - index - 1
|
|
137
|
+
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.`
|
|
138
|
+
const notice = room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : ''
|
|
139
|
+
if (round.kickoff) return [members, notice, leads ? adminNote(room) : '', roomKickoff(rt.uiLanguage?.())].filter(Boolean).join('\n\n')
|
|
140
|
+
return [
|
|
141
|
+
members,
|
|
142
|
+
notice,
|
|
143
|
+
leads ? adminNote(room) : '',
|
|
144
|
+
earlier.length ? `Earlier in the group, before this round (context only):\n${transcript(earlier)}` : '',
|
|
145
|
+
current.length
|
|
146
|
+
? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}`
|
|
147
|
+
: 'Nothing new in this round since your last message.',
|
|
148
|
+
`Speaking order for this round: ${round.slots.map(slotLabel).join(' → ')}.`,
|
|
149
|
+
`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.`}`,
|
|
150
|
+
ROOM_REPLY,
|
|
151
|
+
].filter(Boolean).join('\n\n')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// One member turn inside the room; resolves to its final text, `{ failure }` when its
|
|
155
|
+
// model call failed, or null when it did not answer in time or the round was stopped.
|
|
156
|
+
async function roomTurn(room, member, sessionId, text, note, signal) {
|
|
157
|
+
if (signal.aborted) return null
|
|
158
|
+
const exchangeId = randomUUID()
|
|
159
|
+
room.seen[member.id] = room.seq
|
|
160
|
+
const done = new Promise((resolve) => {
|
|
161
|
+
const finish = (value) => {
|
|
162
|
+
clearTimeout(timer)
|
|
163
|
+
rt.roomWaiters.delete(exchangeId)
|
|
164
|
+
resolve(value)
|
|
165
|
+
}
|
|
166
|
+
const timer = setTimeout(() => finish(null), ROOM_REPLY_TIMEOUT_MS)
|
|
167
|
+
rt.roomWaiters.set(exchangeId, { resolve: finish, fail: failure => finish({ failure }) })
|
|
168
|
+
signal.addEventListener('abort', () => finish(null), { once: true })
|
|
169
|
+
})
|
|
170
|
+
try {
|
|
171
|
+
await rt.deliver({ fromId: undefined, toId: member.id, sessionId, role: 'room', roomId: room.id, exchangeId, note, text })
|
|
172
|
+
} catch (error) {
|
|
173
|
+
rt.warn('ds-bot: group delivery to %s failed: %s', member.name, error)
|
|
174
|
+
rt.roomWaiters.get(exchangeId)?.resolve(null)
|
|
175
|
+
}
|
|
176
|
+
const reply = await done
|
|
177
|
+
if (reply === null || reply?.failure !== undefined) return reply
|
|
178
|
+
return stripOwnName(member.name, String(reply ?? '').trim())
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function runRound(room, round, signal) {
|
|
182
|
+
const limit = round.slots.length + ROOM_EXTRA_TURNS + (room.admin ? ROOM_ADMIN_EXTRA_TURNS : 0)
|
|
183
|
+
const turns = new Map()
|
|
184
|
+
for (let index = 0; index < round.slots.length && round.turns < limit; index += 1) {
|
|
185
|
+
if (signal.aborted) break
|
|
186
|
+
const slot = round.slots[index]
|
|
187
|
+
const member = rt.botOf(slot.id)
|
|
188
|
+
// Settings may change mid-round, for example when the admin removes someone.
|
|
189
|
+
if (member === undefined || !room.members.includes(slot.id)) {
|
|
190
|
+
slot.status = 'left'
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
slot.status = 'speaking'
|
|
194
|
+
round.speaking = member.id
|
|
195
|
+
round.turns += 1
|
|
196
|
+
turns.set(member.id, (turns.get(member.id) ?? 0) + 1)
|
|
197
|
+
let session
|
|
198
|
+
try {
|
|
199
|
+
session = await groupSessionFor(room, member)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
rt.warn('ds-bot: group Session for %s in %s failed: %s', member.name, room.name, error)
|
|
202
|
+
round.speaking = null
|
|
203
|
+
slot.status = 'failed'
|
|
204
|
+
round.failed.push(member.name)
|
|
205
|
+
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() })
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
const prompt = roomPrompt(room, member, round)
|
|
209
|
+
const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}`
|
|
210
|
+
const reply = await roomTurn(room, member, session.id, text, slot.note ?? 'your turn', signal)
|
|
211
|
+
round.speaking = null
|
|
212
|
+
if (reply?.failure !== undefined) {
|
|
213
|
+
// Not a pass: the user sees why the member is silent, and the round goes on.
|
|
214
|
+
slot.status = 'failed'
|
|
215
|
+
round.failed.push(member.name)
|
|
216
|
+
const { code, status, message } = reply.failure
|
|
217
|
+
round.replies.push({ kind: 'failed', botId: member.id, name: member.name, color: member.color, code, ...(status ? { status } : {}), text: message, time: Date.now() })
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
if (reply === null || isPass(reply)) {
|
|
221
|
+
slot.status = reply === null ? 'missed' : 'passed'
|
|
222
|
+
round.passed.push(member.name)
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
slot.status = 'posted'
|
|
226
|
+
appendRoom(room, { who: member.name, botId: member.id, text: reply })
|
|
227
|
+
round.replies.push({ botId: member.id, name: member.name, color: member.color, text: reply, time: Date.now() })
|
|
228
|
+
void rt.save()
|
|
229
|
+
// A called member moves up if it is still waiting, or gets one more turn if it
|
|
230
|
+
// already spoke; either way it goes right after this speaker. Only the admin's
|
|
231
|
+
// "@all" calls everyone, so a member cannot set off a storm.
|
|
232
|
+
const leads = rt.isAdminOf(room, member.id)
|
|
233
|
+
const others = room.members.filter(id => id !== member.id).map(rt.botOf).filter(Boolean)
|
|
234
|
+
const { all, found } = mentionsIn(reply, others)
|
|
235
|
+
const callees = leads && all ? [...found, ...others.filter(other => !found.includes(other))] : found
|
|
236
|
+
let next = index + 1
|
|
237
|
+
for (const peer of callees) {
|
|
238
|
+
if ((turns.get(peer.id) ?? 0) >= (leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH)) continue
|
|
239
|
+
const waiting = round.slots.findIndex((other, at) => at > index && other.id === peer.id && other.status === 'waiting')
|
|
240
|
+
const called = waiting === -1 ? { id: peer.id, name: peer.name, status: 'waiting' } : round.slots.splice(waiting, 1)[0]
|
|
241
|
+
called.note = `${member.name} mentioned you`
|
|
242
|
+
round.slots.splice(next, 0, called)
|
|
243
|
+
next += 1
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
ctx.tools.register(defineTool({
|
|
249
|
+
name: 'group_relay',
|
|
250
|
+
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.',
|
|
251
|
+
parameters: {},
|
|
252
|
+
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] },
|
|
253
|
+
async execute(_args, exec) {
|
|
254
|
+
await rt.load()
|
|
255
|
+
const room = rt.roomOf(exec.agent?.id)
|
|
256
|
+
if (room === undefined) return JSON.stringify({ error: 'Not a group chat' })
|
|
257
|
+
ensureRoomLog(room)
|
|
258
|
+
// Everything queued since the last relay is posted in order, so messages that the
|
|
259
|
+
// Session folds into one turn are not lost; the newest one picks the speakers.
|
|
260
|
+
const queued = rt.roomInbox.get(room.id) ?? []
|
|
261
|
+
rt.roomInbox.delete(room.id)
|
|
262
|
+
const posts = queued.length > 0 ? queued : [{ text: '' }]
|
|
263
|
+
const startSeq = room.seq + 1
|
|
264
|
+
// The opening greeting is a cue for the admin, not a line of the group.
|
|
265
|
+
for (const post of posts.filter(item => !item.kickoff)) {
|
|
266
|
+
// Image readings go into the log only; `@` names are picked from what the user typed.
|
|
267
|
+
const text = post.images === undefined ? post.text : [post.text, ...await rt.imagesForGroup(post.images, room.id)].filter(Boolean).join('\n')
|
|
268
|
+
appendRoom(room, post.botId
|
|
269
|
+
? { who: rt.botOf(post.botId)?.name ?? post.name ?? 'Bot', botId: post.botId, text }
|
|
270
|
+
: { who: 'User', text })
|
|
271
|
+
}
|
|
272
|
+
if (posts.some(post => !post.botId && !post.kickoff)) room.botRounds = 0
|
|
273
|
+
await rt.save()
|
|
274
|
+
const opener = posts.at(-1)
|
|
275
|
+
const round = {
|
|
276
|
+
roomId: room.id,
|
|
277
|
+
members: room.members,
|
|
278
|
+
startSeq,
|
|
279
|
+
...(opener.kickoff ? { kickoff: true } : {}),
|
|
280
|
+
slots: openingSpeakers(room, opener).map(member => ({ id: member.id, name: member.name, status: 'waiting', ...(opener.kickoff ? { note: 'the group was just created' } : {}) })),
|
|
281
|
+
speaking: null,
|
|
282
|
+
turns: 0,
|
|
283
|
+
replies: [],
|
|
284
|
+
passed: [],
|
|
285
|
+
failed: [],
|
|
286
|
+
}
|
|
287
|
+
rt.roomProgress.set(exec.callId, round)
|
|
288
|
+
try {
|
|
289
|
+
await runRound(room, round, exec.signal)
|
|
290
|
+
} catch (error) {
|
|
291
|
+
rt.warn('ds-bot: group round in %s stopped: %s', room.name, error)
|
|
292
|
+
} finally {
|
|
293
|
+
rt.roomProgress.delete(exec.callId)
|
|
294
|
+
}
|
|
295
|
+
await rt.save()
|
|
296
|
+
for (const post of posts) {
|
|
297
|
+
if (post.reportBack && rt.botOf(post.botId)) void reportRound(room, post, round)
|
|
298
|
+
}
|
|
299
|
+
exec.concludeTurn?.()
|
|
300
|
+
return JSON.stringify({ replies: round.replies, passed: round.passed, ...(round.failed.length > 0 ? { failed: round.failed } : {}) })
|
|
301
|
+
},
|
|
302
|
+
}))
|
|
303
|
+
|
|
304
|
+
// Private context enters a group only through this tool, and the group sees each use.
|
|
305
|
+
ctx.tools.register(defineTool({
|
|
306
|
+
name: 'read_own_chat',
|
|
307
|
+
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.',
|
|
308
|
+
parameters: {
|
|
309
|
+
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' },
|
|
310
|
+
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' },
|
|
311
|
+
part: { type: 'integer', description: 'Number of an earlier part: shows the handoff note you wrote when it ended' },
|
|
312
|
+
},
|
|
313
|
+
output,
|
|
314
|
+
async execute(args, exec) {
|
|
315
|
+
await rt.load()
|
|
316
|
+
const id = exec.agent?.id
|
|
317
|
+
const owner = rt.groupOwners.get(id)
|
|
318
|
+
const botId = owner?.botId ?? rt.chatOwners.get(id)
|
|
319
|
+
if (botId === undefined) return 'Only Bots on the team can use read_own_chat.'
|
|
320
|
+
const member = rt.botOf(botId)
|
|
321
|
+
const room = rt.roomOf(owner?.roomId)
|
|
322
|
+
const query = String(args.query ?? '').trim().slice(0, 80)
|
|
323
|
+
const around = String(args.around ?? '').trim().slice(0, 40)
|
|
324
|
+
const parts = rt.partsOf(botId)
|
|
325
|
+
const number = Number.isInteger(args.part) ? args.part : undefined
|
|
326
|
+
if (room !== undefined && member !== undefined) {
|
|
327
|
+
ensureRoomLog(room)
|
|
328
|
+
const text = query !== '' ? query : around !== '' ? `around ${around}` : number !== undefined ? `part ${number}` : ''
|
|
329
|
+
appendRoom(room, { who: member.name, botId: member.id, kind: 'lookup', text })
|
|
330
|
+
for (const round of rt.roomProgress.values()) {
|
|
331
|
+
if (round.roomId === room.id) round.replies.push({ kind: 'lookup', botId: member.id, name: member.name, color: member.color, text, time: Date.now() })
|
|
332
|
+
}
|
|
333
|
+
void rt.save()
|
|
334
|
+
}
|
|
335
|
+
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.` : ''
|
|
336
|
+
if (query === '' && around !== '') {
|
|
337
|
+
const found = await rt.aroundOwnChat(botId, around)
|
|
338
|
+
if (found.error) return `${found.error}${partsNote}`
|
|
339
|
+
if (found.lines.length === 0) return `Nothing was said around ${around}.${partsNote}`
|
|
340
|
+
const heading = found.exact ? `around ${around}` : `with no message at ${around}; the nearest ones`
|
|
341
|
+
return `Your own chat ${heading} (oldest first):\n${found.lines.join('\n')}${partsNote}`
|
|
342
|
+
}
|
|
343
|
+
if (query === '' && number !== undefined) {
|
|
344
|
+
const part = parts[number - 1]
|
|
345
|
+
if (part === undefined) return `Your own chat has no part ${number}.${partsNote}`
|
|
346
|
+
if (part.endedAt === undefined) return `Part ${number} is the current one; it is in your context.${partsNote}`
|
|
347
|
+
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}`
|
|
348
|
+
}
|
|
349
|
+
if (query === '') {
|
|
350
|
+
const lines = await rt.latestOwnChat(botId, { askingId: id })
|
|
351
|
+
return lines.length === 0 ? `Your own chat is empty.${partsNote}` : `Your own chat (oldest first):\n${lines.join('\n')}${partsNote}`
|
|
352
|
+
}
|
|
353
|
+
const { lines, every } = await rt.searchOwnChat(botId, query, { askingId: id })
|
|
354
|
+
if (lines.length === 0) return `Nothing in your own chat mentions "${query}".${partsNote}`
|
|
355
|
+
const heading = every ? `messages mentioning "${query}"` : `no message has all of "${query}"; messages with the most of these words`
|
|
356
|
+
return `Your own chat, ${heading} (oldest first):\n${lines.join('\n')}${partsNote}`
|
|
357
|
+
},
|
|
358
|
+
}))
|
|
359
|
+
|
|
360
|
+
ctx.tools.register(defineTool({
|
|
361
|
+
name: 'read_group_chat',
|
|
362
|
+
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.',
|
|
363
|
+
parameters: { group: GROUP_REF },
|
|
364
|
+
output,
|
|
365
|
+
async execute(args, exec) {
|
|
366
|
+
await rt.load()
|
|
367
|
+
const self = rt.selfOf(exec.agent?.id)
|
|
368
|
+
if (rt.botOf(self) === undefined) return 'Only Bots on the team can read group chats.'
|
|
369
|
+
const { room, error } = rt.pickRoom(args.group, self)
|
|
370
|
+
if (error) return error
|
|
371
|
+
ensureRoomLog(room)
|
|
372
|
+
const lines = room.log.slice(-ROOM_READ_MAX)
|
|
373
|
+
return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}`
|
|
374
|
+
},
|
|
375
|
+
}))
|
|
376
|
+
|
|
377
|
+
// A Bot that posts never answers its own post. @mentions choose the speakers; else
|
|
378
|
+
// the admin answers alone in admin mode, or first of everyone.
|
|
379
|
+
function openingSpeakers(room, opener) {
|
|
380
|
+
const members = room.members.map(rt.botOf).filter(bot => bot && bot.id !== opener.botId)
|
|
381
|
+
const admin = members.find(bot => rt.isAdminOf(room, bot.id))
|
|
382
|
+
if (opener.kickoff) return admin ? [admin] : []
|
|
383
|
+
const { all, found } = mentionsIn(opener.text, members)
|
|
384
|
+
if (found.length > 0 && !all) return found
|
|
385
|
+
if (!all && room.mode === 'admin' && admin) return [admin]
|
|
386
|
+
return admin ? [admin, ...members.filter(bot => bot !== admin)] : members
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// A group the user just made opens with its admin asking what the group is for.
|
|
390
|
+
async function greetRoom(roomId) {
|
|
391
|
+
const room = rt.roomOf(roomId)
|
|
392
|
+
if (!room?.admin) return
|
|
393
|
+
const agent = await rt.resolveAgent(room.id)
|
|
394
|
+
agent.followup(createUserMessage({
|
|
395
|
+
content: [{ type: 'text', text: '[Group chat created]' }],
|
|
396
|
+
source: { kind: SOURCE_KIND, form: 'relay', role: 'kickoff', exchangeId: randomUUID(), senderName: 'You', hop: 0, roomId: room.id },
|
|
397
|
+
}))
|
|
398
|
+
await ctx.sessions.flush(agent.session)
|
|
399
|
+
}
|
|
400
|
+
rt.greetRoom = greetRoom
|
|
401
|
+
|
|
402
|
+
async function reportRound(room, post, round) {
|
|
403
|
+
const lines = round.replies.filter(reply => reply.kind === undefined).map(reply => `${reply.name}: ${reply.text}`)
|
|
404
|
+
const text = [
|
|
405
|
+
lines.length > 0 ? `Replies to your post (oldest first):\n${lines.join('\n')}` : 'Nobody replied to your post.',
|
|
406
|
+
round.passed.length > 0 ? `Stayed silent: ${round.passed.join(', ')}.` : '',
|
|
407
|
+
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(', ')}.` : '',
|
|
408
|
+
].filter(Boolean).join('\n\n')
|
|
409
|
+
await rt.deliver({ toId: post.botId, role: 'report', roomId: room.id, text, hop: (post.hop ?? 0) + 1 })
|
|
410
|
+
.catch(error => rt.warn('ds-bot: group report to %s failed: %s', post.name, error))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// A group chat's own Session never consults the model: models drift from "only call
|
|
414
|
+
// group_relay" (they message one member directly when the user names it), so the
|
|
415
|
+
// relay step is synthesized here. Auxiliary calls such as titles still reach the model.
|
|
416
|
+
ctx.on('llm/stream', (options, next) => {
|
|
417
|
+
if (options.sessionId === undefined || options.purpose !== undefined) return next()
|
|
418
|
+
return (async function* relayOrPass() {
|
|
419
|
+
await rt.load()
|
|
420
|
+
if (rt.roomOf(options.sessionId) === undefined) {
|
|
421
|
+
yield* next()
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
// Plugins append context as user-role messages (time, attachments), so only a
|
|
425
|
+
// message from the user, a scheduled task, or a Bot's post after the last model or
|
|
426
|
+
// tool step starts a relay.
|
|
427
|
+
const opens = message => message.role === 'user'
|
|
428
|
+
&& (message.source?.kind === 'user' || message.source?.kind === 'schedule' || (message.source?.kind === SOURCE_KIND && (message.source.role === 'post' || message.source.role === 'kickoff')))
|
|
429
|
+
const latest = [...options.messages].reverse()
|
|
430
|
+
.find(message => message.role === 'assistant' || message.role === 'tool' || opens(message))
|
|
431
|
+
if (latest?.role !== 'user') {
|
|
432
|
+
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
const id = `toolu_relay_${randomUUID().replace(/-/g, '')}`
|
|
436
|
+
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
|
|
437
|
+
yield { type: 'tool-call-delta', index: 0, id, name: 'group_relay', argumentsDelta: '{}' }
|
|
438
|
+
yield { type: 'block-end', index: 0, block: { type: 'tool-call', id, name: 'group_relay', arguments: '{}' } }
|
|
439
|
+
yield { type: 'finish', reason: { kind: 'tool-calls' } }
|
|
440
|
+
})()
|
|
441
|
+
})
|
|
442
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// One writer per plugin home. dsh Web and dsh Desktop share ~/.dsh, and each host keeps
|
|
2
|
+
// the team in memory and writes state.json whole, so two hosts at once would undo each
|
|
3
|
+
// other's changes. The first host to load takes `state.lock`; a second one only reads.
|
|
4
|
+
//
|
|
5
|
+
// The lock is a small JSON file (pid, host, a random token) whose mtime the holder bumps
|
|
6
|
+
// on a timer. It counts as gone when its process has exited (same host) or when it has
|
|
7
|
+
// not been bumped for `staleMs`, which also covers a reused pid after a crash. On another
|
|
8
|
+
// host only the age can tell, so a lock on shared storage waits out `staleMs`. The token,
|
|
9
|
+
// not the pid, decides ownership: a holder that finds another token has lost the lock.
|
|
10
|
+
import { randomUUID } from 'node:crypto'
|
|
11
|
+
import { readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { hostname } from 'node:os'
|
|
13
|
+
|
|
14
|
+
export function pidAlive(pid) {
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0)
|
|
17
|
+
return true
|
|
18
|
+
} catch (error) {
|
|
19
|
+
// EPERM: the process exists but belongs to someone else.
|
|
20
|
+
return error?.code === 'EPERM'
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function homeLock(path, { staleMs = 60_000, beatMs = 15_000, host = hostname(), pid = process.pid, isAlive = pidAlive, now = Date.now } = {}) {
|
|
25
|
+
const token = randomUUID()
|
|
26
|
+
let timer
|
|
27
|
+
|
|
28
|
+
// undefined: no lock file; null: a file that does not parse (being written right now).
|
|
29
|
+
const read = () => {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return error?.code === 'ENOENT' ? undefined : null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const age = () => {
|
|
37
|
+
try { return now() - statSync(path).mtimeMs } catch { return Infinity }
|
|
38
|
+
}
|
|
39
|
+
const live = (holder) => {
|
|
40
|
+
if (holder === undefined) return false
|
|
41
|
+
if (age() >= staleMs) return false
|
|
42
|
+
if (holder === null) return true
|
|
43
|
+
if (holder.host !== host) return true
|
|
44
|
+
// A lock this same process left (a plugin reload whose dispose never ran) is no rival.
|
|
45
|
+
return holder.pid !== pid && isAlive(holder.pid)
|
|
46
|
+
}
|
|
47
|
+
const owns = () => read()?.token === token
|
|
48
|
+
|
|
49
|
+
const beat = () => {
|
|
50
|
+
if (!owns()) {
|
|
51
|
+
stop()
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
const time = new Date(now())
|
|
55
|
+
try { utimesSync(path, time, time) } catch {}
|
|
56
|
+
}
|
|
57
|
+
const stop = () => {
|
|
58
|
+
if (timer !== undefined) clearInterval(timer)
|
|
59
|
+
timer = undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Take the lock if it is free or its holder is gone. Returns undefined when this
|
|
63
|
+
// process holds it, else what the lock file says about the holder.
|
|
64
|
+
function claim() {
|
|
65
|
+
if (owns()) return undefined
|
|
66
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(path, JSON.stringify({ pid, host, token, since: now() }), { flag: 'wx' })
|
|
69
|
+
stop()
|
|
70
|
+
timer = setInterval(beat, beatMs)
|
|
71
|
+
timer.unref?.()
|
|
72
|
+
return undefined
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error?.code !== 'EEXIST') throw error
|
|
75
|
+
}
|
|
76
|
+
const holder = read()
|
|
77
|
+
if (live(holder)) return holder ?? {}
|
|
78
|
+
try { unlinkSync(path) } catch {}
|
|
79
|
+
}
|
|
80
|
+
return read() ?? {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function release() {
|
|
84
|
+
stop()
|
|
85
|
+
if (owns()) {
|
|
86
|
+
try { unlinkSync(path) } catch {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { claim, owns, release, read }
|
|
91
|
+
}
|