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/README.md +5 -5
- package/client.js +1311 -466
- package/index.js +3 -1
- package/package.json +6 -7
- package/src/host/approvals.js +63 -0
- package/src/host/channel.js +14 -3
- package/src/host/constants.js +4 -1
- package/src/host/delivery.js +19 -7
- package/src/host/diagnostics.js +7 -5
- package/src/host/dsh-update.js +8 -6
- package/src/host/groups.js +195 -47
- package/src/host/home-lock.js +16 -10
- package/src/host/images.js +37 -0
- package/src/host/memory.js +27 -16
- package/src/host/parts.js +92 -21
- package/src/host/prompt.js +6 -2
- package/src/host/roster.js +109 -56
- package/src/host/store.js +113 -28
- package/src/host/time-context.js +46 -0
- package/src/host/tools.js +79 -28
- package/src/host/turns.js +176 -38
- package/src/host/update.js +31 -4
- package/src/shared/mentions.js +65 -0
- package/web-search.js +23 -7
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
|
|
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
|
-
|
|
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
|
|
179
|
-
else entry
|
|
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
|
|
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 (
|
|
218
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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:
|
|
237
|
-
toId:
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
}
|
package/src/host/update.js
CHANGED
|
@@ -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
|
|
102
|
+
const repoUrl = () => {
|
|
103
103
|
const repo = repoSlug()
|
|
104
|
-
return repo ? `https://github.com/${repo}
|
|
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:${
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 }
|