ds-bot 0.1.3 → 0.1.5
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 -4
- package/client.js +1948 -1269
- package/index.js +12 -3
- package/package.json +1 -1
- package/src/host/approvals.js +8 -2
- package/src/host/capabilities.js +521 -0
- package/src/host/channel.js +25 -6
- package/src/host/constants.js +11 -0
- package/src/host/delivery.js +21 -6
- package/src/host/folders.js +135 -0
- package/src/host/groups.js +123 -33
- package/src/host/images.js +2 -2
- package/src/host/memory.js +31 -8
- package/src/host/parts.js +100 -32
- package/src/host/prompt.js +5 -5
- package/src/host/resume.js +247 -0
- package/src/host/roster.js +41 -10
- package/src/host/secrets.js +9 -1
- package/src/host/store.js +16 -3
- package/src/host/tools.js +193 -19
- package/src/host/turns.js +126 -14
- package/src/host/usage.js +4 -1
package/src/host/groups.js
CHANGED
|
@@ -9,10 +9,15 @@
|
|
|
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 {
|
|
20
|
+
import { ROOM_LATE_ROUNDS, ROOM_REPLY_TIMEOUT_MS, ROOM_WORK_GRACE_MS, SOURCE_KIND, TEAM_TOOLS } from './constants.js'
|
|
16
21
|
import { escapeRegExp, splitRef, textOf } from './text.js'
|
|
17
22
|
import { mentionSpans } from '../shared/mentions.js'
|
|
18
23
|
import { GROUP_REF, output } from './tools.js'
|
|
@@ -48,9 +53,13 @@ const unseenLines = (room, memberId) => {
|
|
|
48
53
|
return room.log.filter(line => line.seq > since && line.botId !== memberId).slice(-ROOM_DELTA_MAX)
|
|
49
54
|
}
|
|
50
55
|
|
|
51
|
-
const lineText = line =>
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
}
|
|
54
63
|
const transcript = lines => lines.map(lineText).join('\n')
|
|
55
64
|
|
|
56
65
|
// Models often echo the transcript format ("Name: …") despite the instruction.
|
|
@@ -76,13 +85,15 @@ export const referenceNote = references => (references?.length
|
|
|
76
85
|
? `Conversations the user referenced in this round:\n\n${references.map(reference => reference.text).join('\n\n')}`
|
|
77
86
|
: '')
|
|
78
87
|
|
|
79
|
-
const ROOM_REPLY = 'Reply with your message for the group: one or two short messages,
|
|
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 "@".'
|
|
80
90
|
|
|
81
91
|
const slotLabel = (slot) => {
|
|
82
92
|
if (slot.status === 'speaking') return `${slot.name} (you, now)`
|
|
83
93
|
if (slot.status === 'posted') return `${slot.name} (posted)`
|
|
84
94
|
if (slot.status === 'passed') return `${slot.name} (passed)`
|
|
85
95
|
if (slot.status === 'missed') return `${slot.name} (no answer)`
|
|
96
|
+
if (slot.status === 'working') return `${slot.name} (working on it; posts when done)`
|
|
86
97
|
if (slot.status === 'waiting-approval') return `${slot.name} (waiting for the user's approval; posts when done)`
|
|
87
98
|
if (slot.status === 'failed') return `${slot.name} (could not answer: error)`
|
|
88
99
|
if (slot.status === 'left') return `${slot.name} (left the group)`
|
|
@@ -92,7 +103,7 @@ const slotLabel = (slot) => {
|
|
|
92
103
|
const adminNote = room => [
|
|
93
104
|
'You are this group\'s admin.',
|
|
94
105
|
room.mode === 'admin' ? 'Plain messages come to you first: answer them, or call the members who should answer with @Name.' : '',
|
|
95
|
-
'
|
|
106
|
+
'"@all" calls every member. When the user asks, change the group with update_group.',
|
|
96
107
|
].filter(Boolean).join(' ')
|
|
97
108
|
|
|
98
109
|
// The opening turn of a group the user just made without a first message.
|
|
@@ -109,8 +120,11 @@ export function install(rt) {
|
|
|
109
120
|
async function groupSessionFor(room, member) {
|
|
110
121
|
room.sessions ??= {}
|
|
111
122
|
const known = room.sessions[member.id]
|
|
112
|
-
if (known !== undefined)
|
|
113
|
-
|
|
123
|
+
if (known !== undefined) {
|
|
124
|
+
await rt.ensureCapabilities(known)
|
|
125
|
+
return { id: known, brief: undefined }
|
|
126
|
+
}
|
|
127
|
+
const id = await rt.createSession(`${member.name} · ${room.name}`, { dir: rt.workDirOf({ botId: member.id, roomId: room.id }) })
|
|
114
128
|
room.sessions[member.id] = id
|
|
115
129
|
rt.groupOwners.set(id, { botId: member.id, roomId: room.id })
|
|
116
130
|
delete room.seen[member.id]
|
|
@@ -122,6 +136,7 @@ export function install(rt) {
|
|
|
122
136
|
await rt.applyPermission(member, id)
|
|
123
137
|
.catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error))
|
|
124
138
|
await rt.save()
|
|
139
|
+
await rt.ensureCapabilities(id)
|
|
125
140
|
return { id, brief: await rt.ownChatBrief(member) }
|
|
126
141
|
}
|
|
127
142
|
|
|
@@ -138,9 +153,10 @@ export function install(rt) {
|
|
|
138
153
|
const index = round.slots.findIndex(slot => slot.status === 'speaking')
|
|
139
154
|
const before = index
|
|
140
155
|
const after = round.slots.length - index - 1
|
|
141
|
-
const members = `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. Members speak one at a time; each message is posted before the next speaker starts
|
|
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}`
|
|
142
157
|
const notice = room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : ''
|
|
143
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)
|
|
144
160
|
return [
|
|
145
161
|
members,
|
|
146
162
|
notice,
|
|
@@ -150,26 +166,41 @@ export function install(rt) {
|
|
|
150
166
|
? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}`
|
|
151
167
|
: 'Nothing new in this round since your last message.',
|
|
152
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(', ')}.` : '',
|
|
153
170
|
`Speaking order for this round: ${round.slots.map(slotLabel).join(' → ')}.`,
|
|
154
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.`}`,
|
|
155
172
|
ROOM_REPLY,
|
|
156
173
|
].filter(Boolean).join('\n\n')
|
|
157
174
|
}
|
|
158
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
|
+
|
|
159
184
|
// One member turn inside the room; resolves to its final text, `{ failure }` when its
|
|
160
185
|
// model call failed, `{ missed }` when it went quiet for too long, `{ approval }` when
|
|
161
|
-
// it waits for the user's approval,
|
|
162
|
-
//
|
|
163
|
-
|
|
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) {
|
|
164
191
|
if (signal.aborted) return null
|
|
165
192
|
const exchangeId = randomUUID()
|
|
166
193
|
const turn = { botId: member.id, sessionId, exchangeId }
|
|
167
194
|
started.push(turn)
|
|
168
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
|
|
169
198
|
const done = new Promise((resolve) => {
|
|
170
199
|
let timer
|
|
200
|
+
let grace
|
|
171
201
|
const finish = (value) => {
|
|
172
202
|
clearTimeout(timer)
|
|
203
|
+
clearTimeout(grace)
|
|
173
204
|
rt.roomWaiters.delete(exchangeId)
|
|
174
205
|
resolve(value)
|
|
175
206
|
}
|
|
@@ -177,14 +208,17 @@ export function install(rt) {
|
|
|
177
208
|
clearTimeout(timer)
|
|
178
209
|
timer = setTimeout(() => finish({ missed: true }), ROOM_REPLY_TIMEOUT_MS)
|
|
179
210
|
}
|
|
211
|
+
const work = () => { grace ??= setTimeout(() => finish({ working: true }), ROOM_WORK_GRACE_MS) }
|
|
180
212
|
touch()
|
|
181
|
-
rt.roomWaiters.set(exchangeId, { sessionId, botId: member.id, roomId: room.id, resolve: finish, fail: failure => finish({ failure }), touch, approval: () => finish({ approval: true }) })
|
|
213
|
+
rt.roomWaiters.set(exchangeId, { sessionId, botId: member.id, roomId: room.id, resolve: finish, fail: failure => finish({ failure }), touch, work, approval: () => finish({ approval: true }) })
|
|
182
214
|
signal.addEventListener('abort', () => finish(null), { once: true })
|
|
183
215
|
})
|
|
184
216
|
try {
|
|
185
|
-
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 } : {}) })
|
|
186
218
|
// Still held by an earlier request: this turn cannot start until the user answers.
|
|
187
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 })
|
|
188
222
|
} catch (error) {
|
|
189
223
|
rt.warn('ds-bot: group delivery to %s failed: %s', member.name, error)
|
|
190
224
|
rt.roomWaiters.get(exchangeId)?.resolve(null)
|
|
@@ -205,17 +239,25 @@ export function install(rt) {
|
|
|
205
239
|
}
|
|
206
240
|
rt.touchRoomTurn = (sessionId) => { for (const waiter of waitersOf(sessionId)) waiter.touch() }
|
|
207
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
|
+
}
|
|
208
248
|
|
|
209
249
|
// A called member moves up if it is still waiting, or gets one more turn if it
|
|
210
250
|
// 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) {
|
|
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) {
|
|
213
253
|
let next = index + 1
|
|
214
254
|
for (const peer of callees) {
|
|
215
255
|
if ((turns.get(peer.id) ?? 0) >= cap) continue
|
|
216
256
|
const waiting = round.slots.findIndex((other, at) => at > index && other.id === peer.id && other.status === 'waiting')
|
|
217
257
|
const called = waiting === -1 ? { id: peer.id, name: peer.name, status: 'waiting' } : round.slots.splice(waiting, 1)[0]
|
|
218
258
|
called.note = note
|
|
259
|
+
if (by === undefined) delete called.by
|
|
260
|
+
else called.by = by
|
|
219
261
|
round.slots.splice(next, 0, called)
|
|
220
262
|
next += 1
|
|
221
263
|
}
|
|
@@ -258,6 +300,7 @@ export function install(rt) {
|
|
|
258
300
|
}
|
|
259
301
|
// The user spoke in this room, so Bots may start their own rounds again.
|
|
260
302
|
room.botRounds = 0
|
|
303
|
+
room.lateRounds = 0
|
|
261
304
|
void rt.save().catch(() => {})
|
|
262
305
|
return round.slots.length - before
|
|
263
306
|
}
|
|
@@ -296,7 +339,7 @@ export function install(rt) {
|
|
|
296
339
|
const prompt = roomPrompt(room, member, round)
|
|
297
340
|
round.shown?.push(member.id)
|
|
298
341
|
const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}`
|
|
299
|
-
const reply = await roomTurn(room, member, session.id, text, slot.note ?? 'your turn', signal, round.started)
|
|
342
|
+
const reply = await roomTurn(room, member, session.id, text, slot.note ?? 'your turn', signal, round.started, slot.by)
|
|
300
343
|
round.speaking = null
|
|
301
344
|
if (reply?.failure !== undefined) {
|
|
302
345
|
// Not a pass: the user sees why the member is silent, and the round goes on.
|
|
@@ -312,6 +355,11 @@ export function install(rt) {
|
|
|
312
355
|
round.replies.push({ kind: 'approval', botId: member.id, name: member.name, color: member.color, time: Date.now() })
|
|
313
356
|
continue
|
|
314
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
|
+
}
|
|
315
363
|
if (reply === null || reply?.missed || isPass(reply)) {
|
|
316
364
|
slot.status = typeof reply === 'string' ? 'passed' : 'missed'
|
|
317
365
|
round.passed.push(member.name)
|
|
@@ -329,7 +377,7 @@ export function install(rt) {
|
|
|
329
377
|
const { all, found: named } = mentionsIn(reply, everyone)
|
|
330
378
|
const found = named.filter(other => other.id !== member.id)
|
|
331
379
|
const callees = leads && all ? [...found, ...others.filter(other => !found.includes(other))] : found
|
|
332
|
-
callMembers(round, turns, index, callees, `${member.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH)
|
|
380
|
+
callMembers(round, turns, index, callees, `${member.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, member.id)
|
|
333
381
|
}
|
|
334
382
|
}
|
|
335
383
|
|
|
@@ -350,25 +398,37 @@ export function install(rt) {
|
|
|
350
398
|
const posts = queued.length > 0 ? queued : [{ text: '' }]
|
|
351
399
|
const startSeq = room.seq + 1
|
|
352
400
|
// The opening greeting is a cue for the admin, not a line of the group; a member's
|
|
353
|
-
// late failure
|
|
354
|
-
|
|
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))) {
|
|
355
405
|
// Image readings go into the log only; `@` names are picked from what the user typed.
|
|
356
406
|
const text = post.images === undefined ? post.text : [post.text, ...await rt.imagesForGroup(post.images, room.id)].filter(Boolean).join('\n')
|
|
357
407
|
appendRoom(room, post.botId
|
|
358
408
|
? { who: rt.botOf(post.botId)?.name ?? post.name ?? 'Bot', botId: post.botId, text }
|
|
359
409
|
: { who: 'User', text })
|
|
360
410
|
}
|
|
361
|
-
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
|
+
}
|
|
362
415
|
await rt.save()
|
|
363
|
-
|
|
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)
|
|
364
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
|
|
365
421
|
const round = {
|
|
366
422
|
roomId: room.id,
|
|
367
423
|
members: room.members,
|
|
368
424
|
startSeq,
|
|
369
425
|
...(opener.kickoff ? { kickoff: true } : {}),
|
|
370
426
|
...(references.length > 0 ? { references, shown: [] } : {}),
|
|
371
|
-
slots: openingSpeakers(room, opener).map(member => ({
|
|
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
|
+
})),
|
|
372
432
|
speaking: null,
|
|
373
433
|
turns: 0,
|
|
374
434
|
replies: [],
|
|
@@ -382,6 +442,15 @@ export function install(rt) {
|
|
|
382
442
|
round.failed.push(bot?.name ?? post.name)
|
|
383
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() })
|
|
384
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() })
|
|
452
|
+
}
|
|
453
|
+
if (posts.some(post => post.resumed || post.stopped)) await rt.save()
|
|
385
454
|
rt.roomProgress.set(exec.callId, round)
|
|
386
455
|
try {
|
|
387
456
|
await runRound(room, round, exec.signal, exec.agent)
|
|
@@ -476,7 +545,9 @@ export function install(rt) {
|
|
|
476
545
|
}))
|
|
477
546
|
|
|
478
547
|
// A Bot that posts never answers its own post. @mentions choose the speakers; else
|
|
479
|
-
// the admin answers alone in admin mode, or first of everyone.
|
|
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.
|
|
480
551
|
function openingSpeakers(room, opener) {
|
|
481
552
|
const everyone = room.members.map(rt.botOf).filter(Boolean)
|
|
482
553
|
const members = everyone.filter(bot => bot.id !== opener.botId)
|
|
@@ -485,7 +556,7 @@ export function install(rt) {
|
|
|
485
556
|
if (opener.quiet) return []
|
|
486
557
|
const { all, found: named } = mentionsIn(opener.text, everyone, opener.references)
|
|
487
558
|
const found = named.filter(bot => bot.id !== opener.botId)
|
|
488
|
-
if (opener.late) return found.length > 0 ? found :
|
|
559
|
+
if (opener.late) return found.length > 0 ? found : members.filter(bot => bot.id === opener.askedBy)
|
|
489
560
|
if (found.length > 0 && !all) return found
|
|
490
561
|
if (!all && room.mode === 'admin' && admin) return [admin]
|
|
491
562
|
return admin ? [admin, ...members.filter(bot => bot !== admin)] : members
|
|
@@ -502,9 +573,10 @@ export function install(rt) {
|
|
|
502
573
|
}
|
|
503
574
|
rt.postToRoom = postToRoom
|
|
504
575
|
|
|
505
|
-
// A member the round moved on from (it went quiet or waited for an
|
|
506
|
-
// what its turn ended with.
|
|
507
|
-
//
|
|
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.
|
|
508
580
|
async function lateRoomReply(sessionId, info, reply) {
|
|
509
581
|
const owner = rt.groupOwners.get(sessionId)
|
|
510
582
|
const room = rt.roomOf(owner?.roomId ?? info.roomId)
|
|
@@ -512,14 +584,29 @@ export function install(rt) {
|
|
|
512
584
|
if (room === undefined || member === undefined || !room.members.includes(member.id)) return
|
|
513
585
|
const text = stripOwnName(member.name, reply)
|
|
514
586
|
if (isPass(text)) return
|
|
515
|
-
const quiet = (room.
|
|
516
|
-
if (!quiet) room.
|
|
587
|
+
const quiet = (room.lateRounds ?? 0) >= ROOM_LATE_ROUNDS
|
|
588
|
+
if (!quiet) room.lateRounds = (room.lateRounds ?? 0) + 1
|
|
517
589
|
await rt.save()
|
|
518
|
-
await postToRoom(room, member, text, { late: true, ...(quiet ? { quiet: true } : {}) })
|
|
590
|
+
await postToRoom(room, member, text, { late: true, ...(info.askedBy ? { askedBy: info.askedBy } : {}), ...(quiet ? { quiet: true } : {}) })
|
|
519
591
|
.catch(error => rt.warn('ds-bot: late group reply from %s failed: %s', member.name, error))
|
|
520
592
|
}
|
|
521
593
|
rt.lateRoomReply = lateRoomReply
|
|
522
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
|
+
|
|
523
610
|
// The round said such a member posts when done; a failed model call is how it ended.
|
|
524
611
|
// Nobody answers it.
|
|
525
612
|
async function lateRoomFailure(sessionId, info, failure) {
|
|
@@ -550,8 +637,10 @@ export function install(rt) {
|
|
|
550
637
|
async function reportRound(room, post, round) {
|
|
551
638
|
const lines = round.replies.filter(reply => reply.kind === undefined).map(reply => `${reply.name}: ${reply.text}`)
|
|
552
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)
|
|
553
641
|
const text = [
|
|
554
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.` : '',
|
|
555
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.` : '',
|
|
556
645
|
round.passed.length > 0 ? `Stayed silent: ${round.passed.join(', ')}.` : '',
|
|
557
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(', ')}.` : '',
|
|
@@ -562,9 +651,10 @@ export function install(rt) {
|
|
|
562
651
|
|
|
563
652
|
// A group chat's own Session never consults the model: models drift from "only call
|
|
564
653
|
// group_relay" (they message one member directly when the user names it), so the
|
|
565
|
-
// relay step is synthesized here. Auxiliary calls such as titles
|
|
654
|
+
// relay step is synthesized here. Auxiliary calls such as titles, and DS Bot's own
|
|
655
|
+
// calls for the room such as image readings, still reach the model.
|
|
566
656
|
ctx.on('llm/stream', (options, next) => {
|
|
567
|
-
if (options.sessionId === undefined || options.purpose !== undefined) return next()
|
|
657
|
+
if (options.sessionId === undefined || options.purpose !== undefined || rt.ownRequest(options)) return next()
|
|
568
658
|
return (async function* relayOrPass() {
|
|
569
659
|
await rt.load()
|
|
570
660
|
if (rt.roomOf(options.sessionId) === undefined) {
|
package/src/host/images.js
CHANGED
|
@@ -91,10 +91,10 @@ export function install(rt) {
|
|
|
91
91
|
const route = await ocrRoute()
|
|
92
92
|
if (route === undefined) throw new Error('no vision model is available')
|
|
93
93
|
const assembler = new BlockAssembler()
|
|
94
|
-
// No sessionId: this call is not a Session request, so the hook below lets it through.
|
|
95
94
|
for await (const chunk of ctx.llm.stream({
|
|
96
95
|
provider: route.provider,
|
|
97
96
|
model: route.model,
|
|
97
|
+
sessionId,
|
|
98
98
|
messages: rt.tagUsage([{ role: 'user', content: [{ type: 'image', attachment }, { type: 'text', text: OCR_PROMPT }] }], { sessionId, kind: 'image' }),
|
|
99
99
|
maxTokens: 8192,
|
|
100
100
|
signal: AbortSignal.timeout(OCR_TIMEOUT_MS),
|
|
@@ -176,7 +176,7 @@ export function install(rt) {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
ctx.on('llm/stream', (options, next) => {
|
|
179
|
-
if (options.sessionId === undefined || !(options.messages ?? []).some(liveImages)) return next()
|
|
179
|
+
if (options.sessionId === undefined || rt.ownRequest(options) || !(options.messages ?? []).some(liveImages)) return next()
|
|
180
180
|
return (async function* imagesAsTextOrPass() {
|
|
181
181
|
await rt.load()
|
|
182
182
|
if (rt.roomOf(options.sessionId) !== undefined || await readsImages(options.provider, options.model)) {
|
package/src/host/memory.js
CHANGED
|
@@ -919,6 +919,7 @@ export function install(rt) {
|
|
|
919
919
|
for await (const chunk of ctx.llm.stream({
|
|
920
920
|
provider: route.provider,
|
|
921
921
|
model: route.model,
|
|
922
|
+
...(tag?.sessionId === undefined ? {} : { sessionId: tag.sessionId }),
|
|
922
923
|
...(effort === undefined ? {} : { reasoningEffort: effort }),
|
|
923
924
|
system,
|
|
924
925
|
messages,
|
|
@@ -936,6 +937,25 @@ export function install(rt) {
|
|
|
936
937
|
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`)
|
|
937
938
|
}
|
|
938
939
|
|
|
940
|
+
// A served model can still refuse the call (a key without access, say), and
|
|
941
|
+
// desiredModel only falls back for models that are not served; so a failed call
|
|
942
|
+
// is tried once more on the profile default.
|
|
943
|
+
async function botShot(bot, system, text, tag) {
|
|
944
|
+
const own = await modelFor(bot)
|
|
945
|
+
try {
|
|
946
|
+
return await oneShot(own, system, text, tag)
|
|
947
|
+
} catch (error) {
|
|
948
|
+
const fallback = splitRef(await rt.defaultModel())
|
|
949
|
+
if (fallback === undefined || (fallback.provider === own.provider && fallback.model === own.model)) throw error
|
|
950
|
+
try {
|
|
951
|
+
return await oneShot(fallback, system, text, tag)
|
|
952
|
+
} catch (again) {
|
|
953
|
+
const said = failure => failure?.message ?? String(failure)
|
|
954
|
+
throw new Error(`${own.provider}/${own.model}: ${said(error)}; ${fallback.provider}/${fallback.model}: ${said(again)}`)
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
939
959
|
/** @type {Map<string, {hash:string, text:string, at:number}>} */
|
|
940
960
|
const summaries = new Map()
|
|
941
961
|
const summarizing = new Map()
|
|
@@ -955,21 +975,23 @@ export function install(rt) {
|
|
|
955
975
|
const linesFor = files => allEntries(files).map(entry => `- ${entry.file.topic ? `[[${entry.file.topic}]] ` : ''}${entry.text}`)
|
|
956
976
|
|
|
957
977
|
function startSummary(bot, own, team, language, hash) {
|
|
978
|
+
const model = bot.model
|
|
958
979
|
const job = (async () => {
|
|
959
|
-
const text = await
|
|
980
|
+
const text = await botShot(bot, SUMMARY_SYSTEM, summaryRequest({ name: bot.name, own: linesFor(own), team: linesFor(team), language }), { sessionId: rt.chatOf(bot.id), kind: 'memory' })
|
|
960
981
|
const value = { hash, text: text.slice(0, SUMMARY_TEXT_MAX), at: Date.now() }
|
|
961
982
|
summaries.set(bot.id, value)
|
|
962
983
|
summaryFailures.delete(bot.id)
|
|
963
984
|
if (rt.readOnly() === undefined) await writeText(summaryPath(bot.id), JSON.stringify(value, null, 2))
|
|
964
985
|
})().catch((error) => {
|
|
965
|
-
summaryFailures.set(bot.id, { hash, message: error?.message ?? String(error) })
|
|
986
|
+
summaryFailures.set(bot.id, { hash, model, message: error?.message ?? String(error) })
|
|
966
987
|
rt.warn('ds-bot: memory summary for %s failed: %s', bot.name, error)
|
|
967
988
|
}).finally(() => summarizing.delete(bot.id))
|
|
968
989
|
summarizing.set(bot.id, job)
|
|
969
990
|
}
|
|
970
991
|
|
|
971
992
|
// The summary is written again when the entries or the interface language change;
|
|
972
|
-
// a failed one waits for `regenerate
|
|
993
|
+
// a failed one waits for `regenerate`, or a new model for the Bot, instead of
|
|
994
|
+
// retrying on every poll.
|
|
973
995
|
function memoryView(body) {
|
|
974
996
|
const bot = rt.botOf(String(body.botId ?? ''))
|
|
975
997
|
if (bot === undefined) throw new Error('Unknown Bot')
|
|
@@ -979,8 +1001,9 @@ export function install(rt) {
|
|
|
979
1001
|
const language = languageNow()
|
|
980
1002
|
const hash = createHash('sha256').update(JSON.stringify([language, linesFor(own), linesFor(team)])).digest('hex')
|
|
981
1003
|
const saved = savedSummary(bot.id)
|
|
982
|
-
const
|
|
983
|
-
const
|
|
1004
|
+
const prior = summaryFailures.get(bot.id)
|
|
1005
|
+
const failed = prior?.hash === hash && prior.model === bot.model ? prior : undefined
|
|
1006
|
+
const wanted = body.regenerate === true || (body.summarize === true && saved?.hash !== hash && failed === undefined)
|
|
984
1007
|
if (wanted && entries.length > 0 && !summarizing.has(bot.id)) startSummary(bot, own, team, language, hash)
|
|
985
1008
|
return {
|
|
986
1009
|
botId: bot.id,
|
|
@@ -990,7 +1013,7 @@ export function install(rt) {
|
|
|
990
1013
|
summary: entries.length > 0 && saved !== undefined ? { text: saved.text, at: saved.at } : null,
|
|
991
1014
|
fresh: entries.length === 0 || saved?.hash === hash,
|
|
992
1015
|
summarizing: summarizing.has(bot.id),
|
|
993
|
-
error: failed
|
|
1016
|
+
error: failed !== undefined && !summarizing.has(bot.id) ? failed.message : null,
|
|
994
1017
|
}
|
|
995
1018
|
}
|
|
996
1019
|
|
|
@@ -1003,7 +1026,7 @@ export function install(rt) {
|
|
|
1003
1026
|
...allEntries(loadScope(ownScope(bot.id))).map(entry => ({ ...entry, scope: 'own' })),
|
|
1004
1027
|
...allEntries(loadScope(TEAM)).map(entry => ({ ...entry, scope: 'team' })),
|
|
1005
1028
|
]
|
|
1006
|
-
const answer = parseAsk(await
|
|
1029
|
+
const answer = parseAsk(await botShot(bot, ASK_SYSTEM, askRequest({ name: bot.name, numbered, language: languageNow(), text }), { sessionId: rt.chatOf(bot.id), kind: 'memory' }))
|
|
1007
1030
|
const meta = { source: 'memory panel', by: 'user', added: today() }
|
|
1008
1031
|
const changes = []
|
|
1009
1032
|
for (const change of answer.changes.slice(0, ASK_CHANGES_MAX)) {
|
|
@@ -1236,7 +1259,7 @@ export function install(rt) {
|
|
|
1236
1259
|
const sizes = { own: ownFiles[0].content.length, team: teamFiles[0].content.length }
|
|
1237
1260
|
const earlier = batch.earlier.map(line => reviewLine(line.who, line.text, bot))
|
|
1238
1261
|
const language = writtenIn(read.filter(line => line.who === 'user').map(line => line.text.replace(/^User: /, '')), languageNow())
|
|
1239
|
-
const reply = await
|
|
1262
|
+
const reply = await botShot(bot, reviewSystem(writesTeam(bot.id)), reviewRequest({ bot, numbered, left, sizes, where: batch.where, earlier, fresh: fresh.texts, more, language }), { sessionId: rt.chatOf(bot.id), kind: 'memory' })
|
|
1240
1263
|
const changes = parseReview(reply)
|
|
1241
1264
|
if (changes === undefined) throw new Error(`the model's answer is not a list of changes: ${clip(reply.replace(/\s+/g, ' '), 120)}`)
|
|
1242
1265
|
const unknown = changes.find(change => change.op !== 'add' && numbered[Number(change.entry) - 1] === undefined)
|