ds-bot 0.1.2 → 0.1.4
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/README.md +3 -3
- package/client.js +2164 -1102
- package/index.js +10 -1
- package/package.json +6 -4
- package/src/host/approvals.js +69 -0
- package/src/host/capabilities.js +521 -0
- package/src/host/channel.js +26 -4
- package/src/host/constants.js +12 -1
- package/src/host/delivery.js +34 -8
- package/src/host/diagnostics.js +7 -5
- package/src/host/groups.js +296 -59
- package/src/host/home-lock.js +16 -10
- package/src/host/images.js +37 -0
- package/src/host/memory.js +27 -16
- package/src/host/parts.js +94 -21
- package/src/host/prompt.js +7 -3
- package/src/host/resume.js +247 -0
- package/src/host/roster.js +110 -56
- package/src/host/secrets.js +9 -1
- package/src/host/store.js +129 -31
- package/src/host/time-context.js +46 -0
- package/src/host/tools.js +182 -29
- package/src/host/turns.js +263 -39
- package/src/host/update.js +5 -1
- package/src/shared/mentions.js +65 -0
- package/web-search.js +23 -7
package/src/host/groups.js
CHANGED
|
@@ -9,11 +9,17 @@
|
|
|
9
9
|
// goes to every member in roster order, admin first, or to the admin alone in admin
|
|
10
10
|
// mode; "@Name" picks members. A reply that @mentions a member calls that member
|
|
11
11
|
// next, bounded per member and per round; the admin's calls reach further.
|
|
12
|
+
//
|
|
13
|
+
// Work does not hold a round up: a member that is still busy with tools a little after
|
|
14
|
+
// it started, or that is called while still at earlier work, lets the round go on. Its
|
|
15
|
+
// turn keeps running in its group Session, and what it ends with is posted to the group,
|
|
16
|
+
// where the member that asked for it answers.
|
|
12
17
|
import { randomUUID } from 'node:crypto'
|
|
13
18
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
14
19
|
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'
|
|
20
|
+
import { ROOM_LATE_ROUNDS, ROOM_REPLY_TIMEOUT_MS, ROOM_WORK_GRACE_MS, SOURCE_KIND, TEAM_TOOLS } from './constants.js'
|
|
21
|
+
import { escapeRegExp, splitRef, textOf } from './text.js'
|
|
22
|
+
import { mentionSpans } from '../shared/mentions.js'
|
|
17
23
|
import { GROUP_REF, output } from './tools.js'
|
|
18
24
|
import { failureLine } from './turns.js'
|
|
19
25
|
|
|
@@ -47,41 +53,48 @@ const unseenLines = (room, memberId) => {
|
|
|
47
53
|
return room.log.filter(line => line.seq > since && line.botId !== memberId).slice(-ROOM_DELTA_MAX)
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
const lineText = line =>
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
const lineText = (line) => {
|
|
57
|
+
if (line.kind === 'lookup') return `[${line.who} looked up its own chat${line.text ? ` for "${line.text}"` : ''}]`
|
|
58
|
+
if (line.kind === 'resumed') return `[${line.who}'s work here was cut off when DeepSeek Harness restarted; it is picking it back up and posts when done]`
|
|
59
|
+
if (line.kind === 'cut') return `[${line.who}'s work here was cut off by a restart again, so it stopped; ask it to go on if it still matters]`
|
|
60
|
+
if (line.kind === 'stopped') return `[The user stopped ${line.who}'s work here]`
|
|
61
|
+
return `${line.who}: ${line.text}`
|
|
62
|
+
}
|
|
53
63
|
const transcript = lines => lines.map(lineText).join('\n')
|
|
54
64
|
|
|
55
65
|
// Models often echo the transcript format ("Name: …") despite the instruction.
|
|
56
66
|
export const stripOwnName = (name, text) => text.replace(new RegExp(`^\\s*\\**${escapeRegExp(name)}\\**\\s*[::]\\s*`), '').trim()
|
|
57
67
|
export const isPass = text => text === '' || /^\s*[[【((]\s*pass\s*[\]】))]|^\s*pass\s*[.。!!]?\s*$/i.test(text)
|
|
58
68
|
|
|
59
|
-
// "@
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
export const mentionsIn = (text, members) => {
|
|
64
|
-
const byLength = [...members].sort((a, b) => b.name.length - a.name.length)
|
|
69
|
+
// Who "@" calls, in order, by the same rule the bubbles draw. `references` are the
|
|
70
|
+
// round's referenced conversations: each one's "@label" is a reference, not a call.
|
|
71
|
+
// Pass every member, the speaker too, so "@新 Bot 2" said by 新 Bot 2 is never read as
|
|
72
|
+
// a call to 新 Bot; the caller drops the speaker.
|
|
73
|
+
export const mentionsIn = (text, members, references = []) => {
|
|
65
74
|
const found = []
|
|
66
75
|
let all = false
|
|
67
|
-
for (
|
|
68
|
-
if (
|
|
69
|
-
|
|
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)
|
|
76
|
+
for (const span of mentionSpans(text, members, { references: references.flatMap(reference => reference.labels) })) {
|
|
77
|
+
if (span.all) all = true
|
|
78
|
+
else if (!found.includes(span.bot)) found.push(span.bot)
|
|
74
79
|
}
|
|
75
80
|
return { all, found }
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
|
|
83
|
+
// What the user referenced in this round, for a member that has not been shown it yet.
|
|
84
|
+
export const referenceNote = references => (references?.length
|
|
85
|
+
? `Conversations the user referenced in this round:\n\n${references.map(reference => reference.text).join('\n\n')}`
|
|
86
|
+
: '')
|
|
87
|
+
|
|
88
|
+
const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.'
|
|
89
|
+
const ROOM_CALLS = 'Writing "@Name" calls that member next, to answer or to do what you ask; when it finishes work you gave it, you answer its result. To only mention a member, write its name without "@".'
|
|
79
90
|
|
|
80
91
|
const slotLabel = (slot) => {
|
|
81
92
|
if (slot.status === 'speaking') return `${slot.name} (you, now)`
|
|
82
93
|
if (slot.status === 'posted') return `${slot.name} (posted)`
|
|
83
94
|
if (slot.status === 'passed') return `${slot.name} (passed)`
|
|
84
95
|
if (slot.status === 'missed') return `${slot.name} (no answer)`
|
|
96
|
+
if (slot.status === 'working') return `${slot.name} (working on it; posts when done)`
|
|
97
|
+
if (slot.status === 'waiting-approval') return `${slot.name} (waiting for the user's approval; posts when done)`
|
|
85
98
|
if (slot.status === 'failed') return `${slot.name} (could not answer: error)`
|
|
86
99
|
if (slot.status === 'left') return `${slot.name} (left the group)`
|
|
87
100
|
return slot.name
|
|
@@ -90,7 +103,7 @@ const slotLabel = (slot) => {
|
|
|
90
103
|
const adminNote = room => [
|
|
91
104
|
'You are this group\'s admin.',
|
|
92
105
|
room.mode === 'admin' ? 'Plain messages come to you first: answer them, or call the members who should answer with @Name.' : '',
|
|
93
|
-
'
|
|
106
|
+
'"@all" calls every member. When the user asks, change the group with update_group.',
|
|
94
107
|
].filter(Boolean).join(' ')
|
|
95
108
|
|
|
96
109
|
// The opening turn of a group the user just made without a first message.
|
|
@@ -107,7 +120,10 @@ export function install(rt) {
|
|
|
107
120
|
async function groupSessionFor(room, member) {
|
|
108
121
|
room.sessions ??= {}
|
|
109
122
|
const known = room.sessions[member.id]
|
|
110
|
-
if (known !== undefined)
|
|
123
|
+
if (known !== undefined) {
|
|
124
|
+
await rt.ensureCapabilities(known)
|
|
125
|
+
return { id: known, brief: undefined }
|
|
126
|
+
}
|
|
111
127
|
const id = await rt.createSession(`${member.name} · ${room.name}`)
|
|
112
128
|
room.sessions[member.id] = id
|
|
113
129
|
rt.groupOwners.set(id, { botId: member.id, roomId: room.id })
|
|
@@ -120,6 +136,7 @@ export function install(rt) {
|
|
|
120
136
|
await rt.applyPermission(member, id)
|
|
121
137
|
.catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error))
|
|
122
138
|
await rt.save()
|
|
139
|
+
await rt.ensureCapabilities(id)
|
|
123
140
|
return { id, brief: await rt.ownChatBrief(member) }
|
|
124
141
|
}
|
|
125
142
|
|
|
@@ -136,9 +153,10 @@ export function install(rt) {
|
|
|
136
153
|
const index = round.slots.findIndex(slot => slot.status === 'speaking')
|
|
137
154
|
const before = index
|
|
138
155
|
const after = round.slots.length - index - 1
|
|
139
|
-
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
|
|
156
|
+
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. ${ROOM_CALLS}`
|
|
140
157
|
const notice = room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : ''
|
|
141
158
|
if (round.kickoff) return [members, notice, leads ? adminNote(room) : '', roomKickoff(rt.uiLanguage?.())].filter(Boolean).join('\n\n')
|
|
159
|
+
const working = workingIn(room).filter(item => item.bot.id !== member.id)
|
|
142
160
|
return [
|
|
143
161
|
members,
|
|
144
162
|
notice,
|
|
@@ -147,44 +165,155 @@ export function install(rt) {
|
|
|
147
165
|
current.length
|
|
148
166
|
? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}`
|
|
149
167
|
: 'Nothing new in this round since your last message.',
|
|
168
|
+
round.shown?.includes(member.id) ? '' : referenceNote(round.references),
|
|
169
|
+
working.length > 0 ? `Still at work on what they were asked; each posts here when done: ${working.map(item => `${item.bot.name} (${minutesSince(item.since)} min)`).join(', ')}.` : '',
|
|
150
170
|
`Speaking order for this round: ${round.slots.map(slotLabel).join(' → ')}.`,
|
|
151
171
|
`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.`}`,
|
|
152
172
|
ROOM_REPLY,
|
|
153
173
|
].filter(Boolean).join('\n\n')
|
|
154
174
|
}
|
|
155
175
|
|
|
176
|
+
// Members whose turn in this group is running, and since when.
|
|
177
|
+
const workingIn = room => room.members.flatMap((id) => {
|
|
178
|
+
const entry = rt.live.get(room.sessions?.[id])
|
|
179
|
+
const bot = rt.botOf(id)
|
|
180
|
+
return entry?.turn !== undefined && bot !== undefined ? [{ bot, since: entry.since ?? Date.now() }] : []
|
|
181
|
+
})
|
|
182
|
+
const minutesSince = at => Math.max(1, Math.round((Date.now() - at) / 60000))
|
|
183
|
+
|
|
156
184
|
// One member turn inside the room; resolves to its final text, `{ failure }` when its
|
|
157
|
-
// model call failed,
|
|
158
|
-
|
|
185
|
+
// model call failed, `{ missed }` when it went quiet for too long, `{ approval }` when
|
|
186
|
+
// it waits for the user's approval, `{ working }` when it goes on working without the
|
|
187
|
+
// round, or null when the round was stopped. The wait restarts on each event of the
|
|
188
|
+
// member's Session, so a member at work is never cut off. `by` is the member that
|
|
189
|
+
// called it, which answers what the turn posts after the round moved on.
|
|
190
|
+
async function roomTurn(room, member, sessionId, text, note, signal, started, by) {
|
|
159
191
|
if (signal.aborted) return null
|
|
160
192
|
const exchangeId = randomUUID()
|
|
193
|
+
const turn = { botId: member.id, sessionId, exchangeId }
|
|
194
|
+
started.push(turn)
|
|
161
195
|
room.seen[member.id] = room.seq
|
|
196
|
+
// Checked before the delivery, which may start the turn at once.
|
|
197
|
+
const busy = rt.live.get(sessionId)?.turn !== undefined
|
|
162
198
|
const done = new Promise((resolve) => {
|
|
199
|
+
let timer
|
|
200
|
+
let grace
|
|
163
201
|
const finish = (value) => {
|
|
164
202
|
clearTimeout(timer)
|
|
203
|
+
clearTimeout(grace)
|
|
165
204
|
rt.roomWaiters.delete(exchangeId)
|
|
166
205
|
resolve(value)
|
|
167
206
|
}
|
|
168
|
-
const
|
|
169
|
-
|
|
207
|
+
const touch = () => {
|
|
208
|
+
clearTimeout(timer)
|
|
209
|
+
timer = setTimeout(() => finish({ missed: true }), ROOM_REPLY_TIMEOUT_MS)
|
|
210
|
+
}
|
|
211
|
+
const work = () => { grace ??= setTimeout(() => finish({ working: true }), ROOM_WORK_GRACE_MS) }
|
|
212
|
+
touch()
|
|
213
|
+
rt.roomWaiters.set(exchangeId, { sessionId, botId: member.id, roomId: room.id, resolve: finish, fail: failure => finish({ failure }), touch, work, approval: () => finish({ approval: true }) })
|
|
170
214
|
signal.addEventListener('abort', () => finish(null), { once: true })
|
|
171
215
|
})
|
|
172
216
|
try {
|
|
173
|
-
await rt.deliver({ fromId: undefined, toId: member.id, sessionId, role: 'room', roomId: room.id, exchangeId, note, text })
|
|
217
|
+
await rt.deliver({ fromId: undefined, toId: member.id, sessionId, role: 'room', roomId: room.id, exchangeId, note, text, ...(by ? { askedBy: by } : {}) })
|
|
218
|
+
// Still held by an earlier request: this turn cannot start until the user answers.
|
|
219
|
+
if (rt.waitsForApproval?.(sessionId)) rt.roomWaiters.get(exchangeId)?.approval()
|
|
220
|
+
// Still at earlier work: the call waits in its inbox and is answered after that.
|
|
221
|
+
else if (busy) rt.roomWaiters.get(exchangeId)?.resolve({ working: true })
|
|
174
222
|
} catch (error) {
|
|
175
223
|
rt.warn('ds-bot: group delivery to %s failed: %s', member.name, error)
|
|
176
224
|
rt.roomWaiters.get(exchangeId)?.resolve(null)
|
|
177
225
|
}
|
|
178
226
|
const reply = await done
|
|
179
|
-
|
|
227
|
+
// A turn that ended leaves nothing for a stop to cancel.
|
|
228
|
+
if (typeof reply === 'string' || reply?.failure !== undefined) started.splice(started.indexOf(turn), 1)
|
|
229
|
+
if (reply === null || typeof reply === 'object') return reply
|
|
180
230
|
return stripOwnName(member.name, String(reply ?? '').trim())
|
|
181
231
|
}
|
|
182
232
|
|
|
183
|
-
|
|
184
|
-
|
|
233
|
+
// A member that reaches its context limit mid-turn goes on in a fresh Session, so a
|
|
234
|
+
// waiter is also found by the member and group that Session now speaks for.
|
|
235
|
+
const waitersOf = (sessionId) => {
|
|
236
|
+
const owner = rt.groupOwners.get(sessionId)
|
|
237
|
+
return [...rt.roomWaiters.values()].filter(waiter => waiter.sessionId === sessionId
|
|
238
|
+
|| (owner !== undefined && waiter.botId === owner.botId && waiter.roomId === owner.roomId))
|
|
239
|
+
}
|
|
240
|
+
rt.touchRoomTurn = (sessionId) => { for (const waiter of waitersOf(sessionId)) waiter.touch() }
|
|
241
|
+
rt.yieldRoomTurn = (sessionId) => { for (const waiter of waitersOf(sessionId)) waiter.approval() }
|
|
242
|
+
// The team's own tools are quick; any other tool is the start of work.
|
|
243
|
+
const QUICK_TOOLS = new Set(TEAM_TOOLS)
|
|
244
|
+
rt.workRoomTurn = (sessionId, tool) => {
|
|
245
|
+
if (QUICK_TOOLS.has(tool)) return
|
|
246
|
+
for (const waiter of waitersOf(sessionId)) waiter.work()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// A called member moves up if it is still waiting, or gets one more turn if it
|
|
250
|
+
// already spoke; either way it goes right after slot `index`. `cap` is the caller's
|
|
251
|
+
// per-member turn bound for the round; `by` is the calling member, if a member called.
|
|
252
|
+
function callMembers(round, turns, index, callees, note, cap, by) {
|
|
253
|
+
let next = index + 1
|
|
254
|
+
for (const peer of callees) {
|
|
255
|
+
if ((turns.get(peer.id) ?? 0) >= cap) continue
|
|
256
|
+
const waiting = round.slots.findIndex((other, at) => at > index && other.id === peer.id && other.status === 'waiting')
|
|
257
|
+
const called = waiting === -1 ? { id: peer.id, name: peer.name, status: 'waiting' } : round.slots.splice(waiting, 1)[0]
|
|
258
|
+
called.note = note
|
|
259
|
+
if (by === undefined) delete called.by
|
|
260
|
+
else called.by = by
|
|
261
|
+
round.slots.splice(next, 0, called)
|
|
262
|
+
next += 1
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// The user's messages waiting in the group Session's inbox while a round runs, from
|
|
267
|
+
// the composer whether it queued or steered them. Images and chat references are
|
|
268
|
+
// only read when the Session takes the message itself, so a message with either
|
|
269
|
+
// stays for the next round, and so does everything after it in that list.
|
|
270
|
+
const waitingLines = agent => ['nextStep', 'nextTurn'].flatMap((list) => {
|
|
271
|
+
const lines = []
|
|
272
|
+
for (const message of agent?.inbox?.[list] ?? []) {
|
|
273
|
+
if (message.source?.kind !== 'user') continue
|
|
274
|
+
const content = Array.isArray(message.content) ? message.content : []
|
|
275
|
+
const text = textOf(content)
|
|
276
|
+
if (text === '' || content.some(block => block?.type !== 'text') || text.includes('dsh-session:')) break
|
|
277
|
+
lines.push({ id: message.id, text })
|
|
278
|
+
}
|
|
279
|
+
return lines
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// Messages the user sent while this round is running post into the log before the
|
|
283
|
+
// next speaker, so everyone still to speak sees them. The members the message's own
|
|
284
|
+
// round would open with answer it next, inside this round. Each message leaves the
|
|
285
|
+
// Session's inbox, so it does not open another round when this one ends. Returns
|
|
286
|
+
// the number of turns added, which the round's limit allows for.
|
|
287
|
+
function drainRoomInbox(room, round, turns, index, agent) {
|
|
288
|
+
// The greeting's prompt tells the admin the user has not written yet.
|
|
289
|
+
if (round.kickoff) return 0
|
|
290
|
+
const lines = waitingLines(agent).filter(line => agent.inbox.remove(line.id))
|
|
291
|
+
if (lines.length === 0) return 0
|
|
292
|
+
const before = round.slots.length
|
|
293
|
+
const members = room.members.map(rt.botOf).filter(Boolean)
|
|
294
|
+
for (const line of lines) {
|
|
295
|
+
appendRoom(room, { who: 'User', text: line.text })
|
|
296
|
+
round.replies.push({ kind: 'user', text: line.text, time: Date.now() })
|
|
297
|
+
const { all, found } = mentionsIn(line.text, members)
|
|
298
|
+
const note = all || found.length > 0 ? 'the user mentioned you' : 'new message from the user'
|
|
299
|
+
callMembers(round, turns, index - 1, openingSpeakers(room, { text: line.text }), note, ROOM_ADMIN_TURNS_EACH)
|
|
300
|
+
}
|
|
301
|
+
// The user spoke in this room, so Bots may start their own rounds again.
|
|
302
|
+
room.botRounds = 0
|
|
303
|
+
room.lateRounds = 0
|
|
304
|
+
void rt.save().catch(() => {})
|
|
305
|
+
return round.slots.length - before
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function runRound(room, round, signal, agent) {
|
|
309
|
+
let limit = round.slots.length + ROOM_EXTRA_TURNS + (room.admin ? ROOM_ADMIN_EXTRA_TURNS : 0)
|
|
185
310
|
const turns = new Map()
|
|
186
|
-
|
|
311
|
+
// The inbox check keeps a message sent during the last speaker's turn inside
|
|
312
|
+
// this round: it drains at the loop's head even once the slots ran out.
|
|
313
|
+
for (let index = 0; (index < round.slots.length || waitingLines(agent).length > 0) && round.turns < limit; index += 1) {
|
|
187
314
|
if (signal.aborted) break
|
|
315
|
+
limit += drainRoomInbox(room, round, turns, index, agent)
|
|
316
|
+
if (index >= round.slots.length) break
|
|
188
317
|
const slot = round.slots[index]
|
|
189
318
|
const member = rt.botOf(slot.id)
|
|
190
319
|
// Settings may change mid-round, for example when the admin removes someone.
|
|
@@ -208,8 +337,9 @@ export function install(rt) {
|
|
|
208
337
|
continue
|
|
209
338
|
}
|
|
210
339
|
const prompt = roomPrompt(room, member, round)
|
|
340
|
+
round.shown?.push(member.id)
|
|
211
341
|
const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}`
|
|
212
|
-
const reply = await roomTurn(room, member, session.id, text, slot.note ?? 'your turn', signal)
|
|
342
|
+
const reply = await roomTurn(room, member, session.id, text, slot.note ?? 'your turn', signal, round.started, slot.by)
|
|
213
343
|
round.speaking = null
|
|
214
344
|
if (reply?.failure !== undefined) {
|
|
215
345
|
// Not a pass: the user sees why the member is silent, and the round goes on.
|
|
@@ -219,31 +349,35 @@ export function install(rt) {
|
|
|
219
349
|
round.replies.push({ kind: 'failed', botId: member.id, name: member.name, color: member.color, code, ...(status ? { status } : {}), text: message, time: Date.now() })
|
|
220
350
|
continue
|
|
221
351
|
}
|
|
222
|
-
|
|
223
|
-
|
|
352
|
+
// The member's turn goes on in the background and posts when it ends.
|
|
353
|
+
if (reply?.approval) {
|
|
354
|
+
slot.status = 'waiting-approval'
|
|
355
|
+
round.replies.push({ kind: 'approval', botId: member.id, name: member.name, color: member.color, time: Date.now() })
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
358
|
+
if (reply?.working) {
|
|
359
|
+
slot.status = 'working'
|
|
360
|
+
round.replies.push({ kind: 'working', botId: member.id, name: member.name, color: member.color, time: Date.now() })
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
if (reply === null || reply?.missed || isPass(reply)) {
|
|
364
|
+
slot.status = typeof reply === 'string' ? 'passed' : 'missed'
|
|
224
365
|
round.passed.push(member.name)
|
|
366
|
+
if (reply?.missed) round.replies.push({ kind: 'missed', botId: member.id, name: member.name, color: member.color, time: Date.now() })
|
|
225
367
|
continue
|
|
226
368
|
}
|
|
227
369
|
slot.status = 'posted'
|
|
228
370
|
appendRoom(room, { who: member.name, botId: member.id, text: reply })
|
|
229
371
|
round.replies.push({ botId: member.id, name: member.name, color: member.color, text: reply, time: Date.now() })
|
|
230
|
-
void rt.save()
|
|
231
|
-
//
|
|
232
|
-
// already spoke; either way it goes right after this speaker. Only the admin's
|
|
233
|
-
// "@all" calls everyone, so a member cannot set off a storm.
|
|
372
|
+
void rt.save().catch(() => {})
|
|
373
|
+
// Only the admin's "@all" calls everyone, so a member cannot set off a storm.
|
|
234
374
|
const leads = rt.isAdminOf(room, member.id)
|
|
235
|
-
const
|
|
236
|
-
const
|
|
375
|
+
const everyone = room.members.map(rt.botOf).filter(Boolean)
|
|
376
|
+
const others = everyone.filter(other => other.id !== member.id)
|
|
377
|
+
const { all, found: named } = mentionsIn(reply, everyone)
|
|
378
|
+
const found = named.filter(other => other.id !== member.id)
|
|
237
379
|
const callees = leads && all ? [...found, ...others.filter(other => !found.includes(other))] : found
|
|
238
|
-
|
|
239
|
-
for (const peer of callees) {
|
|
240
|
-
if ((turns.get(peer.id) ?? 0) >= (leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH)) continue
|
|
241
|
-
const waiting = round.slots.findIndex((other, at) => at > index && other.id === peer.id && other.status === 'waiting')
|
|
242
|
-
const called = waiting === -1 ? { id: peer.id, name: peer.name, status: 'waiting' } : round.slots.splice(waiting, 1)[0]
|
|
243
|
-
called.note = `${member.name} mentioned you`
|
|
244
|
-
round.slots.splice(next, 0, called)
|
|
245
|
-
next += 1
|
|
246
|
-
}
|
|
380
|
+
callMembers(round, turns, index, callees, `${member.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, member.id)
|
|
247
381
|
}
|
|
248
382
|
}
|
|
249
383
|
|
|
@@ -263,37 +397,71 @@ export function install(rt) {
|
|
|
263
397
|
rt.roomInbox.delete(room.id)
|
|
264
398
|
const posts = queued.length > 0 ? queued : [{ text: '' }]
|
|
265
399
|
const startSeq = room.seq + 1
|
|
266
|
-
// The opening greeting is a cue for the admin, not a line of the group
|
|
267
|
-
|
|
400
|
+
// The opening greeting is a cue for the admin, not a line of the group; a member's
|
|
401
|
+
// late failure, its work picked up after a restart, or its work the user stopped is
|
|
402
|
+
// an event of the round.
|
|
403
|
+
const event = item => item.failed || item.resumed || item.stopped
|
|
404
|
+
for (const post of posts.filter(item => !item.kickoff && !event(item))) {
|
|
268
405
|
// Image readings go into the log only; `@` names are picked from what the user typed.
|
|
269
406
|
const text = post.images === undefined ? post.text : [post.text, ...await rt.imagesForGroup(post.images, room.id)].filter(Boolean).join('\n')
|
|
270
407
|
appendRoom(room, post.botId
|
|
271
408
|
? { who: rt.botOf(post.botId)?.name ?? post.name ?? 'Bot', botId: post.botId, text }
|
|
272
409
|
: { who: 'User', text })
|
|
273
410
|
}
|
|
274
|
-
if (posts.some(post => !post.botId && !post.kickoff))
|
|
411
|
+
if (posts.some(post => !post.botId && !post.kickoff)) {
|
|
412
|
+
room.botRounds = 0
|
|
413
|
+
room.lateRounds = 0
|
|
414
|
+
}
|
|
275
415
|
await rt.save()
|
|
276
|
-
|
|
416
|
+
// An event post never picks the speakers over a message queued with it.
|
|
417
|
+
const opener = posts.findLast(post => !event(post)) ?? posts.at(-1)
|
|
418
|
+
const references = posts.flatMap(post => post.references ?? [])
|
|
419
|
+
// A member a Bot's post calls by name works for that Bot.
|
|
420
|
+
const named = opener.botId === undefined ? [] : mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean)).found
|
|
277
421
|
const round = {
|
|
278
422
|
roomId: room.id,
|
|
279
423
|
members: room.members,
|
|
280
424
|
startSeq,
|
|
281
425
|
...(opener.kickoff ? { kickoff: true } : {}),
|
|
282
|
-
|
|
426
|
+
...(references.length > 0 ? { references, shown: [] } : {}),
|
|
427
|
+
slots: openingSpeakers(room, opener).map(member => ({
|
|
428
|
+
id: member.id, name: member.name, status: 'waiting',
|
|
429
|
+
...(opener.kickoff ? { note: 'the group was just created' } : {}),
|
|
430
|
+
...(named.includes(member) ? { by: opener.botId } : {}),
|
|
431
|
+
})),
|
|
283
432
|
speaking: null,
|
|
284
433
|
turns: 0,
|
|
285
434
|
replies: [],
|
|
286
435
|
passed: [],
|
|
287
436
|
failed: [],
|
|
437
|
+
started: [],
|
|
438
|
+
}
|
|
439
|
+
for (const post of posts.filter(item => item.failed)) {
|
|
440
|
+
const bot = rt.botOf(post.botId)
|
|
441
|
+
const { code, status, message } = post.failed
|
|
442
|
+
round.failed.push(bot?.name ?? post.name)
|
|
443
|
+
round.replies.push({ kind: 'failed', botId: post.botId, name: bot?.name ?? post.name, color: bot?.color, code, ...(status ? { status } : {}), text: message, time: Date.now() })
|
|
444
|
+
}
|
|
445
|
+
// The log line tells the admin and the other members on their next turn.
|
|
446
|
+
for (const post of posts.filter(item => item.resumed || item.stopped)) {
|
|
447
|
+
const bot = rt.botOf(post.botId)
|
|
448
|
+
const name = bot?.name ?? post.name
|
|
449
|
+
const kind = post.stopped ? 'stopped' : post.again ? 'cut' : 'resumed'
|
|
450
|
+
appendRoom(room, { who: name, botId: post.botId, kind, text: '' })
|
|
451
|
+
round.replies.push({ kind, botId: post.botId, name, color: bot?.color, time: Date.now() })
|
|
288
452
|
}
|
|
453
|
+
if (posts.some(post => post.resumed || post.stopped)) await rt.save()
|
|
289
454
|
rt.roomProgress.set(exec.callId, round)
|
|
290
455
|
try {
|
|
291
|
-
await runRound(room, round, exec.signal)
|
|
456
|
+
await runRound(room, round, exec.signal, exec.agent)
|
|
292
457
|
} catch (error) {
|
|
293
458
|
rt.warn('ds-bot: group round in %s stopped: %s', room.name, error)
|
|
294
459
|
} finally {
|
|
295
460
|
rt.roomProgress.delete(exec.callId)
|
|
296
461
|
}
|
|
462
|
+
if (exec.signal?.aborted) {
|
|
463
|
+
for (const turn of round.started) void rt.stopRoomTurn?.(room.sessions?.[turn.botId] ?? turn.sessionId, turn.exchangeId)
|
|
464
|
+
}
|
|
297
465
|
await rt.save()
|
|
298
466
|
for (const post of posts) {
|
|
299
467
|
if (post.reportBack && rt.botOf(post.botId)) void reportRound(room, post, round)
|
|
@@ -332,7 +500,7 @@ export function install(rt) {
|
|
|
332
500
|
for (const round of rt.roomProgress.values()) {
|
|
333
501
|
if (round.roomId === room.id) round.replies.push({ kind: 'lookup', botId: member.id, name: member.name, color: member.color, text, time: Date.now() })
|
|
334
502
|
}
|
|
335
|
-
void rt.save()
|
|
503
|
+
void rt.save().catch(() => {})
|
|
336
504
|
}
|
|
337
505
|
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.` : ''
|
|
338
506
|
if (query === '' && around !== '') {
|
|
@@ -377,17 +545,82 @@ export function install(rt) {
|
|
|
377
545
|
}))
|
|
378
546
|
|
|
379
547
|
// A Bot that posts never answers its own post. @mentions choose the speakers; else
|
|
380
|
-
// the admin answers alone in admin mode, or first of everyone.
|
|
548
|
+
// the admin answers alone in admin mode, or first of everyone. What a member posts
|
|
549
|
+
// after its round moved on is answered by the member that asked for it; work the
|
|
550
|
+
// user asked for is left for the user to read.
|
|
381
551
|
function openingSpeakers(room, opener) {
|
|
382
|
-
const
|
|
552
|
+
const everyone = room.members.map(rt.botOf).filter(Boolean)
|
|
553
|
+
const members = everyone.filter(bot => bot.id !== opener.botId)
|
|
383
554
|
const admin = members.find(bot => rt.isAdminOf(room, bot.id))
|
|
384
555
|
if (opener.kickoff) return admin ? [admin] : []
|
|
385
|
-
|
|
556
|
+
if (opener.quiet) return []
|
|
557
|
+
const { all, found: named } = mentionsIn(opener.text, everyone, opener.references)
|
|
558
|
+
const found = named.filter(bot => bot.id !== opener.botId)
|
|
559
|
+
if (opener.late) return found.length > 0 ? found : members.filter(bot => bot.id === opener.askedBy)
|
|
386
560
|
if (found.length > 0 && !all) return found
|
|
387
561
|
if (!all && room.mode === 'admin' && admin) return [admin]
|
|
388
562
|
return admin ? [admin, ...members.filter(bot => bot !== admin)] : members
|
|
389
563
|
}
|
|
390
564
|
|
|
565
|
+
// A Bot's message into the group's own Session, which relays it as a new round.
|
|
566
|
+
async function postToRoom(room, sender, text, extra = {}) {
|
|
567
|
+
const agent = await rt.resolveAgent(room.id)
|
|
568
|
+
agent.followup(createUserMessage({
|
|
569
|
+
content: [{ type: 'text', text: `[Group post from ${sender.name}]\n${text}` }],
|
|
570
|
+
source: { kind: SOURCE_KIND, form: 'relay', role: 'post', exchangeId: randomUUID(), senderName: sender.name, senderSessionId: sender.id, roomId: room.id, hop: 0, ...extra },
|
|
571
|
+
}))
|
|
572
|
+
await ctx.sessions.flush(agent.session)
|
|
573
|
+
}
|
|
574
|
+
rt.postToRoom = postToRoom
|
|
575
|
+
|
|
576
|
+
// A member the round moved on from (it went on working, went quiet or waited for an
|
|
577
|
+
// approval) posts what its turn ended with. The member that asked for it and anyone
|
|
578
|
+
// it names answer; past the late round limit nobody does, so late posts cannot keep a
|
|
579
|
+
// group talking.
|
|
580
|
+
async function lateRoomReply(sessionId, info, reply) {
|
|
581
|
+
const owner = rt.groupOwners.get(sessionId)
|
|
582
|
+
const room = rt.roomOf(owner?.roomId ?? info.roomId)
|
|
583
|
+
const member = rt.botOf(owner?.botId)
|
|
584
|
+
if (room === undefined || member === undefined || !room.members.includes(member.id)) return
|
|
585
|
+
const text = stripOwnName(member.name, reply)
|
|
586
|
+
if (isPass(text)) return
|
|
587
|
+
const quiet = (room.lateRounds ?? 0) >= ROOM_LATE_ROUNDS
|
|
588
|
+
if (!quiet) room.lateRounds = (room.lateRounds ?? 0) + 1
|
|
589
|
+
await rt.save()
|
|
590
|
+
await postToRoom(room, member, text, { late: true, ...(info.askedBy ? { askedBy: info.askedBy } : {}), ...(quiet ? { quiet: true } : {}) })
|
|
591
|
+
.catch(error => rt.warn('ds-bot: late group reply from %s failed: %s', member.name, error))
|
|
592
|
+
}
|
|
593
|
+
rt.lateRoomReply = lateRoomReply
|
|
594
|
+
|
|
595
|
+
// The user stops a member's work in this group from the group's details. Messages
|
|
596
|
+
// still waiting for that member here go with it.
|
|
597
|
+
async function stopMemberWork(roomId, botId) {
|
|
598
|
+
await rt.load()
|
|
599
|
+
const room = rt.roomOf(roomId)
|
|
600
|
+
const member = rt.botOf(botId)
|
|
601
|
+
const sessionId = room?.sessions?.[botId]
|
|
602
|
+
if (room === undefined || member === undefined || sessionId === undefined || rt.live.get(sessionId)?.turn === undefined) return false
|
|
603
|
+
const agent = await rt.resolveAgent(sessionId)
|
|
604
|
+
agent.cancel({ kind: 'user' })
|
|
605
|
+
await postToRoom(room, member, '', { quiet: true, stopped: true })
|
|
606
|
+
return true
|
|
607
|
+
}
|
|
608
|
+
rt.stopMemberWork = stopMemberWork
|
|
609
|
+
|
|
610
|
+
// The round said such a member posts when done; a failed model call is how it ended.
|
|
611
|
+
// Nobody answers it.
|
|
612
|
+
async function lateRoomFailure(sessionId, info, failure) {
|
|
613
|
+
const owner = rt.groupOwners.get(sessionId)
|
|
614
|
+
const room = rt.roomOf(owner?.roomId ?? info.roomId)
|
|
615
|
+
const member = rt.botOf(owner?.botId)
|
|
616
|
+
if (room === undefined || member === undefined || !room.members.includes(member.id)) return
|
|
617
|
+
const { code, status, message } = failure
|
|
618
|
+
await postToRoom(room, member, `Could not finish: its model call failed (${failureLine(failure)}).`, {
|
|
619
|
+
late: true, quiet: true, failed: { code, ...(status ? { status } : {}), message },
|
|
620
|
+
}).catch(error => rt.warn('ds-bot: late group failure from %s failed: %s', member.name, error))
|
|
621
|
+
}
|
|
622
|
+
rt.lateRoomFailure = lateRoomFailure
|
|
623
|
+
|
|
391
624
|
// A group the user just made opens with its admin asking what the group is for.
|
|
392
625
|
async function greetRoom(roomId) {
|
|
393
626
|
const room = rt.roomOf(roomId)
|
|
@@ -403,8 +636,12 @@ export function install(rt) {
|
|
|
403
636
|
|
|
404
637
|
async function reportRound(room, post, round) {
|
|
405
638
|
const lines = round.replies.filter(reply => reply.kind === undefined).map(reply => `${reply.name}: ${reply.text}`)
|
|
639
|
+
const waiting = round.replies.filter(reply => reply.kind === 'approval').map(reply => reply.name)
|
|
640
|
+
const working = round.replies.filter(reply => reply.kind === 'working').map(reply => reply.name)
|
|
406
641
|
const text = [
|
|
407
642
|
lines.length > 0 ? `Replies to your post (oldest first):\n${lines.join('\n')}` : 'Nobody replied to your post.',
|
|
643
|
+
working.length > 0 ? `Still working on it: ${working.join(', ')}. Each posts its result in the group when done, not to you; read_group_chat shows it.` : '',
|
|
644
|
+
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.` : '',
|
|
408
645
|
round.passed.length > 0 ? `Stayed silent: ${round.passed.join(', ')}.` : '',
|
|
409
646
|
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(', ')}.` : '',
|
|
410
647
|
].filter(Boolean).join('\n\n')
|
package/src/host/home-lock.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// other's changes. The first host to load takes `state.lock`; a second one only reads.
|
|
4
4
|
//
|
|
5
5
|
// The lock is a small JSON file (pid, host, a random token) whose mtime the holder bumps
|
|
6
|
-
// on a timer.
|
|
7
|
-
//
|
|
8
|
-
// host only
|
|
9
|
-
//
|
|
6
|
+
// on a timer. On the same host, its process decides whether it is live; `staleMs` only
|
|
7
|
+
// covers other hosts and unreadable files. A reused pid after a crash keeps the second
|
|
8
|
+
// host read-only until that process ends. The token, not the pid, decides ownership: a
|
|
9
|
+
// holder that finds another token has lost the lock.
|
|
10
10
|
import { randomUUID } from 'node:crypto'
|
|
11
11
|
import { readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
|
|
12
12
|
import { hostname } from 'node:os'
|
|
@@ -38,11 +38,9 @@ export function homeLock(path, { staleMs = 60_000, beatMs = 15_000, host = hostn
|
|
|
38
38
|
}
|
|
39
39
|
const live = (holder) => {
|
|
40
40
|
if (holder === undefined) return false
|
|
41
|
-
if (age()
|
|
42
|
-
if (holder ===
|
|
43
|
-
|
|
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)
|
|
41
|
+
if (holder === null) return age() < staleMs
|
|
42
|
+
if (holder.host === host) return holder.pid !== pid && isAlive(holder.pid)
|
|
43
|
+
return age() < staleMs
|
|
46
44
|
}
|
|
47
45
|
const owns = () => read()?.token === token
|
|
48
46
|
|
|
@@ -75,7 +73,15 @@ export function homeLock(path, { staleMs = 60_000, beatMs = 15_000, host = hostn
|
|
|
75
73
|
}
|
|
76
74
|
const holder = read()
|
|
77
75
|
if (live(holder)) return holder ?? {}
|
|
78
|
-
|
|
76
|
+
const current = read()
|
|
77
|
+
const unchanged = holder === null
|
|
78
|
+
? current === null
|
|
79
|
+
: holder?.token !== undefined
|
|
80
|
+
? current?.token === holder.token
|
|
81
|
+
: JSON.stringify(current) === JSON.stringify(holder)
|
|
82
|
+
if (unchanged) {
|
|
83
|
+
try { unlinkSync(path) } catch {}
|
|
84
|
+
}
|
|
79
85
|
}
|
|
80
86
|
return read() ?? {}
|
|
81
87
|
}
|
package/src/host/images.js
CHANGED
|
@@ -8,6 +8,25 @@ import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
|
|
8
8
|
import { OCR_CACHE_MAX, OCR_PROMPT, OCR_RETRY_MS, OCR_TIMEOUT_MS } from './constants.js'
|
|
9
9
|
import { splitRef } from './text.js'
|
|
10
10
|
|
|
11
|
+
export function offloadTargets(session, beforeSeq) {
|
|
12
|
+
const targets = []
|
|
13
|
+
for (const seq of session.surface.nodes) {
|
|
14
|
+
if (seq >= beforeSeq) continue
|
|
15
|
+
const event = session.eventAt(seq)
|
|
16
|
+
if (event?.type !== 'user/message' && event?.type !== 'tool/result') continue
|
|
17
|
+
const message = session.deriveEventMessage(event)
|
|
18
|
+
const imageIndexes = []
|
|
19
|
+
let imageIndex = 0
|
|
20
|
+
for (const block of message?.content ?? []) {
|
|
21
|
+
if (block?.type !== 'image') continue
|
|
22
|
+
if (block.offloaded !== true) imageIndexes.push(imageIndex)
|
|
23
|
+
imageIndex += 1
|
|
24
|
+
}
|
|
25
|
+
if (imageIndexes.length > 0) targets.push({ seq, imageIndexes })
|
|
26
|
+
}
|
|
27
|
+
return targets
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
export function install(rt) {
|
|
12
31
|
const { ctx, config, home } = rt
|
|
13
32
|
|
|
@@ -170,5 +189,23 @@ export function install(rt) {
|
|
|
170
189
|
})()
|
|
171
190
|
})
|
|
172
191
|
|
|
192
|
+
ctx.on('agent/status', ({ agent, status }) => {
|
|
193
|
+
if (status !== 'idle' || !agent?.session || !rt.isBotSession(agent.id) || rt.readOnly() !== undefined) return
|
|
194
|
+
const beforeSeq = agent.session.seq
|
|
195
|
+
setImmediate(() => {
|
|
196
|
+
void (async () => {
|
|
197
|
+
try {
|
|
198
|
+
if (!ctx.sessions.messageProjections.some(projection => projection.type === 'image/offload')) return
|
|
199
|
+
const targets = offloadTargets(agent.session, beforeSeq)
|
|
200
|
+
if (targets.length === 0) return
|
|
201
|
+
agent.session.append('image/offload', { targets })
|
|
202
|
+
await ctx.sessions.flush(agent.session)
|
|
203
|
+
} catch (error) {
|
|
204
|
+
rt.warn('ds-bot: old images for %s not offloaded: %s', agent.id, error)
|
|
205
|
+
}
|
|
206
|
+
})()
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
173
210
|
Object.assign(rt, { imagesForGroup })
|
|
174
211
|
}
|