@soffits/dsh-telegram-bot 0.0.0-stage → 0.4.0

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,339 @@
1
+ /**
2
+ * Describe what an incoming Telegram message replies to, quotes or forwards,
3
+ * as short labelled plain text the agent reads before the message itself.
4
+ */
5
+
6
+ /** Longest quoted or forwarded text passed to the agent, in characters. */
7
+ export const QUOTE_LIMIT = 2000
8
+
9
+ /** Longest text of a message sent on someone's behalf passed to the agent, in characters. */
10
+ export const SENT_TEXT_LIMIT = 8000
11
+
12
+ /** Largest file Telegram lets a bot download (getFile). */
13
+ export const DOWNLOAD_LIMIT_BYTES = 20 * 1024 * 1024
14
+
15
+ /** Labels the bridge puts on prompts it mirrors from other clients. */
16
+ const MIRROR_LABELS = [
17
+ ['🖥 From Web:', 'the Web UI (a user prompt this bot mirrored into the chat, not an assistant answer)'],
18
+ ['🖥 Из Web:', 'the Web UI (a user prompt this bot mirrored into the chat, not an assistant answer)'],
19
+ ['⏰ Scheduled task:', 'a scheduled task (a prompt this bot mirrored into the chat, not an assistant answer)'],
20
+ ['⏰ Задача по расписанию:', 'a scheduled task (a prompt this bot mirrored into the chat, not an assistant answer)'],
21
+ ]
22
+
23
+ /**
24
+ * Text or caption of a message.
25
+ * @param message - Telegram message or external reply.
26
+ * @returns the text, or an empty string.
27
+ */
28
+ export function messageBody(message) {
29
+ if (typeof message?.text === 'string') return message.text
30
+ if (typeof message?.caption === 'string') return message.caption
31
+ return ''
32
+ }
33
+
34
+ /**
35
+ * True when the message was forwarded from somewhere else.
36
+ * @param message - Telegram message.
37
+ */
38
+ export function isForwarded(message) {
39
+ return originOf(message) !== undefined
40
+ }
41
+
42
+ /**
43
+ * The photo, document, spoken recording (voice, audio, round video) or
44
+ * sticker of a message, when the bot can download it.
45
+ * @param message - Telegram message or external reply.
46
+ * @returns the file kind and size, or undefined.
47
+ */
48
+ export function downloadableFile(message) {
49
+ const photo = Array.isArray(message?.photo) ? message.photo.at(-1) : undefined
50
+ const candidates = [['photo', photo], ['document', message?.document], ['voice', message?.voice], ['video_note', message?.video_note], ['audio', message?.audio], ['sticker', message?.sticker]]
51
+ const found = candidates.find(([, file]) => typeof file?.file_id === 'string')
52
+ if (found === undefined) return undefined
53
+ const [kind, file] = found
54
+ return { kind, size: typeof file.file_size === 'number' ? file.file_size : undefined }
55
+ }
56
+
57
+ /**
58
+ * The message a reply points at, ignoring the topic's own first message,
59
+ * which Telegram sets as the reply target of every message in a topic.
60
+ * @param message - Telegram message.
61
+ * @returns the replied-to message, or undefined.
62
+ */
63
+ export function repliedMessage(message) {
64
+ const target = message?.reply_to_message
65
+ if (target === undefined || target === null) return undefined
66
+ if (target.forum_topic_created !== undefined) return undefined
67
+ if (message.is_topic_message === true && target.message_id === message.message_thread_id) return undefined
68
+ return target
69
+ }
70
+
71
+ /**
72
+ * Chat and message id of the message a reply points at, for looking it up
73
+ * in the sent-message log.
74
+ * @param message - Telegram message.
75
+ * @returns { chatId, messageId }, or undefined when it is not a reply.
76
+ */
77
+ export function replyReference(message) {
78
+ if (originOf(message) !== undefined) return undefined
79
+ const target = repliedMessage(message)
80
+ if (target !== undefined) return { chatId: target.chat?.id ?? message.chat?.id, messageId: target.message_id }
81
+ const external = message?.external_reply
82
+ if (external?.chat?.id !== undefined && Number.isSafeInteger(external.message_id)) return { chatId: external.chat.id, messageId: external.message_id }
83
+ return undefined
84
+ }
85
+
86
+ /**
87
+ * Build the prompt text for one message: context lines, then the message.
88
+ * A forwarded message is shown in full inside its context block.
89
+ * @param message - Telegram message.
90
+ * @param options - botId: this bot's user id; replyFile: outcome of attaching
91
+ * the replied-to file ('attached', 'too-large', 'failed'); ownFile: true when
92
+ * the message's own photo or document is attached; maxQuote: text limit;
93
+ * sent: the sent-message log entry the replied-to message belongs to;
94
+ * currentSession: the session now bound to the topic.
95
+ * @returns the prompt text, possibly empty.
96
+ */
97
+ export function composePrompt(message, options = {}) {
98
+ const body = messageBody(message).trim()
99
+ const forward = describeForward(message, body, options)
100
+ const reply = forward === undefined ? describeReply(message, options) : undefined
101
+ const sent = reply === undefined ? undefined : describeSent(options.sent, { currentSession: options.currentSession })
102
+ const blocks = [reply, sent, forward].filter((block) => block !== undefined)
103
+ if (forward === undefined && body.length > 0) blocks.push(body)
104
+ return blocks.join('\n\n')
105
+ }
106
+
107
+ /**
108
+ * The line that tells the agent who wrote a Telegram message. Several allowed
109
+ * users can talk to the same bot, each in their own chat.
110
+ * @param user - Telegram `from` of the message.
111
+ * @returns a one-line label, or undefined without a sender.
112
+ */
113
+ export function senderLabel(user) {
114
+ if (user === undefined || user === null || user.id === undefined) return undefined
115
+ const name = [user.first_name, user.last_name].filter((part) => typeof part === 'string' && part.trim().length > 0).join(' ').trim()
116
+ const details = []
117
+ if (typeof user.username === 'string' && user.username.length > 0) details.push(`@${user.username}`)
118
+ details.push(`user id ${user.id}`)
119
+ return name.length > 0 ? `[Telegram message from ${name} (${details.join(', ')})]` : `[Telegram message from ${details.join(', ')}]`
120
+ }
121
+
122
+ /**
123
+ * Describe the replied-to or externally replied message, if any.
124
+ * @returns the context block, or undefined.
125
+ */
126
+ export function describeReply(message, options = {}) {
127
+ const limit = options.maxQuote ?? QUOTE_LIMIT
128
+ const quote = typeof message?.quote?.text === 'string' && message.quote.text.trim().length > 0 ? message.quote.text : undefined
129
+ const target = repliedMessage(message)
130
+ if (target !== undefined) {
131
+ const details = [`sent ${formatDate(target.date)}`]
132
+ const origin = originOf(target)
133
+ if (origin !== undefined) details.push(`forwarded from ${describeOrigin(origin, message, options)}${origin.date === undefined ? '' : `, originally sent ${formatDate(origin.date)}`}`)
134
+ const media = mediaNote(target, options.replyFile)
135
+ if (media !== undefined) details.push(media)
136
+ const text = quote ?? messageBody(target)
137
+ const label = quote === undefined ? '' : '; the user quoted only this part'
138
+ const head = `[Replying to a message from ${describeSender(target, message, options)} (${details.join('; ')})${label}]`
139
+ return text.trim().length === 0 ? head : `${head}\n${quoteLines(text, limit)}`
140
+ }
141
+ const external = message?.external_reply
142
+ if (external === undefined || external === null) {
143
+ if (message?.reply_to_story !== undefined) return '[Replying to a Telegram story]'
144
+ return undefined
145
+ }
146
+ const details = []
147
+ if (external.origin !== undefined) {
148
+ details.push(`from ${describeOrigin(external.origin, message, options)}`)
149
+ if (external.origin.date !== undefined) details.push(`sent ${formatDate(external.origin.date)}`)
150
+ }
151
+ if (external.chat !== undefined && external.origin?.type !== 'channel') details.push(`in ${chatName(external.chat)}`)
152
+ const link = messageLink(external.chat, external.message_id)
153
+ if (link !== undefined) details.push(link)
154
+ const media = mediaNote(external, options.replyFile)
155
+ if (media !== undefined) details.push(media)
156
+ const head = `[Replying to a message from another chat${details.length === 0 ? '' : ` (${details.join('; ')})`}${quote === undefined ? '' : '; the user quoted this part'}]`
157
+ if (quote !== undefined) return `${head}\n${quoteLines(quote, limit)}`
158
+ return `${head}\n(The bot cannot see the text of messages in other chats unless the user quotes it.)`
159
+ }
160
+
161
+ /**
162
+ * Describe a forwarded message together with its content.
163
+ * @returns the context block, or undefined when the message is not forwarded.
164
+ */
165
+ export function describeForward(message, body = messageBody(message).trim(), options = {}) {
166
+ const origin = originOf(message)
167
+ if (origin === undefined) return undefined
168
+ const details = []
169
+ if (origin.date !== undefined) details.push(`originally sent ${formatDate(origin.date)}`)
170
+ const link = origin.type === 'channel' ? messageLink(origin.chat, origin.message_id) : undefined
171
+ if (link !== undefined) details.push(link)
172
+ const media = mediaNote(message, options.ownFile === true ? 'attached' : undefined)
173
+ if (media !== undefined) details.push(media)
174
+ const head = `[Forwarded message from ${describeOrigin(origin, message, options)}${details.length === 0 ? '' : ` (${details.join('; ')})`}]`
175
+ return body.length === 0 ? head : `${head}\n${quoteLines(body, options.maxQuote ?? QUOTE_LIMIT)}`
176
+ }
177
+
178
+ /**
179
+ * Describe a message the bot sent into a topic on someone's behalf (a script,
180
+ * a job, a plugin or another session): who sent it, when, where it was
181
+ * recorded, and its full text, which may span several Telegram messages.
182
+ * @param sent - entry from the sent-message log.
183
+ * @param options - currentSession: session now bound to the topic; limit: text limit.
184
+ * @returns the context block, or undefined.
185
+ */
186
+ export function describeSent(sent, options = {}) {
187
+ if (sent === undefined || sent === null) return undefined
188
+ const details = []
189
+ const time = Date.parse(sent.sentAt ?? '')
190
+ details.push(Number.isFinite(time) ? `sent ${formatDate(time / 1000)}` : 'sent at an unknown time')
191
+ if (typeof sent.job === 'string' && sent.job.length > 0) details.push(`by job "${sent.job}"`)
192
+ if (typeof sent.fromSession === 'string' && sent.fromSession.length > 0) details.push(`from DSH session ${sent.fromSession}`)
193
+ else if (typeof sent.label === 'string' && sent.label.length > 0 && sent.job == null) details.push(`by ${sent.label}`)
194
+ if (Array.isArray(sent.messageIds) && sent.messageIds.length > 1) details.push(`${sent.messageIds.length} Telegram messages`)
195
+ if (sent.sticker !== undefined && sent.sticker !== null) details.push(`with sticker ${[sent.sticker.emoji, sent.sticker.setName === null || sent.sticker.setName === undefined ? undefined : `from set "${sent.sticker.setName}"`, `(${sent.sticker.id})`].filter(Boolean).join(' ')}`)
196
+ if (Array.isArray(sent.files) && sent.files.length > 0) details.push(`with ${sent.files.length === 1 ? 'file' : 'files'} ${sent.files.map((name) => `"${name}"`).join(', ')}`)
197
+ const bound = typeof sent.boundSession === 'string' ? sent.boundSession : undefined
198
+ if (bound !== undefined && sent.recorded === true && bound !== options.currentSession) details.push(`recorded in session ${bound}, not this one`)
199
+ else if (bound === undefined) details.push('the topic had no session then')
200
+ const head = `[The replied-to message was posted by the bot on behalf of another sender (${details.join('; ')}). Full text:]`
201
+ const body = typeof sent.text === 'string' && sent.text.trim().length > 0
202
+ ? quoteLines(sent.text, options.limit ?? SENT_TEXT_LIMIT) + (sent.textCut > 0 ? `\n> […${sent.textCut} more characters not kept]` : '')
203
+ : '> (no text)'
204
+ const sessions = [sent.fromSession, bound !== options.currentSession && sent.recorded === true ? bound : undefined]
205
+ .filter((id, index, all) => typeof id === 'string' && id.length > 0 && all.indexOf(id) === index)
206
+ const hint = sessions.length === 0 ? '' : `\n(For more context, open session ${sessions.join(' or ')}.)`
207
+ return `${head}\n${body}${hint}`
208
+ }
209
+
210
+ /** Forward origin of a message, including the fields older Bot API versions used. */
211
+ function originOf(message) {
212
+ if (message?.forward_origin !== undefined && message.forward_origin !== null) return message.forward_origin
213
+ const date = message?.forward_date
214
+ if (message?.forward_from_chat !== undefined) {
215
+ return { type: message.forward_from_chat.type === 'channel' ? 'channel' : 'chat', chat: message.forward_from_chat, sender_chat: message.forward_from_chat, message_id: message.forward_from_message_id, author_signature: message.forward_signature, date }
216
+ }
217
+ if (message?.forward_from !== undefined) return { type: 'user', sender_user: message.forward_from, date }
218
+ if (typeof message?.forward_sender_name === 'string') return { type: 'hidden_user', sender_user_name: message.forward_sender_name, date }
219
+ return undefined
220
+ }
221
+
222
+ /** Who wrote a replied-to message, from the reader's point of view. */
223
+ function describeSender(target, message, options) {
224
+ const from = target.from
225
+ if (from !== undefined && options.botId !== undefined && from.id === options.botId) {
226
+ const text = messageBody(target)
227
+ const mirrored = MIRROR_LABELS.find(([label]) => text.startsWith(label))
228
+ return mirrored === undefined ? 'the assistant (this bot)' : mirrored[1]
229
+ }
230
+ if (target.sender_chat !== undefined) return chatName(target.sender_chat)
231
+ if (from !== undefined && message?.from !== undefined && from.id === message.from.id) return 'the user (their own earlier message)'
232
+ if (from !== undefined) return personName(from)
233
+ return 'an unknown sender'
234
+ }
235
+
236
+ /** Name the origin of a forwarded message. */
237
+ function describeOrigin(origin, message, options) {
238
+ const signature = typeof origin.author_signature === 'string' && origin.author_signature.length > 0 ? `, signed ${origin.author_signature}` : ''
239
+ if (origin.type === 'user') {
240
+ const user = origin.sender_user
241
+ if (user !== undefined && options.botId !== undefined && user.id === options.botId) return 'the assistant (this bot)'
242
+ if (user !== undefined && message?.from !== undefined && user.id === message.from.id) return 'the user themselves'
243
+ return user === undefined ? 'a user' : personName(user)
244
+ }
245
+ if (origin.type === 'hidden_user') return `${origin.sender_user_name ?? 'a user'} (account hidden)`
246
+ if (origin.type === 'channel') return `channel ${chatName(origin.chat)}${signature}`
247
+ if (origin.type === 'chat') return `${chatName(origin.sender_chat)}${signature}`
248
+ return 'an unknown source'
249
+ }
250
+
251
+ function personName(user) {
252
+ const name = [user.first_name, user.last_name].filter((part) => typeof part === 'string' && part.length > 0).join(' ')
253
+ const handle = typeof user.username === 'string' && user.username.length > 0 ? `@${user.username}` : ''
254
+ const label = name.length > 0 ? (handle.length > 0 ? `${name} (${handle})` : name) : (handle.length > 0 ? handle : 'a user')
255
+ return user.is_bot === true ? `${label}, a bot` : label
256
+ }
257
+
258
+ function chatName(chat) {
259
+ if (chat === undefined || chat === null) return 'a chat'
260
+ const title = typeof chat.title === 'string' && chat.title.length > 0
261
+ ? `"${chat.title}"`
262
+ : [chat.first_name, chat.last_name].filter(Boolean).join(' ')
263
+ const handle = typeof chat.username === 'string' && chat.username.length > 0 ? `@${chat.username}` : ''
264
+ if (title.length > 0) return handle.length > 0 ? `${title} (${handle})` : title
265
+ return handle.length > 0 ? handle : 'a chat'
266
+ }
267
+
268
+ /** Public link to a channel or supergroup message, when one exists. */
269
+ function messageLink(chat, messageId) {
270
+ if (chat === undefined || chat === null || !Number.isSafeInteger(messageId)) return undefined
271
+ if (typeof chat.username === 'string' && chat.username.length > 0) return `https://t.me/${chat.username}/${messageId}`
272
+ const id = String(chat.id ?? '')
273
+ if (id.startsWith('-100')) return `https://t.me/c/${id.slice(4)}/${messageId}`
274
+ return undefined
275
+ }
276
+
277
+ /** Note the media a message carries and whether it was attached. */
278
+ function mediaNote(message, fileOutcome) {
279
+ const kind = mediaKind(message)
280
+ if (kind === undefined) return undefined
281
+ if (downloadableFile(message) === undefined) return `contains ${kind}, not attached`
282
+ if (fileOutcome === 'attached') return `contains ${kind}, attached`
283
+ if (fileOutcome === 'too-large') return `contains ${kind}, too large to attach`
284
+ if (fileOutcome === 'failed') return `contains ${kind}, could not be downloaded`
285
+ return `contains ${kind}`
286
+ }
287
+
288
+ function mediaKind(message) {
289
+ if (message === undefined || message === null) return undefined
290
+ if (Array.isArray(message.photo) && message.photo.length > 0) return 'a photo'
291
+ if (message.document !== undefined && message.animation === undefined) {
292
+ return typeof message.document.file_name === 'string' ? `a file "${message.document.file_name}"` : 'a file'
293
+ }
294
+ if (message.video !== undefined) return 'a video'
295
+ if (message.animation !== undefined) return 'a GIF'
296
+ if (message.audio !== undefined) {
297
+ const title = [message.audio.performer, message.audio.title].filter(Boolean).join(' - ')
298
+ return title.length > 0 ? `audio "${title}"` : 'audio'
299
+ }
300
+ if (message.voice !== undefined) return 'a voice message'
301
+ if (message.video_note !== undefined) return 'a video message'
302
+ if (message.sticker !== undefined) {
303
+ const emoji = typeof message.sticker.emoji === 'string' ? ` ${message.sticker.emoji}` : ''
304
+ const set = typeof message.sticker.set_name === 'string' ? ` from set "${message.sticker.set_name}"` : ''
305
+ return `a sticker${emoji}${set}`
306
+ }
307
+ if (message.poll !== undefined) return `a poll "${message.poll.question ?? ''}"`
308
+ if (message.venue !== undefined) return `a venue "${message.venue.title ?? ''}"`
309
+ if (message.location !== undefined) return `a location (${message.location.latitude}, ${message.location.longitude})`
310
+ if (message.contact !== undefined) return `a contact (${[message.contact.first_name, message.contact.last_name].filter(Boolean).join(' ')})`
311
+ if (message.story !== undefined) return 'a story'
312
+ if (message.paid_media !== undefined) return 'paid media'
313
+ if (message.dice !== undefined) return `a dice roll ${message.dice.emoji ?? ''}`.trim()
314
+ if (message.checklist !== undefined) return 'a checklist'
315
+ if (message.game !== undefined) return 'a game'
316
+ if (message.invoice !== undefined) return 'an invoice'
317
+ return undefined
318
+ }
319
+
320
+ /** UTC date for a Telegram unix timestamp. */
321
+ function formatDate(seconds) {
322
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return 'at an unknown time'
323
+ return `${new Date(seconds * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC`
324
+ }
325
+
326
+ /** Quote text line by line, cut at a word boundary near the limit. */
327
+ function quoteLines(text, limit) {
328
+ let shown = text.trim()
329
+ let note = ''
330
+ if (shown.length > limit) {
331
+ let cut = shown.lastIndexOf(' ', limit)
332
+ const newline = shown.lastIndexOf('\n', limit)
333
+ if (newline > cut) cut = newline
334
+ if (cut < limit * 0.8) cut = limit
335
+ note = `\n> […truncated, ${shown.length - cut} more characters]`
336
+ shown = shown.slice(0, cut).trimEnd()
337
+ }
338
+ return shown.split('\n').map((line) => `> ${line}`).join('\n') + note
339
+ }
package/src/models.js ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Model selection for Telegram topics. The session's live agent owns its
3
+ * model (`agent.model.current()`); the bot keeps no copy of it. The picker
4
+ * reads the same catalog as the Web model picker
5
+ * (`sessionController.modelCatalog()`), so every configured provider and
6
+ * model shows up with its Web display name.
7
+ */
8
+ import { createHash } from 'node:crypto'
9
+
10
+ /** Rows of providers or models shown per picker page, sized for a phone. */
11
+ export const MODEL_PAGE_SIZE = 8
12
+
13
+ /** How long one catalog read is reused for labels and menus. */
14
+ const CATALOG_TTL_MS = 60_000
15
+
16
+ /**
17
+ * Short stable key for a provider or a provider/model pair, for callback data
18
+ * (Telegram allows 64 bytes, and provider/model ids can be longer).
19
+ * @param provider - provider id.
20
+ * @param model - model id, or undefined for the provider itself.
21
+ * @returns 12 hex characters.
22
+ */
23
+ export function catalogKey(provider, model) {
24
+ return createHash('sha256').update(`${provider}\n${model ?? ''}`).digest('hex').slice(0, 12)
25
+ }
26
+
27
+ /**
28
+ * Copy a selection, dropping an absent effort.
29
+ * @param selection - provider, model and optional effort.
30
+ * @returns a detached selection.
31
+ */
32
+ function toSelection({ provider, model, reasoningEffort }) {
33
+ return { provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }) }
34
+ }
35
+
36
+ /**
37
+ * Read a session's current model: the live agent's own choice, or, for a
38
+ * session that is not running, its recorded selection.
39
+ * @param ctx - Host context.
40
+ * @param sessionId - DSH session id.
41
+ * @returns the selection, or undefined when the session has none recorded.
42
+ */
43
+ export async function sessionModelOf(ctx, sessionId) {
44
+ if (typeof sessionId !== 'string') return undefined
45
+ const agent = ctx.get('agents')?.get(sessionId)
46
+ if (typeof agent?.model?.current === 'function') {
47
+ const current = agent.model.current()
48
+ return current === undefined ? undefined : toSelection(current)
49
+ }
50
+ const query = ctx.get('sessionQuery')
51
+ if (typeof query?.observeSession !== 'function') return undefined
52
+ let observation
53
+ try {
54
+ observation = await query.observeSession(sessionId)
55
+ // The modelSelection projection's view: `next` is the pending choice or
56
+ // the route of the last request.
57
+ const view = observation?.projections?.values?.modelSelection
58
+ const recorded = view?.next ?? view?.pending ?? view?.lastUsed
59
+ return typeof recorded?.provider === 'string' && typeof recorded?.model === 'string' ? toSelection(recorded) : undefined
60
+ } catch {
61
+ return undefined
62
+ } finally {
63
+ observation?.[Symbol.dispose]?.()
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Read the deployment default model (the one the Web UI saves).
69
+ * @param ctx - Host context.
70
+ * @returns the selection, or undefined without a default-model service.
71
+ */
72
+ export function defaultModelOf(ctx) {
73
+ try {
74
+ const selection = ctx.get('agentDefaultModel')?.currentSelection()
75
+ if (typeof selection?.provider === 'string' && typeof selection?.model === 'string') return toSelection(selection)
76
+ } catch {
77
+ // No default configured.
78
+ }
79
+ return undefined
80
+ }
81
+
82
+ /** Failure codes that mean a route is gone from this host, not briefly down. */
83
+ const GONE_ROUTE_CODES = new Set(['NO_ADAPTER', 'UNKNOWN_MODEL'])
84
+
85
+ /**
86
+ * Move a session off a model route this host no longer has (a provider that
87
+ * was removed, a model dropped from its catalog) onto the default model, so
88
+ * a topic left on that route answers again instead of failing every turn
89
+ * with NO_ADAPTER. The switch is recorded on the session only, the way a job
90
+ * picks its model; the saved default stays as it is. Routes that resolve, or
91
+ * fail for any other reason, are left alone.
92
+ * @param ctx - Host context (`llm`, `agentDefaultModel`).
93
+ * @param agent - the session's live agent.
94
+ * @returns the dead route and the default it moved to, or undefined.
95
+ */
96
+ export async function healGoneRoute(ctx, agent) {
97
+ const current = agent?.model?.current?.()
98
+ const llm = ctx.get('llm')
99
+ if (current === undefined || typeof llm?.resolveCallConfig !== 'function') return undefined
100
+ try {
101
+ await llm.resolveCallConfig(toSelection(current))
102
+ return undefined
103
+ } catch (error) {
104
+ if (!GONE_ROUTE_CODES.has(error?.code)) return undefined
105
+ }
106
+ const fallback = defaultModelOf(ctx)
107
+ if (fallback === undefined || (fallback.provider === current.provider && fallback.model === current.model)) return undefined
108
+ agent.model.select(fallback)
109
+ return { from: toSelection(current), to: fallback }
110
+ }
111
+
112
+ /**
113
+ * Whether two selections name the same provider, model and effort.
114
+ * @param left - first selection.
115
+ * @param right - second selection.
116
+ * @returns true when they agree.
117
+ */
118
+ export function sameSelection(left, right) {
119
+ return left?.provider === right?.provider && left?.model === right?.model && left?.reasoningEffort === right?.reasoningEffort
120
+ }
121
+
122
+ /**
123
+ * Cached reader for the Web model catalog.
124
+ * @param ctx - Host context.
125
+ * @returns `load()` resolving the catalog, or undefined when it is unavailable.
126
+ */
127
+ export function createModelCatalog(ctx) {
128
+ let cached
129
+ let loadedAt = 0
130
+ let pending
131
+ return {
132
+ /**
133
+ * @param fresh - skip the short-lived cache (used before applying a pick).
134
+ * @returns `{ groups, failures }` as the Web picker gets it, or undefined.
135
+ */
136
+ async load(fresh = false) {
137
+ if (!fresh && cached !== undefined && Date.now() - loadedAt < CATALOG_TTL_MS) return cached
138
+ pending ??= (async () => {
139
+ const controller = ctx.get('sessionController')
140
+ if (typeof controller?.modelCatalog !== 'function') return undefined
141
+ const catalog = await controller.modelCatalog()
142
+ cached = { groups: Array.isArray(catalog?.groups) ? catalog.groups : [], failures: Array.isArray(catalog?.failures) ? catalog.failures : [] }
143
+ loadedAt = Date.now()
144
+ return cached
145
+ })().finally(() => { pending = undefined })
146
+ try {
147
+ return await pending
148
+ } catch {
149
+ return cached
150
+ }
151
+ },
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Find a catalog entry for a selection.
157
+ * @param catalog - loaded catalog.
158
+ * @param selection - provider and model.
159
+ * @returns `{ group, model }`, or undefined.
160
+ */
161
+ export function findModel(catalog, selection) {
162
+ const group = catalog?.groups.find((entry) => entry.id === selection?.provider)
163
+ const model = group?.models.find((entry) => entry.id === selection?.model)
164
+ return model === undefined ? undefined : { group, model }
165
+ }
166
+
167
+ /**
168
+ * Resolve a provider callback key.
169
+ * @param catalog - loaded catalog.
170
+ * @param key - `catalogKey(provider)`.
171
+ * @returns the provider group, or undefined.
172
+ */
173
+ export function providerByKey(catalog, key) {
174
+ return catalog?.groups.find((group) => catalogKey(group.id) === key)
175
+ }
176
+
177
+ /**
178
+ * Resolve a model callback key.
179
+ * @param catalog - loaded catalog.
180
+ * @param key - `catalogKey(provider, model)`.
181
+ * @returns `{ group, model }`, or undefined.
182
+ */
183
+ export function modelByKey(catalog, key) {
184
+ for (const group of catalog?.groups ?? []) {
185
+ const model = group.models.find((entry) => catalogKey(group.id, entry.id) === key)
186
+ if (model !== undefined) return { group, model }
187
+ }
188
+ return undefined
189
+ }
190
+
191
+ /**
192
+ * Display name of a selection's model, as the Web picker shows it.
193
+ * @param catalog - loaded catalog, or undefined.
194
+ * @param selection - provider and model.
195
+ * @returns the name, or the raw model id.
196
+ */
197
+ export function modelName(catalog, selection) {
198
+ if (selection === undefined) return '?'
199
+ return findModel(catalog, selection)?.model.name ?? selection.model
200
+ }
201
+
202
+ /**
203
+ * Effort choices for one catalog model, the same list the Web picker builds:
204
+ * a provider-default entry when the model has no default effort, then its
205
+ * efforts. A model without reasoning has none.
206
+ * @param model - catalog model entry.
207
+ * @param defaultLabel - label for the provider default.
208
+ * @returns `{ id, name }` entries; `id` undefined means the provider default.
209
+ */
210
+ export function effortChoices(model, defaultLabel) {
211
+ const reasoning = model?.reasoning
212
+ if (reasoning === undefined) return []
213
+ return [
214
+ ...(reasoning.defaultEffort === undefined ? [{ id: undefined, name: defaultLabel }] : []),
215
+ ...reasoning.efforts.map((effort) => ({ id: effort.id, name: effort.name })),
216
+ ]
217
+ }
218
+
219
+ /**
220
+ * Display name of a selection's effective effort.
221
+ * @param catalog - loaded catalog, or undefined.
222
+ * @param selection - current selection.
223
+ * @param defaultLabel - label for the provider default.
224
+ * @returns the name.
225
+ */
226
+ export function effortName(catalog, selection, defaultLabel) {
227
+ const reasoning = findModel(catalog, selection)?.model.reasoning
228
+ const effective = selection?.reasoningEffort ?? reasoning?.defaultEffort
229
+ if (effective === undefined) return defaultLabel
230
+ return reasoning?.efforts.find((effort) => effort.id === effective)?.name ?? effective
231
+ }
232
+
233
+ /**
234
+ * The selection a model pick makes, by the Web picker's rule: the same model
235
+ * keeps its effort, another model starts at its default effort.
236
+ * @param current - the session's current selection.
237
+ * @param provider - picked provider id.
238
+ * @param model - picked catalog model entry.
239
+ * @returns the selection to apply.
240
+ */
241
+ export function pickedSelection(current, provider, model) {
242
+ const same = current?.provider === provider && current?.model === model.id
243
+ const reasoningEffort = same ? current.reasoningEffort ?? model.reasoning?.defaultEffort : model.reasoning?.defaultEffort
244
+ return toSelection({ provider, model: model.id, reasoningEffort })
245
+ }
246
+
247
+ /**
248
+ * Cut one page out of a list.
249
+ * @param items - all entries.
250
+ * @param page - requested page, clamped to the valid range.
251
+ * @returns the page's items, its index and the page count.
252
+ */
253
+ export function pageOf(items, page) {
254
+ const pages = Math.max(1, Math.ceil(items.length / MODEL_PAGE_SIZE))
255
+ const index = Math.min(Math.max(0, Number.isSafeInteger(page) ? page : 0), pages - 1)
256
+ return { items: items.slice(index * MODEL_PAGE_SIZE, (index + 1) * MODEL_PAGE_SIZE), page: index, pages }
257
+ }