@otto-assistant/otto 0.1.2 → 0.7.15

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