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.
@@ -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
- // "@Name" mentions in order of appearance. Longest names match first so "@Mx" never
60
- // reads as "@M"; only ASCII letters and digits continue a name, so "@K你好" is K.
61
- const ROOM_ALL = ['all', 'everyone', '所有人', '全体']
62
- const NAME_GOES_ON = /^[a-z0-9_-]/i
63
- export const mentionsIn = (text, members) => {
64
- const byLength = [...members].sort((a, b) => b.name.length - a.name.length)
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 (let at = text.indexOf('@'); at !== -1; at = text.indexOf('@', at + 1)) {
68
- if (at > 0 && /[a-z0-9_.]/i.test(text[at - 1])) continue
69
- const rest = text.slice(at + 1)
70
- const starts = word => rest.toLowerCase().startsWith(word.toLowerCase()) && !NAME_GOES_ON.test(rest.slice(word.length))
71
- if (ROOM_ALL.some(starts)) all = true
72
- const hit = byLength.find(member => starts(member.name))
73
- if (hit && !found.includes(hit)) found.push(hit)
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, or null when it did not answer in time or the round was stopped.
158
- async function roomTurn(room, member, sessionId, text, note, signal) {
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 timer = setTimeout(() => finish(null), ROOM_REPLY_TIMEOUT_MS)
169
- rt.roomWaiters.set(exchangeId, { resolve: finish, fail: failure => finish({ failure }) })
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
- if (reply === null || reply?.failure !== undefined) return reply
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
- async function runRound(room, round, signal) {
184
- const limit = round.slots.length + ROOM_EXTRA_TURNS + (room.admin ? ROOM_ADMIN_EXTRA_TURNS : 0)
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
- for (let index = 0; index < round.slots.length && round.turns < limit; index += 1) {
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
- if (reply === null || isPass(reply)) {
223
- slot.status = reply === null ? 'missed' : 'passed'
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
- // A called member moves up if it is still waiting, or gets one more turn if it
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 others = room.members.filter(id => id !== member.id).map(rt.botOf).filter(Boolean)
236
- const { all, found } = mentionsIn(reply, others)
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
- let next = index + 1
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
- for (const post of posts.filter(item => !item.kickoff)) {
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 members = room.members.map(rt.botOf).filter(bot => bot && bot.id !== opener.botId)
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
- const { all, found } = mentionsIn(opener.text, members)
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')
@@ -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. It counts as gone when its process has exited (same host) or when it has
7
- // not been bumped for `staleMs`, which also covers a reused pid after a crash. On another
8
- // host only the age can tell, so a lock on shared storage waits out `staleMs`. The token,
9
- // not the pid, decides ownership: a holder that finds another token has lost the lock.
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() >= staleMs) return false
42
- if (holder === null) return true
43
- if (holder.host !== host) return true
44
- // A lock this same process left (a plugin reload whose dispose never ran) is no rival.
45
- return holder.pid !== pid && isAlive(holder.pid)
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
- try { unlinkSync(path) } catch {}
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
  }
@@ -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
  }
@@ -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 off = info?.reasoning?.efforts?.some(effort => effort.id === 'off')
909
- const assembler = new BlockAssembler()
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 await (const chunk of ctx.llm.stream({
913
- provider: route.provider,
914
- model: route.model,
915
- ...(off ? { reasoningEffort: 'off' } : {}),
916
- system,
917
- messages,
918
- maxTokens: SUMMARY_MAX_TOKENS,
919
- signal: AbortSignal.timeout(MODEL_TIMEOUT_MS),
920
- })) assembler.push(chunk)
921
- const finish = assembler.finish
922
- if (finish.kind === 'error' || finish.kind === 'aborted') throw new Error(finish.failure?.message ?? finish.kind)
923
- const reply = assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('\n').trim()
924
- if (reply === '') throw new Error('The model wrote nothing')
925
- return reply
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}>} */