kimaki 0.4.21 → 0.4.23

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.
Files changed (43) hide show
  1. package/dist/channel-management.js +92 -0
  2. package/dist/cli.js +10 -2
  3. package/dist/database.js +130 -0
  4. package/dist/discord-bot.js +381 -0
  5. package/dist/discord-utils.js +151 -0
  6. package/dist/discordBot.js +60 -31
  7. package/dist/escape-backticks.test.js +1 -1
  8. package/dist/fork.js +163 -0
  9. package/dist/format-tables.js +93 -0
  10. package/dist/format-tables.test.js +418 -0
  11. package/dist/interaction-handler.js +750 -0
  12. package/dist/markdown.js +3 -3
  13. package/dist/message-formatting.js +188 -0
  14. package/dist/model-command.js +293 -0
  15. package/dist/opencode.js +135 -0
  16. package/dist/session-handler.js +467 -0
  17. package/dist/system-message.js +92 -0
  18. package/dist/tools.js +3 -5
  19. package/dist/utils.js +31 -0
  20. package/dist/voice-handler.js +528 -0
  21. package/dist/voice.js +257 -35
  22. package/package.json +3 -2
  23. package/src/channel-management.ts +145 -0
  24. package/src/cli.ts +10 -2
  25. package/src/database.ts +155 -0
  26. package/src/discord-bot.ts +506 -0
  27. package/src/discord-utils.ts +208 -0
  28. package/src/escape-backticks.test.ts +1 -1
  29. package/src/fork.ts +224 -0
  30. package/src/format-tables.test.ts +440 -0
  31. package/src/format-tables.ts +106 -0
  32. package/src/interaction-handler.ts +1000 -0
  33. package/src/markdown.ts +3 -3
  34. package/src/message-formatting.ts +227 -0
  35. package/src/model-command.ts +380 -0
  36. package/src/opencode.ts +180 -0
  37. package/src/session-handler.ts +601 -0
  38. package/src/system-message.ts +92 -0
  39. package/src/tools.ts +3 -5
  40. package/src/utils.ts +37 -0
  41. package/src/voice-handler.ts +745 -0
  42. package/src/voice.ts +354 -36
  43. package/src/discordBot.ts +0 -3643
package/src/markdown.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { OpencodeClient } from '@opencode-ai/sdk'
2
- import { format } from 'date-fns'
3
2
  import * as yaml from 'js-yaml'
3
+ import { formatDateTime } from './utils.js'
4
4
  import { extractNonXmlContent } from './xml.js'
5
5
 
