ds-bot 0.1.2 → 0.1.4

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