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.
package/src/host/turns.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // triggered, the waiters and inboxes of group rounds, and routing a finished turn's reply.
3
3
  //
4
4
  // Turn tracking: which turns were triggered by another Bot, and what they said.
5
- import { REPLY_KIND, SOURCE_KIND } from './constants.js'
5
+ import { REFERENCE_KIND, REPLY_KIND, SOURCE_KIND } from './constants.js'
6
6
  import { textOf } from './text.js'
7
7
  import { reminderText } from './schedules.js'
8
8
 
@@ -21,18 +21,81 @@ export function turnFailure(error) {
21
21
 
22
22
  export const failureLine = failure => `${failure.code}${failure.status ? ` ${failure.status}` : ''}${failure.message ? `: ${failure.message.slice(0, 200)}` : ''}`
23
23
 
24
+ // A group's own Session never reaches a model, so the snapshot DSH prepared there
25
+ // rides with the user's queued message to the members who answer it.
26
+ export function attachReference(queued, source, text) {
27
+ const post = queued?.findLast(item => item.botId === undefined && item.kickoff !== true)
28
+ if (post === undefined || text === '') return
29
+ const labels = (Array.isArray(source?.references) ? source.references : [])
30
+ .map(reference => reference?.label).filter(label => typeof label === 'string' && label !== '')
31
+ post.references = [...(post.references ?? []), { labels, text }]
32
+ }
33
+
34
+ // A tool call some models write into their text instead of making it (the XML-like
35
+ // form of their own chat template).
36
+ const WRITTEN_CALL = /<tool_call>\s*<function=|<function=[\w.-]+>\s*<parameter=/
37
+ export const writesToolCall = text => typeof text === 'string' && WRITTEN_CALL.test(text)
38
+ export const stripWrittenCalls = text => text
39
+ .replace(/<tool_call>[\s\S]*?(?:<\/tool_call>|$)/g, '')
40
+ .replace(/<function=[\w.-]+>[\s\S]*?(?:<\/function>|$)/g, '')
41
+ .trim()
42
+
43
+ const RETRY_TEXT = 'Your last reply wrote a tool call as plain text (<tool_call>…), so it did not run and nothing happened. Call the tool through the tool interface now, then reply as you meant to.'
44
+
24
45
  export function install(rt) {
25
46
  const { ctx, state } = rt
26
47
 
27
- /** @type {Map<string, {turn?:number, pending?:any, info?:any, texts:string[]}>} */
48
+ // The retry goes where the turn's trigger came from: a group round's waiter or a
49
+ // Bot's request keeps its exchange; anything else is a nudge in the Bot's own chat.
50
+ const retryWrittenCall = (id, info) => {
51
+ const keeps = info !== undefined && (info.role === 'room' || expectsReply(info))
52
+ return rt.deliver({
53
+ ...(keeps && info.senderSessionId ? { fromId: info.senderSessionId } : {}),
54
+ toId: rt.selfOf(id),
55
+ sessionId: id,
56
+ role: keeps ? info.role : 'retry',
57
+ ...(keeps && info.roomId ? { roomId: info.roomId } : {}),
58
+ hop: keeps ? info.hop ?? 0 : 0,
59
+ ...(keeps && info.exchangeId ? { exchangeId: info.exchangeId } : {}),
60
+ ...(keeps && info.audience ? { audience: info.audience } : {}),
61
+ ...(keeps && info.askedBy ? { askedBy: info.askedBy } : {}),
62
+ ...(keeps && info.senderRoomId ? { fromRoomId: info.senderRoomId } : {}),
63
+ note: 'your tool call did not run',
64
+ retried: true,
65
+ text: RETRY_TEXT,
66
+ })
67
+ }
68
+
69
+ /** @type {Map<string, {turn?:number, pending?:any, info?:any, texts:string[], opened?:boolean, joined?:{info:any, from:number}[]}>} */
28
70
  const live = new Map()
29
- /** @type {Map<string, {resolve:(text:string)=>void}>} member turn waiters keyed by exchange id */
71
+ /** @type {Map<string, {sessionId:string, resolve:(text:string|null)=>void, fail:(failure:any)=>void, touch:()=>void, approval:()=>void}>} member turn waiters keyed by exchange id */
30
72
  const roomWaiters = new Map()
31
73
  /** @type {Map<string, {roomId:string, replies:any[], members:string[], speaking:string|null}>} */
32
74
  const roomProgress = new Map()
33
- /** @type {Map<string, {text:string, botId?:string, name?:string, reportBack?:boolean, hop?:number, kickoff?:boolean}[]>} messages a room's next relay posts */
75
+ /** @type {Map<string, {text:string, botId?:string, name?:string, reportBack?:boolean, hop?:number, kickoff?:boolean, references?:{labels:string[], text:string}[]}[]>} messages a room's next relay posts */
34
76
  const roomInbox = new Map()
35
77
  const queueRoomMessage = (roomId, item) => roomInbox.set(roomId, [...(roomInbox.get(roomId) ?? []), item])
78
+ // Member turns of a group round the user stopped: what they end with stays out of the group.
79
+ const stoppedRoomTurns = new Set()
80
+
81
+ // A stopped round stops the member work it started: the turn that answers it, or the
82
+ // delivery still waiting for the member's Session. An earlier turn there keeps going.
83
+ // An exchange whose turn already ended stays in the set, so it is bounded.
84
+ const STOPPED_KEPT = 200
85
+ async function stopRoomTurn(sessionId, exchangeId) {
86
+ stoppedRoomTurns.add(exchangeId)
87
+ if (stoppedRoomTurns.size > STOPPED_KEPT) stoppedRoomTurns.delete(stoppedRoomTurns.values().next().value)
88
+ const agent = await rt.resolveAgent(sessionId).catch(() => undefined)
89
+ if (agent === undefined) return
90
+ const entry = live.get(sessionId)
91
+ if (entry?.turn !== undefined && entry.info?.exchangeId === exchangeId) {
92
+ agent.cancel({ kind: 'hook', reason: 'ds-bot/stopped' })
93
+ return
94
+ }
95
+ for (const message of [...(agent.inbox?.nextStep ?? []), ...(agent.inbox?.nextTurn ?? [])]) {
96
+ if (message.source?.exchangeId === exchangeId && agent.inbox.remove(message.id)) stoppedRoomTurns.delete(exchangeId)
97
+ }
98
+ }
36
99
 
37
100
  const liveOf = id => {
38
101
  let entry = live.get(id)
@@ -45,7 +108,7 @@ export function install(rt) {
45
108
  /** @type {Record<string, string>} */
46
109
  let activity = {}
47
110
  const activityOf = (tool) => {
48
- if (/^(message_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(tool)) return 'sending'
111
+ if (/^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(tool)) return 'sending'
49
112
  if (tool === 'group_relay') return 'orbit'
50
113
  if (/search|fetch|browse|browser|read|grep|glob|list/i.test(tool)) return 'searching'
51
114
  return 'working'
@@ -63,26 +126,33 @@ export function install(rt) {
63
126
  if (!turns.includes(turn)) {
64
127
  turns.push(turn)
65
128
  if (turns.length > 400) turns.splice(0, turns.length - 400)
66
- void rt.save()
129
+ void rt.save().catch(() => {})
67
130
  }
68
131
  }
69
132
  const unmarkExchangeTurn = (sessionId, turn) => {
70
133
  const turns = state.exchangeTurns[sessionId] ?? []
71
134
  if (!turns.includes(turn)) return
72
135
  state.exchangeTurns[sessionId] = turns.filter(other => other !== turn)
73
- void rt.save()
136
+ void rt.save().catch(() => {})
74
137
  }
75
138
 
76
139
  // A request whose audience is the user is answered in the recipient's own chat: that
77
140
  // turn stays visible there and its reply is not routed back to the sender.
78
141
  const answersUser = info => info.role === 'request' && info.audience === 'user'
142
+ // Roles whose turn owes the sender a reply: a 'stopped' notice asks the Bot to
143
+ // acknowledge, exactly like a request.
144
+ const expectsReply = info => info.role === 'request' || info.role === 'stopped'
79
145
 
80
146
  // A group Session is hidden as a whole, so its turns need no marking. A handoff
81
147
  // package that only opens a part is acknowledged with a fixed line, also hidden.
82
148
  const claimTrigger = (sessionId, entry, info) => {
83
149
  entry.info = info
150
+ // A turn that was stopped for a part switch brings its joined requests along.
151
+ if (Array.isArray(info.joined) && info.joined.length > 0) {
152
+ entry.joined = info.joined.filter(item => item?.info !== undefined && item.info !== null)
153
+ }
84
154
  if (rt.groupOwners.has(sessionId)) return
85
- if ((info.role === 'request' && !answersUser(info)) || info.role === 'room' || (info.role === 'handoff' && info.resume !== true)) {
155
+ if ((expectsReply(info) && !answersUser(info)) || info.role === 'room' || (info.role === 'handoff' && info.resume !== true)) {
86
156
  markExchangeTurn(sessionId, entry.turn)
87
157
  }
88
158
  }
@@ -102,9 +172,52 @@ export function install(rt) {
102
172
  if (job.roomId === undefined) merged[job.botId] = 'thinking'
103
173
  else merged[job.botId] ??= 'thinking'
104
174
  }
175
+ for (const approval of rt.approvalsView?.() ?? []) merged[approval.botId] = 'alert'
105
176
  return merged
106
177
  }
107
178
 
179
+ // Per Bot, the turn it is running and its current tool; its own chat wins over a group.
180
+ const workView = () => {
181
+ const bots = {}
182
+ for (const [id, entry] of live) {
183
+ if (entry.turn === undefined) continue
184
+ const owner = rt.groupOwners.get(id)
185
+ const botId = owner?.botId ?? rt.chatOwners.get(id)
186
+ if (botId === undefined || (owner !== undefined && bots[botId] !== undefined)) continue
187
+ bots[botId] = { since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), ...(owner ? { roomId: owner.roomId } : {}) }
188
+ }
189
+ return bots
190
+ }
191
+
192
+ // Per group, the members whose turn there is running; the round's current speaker is
193
+ // marked, since its round shows it.
194
+ const roomWorkView = () => {
195
+ const speaking = new Set([...roomProgress.values()].map(round => `${round.roomId}:${round.speaking}`))
196
+ const rooms = {}
197
+ for (const [id, entry] of live) {
198
+ const owner = rt.groupOwners.get(id)
199
+ if (entry.turn === undefined || owner === undefined) continue
200
+ rooms[owner.roomId] = [...(rooms[owner.roomId] ?? []), {
201
+ botId: owner.botId,
202
+ since: entry.since,
203
+ ...(entry.tool ? { tool: entry.tool } : {}),
204
+ ...(speaking.has(`${owner.roomId}:${owner.botId}`) ? { speaking: true } : {}),
205
+ }]
206
+ }
207
+ return rooms
208
+ }
209
+
210
+ // What a running tool call is about, for an approval card: its own description and
211
+ // the command, path or address it acts on.
212
+ const callDetail = (sessionId, callId) => {
213
+ let args
214
+ try { args = JSON.parse(live.get(sessionId)?.calls?.get(callId) ?? '{}') } catch { args = {} }
215
+ const first = keys => keys.map(key => args?.[key]).find(value => typeof value === 'string' && value.trim() !== '')
216
+ const text = first(['description', 'justification'])
217
+ const code = first(['command', 'cmd', 'file_path', 'path', 'url', 'query'])
218
+ return { ...(text ? { text: text.trim().slice(0, 300) } : {}), ...(code ? { code: code.trim().slice(0, 400) } : {}) }
219
+ }
220
+
108
221
  // Where a Session speaks, for the diagnostics log.
109
222
  const placeOf = (id) => {
110
223
  const room = rt.roomOf(rt.groupOwners.get(id)?.roomId) ?? rt.roomOf(id)
@@ -128,21 +241,32 @@ export function install(rt) {
128
241
  if (id === undefined) return
129
242
  if (!rt.isBotSession(id) && !rt.roomOf(id)) return
130
243
  const entry = liveOf(id)
244
+ if (rt.groupOwners.has(id)) rt.touchRoomTurn?.(id)
131
245
  switch (event.type) {
132
- case 'tool/call':
133
- setActivity(id, event.data?.name === 'ask_user' ? undefined : activityOf(String(event.data?.name ?? '')))
246
+ case 'tool/call': {
247
+ const name = String(event.data?.name ?? '')
248
+ setActivity(id, name === 'ask_user' ? undefined : activityOf(name))
249
+ entry.tool = name
250
+ if (rt.groupOwners.has(id)) rt.workRoomTurn?.(id, name)
251
+ if (typeof event.data?.callId === 'string') (entry.calls ??= new Map()).set(event.data.callId, String(event.data.arguments ?? ''))
134
252
  break
253
+ }
135
254
  case 'tool/result':
136
255
  if (entry.turn !== undefined) setActivity(id, 'thinking')
256
+ entry.tool = undefined
257
+ entry.calls?.delete(event.data?.callId)
137
258
  break
138
259
  case 'turn/start':
139
260
  setActivity(id, 'thinking')
140
261
  entry.turn = event.data.turn
262
+ entry.since = event.time ?? Date.now()
141
263
  entry.texts = []
142
264
  entry.info = undefined
143
265
  // A turn turned away before its first step may end without a turn/end.
144
266
  entry.switched = false
145
267
  entry.answers = false
268
+ entry.opened = false
269
+ entry.joined = []
146
270
  if (entry.pending !== undefined) {
147
271
  claimTrigger(id, entry, entry.pending)
148
272
  entry.pending = undefined
@@ -153,6 +277,14 @@ export function install(rt) {
153
277
  // A message that shares a step with a bare handoff package is what the turn
154
278
  // answers, so the turn is not a hidden acknowledgement.
155
279
  const bare = info => info?.handoff === true && info.resume !== true
280
+ // Whether the turn was already opened by an earlier message decides what a
281
+ // Bot relay that lands inside it may claim: the first message of a turn
282
+ // still claims it as today, a later one only joins it.
283
+ const opened = entry.opened === true
284
+ if (entry.turn !== undefined && !bare(source)
285
+ && (source?.kind === 'user' || source?.kind === REPLY_KIND || source?.kind === 'schedule' || source?.kind === SOURCE_KIND)) {
286
+ entry.opened = true
287
+ }
156
288
  if (entry.turn !== undefined && (source?.kind === 'user' || source?.kind === REPLY_KIND || source?.kind === SOURCE_KIND)) {
157
289
  if (bare(source)) {
158
290
  if (entry.answers) break
@@ -171,15 +303,36 @@ export function install(rt) {
171
303
  name: source.senderName,
172
304
  reportBack: source.reportBack === true,
173
305
  hop: source.hop ?? 0,
306
+ ...(source.late ? { late: true } : {}),
307
+ ...(source.quiet ? { quiet: true } : {}),
308
+ ...(source.failed ? { failed: source.failed } : {}),
309
+ ...(source.resumed ? { resumed: true } : {}),
310
+ ...(source.again ? { again: true } : {}),
311
+ ...(source.stopped ? { stopped: true } : {}),
312
+ ...(source.askedBy ? { askedBy: source.askedBy } : {}),
174
313
  })
175
314
  } else if (source?.kind === SOURCE_KIND && source.role === 'kickoff' && rt.roomOf(id)) {
176
315
  queueRoomMessage(id, { text: '', kickoff: true })
177
316
  } else if (source?.kind === SOURCE_KIND) {
178
- if (entry.turn !== undefined && entry.info === undefined) claimTrigger(id, entry, source)
179
- else entry.pending = source
317
+ if (entry.turn === undefined) entry.pending = source
318
+ else if (!opened) claimTrigger(id, entry, source)
319
+ else if (expectsReply(source) && !answersUser(source)
320
+ && source.exchangeId !== entry.info?.exchangeId
321
+ && !(entry.joined ?? []).some(joined => joined.info.exchangeId === source.exchangeId)) {
322
+ // A request that lands inside a running turn steers that work but is
323
+ // answered in a hidden turn of its own once the work is done, so the
324
+ // sender never gets the turn's text. A failed-switch resume message
325
+ // re-sends the trigger's own source; the same exchangeId must not join twice.
326
+ const text = textOf(event.data.content).replace(/^\[[^\]\n]*\]\n?/, '')
327
+ entry.joined = [...(entry.joined ?? []), { info: source, text }]
328
+ }
329
+ // A mid-turn relay that owes no reply only informs the turn: it neither
330
+ // claims the running turn nor queues as the next one's trigger.
180
331
  } else if (source?.kind === 'schedule' && rt.roomOf(id)) {
181
332
  // A group's scheduled task reaches the group as the user's message.
182
333
  queueRoomMessage(id, { text: reminderText(textOf(event.data.content)) })
334
+ } else if (source?.kind === REFERENCE_KIND && rt.roomOf(id)) {
335
+ attachReference(roomInbox.get(id), source, textOf(event.data.content))
183
336
  } else if (source?.kind === 'user' || source?.kind === REPLY_KIND) {
184
337
  if (rt.roomOf(id) && source.kind === 'user') {
185
338
  const images = (Array.isArray(event.data.content) ? event.data.content : [])
@@ -191,60 +344,131 @@ export function install(rt) {
191
344
  // Any user message answers or supersedes the open question card.
192
345
  if (state.questions[id] !== undefined) {
193
346
  delete state.questions[id]
194
- void rt.save()
347
+ void rt.save().catch(() => {})
195
348
  }
196
349
  }
197
350
  break
198
351
  }
199
352
  case 'assistant/message': {
200
- const text = textOf(event.data?.message?.content)
353
+ const content = event.data?.message?.content
354
+ const text = textOf(content)
201
355
  if (text !== '') entry.texts.push(text)
356
+ entry.lastText = text
357
+ entry.lastCalled = Array.isArray(content) && content.some(block => block?.type === 'tool-call')
202
358
  break
203
359
  }
204
360
  case 'turn/end': {
205
361
  setActivity(id, undefined)
206
362
  const info = entry.info
207
- const reply = entry.texts.join('\n\n').trim()
363
+ const joined = entry.joined ?? []
364
+ const texts = entry.texts
365
+ const { lastText, lastCalled } = entry
208
366
  // A turn stopped for a switch goes on in the next part, which answers instead.
209
367
  const switched = entry.switched === true
210
368
  entry.turn = undefined
211
369
  entry.info = undefined
212
370
  entry.texts = []
213
371
  entry.switched = false
372
+ entry.joined = []
373
+ entry.since = undefined
374
+ entry.tool = undefined
375
+ entry.calls = undefined
376
+ entry.lastText = undefined
377
+ entry.lastCalled = undefined
214
378
  const reason = event.data?.reason
215
379
  const failure = reason?.kind === 'error' && !switched ? turnFailure(reason.error) : undefined
216
380
  if (failure !== undefined) noteTurnFailure(id, failure)
217
- if (info === undefined || switched) break
218
- if (info.role === 'room') {
381
+ if (switched) break
382
+ // A model that writes its tool call as text ends the turn as if it were done,
383
+ // though nothing ran. It gets one more turn on the same trigger, so a waiting
384
+ // round or sender gets the real answer; a second time, the text goes on without
385
+ // the call.
386
+ const wroteCall = failure === undefined && reason?.kind === 'completed' && lastCalled !== true && writesToolCall(lastText)
387
+ const retry = wroteCall && info?.retried !== true && !(info?.role === 'room' && stoppedRoomTurns.has(info.exchangeId))
388
+ if (retry) void retryWrittenCall(id, info).catch(error => rt.warn('ds-bot: asking %s to run its written tool call failed: %s', id, error))
389
+ const replyText = () => {
390
+ const reply = (wroteCall ? texts.map(stripWrittenCalls) : texts).join('\n\n').trim()
391
+ return wroteCall && reply === '' ? 'Tried to run a tool but wrote the call as text, so it did not run.' : reply
392
+ }
393
+ // Whoever the turn answers gets the same three-way rule: a failure notice,
394
+ // the turn's text (or a stopped-before-answer line), or nothing.
395
+ const answer = (source, reply) => {
396
+ if (!expectsReply(source) || answersUser(source)) return
397
+ const name = rt.botOf(rt.selfOf(id))?.name ?? 'The other Bot'
398
+ // A sender that wrote from a group gets the reply in its Session there, while
399
+ // it is still a member.
400
+ const back = rt.roomOf(source.senderRoomId)?.sessions?.[source.senderSessionId]
401
+ if (failure !== undefined) {
402
+ // The sender was told a reply comes later; without this it would wait forever.
403
+ void rt.deliver({
404
+ fromId: rt.selfOf(id),
405
+ toId: source.senderSessionId,
406
+ ...(back ? { sessionId: back } : {}),
407
+ text: `${name} could not answer your message: its model call failed (${failureLine(failure)}). Tell the user if it matters; do not retry right away.`,
408
+ role: 'reply',
409
+ hop: (source.hop ?? 0) + 1,
410
+ replyTo: source.exchangeId,
411
+ }).catch(error => rt.warn('ds-bot: failure notice delivery failed: %s', error))
412
+ } else if (reply !== '' || reason?.kind === 'aborted') {
413
+ // A turn stopped before it could answer still owes the sender a line,
414
+ // or it would wait forever for a reply that never comes.
415
+ void rt.deliver({
416
+ fromId: rt.selfOf(id),
417
+ toId: source.senderSessionId,
418
+ ...(back ? { sessionId: back } : {}),
419
+ text: reply !== '' ? reply : `${name} was stopped before it could answer your message.`,
420
+ role: 'reply',
421
+ hop: (source.hop ?? 0) + 1,
422
+ replyTo: source.exchangeId,
423
+ }).catch(error => rt.warn('ds-bot: reply delivery failed: %s', error))
424
+ }
425
+ }
426
+ // A retried turn answers the same waiter or sender, so this one passes nothing on.
427
+ if (!retry && info?.role === 'room') {
219
428
  const waiter = roomWaiters.get(info.exchangeId)
220
429
  roomWaiters.delete(info.exchangeId)
221
- if (failure !== undefined) waiter?.fail(failure)
222
- else waiter?.resolve(reason?.kind === 'aborted' ? null : reply)
223
- } else if (info.role === 'request' && !answersUser(info) && failure !== undefined) {
224
- // The sender was told a reply comes later; without this it would wait forever.
225
- const name = rt.botOf(rt.selfOf(id))?.name ?? 'The other Bot'
226
- void rt.deliver({
227
- fromId: rt.selfOf(id),
228
- toId: info.senderSessionId,
229
- text: `${name} could not answer your message: its model call failed (${failureLine(failure)}). Tell the user if it matters; do not retry right away.`,
230
- role: 'reply',
231
- hop: (info.hop ?? 0) + 1,
232
- replyTo: info.exchangeId,
233
- }).catch(error => rt.warn('ds-bot: failure notice delivery failed: %s', error))
234
- } else if (info.role === 'request' && !answersUser(info) && reply !== '') {
430
+ const stopped = stoppedRoomTurns.delete(info.exchangeId)
431
+ if (waiter !== undefined) {
432
+ if (failure !== undefined) waiter.fail(failure)
433
+ else waiter.resolve(reason?.kind === 'aborted' ? null : replyText())
434
+ } else if (!stopped && reason?.kind !== 'aborted') {
435
+ // The round moved on without this member; how its turn ends still reaches the group.
436
+ if (failure !== undefined) void rt.lateRoomFailure?.(id, info, failure)
437
+ else void rt.lateRoomReply?.(id, info, replyText())
438
+ }
439
+ } else if (!retry && info !== undefined) {
440
+ // A group Session speaks only in its group: the turn that takes a reply to a
441
+ // message it sent from there posts what it ends with.
442
+ if (rt.groupOwners.has(id) && (info.role === 'reply' || info.role === 'retry')) {
443
+ if (failure !== undefined) void rt.lateRoomFailure?.(id, info, failure)
444
+ else if (reason?.kind !== 'aborted') void rt.lateRoomReply?.(id, info, replyText())
445
+ } else {
446
+ answer(info, replyText())
447
+ }
448
+ }
449
+ for (const request of joined) {
450
+ // A failed or stopped turn closes the request like any other; a finished
451
+ // one hands it back to the Bot to answer next.
452
+ if (failure !== undefined || reason?.kind === 'aborted') {
453
+ answer(request.info, '')
454
+ continue
455
+ }
235
456
  void rt.deliver({
236
- fromId: rt.selfOf(id),
237
- toId: info.senderSessionId,
238
- text: reply,
239
- role: 'reply',
240
- hop: (info.hop ?? 0) + 1,
241
- replyTo: info.exchangeId,
242
- }).catch(error => rt.warn('ds-bot: reply delivery failed: %s', error))
457
+ fromId: request.info.senderSessionId,
458
+ toId: rt.selfOf(id),
459
+ sessionId: id,
460
+ text: request.text,
461
+ role: 'request',
462
+ hop: request.info.hop ?? 0,
463
+ exchangeId: request.info.exchangeId,
464
+ ...(request.info.senderRoomId ? { fromRoomId: request.info.senderRoomId } : {}),
465
+ deferred: true,
466
+ }).catch(error => rt.warn('ds-bot: deferred request delivery failed: %s', error))
243
467
  }
244
468
  break
245
469
  }
246
470
  }
247
471
  })
248
472
 
249
- Object.assign(rt, { live, liveOf, roomWaiters, roomProgress, roomInbox, activityView })
473
+ Object.assign(rt, { live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, callDetail, stopRoomTurn })
250
474
  }
@@ -161,8 +161,12 @@ export function install(rt) {
161
161
  const release = await fetchJson(`https://api.github.com/repos/${source.owner}/${source.repo}/releases/latest`, { 'user-agent': `${PKG.name}/${PKG.version}` })
162
162
  const version = cleanVersion(release?.tag_name)
163
163
  if (!version) return null
164
+ // The API answers through redirects, so the release's html_url names the
165
+ // repository's current address; the install moves to it.
166
+ const current = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/releases\//.exec(release.html_url ?? '')
167
+ const [owner, repo] = current ? [current[1], current[2]] : [source.owner, source.repo]
164
168
  const fragment = [release.tag_name, ...source.rest].join('&')
165
- return { version, spec: `github:${source.owner}/${source.repo}#${fragment}` }
169
+ return { version, spec: `github:${owner}/${repo}#${fragment}` }
166
170
  }
167
171
 
168
172
  async function runCheck() {
@@ -0,0 +1,65 @@
1
+ // "@Name" is read by one rule everywhere: the group host picks who answers with it,
2
+ // and the client draws the same mentions in the bubbles, so what the user sees called
3
+ // is what gets called. Pure, so both sides import it.
4
+
5
+ export const ALL_WORDS = ['all', 'everyone', '所有人', '全体']
6
+ // A letter, digit, "_" or "-" after a name means a longer word ("@Mi" in "@MiniMax"),
7
+ // and "/", "\" or a "." that goes on into a word mean a path: DSH writes a file
8
+ // reference as "@docs/guide.md", which must not call a Bot named Docs. A letter,
9
+ // digit, "_", "." or "/" before "@" means an address ("a@b", "medium.com/@Writer").
10
+ const NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i
11
+ const ADDRESS_BEFORE = /[a-z0-9_./]/i
12
+ // Code and Markdown links, with or without a title, are literal text.
13
+ const LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g
14
+
15
+ const fitsAt = (text, at, word) => text.slice(at, at + word.length).toLowerCase() === word.toLowerCase()
16
+ && !NAME_GOES_ON.test(text.slice(at + word.length))
17
+ const overlaps = (spans, start, end) => spans.some(span => start < span.end && span.start < end)
18
+
19
+ // What a Bot may be named: something "@" can call without a second reading.
20
+ export function nameProblem(name) {
21
+ const text = String(name ?? '').trim()
22
+ if (text === '') return 'A Bot needs a name'
23
+ if (/[@`\r\n]/.test(text)) return 'A Bot name cannot contain @, ` or a line break'
24
+ if (ALL_WORDS.includes(text.toLowerCase())) return `"${text}" calls everyone in a group, so it cannot be a Bot name`
25
+ return undefined
26
+ }
27
+
28
+ // DSH writes each referenced conversation into the message once as "@label"; that one
29
+ // occurrence per listed label is a reference, not a call. Longest labels go first so
30
+ // "@K notes" is never read as the label "K".
31
+ export function referenceSpans(text, labels = []) {
32
+ const spans = []
33
+ for (const label of [...labels].sort((a, b) => b.length - a.length)) {
34
+ const mention = `@${label}`
35
+ for (let at = text.indexOf(mention); at !== -1; at = text.indexOf(mention, at + 1)) {
36
+ const end = at + mention.length
37
+ if ((at > 0 && ADDRESS_BEFORE.test(text[at - 1])) || NAME_GOES_ON.test(text.slice(end)) || overlaps(spans, at, end)) continue
38
+ spans.push({ start: at, end, label })
39
+ break
40
+ }
41
+ }
42
+ return spans.sort((a, b) => a.start - b.start)
43
+ }
44
+
45
+ // Every "@Name" and "@all" outside code, links and references, in order. Longest names
46
+ // match first, so "@新 Bot 2" is never "@新 Bot"; a name may hold spaces.
47
+ export function mentionSpans(text, bots, { references = [] } = {}) {
48
+ const value = String(text ?? '')
49
+ const skip = [
50
+ ...[...value.matchAll(LITERAL)].map(match => ({ start: match.index, end: match.index + match[0].length })),
51
+ ...referenceSpans(value, references),
52
+ ]
53
+ const byLength = bots.filter(bot => bot?.name).sort((a, b) => b.name.length - a.name.length)
54
+ const spans = []
55
+ for (let at = value.indexOf('@'); at !== -1; at = value.indexOf('@', at + 1)) {
56
+ if ((at > 0 && ADDRESS_BEFORE.test(value[at - 1])) || overlaps(skip, at, at + 1)) continue
57
+ const bot = byLength.find(candidate => fitsAt(value, at + 1, candidate.name))
58
+ const all = bot ? undefined : ALL_WORDS.find(word => fitsAt(value, at + 1, word))
59
+ if (!bot && !all) continue
60
+ const end = at + 1 + (bot ? bot.name.length : all.length)
61
+ spans.push(bot ? { start: at, end, bot } : { start: at, end, all: true })
62
+ at = end - 1
63
+ }
64
+ return spans
65
+ }
package/web-search.js CHANGED
@@ -472,6 +472,15 @@ export const ENGINES = {
472
472
 
473
473
  const aborted = signal => new WebError('web search aborted', 'WEB_ABORTED', { cause: signal?.reason })
474
474
 
475
+ // AbortSignal.timeout's wait is unref'd, so under Node 22's test runner a pending subtest
476
+ // is cancelled as soon as nothing ref'd holds the loop — and a mocked fetch holds nothing.
477
+ // A plain setTimeout keeps the loop honest; the abort reason stays a TimeoutError.
478
+ const timeoutAfter = (ms) => {
479
+ const timeout = new AbortController()
480
+ const timer = setTimeout(() => timeout.abort(new DOMException('The operation timed out.', 'TimeoutError')), ms)
481
+ return { timeout, timer }
482
+ }
483
+
475
484
  function checkSearchApi(api, index) {
476
485
  const where = `web-search-auto: searchApis[${index}]`
477
486
  if (!isRecord(api)) throw new Error(`${where} must be an object`)
@@ -551,7 +560,7 @@ export function createAutoSearchProvider(deps, config = {}) {
551
560
 
552
561
  async function attempt(candidate, route, credential, request, signal, budget, known) {
553
562
  const call = candidate.request({ query: request.query, maxResults: request.maxResults })
554
- const timeout = AbortSignal.timeout(budget)
563
+ const { timeout, timer } = timeoutAfter(budget)
555
564
  let response
556
565
  let text
557
566
  try {
@@ -561,12 +570,14 @@ export function createAutoSearchProvider(deps, config = {}) {
561
570
  redirect: 'error',
562
571
  headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': USER_AGENT, ...route.headers, ...call.headers, ...credential.headers(candidate) },
563
572
  body: JSON.stringify(call.body),
564
- signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
573
+ signal: signal === undefined ? timeout.signal : AbortSignal.any([signal, timeout.signal]),
565
574
  })
566
575
  text = await response.text()
567
576
  } catch (error) {
568
577
  if (signal?.aborted) throw aborted(signal)
569
- return { reason: timeout.aborted ? `timed out after ${seconds(budget)}` : `request failed: ${reasonOf(error)}` }
578
+ return timeout.signal.aborted ? { reason: `timed out after ${seconds(budget)}`, timedOut: true } : { reason: `request failed: ${reasonOf(error)}` }
579
+ } finally {
580
+ clearTimeout(timer)
570
581
  }
571
582
  if (!response.ok) {
572
583
  const detail = errorDetail(text)
@@ -593,7 +604,7 @@ export function createAutoSearchProvider(deps, config = {}) {
593
604
  const keyOf = candidate => [route.provider, candidate.url, candidate.model ?? ''].join('\n')
594
605
  const works = candidate => memory.get(keyOf(candidate))?.works === true
595
606
  const ordered = [...plan.candidates.filter(works), ...plan.candidates.filter(candidate => !works(candidate))]
596
- const deadline = now() + options.nativeTimeoutMs
607
+ let deadline = now() + options.nativeTimeoutMs
597
608
  const notes = []
598
609
  let credential
599
610
  for (const candidate of ordered) {
@@ -622,6 +633,9 @@ export function createAutoSearchProvider(deps, config = {}) {
622
633
  notes.push(`${where}: ${outcome.reason}`)
623
634
  if (outcome.skip !== undefined) memory.set(keyOf(candidate), { until: now() + SKIP_FOR[outcome.skip], reason: outcome.reason })
624
635
  if (outcome.final) break
636
+ // A timed-out attempt had all the time that was left, but the timer and now() use
637
+ // different clocks, so now() can still read a millisecond before the deadline.
638
+ if (outcome.timedOut) deadline = now()
625
639
  }
626
640
  return { reason: `route "${route.provider}": ${notes.join('; ')}` }
627
641
  }
@@ -651,14 +665,16 @@ export function createAutoSearchProvider(deps, config = {}) {
651
665
  const remaining = deadline - now()
652
666
  if (remaining <= 0) { attempts.push(`${host}: skipped, out of time`); continue }
653
667
  // Leave later engines a share of the budget when an earlier one hangs.
654
- const timeout = AbortSignal.timeout(index === options.engines.length - 1 ? remaining : Math.min(remaining, Math.ceil(options.fallbackTimeoutMs / 2)))
668
+ const { timeout, timer } = timeoutAfter(index === options.engines.length - 1 ? remaining : Math.min(remaining, Math.ceil(options.fallbackTimeoutMs / 2)))
655
669
  let fetched
656
670
  try {
657
- fetched = await fetchPage(engine, url, signal === undefined ? timeout : AbortSignal.any([signal, timeout]))
671
+ fetched = await fetchPage(engine, url, signal === undefined ? timeout.signal : AbortSignal.any([signal, timeout.signal]))
658
672
  } catch (error) {
659
673
  if (signal?.aborted) throw aborted(signal)
660
- attempts.push(`${host}: ${timeout.aborted ? 'timed out' : `${error?.code ? `${error.code} ` : ''}${reasonOf(error)}`}`)
674
+ attempts.push(`${host}: ${timeout.signal.aborted ? 'timed out' : `${error?.code ? `${error.code} ` : ''}${reasonOf(error)}`}`)
661
675
  continue
676
+ } finally {
677
+ clearTimeout(timer)
662
678
  }
663
679
  const at = new URL(fetched.url).host
664
680
  if (fetched.page.statusCode !== 200) { attempts.push(`${at}: HTTP ${fetched.page.statusCode}`); continue }