ds-bot 0.1.1 → 0.1.3
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 +5 -5
- package/client.js +1311 -466
- package/index.js +3 -1
- package/package.json +6 -7
- package/src/host/approvals.js +63 -0
- package/src/host/channel.js +14 -3
- package/src/host/constants.js +4 -1
- package/src/host/delivery.js +19 -7
- package/src/host/diagnostics.js +7 -5
- package/src/host/dsh-update.js +8 -6
- package/src/host/groups.js +195 -47
- 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 +92 -21
- package/src/host/prompt.js +6 -2
- package/src/host/roster.js +109 -56
- package/src/host/store.js +113 -28
- package/src/host/time-context.js +46 -0
- package/src/host/tools.js +79 -28
- package/src/host/turns.js +176 -38
- package/src/host/update.js +31 -4
- package/src/shared/mentions.js +65 -0
- package/web-search.js +23 -7
package/src/host/groups.js
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
import { randomUUID } from 'node:crypto'
|
|
13
13
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
14
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'
|
|
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'
|
|
17
18
|
import { GROUP_REF, output } from './tools.js'
|
|
18
19
|
import { failureLine } from './turns.js'
|
|
19
20
|
|
|
@@ -56,25 +57,25 @@ const transcript = lines => lines.map(lineText).join('\n')
|
|
|
56
57
|
export const stripOwnName = (name, text) => text.replace(new RegExp(`^\\s*\\**${escapeRegExp(name)}\\**\\s*[::]\\s*`), '').trim()
|
|
57
58
|
export const isPass = text => text === '' || /^\s*[[【((]\s*pass\s*[\]】))]|^\s*pass\s*[.。!!]?\s*$/i.test(text)
|
|
58
59
|
|
|
59
|
-
// "@
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
export const mentionsIn = (text, members) => {
|
|
64
|
-
const byLength = [...members].sort((a, b) => b.name.length - a.name.length)
|
|
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
65
|
const found = []
|
|
66
66
|
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)
|
|
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)
|
|
74
70
|
}
|
|
75
71
|
return { all, found }
|
|
76
72
|
}
|
|
77
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
|
+
|
|
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.'
|
|
79
80
|
|
|
80
81
|
const slotLabel = (slot) => {
|
|
@@ -82,6 +83,7 @@ const slotLabel = (slot) => {
|
|
|
82
83
|
if (slot.status === 'posted') return `${slot.name} (posted)`
|
|
83
84
|
if (slot.status === 'passed') return `${slot.name} (passed)`
|
|
84
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)`
|
|
85
87
|
if (slot.status === 'failed') return `${slot.name} (could not answer: error)`
|
|
86
88
|
if (slot.status === 'left') return `${slot.name} (left the group)`
|
|
87
89
|
return slot.name
|
|
@@ -147,6 +149,7 @@ export function install(rt) {
|
|
|
147
149
|
current.length
|
|
148
150
|
? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}`
|
|
149
151
|
: 'Nothing new in this round since your last message.',
|
|
152
|
+
round.shown?.includes(member.id) ? '' : referenceNote(round.references),
|
|
150
153
|
`Speaking order for this round: ${round.slots.map(slotLabel).join(' → ')}.`,
|
|
151
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.`}`,
|
|
152
155
|
ROOM_REPLY,
|
|
@@ -154,37 +157,120 @@ export function install(rt) {
|
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
// One member turn inside the room; resolves to its final text, `{ failure }` when its
|
|
157
|
-
// model call failed,
|
|
158
|
-
|
|
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) {
|
|
159
164
|
if (signal.aborted) return null
|
|
160
165
|
const exchangeId = randomUUID()
|
|
166
|
+
const turn = { botId: member.id, sessionId, exchangeId }
|
|
167
|
+
started.push(turn)
|
|
161
168
|
room.seen[member.id] = room.seq
|
|
162
169
|
const done = new Promise((resolve) => {
|
|
170
|
+
let timer
|
|
163
171
|
const finish = (value) => {
|
|
164
172
|
clearTimeout(timer)
|
|
165
173
|
rt.roomWaiters.delete(exchangeId)
|
|
166
174
|
resolve(value)
|
|
167
175
|
}
|
|
168
|
-
const
|
|
169
|
-
|
|
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 }) })
|
|
170
182
|
signal.addEventListener('abort', () => finish(null), { once: true })
|
|
171
183
|
})
|
|
172
184
|
try {
|
|
173
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()
|
|
174
188
|
} catch (error) {
|
|
175
189
|
rt.warn('ds-bot: group delivery to %s failed: %s', member.name, error)
|
|
176
190
|
rt.roomWaiters.get(exchangeId)?.resolve(null)
|
|
177
191
|
}
|
|
178
192
|
const reply = await done
|
|
179
|
-
|
|
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
|
|
180
196
|
return stripOwnName(member.name, String(reply ?? '').trim())
|
|
181
197
|
}
|
|
182
198
|
|
|
183
|
-
|
|
184
|
-
|
|
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)
|
|
185
267
|
const turns = new Map()
|
|
186
|
-
|
|
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) {
|
|
187
271
|
if (signal.aborted) break
|
|
272
|
+
limit += drainRoomInbox(room, round, turns, index, agent)
|
|
273
|
+
if (index >= round.slots.length) break
|
|
188
274
|
const slot = round.slots[index]
|
|
189
275
|
const member = rt.botOf(slot.id)
|
|
190
276
|
// Settings may change mid-round, for example when the admin removes someone.
|
|
@@ -208,8 +294,9 @@ export function install(rt) {
|
|
|
208
294
|
continue
|
|
209
295
|
}
|
|
210
296
|
const prompt = roomPrompt(room, member, round)
|
|
297
|
+
round.shown?.push(member.id)
|
|
211
298
|
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)
|
|
299
|
+
const reply = await roomTurn(room, member, session.id, text, slot.note ?? 'your turn', signal, round.started)
|
|
213
300
|
round.speaking = null
|
|
214
301
|
if (reply?.failure !== undefined) {
|
|
215
302
|
// Not a pass: the user sees why the member is silent, and the round goes on.
|
|
@@ -219,31 +306,30 @@ export function install(rt) {
|
|
|
219
306
|
round.replies.push({ kind: 'failed', botId: member.id, name: member.name, color: member.color, code, ...(status ? { status } : {}), text: message, time: Date.now() })
|
|
220
307
|
continue
|
|
221
308
|
}
|
|
222
|
-
|
|
223
|
-
|
|
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'
|
|
224
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() })
|
|
225
319
|
continue
|
|
226
320
|
}
|
|
227
321
|
slot.status = 'posted'
|
|
228
322
|
appendRoom(room, { who: member.name, botId: member.id, text: reply })
|
|
229
323
|
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.
|
|
324
|
+
void rt.save().catch(() => {})
|
|
325
|
+
// Only the admin's "@all" calls everyone, so a member cannot set off a storm.
|
|
234
326
|
const leads = rt.isAdminOf(room, member.id)
|
|
235
|
-
const
|
|
236
|
-
const
|
|
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)
|
|
237
331
|
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
|
-
}
|
|
332
|
+
callMembers(round, turns, index, callees, `${member.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH)
|
|
247
333
|
}
|
|
248
334
|
}
|
|
249
335
|
|
|
@@ -263,8 +349,9 @@ export function install(rt) {
|
|
|
263
349
|
rt.roomInbox.delete(room.id)
|
|
264
350
|
const posts = queued.length > 0 ? queued : [{ text: '' }]
|
|
265
351
|
const startSeq = room.seq + 1
|
|
266
|
-
// The opening greeting is a cue for the admin, not a line of the group
|
|
267
|
-
|
|
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)) {
|
|
268
355
|
// Image readings go into the log only; `@` names are picked from what the user typed.
|
|
269
356
|
const text = post.images === undefined ? post.text : [post.text, ...await rt.imagesForGroup(post.images, room.id)].filter(Boolean).join('\n')
|
|
270
357
|
appendRoom(room, post.botId
|
|
@@ -274,26 +361,38 @@ export function install(rt) {
|
|
|
274
361
|
if (posts.some(post => !post.botId && !post.kickoff)) room.botRounds = 0
|
|
275
362
|
await rt.save()
|
|
276
363
|
const opener = posts.at(-1)
|
|
364
|
+
const references = posts.flatMap(post => post.references ?? [])
|
|
277
365
|
const round = {
|
|
278
366
|
roomId: room.id,
|
|
279
367
|
members: room.members,
|
|
280
368
|
startSeq,
|
|
281
369
|
...(opener.kickoff ? { kickoff: true } : {}),
|
|
370
|
+
...(references.length > 0 ? { references, shown: [] } : {}),
|
|
282
371
|
slots: openingSpeakers(room, opener).map(member => ({ id: member.id, name: member.name, status: 'waiting', ...(opener.kickoff ? { note: 'the group was just created' } : {}) })),
|
|
283
372
|
speaking: null,
|
|
284
373
|
turns: 0,
|
|
285
374
|
replies: [],
|
|
286
375
|
passed: [],
|
|
287
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() })
|
|
288
384
|
}
|
|
289
385
|
rt.roomProgress.set(exec.callId, round)
|
|
290
386
|
try {
|
|
291
|
-
await runRound(room, round, exec.signal)
|
|
387
|
+
await runRound(room, round, exec.signal, exec.agent)
|
|
292
388
|
} catch (error) {
|
|
293
389
|
rt.warn('ds-bot: group round in %s stopped: %s', room.name, error)
|
|
294
390
|
} finally {
|
|
295
391
|
rt.roomProgress.delete(exec.callId)
|
|
296
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
|
+
}
|
|
297
396
|
await rt.save()
|
|
298
397
|
for (const post of posts) {
|
|
299
398
|
if (post.reportBack && rt.botOf(post.botId)) void reportRound(room, post, round)
|
|
@@ -332,7 +431,7 @@ export function install(rt) {
|
|
|
332
431
|
for (const round of rt.roomProgress.values()) {
|
|
333
432
|
if (round.roomId === room.id) round.replies.push({ kind: 'lookup', botId: member.id, name: member.name, color: member.color, text, time: Date.now() })
|
|
334
433
|
}
|
|
335
|
-
void rt.save()
|
|
434
|
+
void rt.save().catch(() => {})
|
|
336
435
|
}
|
|
337
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.` : ''
|
|
338
437
|
if (query === '' && around !== '') {
|
|
@@ -379,15 +478,62 @@ export function install(rt) {
|
|
|
379
478
|
// A Bot that posts never answers its own post. @mentions choose the speakers; else
|
|
380
479
|
// the admin answers alone in admin mode, or first of everyone.
|
|
381
480
|
function openingSpeakers(room, opener) {
|
|
382
|
-
const
|
|
481
|
+
const everyone = room.members.map(rt.botOf).filter(Boolean)
|
|
482
|
+
const members = everyone.filter(bot => bot.id !== opener.botId)
|
|
383
483
|
const admin = members.find(bot => rt.isAdminOf(room, bot.id))
|
|
384
484
|
if (opener.kickoff) return admin ? [admin] : []
|
|
385
|
-
|
|
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] : []
|
|
386
489
|
if (found.length > 0 && !all) return found
|
|
387
490
|
if (!all && room.mode === 'admin' && admin) return [admin]
|
|
388
491
|
return admin ? [admin, ...members.filter(bot => bot !== admin)] : members
|
|
389
492
|
}
|
|
390
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
|
+
|
|
391
537
|
// A group the user just made opens with its admin asking what the group is for.
|
|
392
538
|
async function greetRoom(roomId) {
|
|
393
539
|
const room = rt.roomOf(roomId)
|
|
@@ -403,8 +549,10 @@ export function install(rt) {
|
|
|
403
549
|
|
|
404
550
|
async function reportRound(room, post, round) {
|
|
405
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)
|
|
406
553
|
const text = [
|
|
407
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.` : '',
|
|
408
556
|
round.passed.length > 0 ? `Stayed silent: ${round.passed.join(', ')}.` : '',
|
|
409
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(', ')}.` : '',
|
|
410
558
|
].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
|
}
|
package/src/host/memory.js
CHANGED
|
@@ -44,6 +44,9 @@ const QUERY_TERMS_MAX = 6
|
|
|
44
44
|
const SOURCE_READ_MS = 5_000
|
|
45
45
|
const MODEL_TIMEOUT_MS = 120_000
|
|
46
46
|
const SUMMARY_MAX_TOKENS = 2_048
|
|
47
|
+
// A reply cut off at the limit is tried once more with room for reasoning plus
|
|
48
|
+
// the answer; a model that thinks can spend the smaller budget thinking alone.
|
|
49
|
+
const SUMMARY_RETRY_MAX_TOKENS = 8_192
|
|
47
50
|
const SUMMARY_TEXT_MAX = 8_000
|
|
48
51
|
const ASK_TEXT_MAX = 2_000
|
|
49
52
|
const ASK_CHANGES_MAX = 20
|
|
@@ -905,24 +908,32 @@ export function install(rt) {
|
|
|
905
908
|
// the tag the meter would file them under "other".
|
|
906
909
|
async function oneShot(route, system, text, tag) {
|
|
907
910
|
const info = await ctx.llm.resolveModelInfo(route.provider, route.model).catch(() => undefined)
|
|
908
|
-
const
|
|
909
|
-
|
|
911
|
+
const efforts = info?.reasoning?.efforts ?? []
|
|
912
|
+
// Thinking counts against the same output budget as the answer, so ask for
|
|
913
|
+
// the least the model offers: 'off' when it has one, else its lowest level.
|
|
914
|
+
const effort = efforts.some(entry => entry.id === 'off') ? 'off' : efforts.at(0)?.id
|
|
910
915
|
const messages = [{ role: 'user', content: [{ type: 'text', text }] }]
|
|
911
916
|
if (tag !== undefined) rt.tagUsage?.(messages, tag)
|
|
912
|
-
for
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
917
|
+
for (const maxTokens of [SUMMARY_MAX_TOKENS, SUMMARY_RETRY_MAX_TOKENS]) {
|
|
918
|
+
const assembler = new BlockAssembler()
|
|
919
|
+
for await (const chunk of ctx.llm.stream({
|
|
920
|
+
provider: route.provider,
|
|
921
|
+
model: route.model,
|
|
922
|
+
...(effort === undefined ? {} : { reasoningEffort: effort }),
|
|
923
|
+
system,
|
|
924
|
+
messages,
|
|
925
|
+
maxTokens,
|
|
926
|
+
signal: AbortSignal.timeout(MODEL_TIMEOUT_MS),
|
|
927
|
+
})) assembler.push(chunk)
|
|
928
|
+
const finish = assembler.finish
|
|
929
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') throw new Error(finish.failure?.message ?? finish.kind)
|
|
930
|
+
const reply = assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('\n').trim()
|
|
931
|
+
if (finish.kind !== 'max-tokens' && reply !== '') return reply
|
|
932
|
+
// A reply cut off at the limit — all of it thinking, or a half-written
|
|
933
|
+
// answer — is dropped, never used; the retry gives it room once.
|
|
934
|
+
if (finish.kind !== 'max-tokens' && maxTokens === SUMMARY_MAX_TOKENS) throw new Error('The model wrote nothing')
|
|
935
|
+
}
|
|
936
|
+
throw new Error(`The model's reasoning used up the output budget (${SUMMARY_MAX_TOKENS} and ${SUMMARY_RETRY_MAX_TOKENS} tokens); no complete answer was written`)
|
|
926
937
|
}
|
|
927
938
|
|
|
928
939
|
/** @type {Map<string, {hash:string, text:string, at:number}>} */
|