@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.
@@ -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
+ }