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.
@@ -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 { ROOM_BOT_ROUNDS, ROOM_REPLY_TIMEOUT_MS, SOURCE_KIND } from './constants.js'
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 => (line.kind === 'lookup'
52
- ? `[${line.who} looked up its own chat${line.text ? ` for "${line.text}"` : ''}]`
53
- : `${line.who}: ${line.text}`)
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, no name prefix. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.'
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
- 'Writing "@Name" calls that member to speak right after you, and "@all" calls every member. When the user asks, change the group with update_group.',
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) return { id: known, brief: undefined }
113
- const id = await rt.createSession(`${member.name} · ${room.name}`)
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, 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) {
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 is an event of the round.
354
- for (const post of posts.filter(item => !item.kickoff && !item.failed)) {
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)) room.botRounds = 0
411
+ if (posts.some(post => !post.botId && !post.kickoff)) {
412
+ room.botRounds = 0
413
+ room.lateRounds = 0
414
+ }
362
415
  await rt.save()
363
- const opener = posts.at(-1)
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 => ({ id: member.id, name: member.name, status: 'waiting', ...(opener.kickoff ? { note: 'the group was just created' } : {}) })),
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 : admin ? [admin] : []
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 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.
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.botRounds ?? 0) >= ROOM_BOT_ROUNDS
516
- if (!quiet) room.botRounds = (room.botRounds ?? 0) + 1
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 still reach the model.
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) {
@@ -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)) {
@@ -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 oneShot(await modelFor(bot), SUMMARY_SYSTEM, summaryRequest({ name: bot.name, own: linesFor(own), team: linesFor(team), language }), { sessionId: rt.chatOf(bot.id), kind: 'memory' })
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` instead of retrying on every poll.
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 failed = summaryFailures.get(bot.id)
983
- const wanted = body.regenerate === true || (body.summarize === true && saved?.hash !== hash && failed?.hash !== hash)
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?.hash === hash && !summarizing.has(bot.id) ? failed.message : null,
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 oneShot(await modelFor(bot), ASK_SYSTEM, askRequest({ name: bot.name, numbered, language: languageNow(), text }), { sessionId: rt.chatOf(bot.id), kind: 'memory' }))
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 oneShot(await modelFor(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' })
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)