@brianli/kimaki 0.4.72-brianli.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.
Files changed (328) hide show
  1. package/bin.js +2 -0
  2. package/dist/ai-tool-to-genai.js +233 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/ai-tool.js +6 -0
  5. package/dist/bin.js +87 -0
  6. package/dist/bot-token.js +121 -0
  7. package/dist/bot-token.test.js +134 -0
  8. package/dist/channel-management.js +101 -0
  9. package/dist/cli-parsing.test.js +89 -0
  10. package/dist/cli.js +2529 -0
  11. package/dist/commands/abort.js +82 -0
  12. package/dist/commands/action-buttons.js +257 -0
  13. package/dist/commands/add-project.js +114 -0
  14. package/dist/commands/agent.js +291 -0
  15. package/dist/commands/ask-question.js +223 -0
  16. package/dist/commands/compact.js +120 -0
  17. package/dist/commands/context-usage.js +140 -0
  18. package/dist/commands/create-new-project.js +118 -0
  19. package/dist/commands/diff.js +128 -0
  20. package/dist/commands/file-upload.js +275 -0
  21. package/dist/commands/fork.js +217 -0
  22. package/dist/commands/gemini-apikey.js +70 -0
  23. package/dist/commands/login.js +490 -0
  24. package/dist/commands/mention-mode.js +51 -0
  25. package/dist/commands/merge-worktree.js +124 -0
  26. package/dist/commands/model.js +694 -0
  27. package/dist/commands/permissions.js +163 -0
  28. package/dist/commands/queue.js +217 -0
  29. package/dist/commands/remove-project.js +115 -0
  30. package/dist/commands/restart-opencode-server.js +116 -0
  31. package/dist/commands/resume.js +159 -0
  32. package/dist/commands/run-command.js +79 -0
  33. package/dist/commands/session-id.js +78 -0
  34. package/dist/commands/session.js +192 -0
  35. package/dist/commands/share.js +80 -0
  36. package/dist/commands/types.js +2 -0
  37. package/dist/commands/undo-redo.js +159 -0
  38. package/dist/commands/unset-model.js +152 -0
  39. package/dist/commands/upgrade.js +42 -0
  40. package/dist/commands/user-command.js +148 -0
  41. package/dist/commands/verbosity.js +60 -0
  42. package/dist/commands/worktree-settings.js +50 -0
  43. package/dist/commands/worktree.js +299 -0
  44. package/dist/condense-memory.js +33 -0
  45. package/dist/config.js +110 -0
  46. package/dist/database.js +1050 -0
  47. package/dist/db.js +159 -0
  48. package/dist/db.test.js +49 -0
  49. package/dist/discord-api.js +28 -0
  50. package/dist/discord-auth.js +231 -0
  51. package/dist/discord-auth.test.js +80 -0
  52. package/dist/discord-bot.js +997 -0
  53. package/dist/discord-utils.js +560 -0
  54. package/dist/discord-utils.test.js +115 -0
  55. package/dist/errors.js +167 -0
  56. package/dist/escape-backticks.test.js +429 -0
  57. package/dist/format-tables.js +122 -0
  58. package/dist/format-tables.test.js +199 -0
  59. package/dist/forum-sync/config.js +79 -0
  60. package/dist/forum-sync/discord-operations.js +154 -0
  61. package/dist/forum-sync/index.js +5 -0
  62. package/dist/forum-sync/markdown.js +117 -0
  63. package/dist/forum-sync/sync-to-discord.js +417 -0
  64. package/dist/forum-sync/sync-to-files.js +190 -0
  65. package/dist/forum-sync/types.js +53 -0
  66. package/dist/forum-sync/watchers.js +307 -0
  67. package/dist/gateway-consumer.js +232 -0
  68. package/dist/gateway-consumer.test.js +18 -0
  69. package/dist/genai-worker-wrapper.js +111 -0
  70. package/dist/genai-worker.js +311 -0
  71. package/dist/genai.js +232 -0
  72. package/dist/generated/browser.js +17 -0
  73. package/dist/generated/client.js +35 -0
  74. package/dist/generated/commonInputTypes.js +10 -0
  75. package/dist/generated/enums.js +30 -0
  76. package/dist/generated/internal/class.js +41 -0
  77. package/dist/generated/internal/prismaNamespace.js +239 -0
  78. package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
  79. package/dist/generated/models/bot_api_keys.js +1 -0
  80. package/dist/generated/models/bot_tokens.js +1 -0
  81. package/dist/generated/models/channel_agents.js +1 -0
  82. package/dist/generated/models/channel_directories.js +1 -0
  83. package/dist/generated/models/channel_mention_mode.js +1 -0
  84. package/dist/generated/models/channel_models.js +1 -0
  85. package/dist/generated/models/channel_verbosity.js +1 -0
  86. package/dist/generated/models/channel_worktrees.js +1 -0
  87. package/dist/generated/models/forum_sync_configs.js +1 -0
  88. package/dist/generated/models/global_models.js +1 -0
  89. package/dist/generated/models/ipc_requests.js +1 -0
  90. package/dist/generated/models/part_messages.js +1 -0
  91. package/dist/generated/models/scheduled_tasks.js +1 -0
  92. package/dist/generated/models/session_agents.js +1 -0
  93. package/dist/generated/models/session_models.js +1 -0
  94. package/dist/generated/models/session_start_sources.js +1 -0
  95. package/dist/generated/models/thread_sessions.js +1 -0
  96. package/dist/generated/models/thread_worktrees.js +1 -0
  97. package/dist/generated/models.js +1 -0
  98. package/dist/heap-monitor.js +95 -0
  99. package/dist/hrana-server.js +416 -0
  100. package/dist/hrana-server.test.js +368 -0
  101. package/dist/image-utils.js +112 -0
  102. package/dist/interaction-handler.js +327 -0
  103. package/dist/ipc-polling.js +251 -0
  104. package/dist/kimaki-digital-twin.e2e.test.js +165 -0
  105. package/dist/limit-heading-depth.js +25 -0
  106. package/dist/limit-heading-depth.test.js +105 -0
  107. package/dist/logger.js +160 -0
  108. package/dist/markdown.js +342 -0
  109. package/dist/markdown.test.js +253 -0
  110. package/dist/message-formatting.js +433 -0
  111. package/dist/message-formatting.test.js +73 -0
  112. package/dist/openai-realtime.js +228 -0
  113. package/dist/opencode-plugin-loading.e2e.test.js +91 -0
  114. package/dist/opencode-plugin.js +536 -0
  115. package/dist/opencode-plugin.test.js +98 -0
  116. package/dist/opencode.js +409 -0
  117. package/dist/privacy-sanitizer.js +105 -0
  118. package/dist/runtime-mode.js +51 -0
  119. package/dist/runtime-mode.test.js +115 -0
  120. package/dist/sentry.js +127 -0
  121. package/dist/session-handler/state.js +151 -0
  122. package/dist/session-handler.js +1874 -0
  123. package/dist/session-search.js +100 -0
  124. package/dist/session-search.test.js +40 -0
  125. package/dist/startup-service.js +153 -0
  126. package/dist/system-message.js +499 -0
  127. package/dist/task-runner.js +282 -0
  128. package/dist/task-schedule.js +191 -0
  129. package/dist/task-schedule.test.js +71 -0
  130. package/dist/thinking-utils.js +35 -0
  131. package/dist/thread-message-queue.e2e.test.js +781 -0
  132. package/dist/tools.js +359 -0
  133. package/dist/unnest-code-blocks.js +136 -0
  134. package/dist/unnest-code-blocks.test.js +641 -0
  135. package/dist/upgrade.js +114 -0
  136. package/dist/utils.js +109 -0
  137. package/dist/voice-handler.js +606 -0
  138. package/dist/voice.js +304 -0
  139. package/dist/voice.test.js +187 -0
  140. package/dist/wait-session.js +94 -0
  141. package/dist/worker-types.js +4 -0
  142. package/dist/worktree-utils.js +727 -0
  143. package/dist/xml.js +92 -0
  144. package/dist/xml.test.js +32 -0
  145. package/package.json +82 -0
  146. package/schema.prisma +246 -0
  147. package/skills/batch/SKILL.md +87 -0
  148. package/skills/critique/SKILL.md +129 -0
  149. package/skills/errore/SKILL.md +589 -0
  150. package/skills/goke/.prettierrc +5 -0
  151. package/skills/goke/CHANGELOG.md +40 -0
  152. package/skills/goke/LICENSE +21 -0
  153. package/skills/goke/README.md +666 -0
  154. package/skills/goke/SKILL.md +458 -0
  155. package/skills/goke/package.json +43 -0
  156. package/skills/goke/src/__test__/coerce.test.ts +411 -0
  157. package/skills/goke/src/__test__/index.test.ts +1798 -0
  158. package/skills/goke/src/__test__/types.test-d.ts +111 -0
  159. package/skills/goke/src/coerce.ts +547 -0
  160. package/skills/goke/src/goke.ts +1362 -0
  161. package/skills/goke/src/index.ts +16 -0
  162. package/skills/goke/src/mri.ts +164 -0
  163. package/skills/goke/tsconfig.json +15 -0
  164. package/skills/jitter/EDITOR.md +219 -0
  165. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  166. package/skills/jitter/SKILL.md +158 -0
  167. package/skills/jitter/jitter-clipboard.json +1042 -0
  168. package/skills/jitter/package.json +14 -0
  169. package/skills/jitter/tsconfig.json +15 -0
  170. package/skills/jitter/utils/actions.ts +212 -0
  171. package/skills/jitter/utils/export.ts +114 -0
  172. package/skills/jitter/utils/index.ts +141 -0
  173. package/skills/jitter/utils/snapshot.ts +154 -0
  174. package/skills/jitter/utils/traverse.ts +246 -0
  175. package/skills/jitter/utils/types.ts +279 -0
  176. package/skills/jitter/utils/wait.ts +133 -0
  177. package/skills/playwriter/SKILL.md +31 -0
  178. package/skills/security-review/SKILL.md +208 -0
  179. package/skills/simplify/SKILL.md +58 -0
  180. package/skills/termcast/SKILL.md +945 -0
  181. package/skills/tuistory/SKILL.md +250 -0
  182. package/skills/zustand-centralized-state/SKILL.md +582 -0
  183. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  184. package/src/__snapshots__/compact-session-context.md +41 -0
  185. package/src/__snapshots__/first-session-no-info.md +17 -0
  186. package/src/__snapshots__/first-session-with-info.md +23 -0
  187. package/src/__snapshots__/session-1.md +17 -0
  188. package/src/__snapshots__/session-2.md +5871 -0
  189. package/src/__snapshots__/session-3.md +17 -0
  190. package/src/__snapshots__/session-with-tools.md +5871 -0
  191. package/src/ai-tool-to-genai.test.ts +296 -0
  192. package/src/ai-tool-to-genai.ts +282 -0
  193. package/src/ai-tool.ts +39 -0
  194. package/src/bin.ts +108 -0
  195. package/src/bot-token.test.ts +171 -0
  196. package/src/bot-token.ts +159 -0
  197. package/src/channel-management.ts +172 -0
  198. package/src/cli-parsing.test.ts +132 -0
  199. package/src/cli.ts +3605 -0
  200. package/src/commands/abort.ts +112 -0
  201. package/src/commands/action-buttons.ts +376 -0
  202. package/src/commands/add-project.ts +152 -0
  203. package/src/commands/agent.ts +404 -0
  204. package/src/commands/ask-question.ts +330 -0
  205. package/src/commands/compact.ts +157 -0
  206. package/src/commands/context-usage.ts +199 -0
  207. package/src/commands/create-new-project.ts +179 -0
  208. package/src/commands/diff.ts +165 -0
  209. package/src/commands/file-upload.ts +389 -0
  210. package/src/commands/fork.ts +320 -0
  211. package/src/commands/gemini-apikey.ts +104 -0
  212. package/src/commands/login.ts +634 -0
  213. package/src/commands/mention-mode.ts +77 -0
  214. package/src/commands/merge-worktree.ts +177 -0
  215. package/src/commands/model.ts +961 -0
  216. package/src/commands/permissions.ts +261 -0
  217. package/src/commands/queue.ts +296 -0
  218. package/src/commands/remove-project.ts +155 -0
  219. package/src/commands/restart-opencode-server.ts +162 -0
  220. package/src/commands/resume.ts +242 -0
  221. package/src/commands/run-command.ts +123 -0
  222. package/src/commands/session-id.ts +109 -0
  223. package/src/commands/session.ts +250 -0
  224. package/src/commands/share.ts +106 -0
  225. package/src/commands/types.ts +25 -0
  226. package/src/commands/undo-redo.ts +221 -0
  227. package/src/commands/unset-model.ts +189 -0
  228. package/src/commands/upgrade.ts +52 -0
  229. package/src/commands/user-command.ts +193 -0
  230. package/src/commands/verbosity.ts +88 -0
  231. package/src/commands/worktree-settings.ts +79 -0
  232. package/src/commands/worktree.ts +431 -0
  233. package/src/condense-memory.ts +36 -0
  234. package/src/config.ts +148 -0
  235. package/src/database.ts +1530 -0
  236. package/src/db.test.ts +60 -0
  237. package/src/db.ts +190 -0
  238. package/src/discord-api.ts +35 -0
  239. package/src/discord-bot.ts +1316 -0
  240. package/src/discord-utils.test.ts +132 -0
  241. package/src/discord-utils.ts +767 -0
  242. package/src/errors.ts +213 -0
  243. package/src/escape-backticks.test.ts +469 -0
  244. package/src/format-tables.test.ts +223 -0
  245. package/src/format-tables.ts +145 -0
  246. package/src/forum-sync/config.ts +92 -0
  247. package/src/forum-sync/discord-operations.ts +241 -0
  248. package/src/forum-sync/index.ts +9 -0
  249. package/src/forum-sync/markdown.ts +176 -0
  250. package/src/forum-sync/sync-to-discord.ts +595 -0
  251. package/src/forum-sync/sync-to-files.ts +294 -0
  252. package/src/forum-sync/types.ts +175 -0
  253. package/src/forum-sync/watchers.ts +454 -0
  254. package/src/genai-worker-wrapper.ts +164 -0
  255. package/src/genai-worker.ts +386 -0
  256. package/src/genai.ts +321 -0
  257. package/src/generated/browser.ts +109 -0
  258. package/src/generated/client.ts +131 -0
  259. package/src/generated/commonInputTypes.ts +512 -0
  260. package/src/generated/enums.ts +46 -0
  261. package/src/generated/internal/class.ts +362 -0
  262. package/src/generated/internal/prismaNamespace.ts +2251 -0
  263. package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
  264. package/src/generated/models/bot_api_keys.ts +1288 -0
  265. package/src/generated/models/bot_tokens.ts +1577 -0
  266. package/src/generated/models/channel_agents.ts +1256 -0
  267. package/src/generated/models/channel_directories.ts +2104 -0
  268. package/src/generated/models/channel_mention_mode.ts +1300 -0
  269. package/src/generated/models/channel_models.ts +1288 -0
  270. package/src/generated/models/channel_verbosity.ts +1224 -0
  271. package/src/generated/models/channel_worktrees.ts +1308 -0
  272. package/src/generated/models/forum_sync_configs.ts +1452 -0
  273. package/src/generated/models/global_models.ts +1288 -0
  274. package/src/generated/models/ipc_requests.ts +1485 -0
  275. package/src/generated/models/part_messages.ts +1302 -0
  276. package/src/generated/models/scheduled_tasks.ts +2320 -0
  277. package/src/generated/models/session_agents.ts +1086 -0
  278. package/src/generated/models/session_models.ts +1114 -0
  279. package/src/generated/models/session_start_sources.ts +1408 -0
  280. package/src/generated/models/thread_sessions.ts +1599 -0
  281. package/src/generated/models/thread_worktrees.ts +1352 -0
  282. package/src/generated/models.ts +29 -0
  283. package/src/heap-monitor.ts +121 -0
  284. package/src/hrana-server.test.ts +428 -0
  285. package/src/hrana-server.ts +547 -0
  286. package/src/image-utils.ts +149 -0
  287. package/src/interaction-handler.ts +461 -0
  288. package/src/ipc-polling.ts +325 -0
  289. package/src/kimaki-digital-twin.e2e.test.ts +201 -0
  290. package/src/limit-heading-depth.test.ts +116 -0
  291. package/src/limit-heading-depth.ts +26 -0
  292. package/src/logger.ts +203 -0
  293. package/src/markdown.test.ts +360 -0
  294. package/src/markdown.ts +410 -0
  295. package/src/message-formatting.test.ts +81 -0
  296. package/src/message-formatting.ts +549 -0
  297. package/src/openai-realtime.ts +362 -0
  298. package/src/opencode-plugin-loading.e2e.test.ts +112 -0
  299. package/src/opencode-plugin.test.ts +108 -0
  300. package/src/opencode-plugin.ts +652 -0
  301. package/src/opencode.ts +554 -0
  302. package/src/privacy-sanitizer.ts +142 -0
  303. package/src/schema.sql +158 -0
  304. package/src/sentry.ts +137 -0
  305. package/src/session-handler/state.ts +232 -0
  306. package/src/session-handler.ts +2668 -0
  307. package/src/session-search.test.ts +50 -0
  308. package/src/session-search.ts +148 -0
  309. package/src/startup-service.ts +200 -0
  310. package/src/system-message.ts +568 -0
  311. package/src/task-runner.ts +425 -0
  312. package/src/task-schedule.test.ts +84 -0
  313. package/src/task-schedule.ts +287 -0
  314. package/src/thinking-utils.ts +61 -0
  315. package/src/thread-message-queue.e2e.test.ts +997 -0
  316. package/src/tools.ts +432 -0
  317. package/src/unnest-code-blocks.test.ts +679 -0
  318. package/src/unnest-code-blocks.ts +168 -0
  319. package/src/upgrade.ts +127 -0
  320. package/src/utils.ts +145 -0
  321. package/src/voice-handler.ts +852 -0
  322. package/src/voice.test.ts +219 -0
  323. package/src/voice.ts +444 -0
  324. package/src/wait-session.ts +147 -0
  325. package/src/worker-types.ts +64 -0
  326. package/src/worktree-utils.ts +988 -0
  327. package/src/xml.test.ts +38 -0
  328. package/src/xml.ts +121 -0
