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.
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,49 @@ 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
+
24
34
  export function install(rt) {
25
35
  const { ctx, state } = rt
26
36
 
27
- /** @type {Map<string, {turn?:number, pending?:any, info?:any, texts:string[]}>} */
37
+ /** @type {Map<string, {turn?:number, pending?:any, info?:any, texts:string[], opened?:boolean, joined?:{info:any, from:number}[]}>} */
28
38
  const live = new Map()
29
- /** @type {Map<string, {resolve:(text:string)=>void}>} member turn waiters keyed by exchange id */
39
+ /** @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
40
  const roomWaiters = new Map()
31
41
  /** @type {Map<string, {roomId:string, replies:any[], members:string[], speaking:string|null}>} */
32
42
  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 */
43
+ /** @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
44
  const roomInbox = new Map()
35
45
  const queueRoomMessage = (roomId, item) => roomInbox.set(roomId, [...(roomInbox.get(roomId) ?? []), item])
46
+ // Member turns of a group round the user stopped: what they end with stays out of the group.
47
+ const stoppedRoomTurns = new Set()
48
+
49
+ // A stopped round stops the member work it started: the turn that answers it, or the
50
+ // delivery still waiting for the member's Session. An earlier turn there keeps going.
51
+ // An exchange whose turn already ended stays in the set, so it is bounded.
52
+ const STOPPED_KEPT = 200
53
+ async function stopRoomTurn(sessionId, exchangeId) {
54
+ stoppedRoomTurns.add(exchangeId)
55
+ if (stoppedRoomTurns.size > STOPPED_KEPT) stoppedRoomTurns.delete(stoppedRoomTurns.values().next().value)
56
+ const agent = await rt.resolveAgent(sessionId).catch(() => undefined)
57
+ if (agent === undefined) return
58
+ const entry = live.get(sessionId)
59
+ if (entry?.turn !== undefined && entry.info?.exchangeId === exchangeId) {
60
+ agent.cancel({ kind: 'hook', reason: 'ds-bot/stopped' })
61
+ return
62
+ }
63
+ for (const message of [...(agent.inbox?.nextStep ?? []), ...(agent.inbox?.nextTurn ?? [])]) {
64
+ if (message.source?.exchangeId === exchangeId && agent.inbox.remove(message.id)) stoppedRoomTurns.delete(exchangeId)
65
+ }
66
+ }
36
67
 
37
68
  const liveOf = id => {
38
69
  let entry = live.get(id)
@@ -45,7 +76,7 @@ export function install(rt) {
45
76
  /** @type {Record<string, string>} */
46
77
  let activity = {}
47
78
  const activityOf = (tool) => {
48
- if (/^(message_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(tool)) return 'sending'
79
+ if (/^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(tool)) return 'sending'
49
80
  if (tool === 'group_relay') return 'orbit'
50
81
  if (/search|fetch|browse|browser|read|grep|glob|list/i.test(tool)) return 'searching'
51
82
  return 'working'
@@ -63,26 +94,33 @@ export function install(rt) {
63
94
  if (!turns.includes(turn)) {
64
95
  turns.push(turn)
65
96
  if (turns.length > 400) turns.splice(0, turns.length - 400)
66
- void rt.save()
97
+ void rt.save().catch(() => {})
67
98
  }
68
99
  }
69
100
  const unmarkExchangeTurn = (sessionId, turn) => {
70
101
  const turns = state.exchangeTurns[sessionId] ?? []
71
102
  if (!turns.includes(turn)) return
72
103
  state.exchangeTurns[sessionId] = turns.filter(other => other !== turn)
73
- void rt.save()
104
+ void rt.save().catch(() => {})
74
105
  }
75
106
 
76
107
  // A request whose audience is the user is answered in the recipient's own chat: that
77
108
  // turn stays visible there and its reply is not routed back to the sender.
78
109
  const answersUser = info => info.role === 'request' && info.audience === 'user'
110
+ // Roles whose turn owes the sender a reply: a 'stopped' notice asks the Bot to
111
+ // acknowledge, exactly like a request.
112
+ const expectsReply = info => info.role === 'request' || info.role === 'stopped'
79
113
 
80
114
  // A group Session is hidden as a whole, so its turns need no marking. A handoff
81
115
  // package that only opens a part is acknowledged with a fixed line, also hidden.
82
116
  const claimTrigger = (sessionId, entry, info) => {
83
117
  entry.info = info
118
+ // A turn that was stopped for a part switch brings its joined requests along.
119
+ if (Array.isArray(info.joined) && info.joined.length > 0) {
120
+ entry.joined = info.joined.filter(item => item?.info !== undefined && item.info !== null)
121
+ }
84
122
  if (rt.groupOwners.has(sessionId)) return
85
- if ((info.role === 'request' && !answersUser(info)) || info.role === 'room' || (info.role === 'handoff' && info.resume !== true)) {
123
+ if ((expectsReply(info) && !answersUser(info)) || info.role === 'room' || (info.role === 'handoff' && info.resume !== true)) {
86
124
  markExchangeTurn(sessionId, entry.turn)
87
125
  }
88
126
  }
@@ -102,9 +140,34 @@ export function install(rt) {
102
140
  if (job.roomId === undefined) merged[job.botId] = 'thinking'
103
141
  else merged[job.botId] ??= 'thinking'
104
142
  }
143
+ for (const approval of rt.approvalsView?.() ?? []) merged[approval.botId] = 'alert'
105
144
  return merged
106
145
  }
107
146
 
147
+ // Per Bot, the turn it is running and its current tool; its own chat wins over a group.
148
+ const workView = () => {
149
+ const bots = {}
150
+ for (const [id, entry] of live) {
151
+ if (entry.turn === undefined) continue
152
+ const owner = rt.groupOwners.get(id)
153
+ const botId = owner?.botId ?? rt.chatOwners.get(id)
154
+ if (botId === undefined || (owner !== undefined && bots[botId] !== undefined)) continue
155
+ bots[botId] = { since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), ...(owner ? { roomId: owner.roomId } : {}) }
156
+ }
157
+ return bots
158
+ }
159
+
160
+ // What a running tool call is about, for an approval card: its own description and
161
+ // the command, path or address it acts on.
162
+ const callDetail = (sessionId, callId) => {
163
+ let args
164
+ try { args = JSON.parse(live.get(sessionId)?.calls?.get(callId) ?? '{}') } catch { args = {} }
165
+ const first = keys => keys.map(key => args?.[key]).find(value => typeof value === 'string' && value.trim() !== '')
166
+ const text = first(['description', 'justification'])
167
+ const code = first(['command', 'cmd', 'file_path', 'path', 'url', 'query'])
168
+ return { ...(text ? { text: text.trim().slice(0, 300) } : {}), ...(code ? { code: code.trim().slice(0, 400) } : {}) }
169
+ }
170
+
108
171
  // Where a Session speaks, for the diagnostics log.
109
172
  const placeOf = (id) => {
110
173
  const room = rt.roomOf(rt.groupOwners.get(id)?.roomId) ?? rt.roomOf(id)
@@ -128,21 +191,31 @@ export function install(rt) {
128
191
  if (id === undefined) return
129
192
  if (!rt.isBotSession(id) && !rt.roomOf(id)) return
130
193
  const entry = liveOf(id)
194
+ if (rt.groupOwners.has(id)) rt.touchRoomTurn?.(id)
131
195
  switch (event.type) {
132
- case 'tool/call':
133
- setActivity(id, event.data?.name === 'ask_user' ? undefined : activityOf(String(event.data?.name ?? '')))
196
+ case 'tool/call': {
197
+ const name = String(event.data?.name ?? '')
198
+ setActivity(id, name === 'ask_user' ? undefined : activityOf(name))
199
+ entry.tool = name
200
+ if (typeof event.data?.callId === 'string') (entry.calls ??= new Map()).set(event.data.callId, String(event.data.arguments ?? ''))
134
201
  break
202
+ }
135
203
  case 'tool/result':
136
204
  if (entry.turn !== undefined) setActivity(id, 'thinking')
205
+ entry.tool = undefined
206
+ entry.calls?.delete(event.data?.callId)
137
207
  break
138
208
  case 'turn/start':
139
209
  setActivity(id, 'thinking')
140
210
  entry.turn = event.data.turn
211
+ entry.since = event.time ?? Date.now()
141
212
  entry.texts = []
142
213
  entry.info = undefined
143
214
  // A turn turned away before its first step may end without a turn/end.
144
215
  entry.switched = false
145
216
  entry.answers = false
217
+ entry.opened = false
218
+ entry.joined = []
146
219
  if (entry.pending !== undefined) {
147
220
  claimTrigger(id, entry, entry.pending)
148
221
  entry.pending = undefined
@@ -153,6 +226,14 @@ export function install(rt) {
153
226
  // A message that shares a step with a bare handoff package is what the turn
154
227
  // answers, so the turn is not a hidden acknowledgement.
155
228
  const bare = info => info?.handoff === true && info.resume !== true
229
+ // Whether the turn was already opened by an earlier message decides what a
230
+ // Bot relay that lands inside it may claim: the first message of a turn
231
+ // still claims it as today, a later one only joins it.
232
+ const opened = entry.opened === true
233
+ if (entry.turn !== undefined && !bare(source)
234
+ && (source?.kind === 'user' || source?.kind === REPLY_KIND || source?.kind === 'schedule' || source?.kind === SOURCE_KIND)) {
235
+ entry.opened = true
236
+ }
156
237
  if (entry.turn !== undefined && (source?.kind === 'user' || source?.kind === REPLY_KIND || source?.kind === SOURCE_KIND)) {
157
238
  if (bare(source)) {
158
239
  if (entry.answers) break
@@ -171,15 +252,32 @@ export function install(rt) {
171
252
  name: source.senderName,
172
253
  reportBack: source.reportBack === true,
173
254
  hop: source.hop ?? 0,
255
+ ...(source.late ? { late: true } : {}),
256
+ ...(source.quiet ? { quiet: true } : {}),
257
+ ...(source.failed ? { failed: source.failed } : {}),
174
258
  })
175
259
  } else if (source?.kind === SOURCE_KIND && source.role === 'kickoff' && rt.roomOf(id)) {
176
260
  queueRoomMessage(id, { text: '', kickoff: true })
177
261
  } else if (source?.kind === SOURCE_KIND) {
178
- if (entry.turn !== undefined && entry.info === undefined) claimTrigger(id, entry, source)
179
- else entry.pending = source
262
+ if (entry.turn === undefined) entry.pending = source
263
+ else if (!opened) claimTrigger(id, entry, source)
264
+ else if (expectsReply(source) && !answersUser(source)
265
+ && source.exchangeId !== entry.info?.exchangeId
266
+ && !(entry.joined ?? []).some(joined => joined.info.exchangeId === source.exchangeId)) {
267
+ // A request that lands inside a running turn steers that work but is
268
+ // answered in a hidden turn of its own once the work is done, so the
269
+ // sender never gets the turn's text. A failed-switch resume message
270
+ // re-sends the trigger's own source; the same exchangeId must not join twice.
271
+ const text = textOf(event.data.content).replace(/^\[[^\]\n]*\]\n?/, '')
272
+ entry.joined = [...(entry.joined ?? []), { info: source, text }]
273
+ }
274
+ // A mid-turn relay that owes no reply only informs the turn: it neither
275
+ // claims the running turn nor queues as the next one's trigger.
180
276
  } else if (source?.kind === 'schedule' && rt.roomOf(id)) {
181
277
  // A group's scheduled task reaches the group as the user's message.
182
278
  queueRoomMessage(id, { text: reminderText(textOf(event.data.content)) })
279
+ } else if (source?.kind === REFERENCE_KIND && rt.roomOf(id)) {
280
+ attachReference(roomInbox.get(id), source, textOf(event.data.content))
183
281
  } else if (source?.kind === 'user' || source?.kind === REPLY_KIND) {
184
282
  if (rt.roomOf(id) && source.kind === 'user') {
185
283
  const images = (Array.isArray(event.data.content) ? event.data.content : [])
@@ -191,7 +289,7 @@ export function install(rt) {
191
289
  // Any user message answers or supersedes the open question card.
192
290
  if (state.questions[id] !== undefined) {
193
291
  delete state.questions[id]
194
- void rt.save()
292
+ void rt.save().catch(() => {})
195
293
  }
196
294
  }
197
295
  break
@@ -204,47 +302,87 @@ export function install(rt) {
204
302
  case 'turn/end': {
205
303
  setActivity(id, undefined)
206
304
  const info = entry.info
207
- const reply = entry.texts.join('\n\n').trim()
305
+ const joined = entry.joined ?? []
306
+ const texts = entry.texts
208
307
  // A turn stopped for a switch goes on in the next part, which answers instead.
209
308
  const switched = entry.switched === true
210
309
  entry.turn = undefined
211
310
  entry.info = undefined
212
311
  entry.texts = []
213
312
  entry.switched = false
313
+ entry.joined = []
314
+ entry.since = undefined
315
+ entry.tool = undefined
316
+ entry.calls = undefined
214
317
  const reason = event.data?.reason
215
318
  const failure = reason?.kind === 'error' && !switched ? turnFailure(reason.error) : undefined
216
319
  if (failure !== undefined) noteTurnFailure(id, failure)
217
- if (info === undefined || switched) break
218
- if (info.role === 'room') {
320
+ if (switched) break
321
+ // Whoever the turn answers gets the same three-way rule: a failure notice,
322
+ // the turn's text (or a stopped-before-answer line), or nothing.
323
+ const answer = (source, reply) => {
324
+ if (!expectsReply(source) || answersUser(source)) return
325
+ const name = rt.botOf(rt.selfOf(id))?.name ?? 'The other Bot'
326
+ if (failure !== undefined) {
327
+ // The sender was told a reply comes later; without this it would wait forever.
328
+ void rt.deliver({
329
+ fromId: rt.selfOf(id),
330
+ toId: source.senderSessionId,
331
+ text: `${name} could not answer your message: its model call failed (${failureLine(failure)}). Tell the user if it matters; do not retry right away.`,
332
+ role: 'reply',
333
+ hop: (source.hop ?? 0) + 1,
334
+ replyTo: source.exchangeId,
335
+ }).catch(error => rt.warn('ds-bot: failure notice delivery failed: %s', error))
336
+ } else if (reply !== '' || reason?.kind === 'aborted') {
337
+ // A turn stopped before it could answer still owes the sender a line,
338
+ // or it would wait forever for a reply that never comes.
339
+ void rt.deliver({
340
+ fromId: rt.selfOf(id),
341
+ toId: source.senderSessionId,
342
+ text: reply !== '' ? reply : `${name} was stopped before it could answer your message.`,
343
+ role: 'reply',
344
+ hop: (source.hop ?? 0) + 1,
345
+ replyTo: source.exchangeId,
346
+ }).catch(error => rt.warn('ds-bot: reply delivery failed: %s', error))
347
+ }
348
+ }
349
+ if (info?.role === 'room') {
219
350
  const waiter = roomWaiters.get(info.exchangeId)
220
351
  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 !== '') {
352
+ const stopped = stoppedRoomTurns.delete(info.exchangeId)
353
+ if (waiter !== undefined) {
354
+ if (failure !== undefined) waiter.fail(failure)
355
+ else waiter.resolve(reason?.kind === 'aborted' ? null : texts.join('\n\n').trim())
356
+ } else if (!stopped && reason?.kind !== 'aborted') {
357
+ // The round moved on without this member; how its turn ends still reaches the group.
358
+ if (failure !== undefined) void rt.lateRoomFailure?.(id, info, failure)
359
+ else void rt.lateRoomReply?.(id, info, texts.join('\n\n').trim())
360
+ }
361
+ } else if (info !== undefined) {
362
+ answer(info, texts.join('\n\n').trim())
363
+ }
364
+ for (const request of joined) {
365
+ // A failed or stopped turn closes the request like any other; a finished
366
+ // one hands it back to the Bot to answer next.
367
+ if (failure !== undefined || reason?.kind === 'aborted') {
368
+ answer(request.info, '')
369
+ continue
370
+ }
235
371
  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))
372
+ fromId: request.info.senderSessionId,
373
+ toId: rt.selfOf(id),
374
+ sessionId: id,
375
+ text: request.text,
376
+ role: 'request',
377
+ hop: request.info.hop ?? 0,
378
+ exchangeId: request.info.exchangeId,
379
+ deferred: true,
380
+ }).catch(error => rt.warn('ds-bot: deferred request delivery failed: %s', error))
243
381
  }
244
382
  break
245
383
  }
246
384
  }
247
385
  })
248
386
 
249
- Object.assign(rt, { live, liveOf, roomWaiters, roomProgress, roomInbox, activityView })
387
+ Object.assign(rt, { live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, callDetail, stopRoomTurn })
250
388
  }
@@ -99,11 +99,17 @@ export async function installSpecOf(ctx) {
99
99
  export const repoSlug = () =>
100
100
  /github\.com[/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/i.exec(String(PKG.repository?.url ?? PKG.repository ?? ''))?.[1] ?? null
101
101
 
102
- const releasesUrl = () => {
102
+ const repoUrl = () => {
103
103
  const repo = repoSlug()
104
- return repo ? `https://github.com/${repo}/releases` : (PKG.homepage ?? null)
104
+ return repo ? `https://github.com/${repo}` : null
105
+ }
106
+ const releasesUrl = () => {
107
+ const repo = repoUrl()
108
+ return repo ? `${repo}/releases` : (PKG.homepage ?? null)
105
109
  }
106
110
 
111
+ export const STARS_TTL_MS = 60 * 60_000
112
+
107
113
  export function install(rt) {
108
114
  const { ctx, config } = rt
109
115
  const enabled = config.updateCheck !== false
@@ -119,10 +125,27 @@ export function install(rt) {
119
125
 
120
126
  const view = () => ({
121
127
  name: status.name, current: status.current, latest: status.latest, source: status.source, notesUrl: status.notesUrl,
128
+ repoUrl: repoUrl(), enabled,
122
129
  checkedAt: status.checkedAt, phase: status.phase, installed: status.installed, error: status.error,
123
130
  available: status.phase !== 'installed' && target !== null && status.latest !== null && newer(status.latest, status.current),
124
131
  })
125
132
 
133
+ // Asked only while the About page is open, so a closed page never calls GitHub.
134
+ // A failed answer keeps the last count and is asked again next time.
135
+ let stars = { count: null, at: 0 }
136
+ let starring = null
137
+ async function repoStars() {
138
+ const repo = repoSlug()
139
+ if (repo === null) return null
140
+ if (Date.now() - stars.at < STARS_TTL_MS) return stars.count
141
+ starring ??= fetchJson(`https://api.github.com/repos/${repo}`, { 'user-agent': `${PKG.name}/${PKG.version}` })
142
+ .then((info) => { if (Number.isInteger(info?.stargazers_count)) stars = { count: info.stargazers_count, at: Date.now() } })
143
+ .catch(error => rt.warn('ds-bot: star count failed: %s', error?.message ?? error))
144
+ .finally(() => { starring = null })
145
+ await starring
146
+ return stars.count
147
+ }
148
+
126
149
  async function newestFromRegistry() {
127
150
  for (const registry of await registryPlan(config, manager())) {
128
151
  try {
@@ -138,8 +161,12 @@ export function install(rt) {
138
161
  const release = await fetchJson(`https://api.github.com/repos/${source.owner}/${source.repo}/releases/latest`, { 'user-agent': `${PKG.name}/${PKG.version}` })
139
162
  const version = cleanVersion(release?.tag_name)
140
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]
141
168
  const fragment = [release.tag_name, ...source.rest].join('&')
142
- return { version, spec: `github:${source.owner}/${source.repo}#${fragment}` }
169
+ return { version, spec: `github:${owner}/${repo}#${fragment}` }
143
170
  }
144
171
 
145
172
  async function runCheck() {
@@ -206,5 +233,5 @@ export function install(rt) {
206
233
  }, 'ds-bot: update check')
207
234
  }
208
235
 
209
- Object.assign(rt, { updateCheck: check, updateStart: start, updateView: view })
236
+ Object.assign(rt, { updateCheck: check, updateStart: start, updateView: view, repoStars })
210
237
  }
@@ -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 }