@thewaifu/dsh-bot 1.1.1
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/LICENSE +201 -0
- package/README.md +304 -0
- package/bin/dsh-bot-web.js +33 -0
- package/client.js +13691 -0
- package/cordis.patch.yml +17 -0
- package/index.js +57 -0
- package/package.json +80 -0
- package/src/host/agents.js +186 -0
- package/src/host/approvals.js +63 -0
- package/src/host/browser.js +258 -0
- package/src/host/channel.js +342 -0
- package/src/host/chat-index.js +293 -0
- package/src/host/connectors.js +169 -0
- package/src/host/constants.js +65 -0
- package/src/host/delivery.js +71 -0
- package/src/host/diagnostics.js +249 -0
- package/src/host/dsh-compat.js +22 -0
- package/src/host/dsh-update.js +170 -0
- package/src/host/groups.js +592 -0
- package/src/host/home-lock.js +97 -0
- package/src/host/images.js +211 -0
- package/src/host/memory.js +1362 -0
- package/src/host/models.js +231 -0
- package/src/host/parts.js +976 -0
- package/src/host/permissions.js +48 -0
- package/src/host/prompt.js +71 -0
- package/src/host/roster.js +471 -0
- package/src/host/schedules.js +151 -0
- package/src/host/secrets.js +530 -0
- package/src/host/steward.js +164 -0
- package/src/host/store.js +344 -0
- package/src/host/text.js +47 -0
- package/src/host/time-context.js +46 -0
- package/src/host/tools.js +425 -0
- package/src/host/turns.js +388 -0
- package/src/host/update.js +237 -0
- package/src/host/usage.js +591 -0
- package/src/host/waifu.js +58 -0
- package/src/shared/mentions.js +65 -0
- package/src/shared/web-port.js +26 -0
- package/web-search.js +764 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// Turns: what each Bot Session is doing now (for its mascot), which turns another Bot
|
|
2
|
+
// triggered, the waiters and inboxes of group rounds, and routing a finished turn's reply.
|
|
3
|
+
//
|
|
4
|
+
// Turn tracking: which turns were triggered by another Bot, and what they said.
|
|
5
|
+
import { REFERENCE_KIND, REPLY_KIND, SOURCE_KIND } from './constants.js'
|
|
6
|
+
import { textOf } from './text.js'
|
|
7
|
+
import { reminderText } from './schedules.js'
|
|
8
|
+
|
|
9
|
+
// A failed turn's `LlmFailure`, kept to what can be shown and logged. Like DSH's own
|
|
10
|
+
// error row, an AUTH failure loses its message: providers may echo part of the key.
|
|
11
|
+
export function turnFailure(error) {
|
|
12
|
+
const code = typeof error?.code === 'string' && error.code !== '' ? error.code : 'UNKNOWN'
|
|
13
|
+
const message = code === 'AUTH' || typeof error?.message !== 'string' ? '' : error.message.trim().slice(0, 500)
|
|
14
|
+
return {
|
|
15
|
+
code,
|
|
16
|
+
message,
|
|
17
|
+
...(typeof error?.status === 'number' ? { status: error.status } : {}),
|
|
18
|
+
...(typeof error?.requestId === 'string' ? { requestId: error.requestId } : {}),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const failureLine = failure => `${failure.code}${failure.status ? ` ${failure.status}` : ''}${failure.message ? `: ${failure.message.slice(0, 200)}` : ''}`
|
|
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
|
+
export function install(rt) {
|
|
35
|
+
const { ctx, state } = rt
|
|
36
|
+
|
|
37
|
+
/** @type {Map<string, {turn?:number, pending?:any, info?:any, texts:string[], opened?:boolean, joined?:{info:any, from:number}[]}>} */
|
|
38
|
+
const live = new Map()
|
|
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 */
|
|
40
|
+
const roomWaiters = new Map()
|
|
41
|
+
/** @type {Map<string, {roomId:string, replies:any[], members:string[], speaking:string|null}>} */
|
|
42
|
+
const roomProgress = new Map()
|
|
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 */
|
|
44
|
+
const roomInbox = new Map()
|
|
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
|
+
}
|
|
67
|
+
|
|
68
|
+
const liveOf = id => {
|
|
69
|
+
let entry = live.get(id)
|
|
70
|
+
if (entry === undefined) live.set(id, entry = { texts: [] })
|
|
71
|
+
return entry
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// What each running Bot is doing right now, for its mascot. Process-local: it is
|
|
75
|
+
// rebuilt from live events and never saved.
|
|
76
|
+
/** @type {Record<string, string>} */
|
|
77
|
+
let activity = {}
|
|
78
|
+
const activityOf = (tool) => {
|
|
79
|
+
if (/^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(tool)) return 'sending'
|
|
80
|
+
if (tool === 'group_relay') return 'orbit'
|
|
81
|
+
if (/search|fetch|browse|browser|read|grep|glob|list/i.test(tool)) return 'searching'
|
|
82
|
+
return 'working'
|
|
83
|
+
}
|
|
84
|
+
const setActivity = (id, value) => {
|
|
85
|
+
if (activity[id] === value) return
|
|
86
|
+
const next = { ...activity }
|
|
87
|
+
if (value === undefined) delete next[id]
|
|
88
|
+
else next[id] = value
|
|
89
|
+
activity = next
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const markExchangeTurn = (sessionId, turn) => {
|
|
93
|
+
const turns = state.exchangeTurns[sessionId] ??= []
|
|
94
|
+
if (!turns.includes(turn)) {
|
|
95
|
+
turns.push(turn)
|
|
96
|
+
if (turns.length > 400) turns.splice(0, turns.length - 400)
|
|
97
|
+
void rt.save().catch(() => {})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const unmarkExchangeTurn = (sessionId, turn) => {
|
|
101
|
+
const turns = state.exchangeTurns[sessionId] ?? []
|
|
102
|
+
if (!turns.includes(turn)) return
|
|
103
|
+
state.exchangeTurns[sessionId] = turns.filter(other => other !== turn)
|
|
104
|
+
void rt.save().catch(() => {})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A request whose audience is the user is answered in the recipient's own chat: that
|
|
108
|
+
// turn stays visible there and its reply is not routed back to the sender.
|
|
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'
|
|
113
|
+
|
|
114
|
+
// A group Session is hidden as a whole, so its turns need no marking. A handoff
|
|
115
|
+
// package that only opens a part is acknowledged with a fixed line, also hidden.
|
|
116
|
+
const claimTrigger = (sessionId, entry, info) => {
|
|
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
|
+
}
|
|
122
|
+
if (rt.groupOwners.has(sessionId)) return
|
|
123
|
+
if ((expectsReply(info) && !answersUser(info)) || info.role === 'room' || (info.role === 'handoff' && info.resume !== true)) {
|
|
124
|
+
markExchangeTurn(sessionId, entry.turn)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Activity is tracked per Session; a Bot's mascot shows its own chat's, else a group's.
|
|
129
|
+
const activityView = () => {
|
|
130
|
+
const merged = {}
|
|
131
|
+
for (const [id, value] of Object.entries(activity)) {
|
|
132
|
+
if (!rt.groupOwners.has(id)) merged[rt.chatOwners.get(id) ?? id] = value
|
|
133
|
+
}
|
|
134
|
+
for (const [id, value] of Object.entries(activity)) {
|
|
135
|
+
const owner = rt.groupOwners.get(id)
|
|
136
|
+
if (owner !== undefined) merged[owner.botId] ??= value
|
|
137
|
+
}
|
|
138
|
+
// A Bot writing its handoff note is thinking.
|
|
139
|
+
for (const job of rt.switchJobs()) {
|
|
140
|
+
if (job.roomId === undefined) merged[job.botId] = 'thinking'
|
|
141
|
+
else merged[job.botId] ??= 'thinking'
|
|
142
|
+
}
|
|
143
|
+
for (const approval of rt.approvalsView?.() ?? []) merged[approval.botId] = 'alert'
|
|
144
|
+
return merged
|
|
145
|
+
}
|
|
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
|
+
|
|
171
|
+
// Where a Session speaks, for the diagnostics log.
|
|
172
|
+
const placeOf = (id) => {
|
|
173
|
+
const room = rt.roomOf(rt.groupOwners.get(id)?.roomId) ?? rt.roomOf(id)
|
|
174
|
+
return room ? `group "${room.name}"` : 'own chat'
|
|
175
|
+
}
|
|
176
|
+
const noteTurnFailure = (id, failure) => {
|
|
177
|
+
rt.noteFailure({
|
|
178
|
+
source: 'turn',
|
|
179
|
+
text: failure.message || 'the model call failed',
|
|
180
|
+
code: failure.code,
|
|
181
|
+
status: failure.status,
|
|
182
|
+
requestId: failure.requestId,
|
|
183
|
+
bot: rt.botOf(rt.selfOf(id))?.name ?? (rt.roomOf(id) ? undefined : 'unknown Bot'),
|
|
184
|
+
where: placeOf(id),
|
|
185
|
+
sessionId: id,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
ctx.on('session/event', (session, event) => {
|
|
190
|
+
const id = session?.id
|
|
191
|
+
if (id === undefined) return
|
|
192
|
+
if (!rt.isBotSession(id) && !rt.roomOf(id)) return
|
|
193
|
+
const entry = liveOf(id)
|
|
194
|
+
if (rt.groupOwners.has(id)) rt.touchRoomTurn?.(id)
|
|
195
|
+
switch (event.type) {
|
|
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 ?? ''))
|
|
201
|
+
break
|
|
202
|
+
}
|
|
203
|
+
case 'tool/result':
|
|
204
|
+
if (entry.turn !== undefined) setActivity(id, 'thinking')
|
|
205
|
+
entry.tool = undefined
|
|
206
|
+
entry.calls?.delete(event.data?.callId)
|
|
207
|
+
break
|
|
208
|
+
case 'turn/start':
|
|
209
|
+
setActivity(id, 'thinking')
|
|
210
|
+
entry.turn = event.data.turn
|
|
211
|
+
entry.since = event.time ?? Date.now()
|
|
212
|
+
entry.texts = []
|
|
213
|
+
entry.info = undefined
|
|
214
|
+
// A turn turned away before its first step may end without a turn/end.
|
|
215
|
+
entry.switched = false
|
|
216
|
+
entry.answers = false
|
|
217
|
+
entry.opened = false
|
|
218
|
+
entry.joined = []
|
|
219
|
+
if (entry.pending !== undefined) {
|
|
220
|
+
claimTrigger(id, entry, entry.pending)
|
|
221
|
+
entry.pending = undefined
|
|
222
|
+
}
|
|
223
|
+
break
|
|
224
|
+
case 'user/message': {
|
|
225
|
+
const source = event.data?.source
|
|
226
|
+
// A message that shares a step with a bare handoff package is what the turn
|
|
227
|
+
// answers, so the turn is not a hidden acknowledgement.
|
|
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
|
+
}
|
|
237
|
+
if (entry.turn !== undefined && (source?.kind === 'user' || source?.kind === REPLY_KIND || source?.kind === SOURCE_KIND)) {
|
|
238
|
+
if (bare(source)) {
|
|
239
|
+
if (entry.answers) break
|
|
240
|
+
} else {
|
|
241
|
+
entry.answers = true
|
|
242
|
+
if (bare(entry.info)) {
|
|
243
|
+
unmarkExchangeTurn(id, entry.turn)
|
|
244
|
+
entry.info = undefined
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (source?.kind === SOURCE_KIND && source.role === 'post' && rt.roomOf(id)) {
|
|
249
|
+
queueRoomMessage(id, {
|
|
250
|
+
text: textOf(event.data.content).replace(/^\[Group post from [^\]]*\]\n?/, ''),
|
|
251
|
+
botId: source.senderSessionId,
|
|
252
|
+
name: source.senderName,
|
|
253
|
+
reportBack: source.reportBack === true,
|
|
254
|
+
hop: source.hop ?? 0,
|
|
255
|
+
...(source.late ? { late: true } : {}),
|
|
256
|
+
...(source.quiet ? { quiet: true } : {}),
|
|
257
|
+
...(source.failed ? { failed: source.failed } : {}),
|
|
258
|
+
})
|
|
259
|
+
} else if (source?.kind === SOURCE_KIND && source.role === 'kickoff' && rt.roomOf(id)) {
|
|
260
|
+
queueRoomMessage(id, { text: '', kickoff: true })
|
|
261
|
+
} else if (source?.kind === SOURCE_KIND) {
|
|
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.
|
|
276
|
+
} else if (source?.kind === 'schedule' && rt.roomOf(id)) {
|
|
277
|
+
// A group's scheduled task reaches the group as the user's message.
|
|
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))
|
|
281
|
+
} else if (source?.kind === 'user' || source?.kind === REPLY_KIND) {
|
|
282
|
+
if (rt.roomOf(id) && source.kind === 'user') {
|
|
283
|
+
const images = (Array.isArray(event.data.content) ? event.data.content : [])
|
|
284
|
+
.filter(block => block?.type === 'image' && !block.offloaded).map(block => block.attachment)
|
|
285
|
+
queueRoomMessage(id, { text: textOf(event.data.content), ...(images.length > 0 ? { images } : {}) })
|
|
286
|
+
}
|
|
287
|
+
const own = rt.chatOwners.get(id)
|
|
288
|
+
if (own !== undefined) rt.dropKickoff(own)
|
|
289
|
+
// Any user message answers or supersedes the open question card.
|
|
290
|
+
if (state.questions[id] !== undefined) {
|
|
291
|
+
delete state.questions[id]
|
|
292
|
+
void rt.save().catch(() => {})
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
break
|
|
296
|
+
}
|
|
297
|
+
case 'assistant/message': {
|
|
298
|
+
const text = textOf(event.data?.message?.content)
|
|
299
|
+
if (text !== '') entry.texts.push(text)
|
|
300
|
+
break
|
|
301
|
+
}
|
|
302
|
+
case 'turn/end': {
|
|
303
|
+
setActivity(id, undefined)
|
|
304
|
+
const info = entry.info
|
|
305
|
+
const joined = entry.joined ?? []
|
|
306
|
+
const texts = entry.texts
|
|
307
|
+
// A turn stopped for a switch goes on in the next part, which answers instead.
|
|
308
|
+
const switched = entry.switched === true
|
|
309
|
+
entry.turn = undefined
|
|
310
|
+
entry.info = undefined
|
|
311
|
+
entry.texts = []
|
|
312
|
+
entry.switched = false
|
|
313
|
+
entry.joined = []
|
|
314
|
+
entry.since = undefined
|
|
315
|
+
entry.tool = undefined
|
|
316
|
+
entry.calls = undefined
|
|
317
|
+
const reason = event.data?.reason
|
|
318
|
+
const failure = reason?.kind === 'error' && !switched ? turnFailure(reason.error) : undefined
|
|
319
|
+
if (failure !== undefined) noteTurnFailure(id, failure)
|
|
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') {
|
|
350
|
+
const waiter = roomWaiters.get(info.exchangeId)
|
|
351
|
+
roomWaiters.delete(info.exchangeId)
|
|
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
|
+
}
|
|
371
|
+
void rt.deliver({
|
|
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))
|
|
381
|
+
}
|
|
382
|
+
break
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
Object.assign(rt, { live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, callDetail, stopRoomTurn })
|
|
388
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Updates: compare this package's version with the newest release where it was
|
|
2
|
+
// installed from (the npm registry, or a GitHub tag for a versioned git install)
|
|
3
|
+
// and, when the user asks, reinstall it through DSH's plugin manager. A new version
|
|
4
|
+
// runs from the next DSH start. A linked or local checkout is never offered one.
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
const PKG = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
9
|
+
export const OFFICIAL_REGISTRY = 'https://registry.npmjs.org/'
|
|
10
|
+
export const MIRROR_REGISTRY = 'https://registry.npmmirror.com/'
|
|
11
|
+
// Every window that opens asks; within this long the last answer stands.
|
|
12
|
+
export const CHECK_TTL_MS = 10 * 60_000
|
|
13
|
+
export const FETCH_TIMEOUT_MS = 8000
|
|
14
|
+
// The first check waits until the host has settled its own start.
|
|
15
|
+
const STARTUP_DELAY_MS = 15_000
|
|
16
|
+
const VERSION = /^v?(\d+\.\d+\.\d+(?:-[\w.]+)?)(?:\+[\w.]+)?$/
|
|
17
|
+
|
|
18
|
+
/** A release version without its `v` and build metadata, or null. */
|
|
19
|
+
export const cleanVersion = value => VERSION.exec(String(value ?? '').trim())?.[1] ?? null
|
|
20
|
+
|
|
21
|
+
/** Whether version `a` is newer than `b` (semver order, prereleases before their release). */
|
|
22
|
+
export function newer(a, b) {
|
|
23
|
+
const parse = (value) => {
|
|
24
|
+
const [core, pre] = (cleanVersion(value) ?? '0.0.0').split(/-(.*)/s)
|
|
25
|
+
return { nums: core.split('.').map(Number), pre: pre ?? null }
|
|
26
|
+
}
|
|
27
|
+
const x = parse(a)
|
|
28
|
+
const y = parse(b)
|
|
29
|
+
for (let index = 0; index < 3; index++) if (x.nums[index] !== y.nums[index]) return x.nums[index] > y.nums[index]
|
|
30
|
+
if (x.pre === y.pre) return false
|
|
31
|
+
if (x.pre === null) return true
|
|
32
|
+
if (y.pre === null) return false
|
|
33
|
+
return x.pre.localeCompare(y.pre, 'en', { numeric: true }) > 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How the profile installed this package, from the plugin manager's `source` spec:
|
|
38
|
+
* `registry` (name@range), `git` (with `owner`, `repo`, the version `ref` and the
|
|
39
|
+
* other fragment parts when it pins a GitHub tag), `path` (link:, file: or a folder),
|
|
40
|
+
* `tarball`, or `unknown`.
|
|
41
|
+
*/
|
|
42
|
+
export function sourceOf(spec) {
|
|
43
|
+
const text = typeof spec === 'string' ? spec.trim() : ''
|
|
44
|
+
if (text === '') return { kind: 'unknown' }
|
|
45
|
+
if (/^(link|file):/i.test(text) || /^([a-z]:[\\/]|[\\/]|~|\.)/i.test(text)) return { kind: 'path' }
|
|
46
|
+
const github = /^(?:github:|git\+https:\/\/github\.com\/|https:\/\/github\.com\/|git@github\.com:)?([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:#(.*))?$/i.exec(text)
|
|
47
|
+
const gitLike = /^(git\+|git:|git@|github:|gitlab:|bitbucket:)/i.test(text) || /\.git(#|$)/i.test(text)
|
|
48
|
+
if (github && (gitLike || !text.startsWith('@'))) {
|
|
49
|
+
const parts = (github[3] ?? '').split('&').filter(Boolean)
|
|
50
|
+
const ref = parts.find(part => !part.includes(':')) ?? ''
|
|
51
|
+
return {
|
|
52
|
+
kind: 'git', owner: github[1], repo: github[2],
|
|
53
|
+
...(cleanVersion(ref) ? { ref } : {}),
|
|
54
|
+
rest: parts.filter(part => part !== ref),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (gitLike) return { kind: 'git' }
|
|
58
|
+
if (/^https?:/i.test(text)) return { kind: 'tarball' }
|
|
59
|
+
return { kind: 'registry' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const normalizeRegistry = url => `${String(url).trim().replace(/\/+$/, '')}/`
|
|
63
|
+
export const encodeName = name => (name.startsWith('@') ? `@${encodeURIComponent(name.slice(1))}` : encodeURIComponent(name))
|
|
64
|
+
|
|
65
|
+
export const fetchJson = async (url, headers = {}) => {
|
|
66
|
+
const response = await fetch(url, { headers: { accept: 'application/json', ...headers }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
|
67
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status}`)
|
|
68
|
+
return response.json()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The registries the plugin manager installs from, in its order: its configured
|
|
72
|
+
// first one (else pnpm's own), then the fallbacks, unless that first one is private.
|
|
73
|
+
export async function registryPlan(config, manager) {
|
|
74
|
+
if (typeof config.updateRegistry === 'string' && config.updateRegistry !== '') return [normalizeRegistry(config.updateRegistry)]
|
|
75
|
+
const configured = await Promise.resolve(manager?.registries?.()).catch(() => null)
|
|
76
|
+
if (!configured) return [OFFICIAL_REGISTRY, MIRROR_REGISTRY]
|
|
77
|
+
const fallbacks = (configured.fallbackRegistries ?? []).map(normalizeRegistry)
|
|
78
|
+
const own = configured.resolved ? normalizeRegistry(configured.resolved) : null
|
|
79
|
+
const first = configured.registry ? normalizeRegistry(configured.registry) : own ?? OFFICIAL_REGISTRY
|
|
80
|
+
const isPublic = first === OFFICIAL_REGISTRY || fallbacks.includes(first)
|
|
81
|
+
return isPublic ? [...new Set([first, ...fallbacks])] : [first]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The plugin manager's `source` for this package; DSH releases before it reported
|
|
85
|
+
// one leave the profile's own dependency entry to say the same.
|
|
86
|
+
export async function installSpecOf(ctx) {
|
|
87
|
+
const manager = ctx.get?.('pluginManager')
|
|
88
|
+
const bundles = await Promise.resolve(manager?.listBundles?.()).catch(() => null)
|
|
89
|
+
const own = Array.isArray(bundles) ? bundles.find(bundle => bundle.name === PKG.name) : undefined
|
|
90
|
+
if (typeof own?.source === 'string') return own.source
|
|
91
|
+
const dir = ctx.get?.('profileContext')?.dir
|
|
92
|
+
if (typeof dir !== 'string') return undefined
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).dependencies?.[PKG.name]
|
|
95
|
+
} catch { return undefined }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The public repository the releases live in, for tags and the "what's new" link.
|
|
99
|
+
export const repoSlug = () =>
|
|
100
|
+
/github\.com[/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/i.exec(String(PKG.repository?.url ?? PKG.repository ?? ''))?.[1] ?? null
|
|
101
|
+
|
|
102
|
+
const repoUrl = () => {
|
|
103
|
+
const repo = repoSlug()
|
|
104
|
+
return repo ? `https://github.com/${repo}` : null
|
|
105
|
+
}
|
|
106
|
+
const releasesUrl = () => {
|
|
107
|
+
const repo = repoUrl()
|
|
108
|
+
return repo ? `${repo}/releases` : (PKG.homepage ?? null)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const STARS_TTL_MS = 60 * 60_000
|
|
112
|
+
|
|
113
|
+
export function install(rt) {
|
|
114
|
+
const { ctx, config } = rt
|
|
115
|
+
const enabled = config.updateCheck !== false
|
|
116
|
+
const manager = () => ctx.get?.('pluginManager')
|
|
117
|
+
|
|
118
|
+
const status = {
|
|
119
|
+
name: PKG.name, current: PKG.version, latest: null, source: 'unknown', notesUrl: releasesUrl(),
|
|
120
|
+
checkedAt: 0, phase: 'idle', installed: null, error: null,
|
|
121
|
+
}
|
|
122
|
+
// Where the newest version was found, and the spec that installs it.
|
|
123
|
+
let target = null
|
|
124
|
+
let checking = null
|
|
125
|
+
|
|
126
|
+
const view = () => ({
|
|
127
|
+
name: status.name, current: status.current, latest: status.latest, source: status.source, notesUrl: status.notesUrl,
|
|
128
|
+
repoUrl: repoUrl(), enabled,
|
|
129
|
+
checkedAt: status.checkedAt, phase: status.phase, installed: status.installed, error: status.error,
|
|
130
|
+
available: status.phase !== 'installed' && target !== null && status.latest !== null && newer(status.latest, status.current),
|
|
131
|
+
})
|
|
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
|
+
|
|
149
|
+
async function newestFromRegistry() {
|
|
150
|
+
for (const registry of await registryPlan(config, manager())) {
|
|
151
|
+
try {
|
|
152
|
+
const version = cleanVersion((await fetchJson(`${registry}${encodeName(PKG.name)}/latest`)).version)
|
|
153
|
+
if (version) return { version, spec: `${PKG.name}@${version}`, registry }
|
|
154
|
+
} catch { /* unreachable, or no copy there yet: ask the next one */ }
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function newestFromGitHub(source) {
|
|
160
|
+
if (!source.ref) return null
|
|
161
|
+
const release = await fetchJson(`https://api.github.com/repos/${source.owner}/${source.repo}/releases/latest`, { 'user-agent': `${PKG.name}/${PKG.version}` })
|
|
162
|
+
const version = cleanVersion(release?.tag_name)
|
|
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]
|
|
168
|
+
const fragment = [release.tag_name, ...source.rest].join('&')
|
|
169
|
+
return { version, spec: `github:${owner}/${repo}#${fragment}` }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function runCheck() {
|
|
173
|
+
const source = sourceOf(await installSpecOf(ctx))
|
|
174
|
+
status.source = source.kind
|
|
175
|
+
let found = null
|
|
176
|
+
try {
|
|
177
|
+
if (source.kind === 'registry') found = await newestFromRegistry()
|
|
178
|
+
else if (source.kind === 'git') found = await newestFromGitHub(source)
|
|
179
|
+
} catch (error) {
|
|
180
|
+
rt.warn('ds-bot: update check failed: %s', error?.message ?? error)
|
|
181
|
+
}
|
|
182
|
+
status.checkedAt = Date.now()
|
|
183
|
+
// Nothing found now (a linked checkout, a moved tag, an unreachable registry)
|
|
184
|
+
// retires the version a check before it found; offering it would install over
|
|
185
|
+
// a source that never updates.
|
|
186
|
+
if (found === null) {
|
|
187
|
+
status.latest = null
|
|
188
|
+
target = null
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
status.latest = found.version
|
|
192
|
+
target = newer(found.version, status.current) ? found : null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Ask where the package came from for its newest version; a fresh check ignores the last answer. */
|
|
196
|
+
async function check({ fresh = false } = {}) {
|
|
197
|
+
if (!enabled) return view()
|
|
198
|
+
if (checking) return checking
|
|
199
|
+
if (!fresh && Date.now() - status.checkedAt < CHECK_TTL_MS) return view()
|
|
200
|
+
checking = runCheck().finally(() => { checking = null })
|
|
201
|
+
await checking
|
|
202
|
+
return view()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Start reinstalling the newest version; the answer comes back through `check`. */
|
|
206
|
+
function start() {
|
|
207
|
+
if (status.phase === 'installing') return view()
|
|
208
|
+
if (target === null) throw new Error('No DS Bot update is available')
|
|
209
|
+
const service = manager()
|
|
210
|
+
if (typeof service?.installBundle !== 'function') throw new Error('The DSH plugin manager is not available in this profile')
|
|
211
|
+
const chosen = target
|
|
212
|
+
Object.assign(status, { phase: 'installing', error: null })
|
|
213
|
+
void Promise.resolve(service.installBundle(chosen.spec, chosen.registry ? { registry: chosen.registry } : {}))
|
|
214
|
+
.then((result) => {
|
|
215
|
+
if (result?.error || !['applied', 'restart-required'].includes(result?.application)) {
|
|
216
|
+
throw new Error(result?.error?.diagnostic ?? result?.error?.code ?? `install ${result?.application ?? 'failed'}`)
|
|
217
|
+
}
|
|
218
|
+
Object.assign(status, { phase: 'installed', installed: cleanVersion(result.version) ?? chosen.version })
|
|
219
|
+
})
|
|
220
|
+
.catch((error) => {
|
|
221
|
+
rt.warn('ds-bot: update to %s failed: %s', chosen.spec, error?.message ?? error)
|
|
222
|
+
Object.assign(status, { phase: 'failed', error: String(error?.message ?? error).slice(0, 600) })
|
|
223
|
+
})
|
|
224
|
+
return view()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (enabled) {
|
|
228
|
+
ctx.effect(() => {
|
|
229
|
+
const timer = setTimeout(() => { void check() }, STARTUP_DELAY_MS)
|
|
230
|
+
// A pending first check never keeps the process alive.
|
|
231
|
+
timer.unref?.()
|
|
232
|
+
return () => clearTimeout(timer)
|
|
233
|
+
}, 'ds-bot: update check')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
Object.assign(rt, { updateCheck: check, updateStart: start, updateView: view, repoStars })
|
|
237
|
+
}
|