@@ -0,0 +1,961 @@
1
+ // /model command - Set the preferred model for this channel or session.
2
+
3
+ import {
4
+ ChatInputCommandInteraction,
5
+ StringSelectMenuInteraction,
6
+ StringSelectMenuBuilder,
7
+ ActionRowBuilder,
8
+ ChannelType,
9
+ type ThreadChannel,
10
+ type TextChannel,
11
+ MessageFlags,
12
+ } from 'discord.js'
13
+ import crypto from 'node:crypto'
14
+ import {
15
+ setChannelModel,
16
+ setSessionModel,
17
+ setSessionAgent,
18
+ getChannelModel,
19
+ getSessionModel,
20
+ getSessionAgent,
21
+ getChannelAgent,
22
+ getThreadSession,
23
+ getGlobalModel,
24
+ setGlobalModel,
25
+ getVariantCascade,
26
+ } from '../database.js'
27
+ import { initializeOpencodeForDirectory } from '../opencode.js'
28
+ import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
29
+ import { abortAndRetrySession, getDefaultModel } from '../session-handler.js'
30
+ import { getThinkingValuesForModel } from '../thinking-utils.js'
31
+ import { createLogger, LogPrefix } from '../logger.js'
32
+ import * as errore from 'errore'
33
+
34
+ const modelLogger = createLogger(LogPrefix.MODEL)
35
+
36
+ // Store context by hash to avoid customId length limits (Discord max: 100 chars)
37
+ const pendingModelContexts = new Map<
38
+ string,
39
+ {
40
+ dir: string
41
+ channelId: string
42
+ sessionId?: string
43
+ isThread: boolean
44
+ providerId?: string
45
+ providerName?: string
46
+ thread?: ThreadChannel
47
+ appId?: string
48
+ selectedModelId?: string
49
+ selectedVariant?: string | null
50
+ availableVariants?: string[]
51
+ }
52
+ >()
53
+
54
+ export type ProviderInfo = {
55
+ id: string
56
+ name: string
57
+ models: Record<
58
+ string,
59
+ {
60
+ id: string
61
+ name: string
62
+ release_date: string
63
+ }
64
+ >
65
+ }
66
+
67
+ export type ModelSource =
68
+ | 'session'
69
+ | 'agent'
70
+ | 'channel'
71
+ | 'global'
72
+ | 'opencode-config'
73
+ | 'opencode-recent'
74
+ | 'opencode-provider-default'
75
+
76
+ export type CurrentModelInfo =
77
+ | { type: 'session'; model: string; providerID: string; modelID: string }
78
+ | {
79
+ type: 'agent'
80
+ model: string
81
+ providerID: string
82
+ modelID: string
83
+ agentName: string
84
+ }
85
+ | { type: 'channel'; model: string; providerID: string; modelID: string }
86
+ | { type: 'global'; model: string; providerID: string; modelID: string }
87
+ | {
88
+ type: 'opencode-config'
89
+ model: string
90
+ providerID: string
91
+ modelID: string
92
+ }
93
+ | {
94
+ type: 'opencode-recent'
95
+ model: string
96
+ providerID: string
97
+ modelID: string
98
+ }
99
+ | {
100
+ type: 'opencode-provider-default'
101
+ model: string
102
+ providerID: string
103
+ modelID: string
104
+ }
105
+ | { type: 'none' }
106
+
107
+ function parseModelId(
108
+ modelString: string,
109
+ ): { providerID: string; modelID: string } | undefined {
110
+ const [providerID, ...modelParts] = modelString.split('/')
111
+ const modelID = modelParts.join('/')
112
+ if (providerID && modelID) {
113
+ return { providerID, modelID }
114
+ }
115
+ return undefined
116
+ }
117
+
118
+ export async function ensureSessionPreferencesSnapshot({
119
+ sessionId,
120
+ channelId,
121
+ appId,
122
+ getClient,
123
+ agentOverride,
124
+ modelOverride,
125
+ force,
126
+ }: {
127
+ sessionId: string
128
+ channelId?: string
129
+ appId?: string
130
+ getClient: Awaited<ReturnType<typeof initializeOpencodeForDirectory>>
131
+ agentOverride?: string
132
+ modelOverride?: string
133
+ force?: boolean
134
+ }): Promise<void> {
135
+ const [sessionAgentPreference, sessionModelPreference] = await Promise.all([
136
+ getSessionAgent(sessionId),
137
+ getSessionModel(sessionId),
138
+ ])
139
+ const shouldBootstrapSessionPreferences =
140
+ force || (!sessionAgentPreference && !sessionModelPreference)
141
+ if (!shouldBootstrapSessionPreferences) {
142
+ return
143
+ }
144
+
145
+ const bootstrappedAgent =
146
+ agentOverride ||
147
+ sessionAgentPreference ||
148
+ (channelId ? await getChannelAgent(channelId) : undefined)
149
+ if (!sessionAgentPreference && bootstrappedAgent) {
150
+ await setSessionAgent(sessionId, bootstrappedAgent)
151
+ modelLogger.log(
152
+ `[MODEL] Snapshotted session agent ${bootstrappedAgent} for session ${sessionId}`,
153
+ )
154
+ }
155
+
156
+ if (sessionModelPreference) {
157
+ return
158
+ }
159
+
160
+ if (modelOverride) {
161
+ const parsedModelOverride = parseModelId(modelOverride)
162
+ if (parsedModelOverride) {
163
+ const bootstrappedVariant = await getVariantCascade({
164
+ sessionId,
165
+ channelId,
166
+ appId,
167
+ })
168
+ await setSessionModel({
169
+ sessionId,
170
+ modelId: modelOverride,
171
+ variant: bootstrappedVariant ?? null,
172
+ })
173
+ modelLogger.log(
174
+ `[MODEL] Snapshotted explicit session model ${modelOverride} for session ${sessionId}`,
175
+ )
176
+ return
177
+ }
178
+ modelLogger.warn(
179
+ `[MODEL] Ignoring invalid explicit model override "${modelOverride}" for session ${sessionId}`,
180
+ )
181
+ }
182
+
183
+ const bootstrappedModel = await getCurrentModelInfo({
184
+ sessionId,
185
+ channelId,
186
+ appId,
187
+ agentPreference: bootstrappedAgent,
188
+ getClient,
189
+ })
190
+ if (bootstrappedModel.type === 'none') {
191
+ return
192
+ }
193
+
194
+ const bootstrappedVariant = await getVariantCascade({
195
+ sessionId,
196
+ channelId,
197
+ appId,
198
+ })
199
+ await setSessionModel({
200
+ sessionId,
201
+ modelId: bootstrappedModel.model,
202
+ variant: bootstrappedVariant ?? null,
203
+ })
204
+ modelLogger.log(
205
+ `[MODEL] Snapshotted session model ${bootstrappedModel.model} for session ${sessionId}`,
206
+ )
207
+ }
208
+
209
+ /**
210
+ * Get the current model info for a channel/session, including where it comes from.
211
+ * Priority: session > agent > channel > global > opencode default
212
+ */
213
+ export async function getCurrentModelInfo({
214
+ sessionId,
215
+ channelId,
216
+ appId,
217
+ agentPreference,
218
+ getClient,
219
+ }: {
220
+ sessionId?: string
221
+ channelId?: string
222
+ appId?: string
223
+ agentPreference?: string
224
+ getClient: Awaited<ReturnType<typeof initializeOpencodeForDirectory>>
225
+ }): Promise<CurrentModelInfo> {
226
+ if (getClient instanceof Error) {
227
+ return { type: 'none' }
228
+ }
229
+
230
+ // 1. Check session model preference
231
+ if (sessionId) {
232
+ const sessionPref = await getSessionModel(sessionId)
233
+ if (sessionPref) {
234
+ const parsed = parseModelId(sessionPref.modelId)
235
+ if (parsed) {
236
+ return { type: 'session', model: sessionPref.modelId, ...parsed }
237
+ }
238
+ }
239
+ }
240
+
241
+ // 2. Check agent's configured model
242
+ const effectiveAgent =
243
+ agentPreference ??
244
+ (sessionId
245
+ ? (await getSessionAgent(sessionId)) ||
246
+ (channelId ? await getChannelAgent(channelId) : undefined)
247
+ : channelId
248
+ ? await getChannelAgent(channelId)
249
+ : undefined)
250
+ if (effectiveAgent) {
251
+ const agentsResponse = await getClient().app.agents({})
252
+ if (agentsResponse.data) {
253
+ const agent = agentsResponse.data.find((a) => a.name === effectiveAgent)
254
+ if (agent?.model) {
255
+ const model = `${agent.model.providerID}/${agent.model.modelID}`
256
+ return {
257
+ type: 'agent',
258
+ model,
259
+ providerID: agent.model.providerID,
260
+ modelID: agent.model.modelID,
261
+ agentName: effectiveAgent,
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ // 3. Check channel model preference
268
+ if (channelId) {
269
+ const channelPref = await getChannelModel(channelId)
270
+ if (channelPref) {
271
+ const parsed = parseModelId(channelPref.modelId)
272
+ if (parsed) {
273
+ return { type: 'channel', model: channelPref.modelId, ...parsed }
274
+ }
275
+ }
276
+ }
277
+
278
+ // 4. Check global model preference
279
+ if (appId) {
280
+ const globalPref = await getGlobalModel(appId)
281
+ if (globalPref) {
282
+ const parsed = parseModelId(globalPref.modelId)
283
+ if (parsed) {
284
+ return { type: 'global', model: globalPref.modelId, ...parsed }
285
+ }
286
+ }
287
+ }
288
+
289
+ // 5. Get opencode default (config > recent > provider default)
290
+ const defaultModel = await getDefaultModel({ getClient })
291
+ if (defaultModel) {
292
+ const model = `${defaultModel.providerID}/${defaultModel.modelID}`
293
+ return {
294
+ type: defaultModel.source,
295
+ model,
296
+ providerID: defaultModel.providerID,
297
+ modelID: defaultModel.modelID,
298
+ }
299
+ }
300
+
301
+ return { type: 'none' }
302
+ }
303
+
304
+ /**
305
+ * Handle the /model slash command.
306
+ * Shows a select menu with available providers.
307
+ */
308
+ export async function handleModelCommand({
309
+ interaction,
310
+ appId,
311
+ }: {
312
+ interaction: ChatInputCommandInteraction
313
+ appId: string
314
+ }): Promise<void> {
315
+ modelLogger.log('[MODEL] handleModelCommand called')
316
+
317
+ // Defer reply immediately to avoid 3-second timeout
318
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral })
319
+ modelLogger.log('[MODEL] Deferred reply')
320
+
321
+ const channel = interaction.channel
322
+
323
+ if (!channel) {
324
+ await interaction.editReply({
325
+ content: 'This command can only be used in a channel',
326
+ })
327
+ return
328
+ }
329
+
330
+ // Determine if we're in a thread or text channel
331
+ const isThread = [
332
+ ChannelType.PublicThread,
333
+ ChannelType.PrivateThread,
334
+ ChannelType.AnnouncementThread,
335
+ ].includes(channel.type)
336
+
337
+ let projectDirectory: string | undefined
338
+ let channelAppId: string | undefined
339
+ let targetChannelId: string
340
+ let sessionId: string | undefined
341
+
342
+ if (isThread) {
343
+ const thread = channel as ThreadChannel
344
+ // Parallelize: resolve metadata and session ID at the same time
345
+ const [textChannel, threadSessionId] = await Promise.all([
346
+ resolveTextChannel(thread),
347
+ getThreadSession(thread.id),
348
+ ])
349
+ const metadata = await getKimakiMetadata(textChannel)
350
+ projectDirectory = metadata.projectDirectory
351
+ channelAppId = metadata.channelAppId
352
+ targetChannelId = textChannel?.id || channel.id
353
+ sessionId = threadSessionId
354
+ } else if (channel.type === ChannelType.GuildText) {
355
+ const textChannel = channel as TextChannel
356
+ const metadata = await getKimakiMetadata(textChannel)
357
+ projectDirectory = metadata.projectDirectory
358
+ channelAppId = metadata.channelAppId
359
+ targetChannelId = channel.id
360
+ } else {
361
+ await interaction.editReply({
362
+ content: 'This command can only be used in text channels or threads',
363
+ })
364
+ return
365
+ }
366
+
367
+ if (channelAppId && channelAppId !== appId) {
368
+ await interaction.editReply({
369
+ content: 'This channel is not configured for this bot',
370
+ })
371
+ return
372
+ }
373
+
374
+ if (!projectDirectory) {
375
+ await interaction.editReply({
376
+ content: 'This channel is not configured with a project directory',
377
+ })
378
+ return
379
+ }
380
+
381
+ try {
382
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
383
+ if (getClient instanceof Error) {
384
+ await interaction.editReply({ content: getClient.message })
385
+ return
386
+ }
387
+
388
+ const effectiveAppId = channelAppId || appId
389
+
390
+ if (isThread && sessionId) {
391
+ await ensureSessionPreferencesSnapshot({
392
+ sessionId,
393
+ channelId: targetChannelId,
394
+ appId: effectiveAppId,
395
+ getClient,
396
+ })
397
+ }
398
+
399
+ // Parallelize: fetch providers, current model info, and variant cascade at the same time.
400
+ // getCurrentModelInfo does DB lookups first (fast) and only hits provider.list as fallback.
401
+ const [providersResponse, currentModelInfo, cascadeVariant] =
402
+ await Promise.all([
403
+ getClient().provider.list({ directory: projectDirectory }),
404
+ getCurrentModelInfo({
405
+ sessionId,
406
+ channelId: targetChannelId,
407
+ appId: effectiveAppId,
408
+ getClient,
409
+ }),
410
+ getVariantCascade({
411
+ sessionId,
412
+ channelId: targetChannelId,
413
+ appId: effectiveAppId,
414
+ }),
415
+ ])
416
+
417
+ if (!providersResponse.data) {
418
+ await interaction.editReply({
419
+ content: 'Failed to fetch providers',
420
+ })
421
+ return
422
+ }
423
+
424
+ const { all: allProviders, connected } = providersResponse.data
425
+
426
+ // Filter to only connected providers (have credentials)
427
+ const availableProviders = allProviders.filter((p) => {
428
+ return connected.includes(p.id)
429
+ })
430
+
431
+ if (availableProviders.length === 0) {
432
+ await interaction.editReply({
433
+ content:
434
+ 'No providers with credentials found. Use `/login` to connect a provider and add credentials.',
435
+ })
436
+ return
437
+ }
438
+
439
+ const currentModelText = (() => {
440
+ switch (currentModelInfo.type) {
441
+ case 'session':
442
+ return `**Current (this thread):** \`${currentModelInfo.model}\``
443
+ case 'agent':
444
+ return `**Current (agent "${currentModelInfo.agentName}"):** \`${currentModelInfo.model}\``
445
+ case 'channel':
446
+ return `**Current (channel override):** \`${currentModelInfo.model}\``
447
+ case 'global':
448
+ return `**Current (global default):** \`${currentModelInfo.model}\``
449
+ case 'opencode-config':
450
+ case 'opencode-recent':
451
+ case 'opencode-provider-default':
452
+ return `**Current (opencode default):** \`${currentModelInfo.model}\``
453
+ case 'none':
454
+ return '**Current:** none'
455
+ }
456
+ })()
457
+
458
+ const variantText = (() => {
459
+ if (currentModelInfo.type === 'none' || !cascadeVariant) {
460
+ return ''
461
+ }
462
+ return `\n**Variant:** \`${cascadeVariant}\``
463
+ })()
464
+
465
+ // Store context with a short hash key to avoid customId length limits
466
+ // Use bot's appId if channel doesn't have one stored (older channels or channels migrated before appId tracking)
467
+ const context = {
468
+ dir: projectDirectory,
469
+ channelId: targetChannelId,
470
+ sessionId: sessionId,
471
+ isThread: isThread,
472
+ thread: isThread ? (channel as ThreadChannel) : undefined,
473
+ appId: channelAppId || appId,
474
+ }
475
+ const contextHash = crypto.randomBytes(8).toString('hex')
476
+ pendingModelContexts.set(contextHash, context)
477
+
478
+ const options = [...availableProviders]
479
+ .sort((a, b) => a.name.localeCompare(b.name))
480
+ .slice(0, 25)
481
+ .map((provider) => {
482
+ const modelCount = Object.keys(provider.models || {}).length
483
+ return {
484
+ label: provider.name.slice(0, 100),
485
+ value: provider.id,
486
+ description:
487
+ `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(
488
+ 0,
489
+ 100,
490
+ ),
491
+ }
492
+ })
493
+
494
+ const selectMenu = new StringSelectMenuBuilder()
495
+ .setCustomId(`model_provider:${contextHash}`)
496
+ .setPlaceholder('Select a provider')
497
+ .addOptions(options)
498
+
499
+ const actionRow =
500
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
501
+
502
+ await interaction.editReply({
503
+ content: `**Set Model Preference**\n${currentModelText}${variantText}\nSelect a provider:`,
504
+ components: [actionRow],
505
+ })
506
+ } catch (error) {
507
+ modelLogger.error('Error loading providers:', error)
508
+ await interaction.editReply({
509
+ content: `Failed to load providers: ${error instanceof Error ? error.message : 'Unknown error'}`,
510
+ })
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Handle the provider select menu interaction.
516
+ * Shows a second select menu with models for the chosen provider.
517
+ */
518
+ export async function handleProviderSelectMenu(
519
+ interaction: StringSelectMenuInteraction,
520
+ ): Promise<void> {
521
+ const customId = interaction.customId
522
+
523
+ if (!customId.startsWith('model_provider:')) {
524
+ return
525
+ }
526
+
527
+ // Defer update immediately to avoid timeout
528
+ await interaction.deferUpdate()
529
+
530
+ const contextHash = customId.replace('model_provider:', '')
531
+ const context = pendingModelContexts.get(contextHash)
532
+
533
+ if (!context) {
534
+ await interaction.editReply({
535
+ content: 'Selection expired. Please run /model again.',
536
+ components: [],
537
+ })
538
+ return
539
+ }
540
+
541
+ const selectedProviderId = interaction.values[0]
542
+ if (!selectedProviderId) {
543
+ await interaction.editReply({
544
+ content: 'No provider selected',
545
+ components: [],
546
+ })
547
+ return
548
+ }
549
+
550
+ try {
551
+ const getClient = await initializeOpencodeForDirectory(context.dir)
552
+ if (getClient instanceof Error) {
553
+ await interaction.editReply({
554
+ content: getClient.message,
555
+ components: [],
556
+ })
557
+ return
558
+ }
559
+
560
+ const providersResponse = await getClient().provider.list({
561
+ directory: context.dir,
562
+ })
563
+
564
+ if (!providersResponse.data) {
565
+ await interaction.editReply({
566
+ content: 'Failed to fetch providers',
567
+ components: [],
568
+ })
569
+ return
570
+ }
571
+
572
+ const provider = providersResponse.data.all.find(
573
+ (p) => p.id === selectedProviderId,
574
+ )
575
+
576
+ if (!provider) {
577
+ await interaction.editReply({
578
+ content: 'Provider not found',
579
+ components: [],
580
+ })
581
+ return
582
+ }
583
+
584
+ const models = Object.entries(provider.models || {})
585
+ .map(([modelId, model]) => ({
586
+ id: modelId,
587
+ name: model.name,
588
+ releaseDate: model.release_date,
589
+ }))
590
+ .sort((a, b) => a.name.localeCompare(b.name))
591
+
592
+ if (models.length === 0) {
593
+ await interaction.editReply({
594
+ content: `No models available for ${provider.name}`,
595
+ components: [],
596
+ })
597
+ return
598
+ }
599
+
600
+ // Take first 25 models (most recent since sorted descending)
601
+ const recentModels = models.slice(0, 25)
602
+
603
+ // Update context with provider info and reuse the same hash
604
+ context.providerId = selectedProviderId
605
+ context.providerName = provider.name
606
+ pendingModelContexts.set(contextHash, context)
607
+
608
+ const options = recentModels.map((model) => {
609
+ const dateStr = model.releaseDate
610
+ ? new Date(model.releaseDate).toLocaleDateString()
611
+ : 'Unknown date'
612
+ return {
613
+ label: model.name.slice(0, 100),
614
+ value: model.id,
615
+ description: dateStr.slice(0, 100),
616
+ }
617
+ })
618
+
619
+ const selectMenu = new StringSelectMenuBuilder()
620
+ .setCustomId(`model_select:${contextHash}`)
621
+ .setPlaceholder('Select a model')
622
+ .addOptions(options)
623
+
624
+ const actionRow =
625
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
626
+
627
+ await interaction.editReply({
628
+ content: `**Set Model Preference**\nProvider: **${provider.name}**\nSelect a model:`,
629
+ components: [actionRow],
630
+ })
631
+ } catch (error) {
632
+ modelLogger.error('Error loading models:', error)
633
+ await interaction.editReply({
634
+ content: `Failed to load models: ${error instanceof Error ? error.message : 'Unknown error'}`,
635
+ components: [],
636
+ })
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Handle the model select menu interaction.
642
+ * Stores the model preference in the database.
643
+ */
644
+ export async function handleModelSelectMenu(
645
+ interaction: StringSelectMenuInteraction,
646
+ ): Promise<void> {
647
+ const customId = interaction.customId
648
+
649
+ if (!customId.startsWith('model_select:')) {
650
+ return
651
+ }
652
+
653
+ // Defer update immediately
654
+ await interaction.deferUpdate()
655
+
656
+ const contextHash = customId.replace('model_select:', '')
657
+ const context = pendingModelContexts.get(contextHash)
658
+
659
+ if (!context || !context.providerId || !context.providerName) {
660
+ await interaction.editReply({
661
+ content: 'Selection expired. Please run /model again.',
662
+ components: [],
663
+ })
664
+ return
665
+ }
666
+
667
+ const selectedModelId = interaction.values[0]
668
+ if (!selectedModelId) {
669
+ await interaction.editReply({
670
+ content: 'No model selected',
671
+ components: [],
672
+ })
673
+ return
674
+ }
675
+
676
+ // Build full model ID: provider_id/model_id
677
+ const fullModelId = `${context.providerId}/${selectedModelId}`
678
+
679
+ try {
680
+ context.selectedModelId = fullModelId
681
+ pendingModelContexts.set(contextHash, context)
682
+
683
+ // Check if model has variants (thinking levels) - if so, show variant picker first
684
+ const getClient = await initializeOpencodeForDirectory(context.dir)
685
+ if (!(getClient instanceof Error)) {
686
+ const providersResponse = await getClient().provider.list({
687
+ directory: context.dir,
688
+ })
689
+ if (providersResponse.data) {
690
+ const variants = getThinkingValuesForModel({
691
+ providers: providersResponse.data.all,
692
+ providerId: context.providerId!,
693
+ modelId: selectedModelId,
694
+ })
695
+ if (variants.length > 0) {
696
+ context.availableVariants = variants
697
+ pendingModelContexts.set(contextHash, context)
698
+
699
+ const variantOptions = [
700
+ {
701
+ label: 'None (default)',
702
+ value: '__none__',
703
+ description: 'Use the model without a specific thinking level',
704
+ },
705
+ ...variants.slice(0, 24).map((v: string) => ({
706
+ label: v.slice(0, 100),
707
+ value: v,
708
+ description: `Use ${v} thinking`.slice(0, 100),
709
+ })),
710
+ ]
711
+
712
+ const selectMenu = new StringSelectMenuBuilder()
713
+ .setCustomId(`model_variant:${contextHash}`)
714
+ .setPlaceholder('Select a thinking level')
715
+ .addOptions(variantOptions)
716
+
717
+ const actionRow =
718
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
719
+ selectMenu,
720
+ )
721
+
722
+ await interaction.editReply({
723
+ content: `**Set Model Preference**\nModel: **${context.providerName}** / **${selectedModelId}**\n\`${fullModelId}\`\nSelect a thinking level:`,
724
+ components: [actionRow],
725
+ })
726
+ return
727
+ }
728
+ }
729
+ }
730
+
731
+ // No variants available - skip to scope
732
+ context.selectedVariant = null
733
+ pendingModelContexts.set(contextHash, context)
734
+ await showScopeMenu({ interaction, contextHash, context })
735
+ } catch (error) {
736
+ modelLogger.error('Error saving model preference:', error)
737
+ await interaction.editReply({
738
+ content: `Failed to save model preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
739
+ components: [],
740
+ })
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Handle the variant select menu interaction.
746
+ * Stores the selected variant and shows the scope menu.
747
+ */
748
+ export async function handleModelVariantSelectMenu(
749
+ interaction: StringSelectMenuInteraction,
750
+ ): Promise<void> {
751
+ const customId = interaction.customId
752
+ if (!customId.startsWith('model_variant:')) {
753
+ return
754
+ }
755
+
756
+ await interaction.deferUpdate()
757
+
758
+ const contextHash = customId.replace('model_variant:', '')
759
+ const context = pendingModelContexts.get(contextHash)
760
+
761
+ if (!context || !context.selectedModelId) {
762
+ await interaction.editReply({
763
+ content: 'Selection expired. Please run /model again.',
764
+ components: [],
765
+ })
766
+ return
767
+ }
768
+
769
+ const selectedValue = interaction.values[0]
770
+ if (!selectedValue) {
771
+ await interaction.editReply({
772
+ content: 'No variant selected',
773
+ components: [],
774
+ })
775
+ return
776
+ }
777
+
778
+ context.selectedVariant = selectedValue === '__none__' ? null : selectedValue
779
+ pendingModelContexts.set(contextHash, context)
780
+
781
+ await showScopeMenu({ interaction, contextHash, context })
782
+ }
783
+
784
+ async function showScopeMenu({
785
+ interaction,
786
+ contextHash,
787
+ context,
788
+ }: {
789
+ interaction: StringSelectMenuInteraction
790
+ contextHash: string
791
+ context: NonNullable<ReturnType<typeof pendingModelContexts.get>>
792
+ }): Promise<void> {
793
+ const modelId = context.selectedModelId!
794
+ const modelDisplay = modelId.split('/')[1] || modelId
795
+ const variantSuffix = context.selectedVariant
796
+ ? ` (${context.selectedVariant})`
797
+ : ''
798
+
799
+ const scopeOptions = [
800
+ ...(context.isThread && context.sessionId
801
+ ? [
802
+ {
803
+ label: 'This session only',
804
+ value: 'session',
805
+ description: 'Override for this session only',
806
+ },
807
+ ]
808
+ : []),
809
+ {
810
+ label: 'This channel only',
811
+ value: 'channel',
812
+ description: 'Override for this channel only',
813
+ },
814
+ {
815
+ label: 'Global default',
816
+ value: 'global',
817
+ description: 'Set for this channel and as default for all others',
818
+ },
819
+ ]
820
+
821
+ const selectMenu = new StringSelectMenuBuilder()
822
+ .setCustomId(`model_scope:${contextHash}`)
823
+ .setPlaceholder('Apply to...')
824
+ .addOptions(scopeOptions)
825
+
826
+ const actionRow =
827
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
828
+
829
+ await interaction.editReply({
830
+ content: `**Set Model Preference**\nModel: **${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nApply to:`,
831
+ components: [actionRow],
832
+ })
833
+ }
834
+
835
+ /**
836
+ * Handle the scope select menu interaction.
837
+ * Applies the model to either the channel or globally.
838
+ */
839
+ export async function handleModelScopeSelectMenu(
840
+ interaction: StringSelectMenuInteraction,
841
+ ): Promise<void> {
842
+ const customId = interaction.customId
843
+
844
+ if (!customId.startsWith('model_scope:')) {
845
+ return
846
+ }
847
+
848
+ // Defer update immediately
849
+ await interaction.deferUpdate()
850
+
851
+ const contextHash = customId.replace('model_scope:', '')
852
+ const context = pendingModelContexts.get(contextHash)
853
+
854
+ if (
855
+ !context ||
856
+ !context.providerId ||
857
+ !context.providerName ||
858
+ !context.selectedModelId
859
+ ) {
860
+ await interaction.editReply({
861
+ content: 'Selection expired. Please run /model again.',
862
+ components: [],
863
+ })
864
+ return
865
+ }
866
+
867
+ const selectedScope = interaction.values[0]
868
+ if (!selectedScope) {
869
+ await interaction.editReply({
870
+ content: 'No scope selected',
871
+ components: [],
872
+ })
873
+ return
874
+ }
875
+
876
+ const modelId = context.selectedModelId
877
+ const modelDisplay = modelId.split('/')[1] || modelId
878
+ const variant = context.selectedVariant ?? null
879
+ const variantSuffix = variant ? ` (${variant})` : ''
880
+ const agentTip =
881
+ '\n_Tip: create [agent .md files](https://github.com/remorses/kimaki/blob/main/docs/model-switching.md) in .opencode/agent/ for one-command model switching_'
882
+
883
+ try {
884
+ if (selectedScope === 'session') {
885
+ if (!context.sessionId) {
886
+ pendingModelContexts.delete(contextHash)
887
+ await interaction.editReply({
888
+ content:
889
+ 'No active session in this thread. Please run /model in a thread with a session.',
890
+ components: [],
891
+ })
892
+ return
893
+ }
894
+ await setSessionModel({ sessionId: context.sessionId, modelId, variant })
895
+ modelLogger.log(
896
+ `Set model ${modelId}${variantSuffix} for session ${context.sessionId}`,
897
+ )
898
+
899
+ let retried = false
900
+ if (context.thread) {
901
+ retried = await abortAndRetrySession({
902
+ sessionId: context.sessionId,
903
+ thread: context.thread,
904
+ projectDirectory: context.dir,
905
+ appId: context.appId,
906
+ channelId: context.channelId,
907
+ })
908
+ }
909
+
910
+ const retryNote = retried
911
+ ? '\n_Retrying current request with new model..._'
912
+ : ''
913
+ await interaction.editReply({
914
+ content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
915
+ flags: MessageFlags.SuppressEmbeds,
916
+ components: [],
917
+ })
918
+ } else if (selectedScope === 'global') {
919
+ if (!context.appId) {
920
+ pendingModelContexts.delete(contextHash)
921
+ await interaction.editReply({
922
+ content: 'Cannot set global model: channel is not linked to a bot',
923
+ components: [],
924
+ })
925
+ return
926
+ }
927
+ await setGlobalModel({ appId: context.appId, modelId, variant })
928
+ await setChannelModel({ channelId: context.channelId, modelId, variant })
929
+ modelLogger.log(
930
+ `Set global model ${modelId}${variantSuffix} for app ${context.appId} and channel ${context.channelId}`,
931
+ )
932
+
933
+ await interaction.editReply({
934
+ content: `Model set for this channel and as global default:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nAll channels will use this model (unless they have their own override).${agentTip}`,
935
+ flags: MessageFlags.SuppressEmbeds,
936
+ components: [],
937
+ })
938
+ } else {
939
+ // channel scope
940
+ await setChannelModel({ channelId: context.channelId, modelId, variant })
941
+ modelLogger.log(
942
+ `Set model ${modelId}${variantSuffix} for channel ${context.channelId}`,
943
+ )
944
+
945
+ await interaction.editReply({
946
+ content: `Model preference set for this channel:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nAll new sessions in this channel will use this model.${agentTip}`,
947
+ flags: MessageFlags.SuppressEmbeds,
948
+ components: [],
949
+ })
950
+ }
951
+
952
+ // Clean up the context from memory
953
+ pendingModelContexts.delete(contextHash)
954
+ } catch (error) {
955
+ modelLogger.error('Error saving model preference:', error)
956
+ await interaction.editReply({
957
+ content: `Failed to save model preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
958
+ components: [],
959
+ })
960
+ }
961
+ }