@otto-assistant/otto 0.1.2 → 0.7.16

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