6
6
  export class ShareMarkdown {
@@ -62,10 +62,10 @@ export class ShareMarkdown {
62
62
  lines.push('## Session Information')
63
63
  lines.push('')
64
64
  lines.push(
65
- `- **Created**: ${format(new Date(session.time.created), 'MMM d, yyyy, h:mm a')}`,
65
+ `- **Created**: ${formatDateTime(new Date(session.time.created))}`,
66
66
  )
67
67
  lines.push(
68
- `- **Updated**: ${format(new Date(session.time.updated), 'MMM d, yyyy, h:mm a')}`,
68
+ `- **Updated**: ${formatDateTime(new Date(session.time.updated))}`,
69
69
  )
70
70
  if (session.version) {
71
71
  lines.push(`- **OpenCode Version**: v${session.version}`)
@@ -0,0 +1,227 @@
1
+ import type { Part, FilePartInput } from '@opencode-ai/sdk'
2
+ import type { Message } from 'discord.js'
3
+ import { createLogger } from './logger.js'
4
+
5
+ const logger = createLogger('FORMATTING')
6
+
7
+ export const TEXT_MIME_TYPES = [
8
+ 'text/',
9
+ 'application/json',
10
+ 'application/xml',
11
+ 'application/javascript',
12
+ 'application/typescript',
13
+ 'application/x-yaml',
14
+ 'application/toml',
15
+ ]
16
+
17
+ export function isTextMimeType(contentType: string | null): boolean {
18
+ if (!contentType) {
19
+ return false
20
+ }
21
+ return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix))
22
+ }
23
+
24
+ export async function getTextAttachments(message: Message): Promise<string> {
25
+ const textAttachments = Array.from(message.attachments.values()).filter(
26
+ (attachment) => isTextMimeType(attachment.contentType),
27
+ )
28
+
29
+ if (textAttachments.length === 0) {
30
+ return ''
31
+ }
32
+
33
+ const textContents = await Promise.all(
34
+ textAttachments.map(async (attachment) => {
35
+ try {
36
+ const response = await fetch(attachment.url)
37
+ if (!response.ok) {
38
+ return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`
39
+ }
40
+ const text = await response.text()
41
+ return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`
42
+ } catch (error) {
43
+ const errMsg = error instanceof Error ? error.message : String(error)
44
+ return `<attachment filename="${attachment.name}" error="${errMsg}" />`
45
+ }
46
+ }),
47
+ )
48
+
49
+ return textContents.join('\n\n')
50
+ }
51
+
52
+ export function getFileAttachments(message: Message): FilePartInput[] {
53
+ const fileAttachments = Array.from(message.attachments.values()).filter(
54
+ (attachment) => {
55
+ const contentType = attachment.contentType || ''
56
+ return (
57
+ contentType.startsWith('image/') || contentType === 'application/pdf'
58
+ )
59
+ },
60
+ )
61
+
62
+ return fileAttachments.map((attachment) => ({
63
+ type: 'file' as const,
64
+ mime: attachment.contentType || 'application/octet-stream',
65
+ filename: attachment.name,
66
+ url: attachment.url,
67
+ }))
68
+ }
69
+
70
+ export function getToolSummaryText(part: Part): string {
71
+ if (part.type !== 'tool') return ''
72
+
73
+ if (part.tool === 'edit') {
74
+ const filePath = (part.state.input?.filePath as string) || ''
75
+ const newString = (part.state.input?.newString as string) || ''
76
+ const oldString = (part.state.input?.oldString as string) || ''
77
+ const added = newString.split('\n').length
78
+ const removed = oldString.split('\n').length
79
+ const fileName = filePath.split('/').pop() || ''
80
+ return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`
81
+ }
82
+
83
+ if (part.tool === 'write') {
84
+ const filePath = (part.state.input?.filePath as string) || ''
85
+ const content = (part.state.input?.content as string) || ''
86
+ const lines = content.split('\n').length
87
+ const fileName = filePath.split('/').pop() || ''
88
+ return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`
89
+ }
90
+
91
+ if (part.tool === 'webfetch') {
92
+ const url = (part.state.input?.url as string) || ''
93
+ const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
94
+ return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : ''
95
+ }
96
+
97
+ if (part.tool === 'read') {
98
+ const filePath = (part.state.input?.filePath as string) || ''
99
+ const fileName = filePath.split('/').pop() || ''
100
+ return fileName ? `*${fileName}*` : ''
101
+ }
102
+
103
+ if (part.tool === 'list') {
104
+ const path = (part.state.input?.path as string) || ''
105
+ const dirName = path.split('/').pop() || path
106
+ return dirName ? `*${dirName}*` : ''
107
+ }
108
+
109
+ if (part.tool === 'glob') {
110
+ const pattern = (part.state.input?.pattern as string) || ''
111
+ return pattern ? `*${pattern}*` : ''
112
+ }
113
+
114
+ if (part.tool === 'grep') {
115
+ const pattern = (part.state.input?.pattern as string) || ''
116
+ return pattern ? `*${pattern}*` : ''
117
+ }
118
+
119
+ if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
120
+ return ''
121
+ }
122
+
123
+ if (part.tool === 'task') {
124
+ const description = (part.state.input?.description as string) || ''
125
+ return description ? `_${description}_` : ''
126
+ }
127
+
128
+ if (part.tool === 'skill') {
129
+ const name = (part.state.input?.name as string) || ''
130
+ return name ? `_${name}_` : ''
131
+ }
132
+
133
+ if (!part.state.input) return ''
134
+
135
+ const inputFields = Object.entries(part.state.input)
136
+ .map(([key, value]) => {
137
+ if (value === null || value === undefined) return null
138
+ const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
139
+ const truncatedValue = stringValue.length > 300 ? stringValue.slice(0, 300) + '…' : stringValue
140
+ return `${key}: ${truncatedValue}`
141
+ })
142
+ .filter(Boolean)
143
+
144
+ if (inputFields.length === 0) return ''
145
+
146
+ return `(${inputFields.join(', ')})`
147
+ }
148
+
149
+ export function formatTodoList(part: Part): string {
150
+ if (part.type !== 'tool' || part.tool !== 'todowrite') return ''
151
+ const todos =
152
+ (part.state.input?.todos as {
153
+ content: string
154
+ status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
155
+ }[]) || []
156
+ const activeIndex = todos.findIndex((todo) => {
157
+ return todo.status === 'in_progress'
158
+ })
159
+ const activeTodo = todos[activeIndex]
160
+ if (activeIndex === -1 || !activeTodo) return ''
161
+ return `${activeIndex + 1}. **${activeTodo.content}**`
162
+ }
163
+
164
+ export function formatPart(part: Part): string {
165
+ if (part.type === 'text') {
166
+ return part.text || ''
167
+ }
168
+
169
+ if (part.type === 'reasoning') {
170
+ if (!part.text?.trim()) return ''
171
+ return `◼︎ thinking`
172
+ }
173
+
174
+ if (part.type === 'file') {
175
+ return `📄 ${part.filename || 'File'}`
176
+ }
177
+
178
+ if (part.type === 'step-start' || part.type === 'step-finish' || part.type === 'patch') {
179
+ return ''
180
+ }
181
+
182
+ if (part.type === 'agent') {
183
+ return `◼︎ agent ${part.id}`
184
+ }
185
+
186
+ if (part.type === 'snapshot') {
187
+ return `◼︎ snapshot ${part.snapshot}`
188
+ }
189
+
190
+ if (part.type === 'tool') {
191
+ if (part.tool === 'todowrite') {
192
+ return formatTodoList(part)
193
+ }
194
+
195
+ if (part.state.status === 'pending') {
196
+ return ''
197
+ }
198
+
199
+ const summaryText = getToolSummaryText(part)
200
+ const stateTitle = 'title' in part.state ? part.state.title : undefined
201
+
202
+ let toolTitle = ''
203
+ if (part.state.status === 'error') {
204
+ toolTitle = part.state.error || 'error'
205
+ } else if (part.tool === 'bash') {
206
+ const command = (part.state.input?.command as string) || ''
207
+ const description = (part.state.input?.description as string) || ''
208
+ const isSingleLine = !command.includes('\n')
209
+ const hasUnderscores = command.includes('_')
210
+ if (isSingleLine && !hasUnderscores && command.length <= 50) {
211
+ toolTitle = `_${command}_`
212
+ } else if (description) {
213
+ toolTitle = `_${description}_`
214
+ } else if (stateTitle) {
215
+ toolTitle = `_${stateTitle}_`
216
+ }
217
+ } else if (stateTitle) {
218
+ toolTitle = `_${stateTitle}_`
219
+ }
220
+
221
+ const icon = part.state.status === 'error' ? '⨯' : '◼︎'
222
+ return `${icon} ${part.tool} ${toolTitle} ${summaryText}`
223
+ }
224
+
225
+ logger.warn('Unknown part type:', part)
226
+ return ''
227
+ }
@@ -0,0 +1,380 @@
1
+ import {
2
+ ChatInputCommandInteraction,
3
+ StringSelectMenuInteraction,
4
+ StringSelectMenuBuilder,
5
+ ActionRowBuilder,
6
+ ChannelType,
7
+ type ThreadChannel,
8
+ type TextChannel,
9
+ } from 'discord.js'
10
+ import crypto from 'node:crypto'
11
+ import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from './database.js'
12
+ import { initializeOpencodeForDirectory } from './opencode.js'
13
+ import { resolveTextChannel, getKimakiMetadata } from './discord-utils.js'
14
+ import { createLogger } from './logger.js'
15
+
16
+ const modelLogger = createLogger('MODEL')
17
+
18
+ // Store context by hash to avoid customId length limits (Discord max: 100 chars)
19
+ const pendingModelContexts = new Map<string, {
20
+ dir: string
21
+ channelId: string
22
+ sessionId?: string
23
+ isThread: boolean
24
+ providerId?: string
25
+ providerName?: string
26
+ }>()
27
+
28
+ type ProviderInfo = {
29
+ id: string
30
+ name: string
31
+ models: Record<
32
+ string,
33
+ {
34
+ id: string
35
+ name: string
36
+ release_date: string
37
+ }
38
+ >
39
+ }
40
+
41
+ /**
42
+ * Handle the /model slash command.
43
+ * Shows a select menu with available providers.
44
+ */
45
+ export async function handleModelCommand({
46
+ interaction,
47
+ appId,
48
+ }: {
49
+ interaction: ChatInputCommandInteraction
50
+ appId: string
51
+ }): Promise<void> {
52
+ modelLogger.log('[MODEL] handleModelCommand called')
53
+
54
+ // Defer reply immediately to avoid 3-second timeout
55
+ await interaction.deferReply({ ephemeral: true })
56
+ modelLogger.log('[MODEL] Deferred reply')
57
+
58
+ // Ensure migrations are run
59
+ runModelMigrations()
60
+
61
+ const channel = interaction.channel
62
+
63
+ if (!channel) {
64
+ await interaction.editReply({
65
+ content: 'This command can only be used in a channel',
66
+ })
67
+ return
68
+ }
69
+
70
+ // Determine if we're in a thread or text channel
71
+ const isThread = [
72
+ ChannelType.PublicThread,
73
+ ChannelType.PrivateThread,
74
+ ChannelType.AnnouncementThread,
75
+ ].includes(channel.type)
76
+
77
+ let projectDirectory: string | undefined
78
+ let channelAppId: string | undefined
79
+ let targetChannelId: string
80
+ let sessionId: string | undefined
81
+
82
+ if (isThread) {
83
+ const thread = channel as ThreadChannel
84
+ const textChannel = await resolveTextChannel(thread)
85
+ const metadata = getKimakiMetadata(textChannel)
86
+ projectDirectory = metadata.projectDirectory
87
+ channelAppId = metadata.channelAppId
88
+ targetChannelId = textChannel?.id || channel.id
89
+
90
+ // Get session ID for this thread
91
+ const row = getDatabase()
92
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
93
+ .get(thread.id) as { session_id: string } | undefined
94
+ sessionId = row?.session_id
95
+ } else if (channel.type === ChannelType.GuildText) {
96
+ const textChannel = channel as TextChannel
97
+ const metadata = getKimakiMetadata(textChannel)
98
+ projectDirectory = metadata.projectDirectory
99
+ channelAppId = metadata.channelAppId
100
+ targetChannelId = channel.id
101
+ } else {
102
+ await interaction.editReply({
103
+ content: 'This command can only be used in text channels or threads',
104
+ })
105
+ return
106
+ }
107
+
108
+ if (channelAppId && channelAppId !== appId) {
109
+ await interaction.editReply({
110
+ content: 'This channel is not configured for this bot',
111
+ })
112
+ return
113
+ }
114
+
115
+ if (!projectDirectory) {
116
+ await interaction.editReply({
117
+ content: 'This channel is not configured with a project directory',
118
+ })
119
+ return
120
+ }
121
+
122
+ try {
123
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
124
+
125
+ const providersResponse = await getClient().provider.list({
126
+ query: { directory: projectDirectory },
127
+ })
128
+
129
+ if (!providersResponse.data) {
130
+ await interaction.editReply({
131
+ content: 'Failed to fetch providers',
132
+ })
133
+ return
134
+ }
135
+
136
+ const { all: allProviders, connected } = providersResponse.data
137
+
138
+ // Filter to only connected providers (have credentials)
139
+ const availableProviders = allProviders.filter((p) => {
140
+ return connected.includes(p.id)
141
+ })
142
+
143
+ if (availableProviders.length === 0) {
144
+ await interaction.editReply({
145
+ content:
146
+ 'No providers with credentials found. Use `/connect` in OpenCode TUI to add provider credentials.',
147
+ })
148
+ return
149
+ }
150
+
151
+ // Store context with a short hash key to avoid customId length limits
152
+ const context = {
153
+ dir: projectDirectory,
154
+ channelId: targetChannelId,
155
+ sessionId: sessionId,
156
+ isThread: isThread,
157
+ }
158
+ const contextHash = crypto.randomBytes(8).toString('hex')
159
+ pendingModelContexts.set(contextHash, context)
160
+
161
+ const options = availableProviders.slice(0, 25).map((provider) => {
162
+ const modelCount = Object.keys(provider.models || {}).length
163
+ return {
164
+ label: provider.name.slice(0, 100),
165
+ value: provider.id,
166
+ description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
167
+ }
168
+ })
169
+
170
+ const selectMenu = new StringSelectMenuBuilder()
171
+ .setCustomId(`model_provider:${contextHash}`)
172
+ .setPlaceholder('Select a provider')
173
+ .addOptions(options)
174
+
175
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
176
+
177
+ await interaction.editReply({
178
+ content: '**Set Model Preference**\nSelect a provider:',
179
+ components: [actionRow],
180
+ })
181
+ } catch (error) {
182
+ modelLogger.error('Error loading providers:', error)
183
+ await interaction.editReply({
184
+ content: `Failed to load providers: ${error instanceof Error ? error.message : 'Unknown error'}`,
185
+ })
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Handle the provider select menu interaction.
191
+ * Shows a second select menu with models for the chosen provider.
192
+ */
193
+ export async function handleProviderSelectMenu(
194
+ interaction: StringSelectMenuInteraction
195
+ ): Promise<void> {
196
+ const customId = interaction.customId
197
+
198
+ if (!customId.startsWith('model_provider:')) {
199
+ return
200
+ }
201
+
202
+ // Defer update immediately to avoid timeout
203
+ await interaction.deferUpdate()
204
+
205
+ const contextHash = customId.replace('model_provider:', '')
206
+ const context = pendingModelContexts.get(contextHash)
207
+
208
+ if (!context) {
209
+ await interaction.editReply({
210
+ content: 'Selection expired. Please run /model again.',
211
+ components: [],
212
+ })
213
+ return
214
+ }
215
+
216
+ const selectedProviderId = interaction.values[0]
217
+ if (!selectedProviderId) {
218
+ await interaction.editReply({
219
+ content: 'No provider selected',
220
+ components: [],
221
+ })
222
+ return
223
+ }
224
+
225
+ try {
226
+ const getClient = await initializeOpencodeForDirectory(context.dir)
227
+
228
+ const providersResponse = await getClient().provider.list({
229
+ query: { directory: context.dir },
230
+ })
231
+
232
+ if (!providersResponse.data) {
233
+ await interaction.editReply({
234
+ content: 'Failed to fetch providers',
235
+ components: [],
236
+ })
237
+ return
238
+ }
239
+
240
+ const provider = providersResponse.data.all.find((p) => p.id === selectedProviderId)
241
+
242
+ if (!provider) {
243
+ await interaction.editReply({
244
+ content: 'Provider not found',
245
+ components: [],
246
+ })
247
+ return
248
+ }
249
+
250
+ const models = Object.entries(provider.models || {})
251
+ .map(([modelId, model]) => ({
252
+ id: modelId,
253
+ name: model.name,
254
+ releaseDate: model.release_date,
255
+ }))
256
+ // Sort by release date descending (most recent first)
257
+ .sort((a, b) => {
258
+ const dateA = a.releaseDate ? new Date(a.releaseDate).getTime() : 0
259
+ const dateB = b.releaseDate ? new Date(b.releaseDate).getTime() : 0
260
+ return dateB - dateA
261
+ })
262
+
263
+ if (models.length === 0) {
264
+ await interaction.editReply({
265
+ content: `No models available for ${provider.name}`,
266
+ components: [],
267
+ })
268
+ return
269
+ }
270
+
271
+ // Take first 25 models (most recent since sorted descending)
272
+ const recentModels = models.slice(0, 25)
273
+
274
+ // Update context with provider info and reuse the same hash
275
+ context.providerId = selectedProviderId
276
+ context.providerName = provider.name
277
+ pendingModelContexts.set(contextHash, context)
278
+
279
+ const options = recentModels.map((model) => {
280
+ const dateStr = model.releaseDate
281
+ ? new Date(model.releaseDate).toLocaleDateString()
282
+ : 'Unknown date'
283
+ return {
284
+ label: model.name.slice(0, 100),
285
+ value: model.id,
286
+ description: dateStr.slice(0, 100),
287
+ }
288
+ })
289
+
290
+ const selectMenu = new StringSelectMenuBuilder()
291
+ .setCustomId(`model_select:${contextHash}`)
292
+ .setPlaceholder('Select a model')
293
+ .addOptions(options)
294
+
295
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
296
+
297
+ await interaction.editReply({
298
+ content: `**Set Model Preference**\nProvider: **${provider.name}**\nSelect a model:`,
299
+ components: [actionRow],
300
+ })
301
+ } catch (error) {
302
+ modelLogger.error('Error loading models:', error)
303
+ await interaction.editReply({
304
+ content: `Failed to load models: ${error instanceof Error ? error.message : 'Unknown error'}`,
305
+ components: [],
306
+ })
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Handle the model select menu interaction.
312
+ * Stores the model preference in the database.
313
+ */
314
+ export async function handleModelSelectMenu(
315
+ interaction: StringSelectMenuInteraction
316
+ ): Promise<void> {
317
+ const customId = interaction.customId
318
+
319
+ if (!customId.startsWith('model_select:')) {
320
+ return
321
+ }
322
+
323
+ // Defer update immediately
324
+ await interaction.deferUpdate()
325
+
326
+ const contextHash = customId.replace('model_select:', '')
327
+ const context = pendingModelContexts.get(contextHash)
328
+
329
+ if (!context || !context.providerId || !context.providerName) {
330
+ await interaction.editReply({
331
+ content: 'Selection expired. Please run /model again.',
332
+ components: [],
333
+ })
334
+ return
335
+ }
336
+
337
+ const selectedModelId = interaction.values[0]
338
+ if (!selectedModelId) {
339
+ await interaction.editReply({
340
+ content: 'No model selected',
341
+ components: [],
342
+ })
343
+ return
344
+ }
345
+
346
+ // Build full model ID: provider_id/model_id
347
+ const fullModelId = `${context.providerId}/${selectedModelId}`
348
+
349
+ try {
350
+ // Store in appropriate table based on context
351
+ if (context.isThread && context.sessionId) {
352
+ // Store for session
353
+ setSessionModel(context.sessionId, fullModelId)
354
+ modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`)
355
+
356
+ await interaction.editReply({
357
+ content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
358
+ components: [],
359
+ })
360
+ } else {
361
+ // Store for channel
362
+ setChannelModel(context.channelId, fullModelId)
363
+ modelLogger.log(`Set model ${fullModelId} for channel ${context.channelId}`)
364
+
365
+ await interaction.editReply({
366
+ content: `Model preference set for this channel:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\`\n\nAll new sessions in this channel will use this model.`,
367
+ components: [],
368
+ })
369
+ }
370
+
371
+ // Clean up the context from memory
372
+ pendingModelContexts.delete(contextHash)
373
+ } catch (error) {
374
+ modelLogger.error('Error saving model preference:', error)
375
+ await interaction.editReply({
376
+ content: `Failed to save model preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
377
+ components: [],
378
+ })
379
+ }
380
+ }