@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,846 @@
1
+ // Discord-specific utility functions.
2
+ // Handles markdown splitting for Discord's 2000-char limit, code block escaping,
3
+ // thread message sending, and channel metadata extraction from topic tags.
4
+
5
+ // Use namespace import for CJS interop — discord.js is CJS and its named
6
+ // exports aren't detectable by all ESM loaders (e.g. tsx/esbuild) because
7
+ // discord.js uses tslib's __exportStar which is opaque to static analysis.
8
+ import * as discord from 'discord.js'
9
+ import type {
10
+ APIInteractionGuildMember,
11
+ AutocompleteInteraction,
12
+ GuildMember as GuildMemberType,
13
+ Guild,
14
+ Message,
15
+ REST as RESTType,
16
+ TextChannel,
17
+ ThreadChannel,
18
+ } from 'discord.js'
19
+ const { ChannelType, GuildMember, MessageFlags, PermissionsBitField, REST, Routes } = discord
20
+ import type { OpencodeClient } from '@opencode-ai/sdk/v2'
21
+ import { discordApiUrl } from './discord-urls.js'
22
+ import { Lexer } from 'marked'
23
+ import { splitTablesFromMarkdown } from './format-tables.js'
24
+ import { getChannelDirectory, getThreadWorktree } from './database.js'
25
+ import { limitHeadingDepth } from './limit-heading-depth.js'
26
+ import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
27
+ import { createLogger, LogPrefix } from './logger.js'
28
+ import * as errore from 'errore'
29
+ import mime from 'mime'
30
+ import fs from 'node:fs'
31
+ import path from 'node:path'
32
+
33
+ const discordLogger = createLogger(LogPrefix.DISCORD)
34
+
35
+ /**
36
+ * Centralized permission check for Otto bot access.
37
+ * Returns true if the member has permission to use the bot:
38
+ * - Server owner, Administrator, Manage Server, or "Otto" role (case-insensitive).
39
+ * Also accepts the legacy "Kimaki" role for backward compat on existing servers.
40
+ * Returns false if member is null or has the "no-otto" / "no-otto" role (overrides all).
41
+ */
42
+ export function hasOttoBotPermission(
43
+ member: GuildMemberType | APIInteractionGuildMember | null,
44
+ guild?: Guild | null,
45
+ ): boolean {
46
+ if (!member) {
47
+ return false
48
+ }
49
+ // Accept both "no-otto" and legacy "no-otto" as deny roles
50
+ const isDenied =
51
+ hasRoleByName(member, 'no-otto', guild) ||
52
+ hasRoleByName(member, 'no-otto', guild)
53
+ if (isDenied) {
54
+ return false
55
+ }
56
+ const memberPermissions =
57
+ member instanceof GuildMember
58
+ ? member.permissions
59
+ : new PermissionsBitField(BigInt(member.permissions))
60
+ const ownerId = member instanceof GuildMember ? member.guild.ownerId : guild?.ownerId
61
+ const memberId = member instanceof GuildMember ? member.id : member.user.id
62
+ const isOwner = ownerId ? memberId === ownerId : false
63
+ const isAdmin = memberPermissions.has(PermissionsBitField.Flags.Administrator)
64
+ const canManageServer = memberPermissions.has(PermissionsBitField.Flags.ManageGuild)
65
+ // Accept both "otto" and legacy "otto" as allow roles
66
+ const hasOttoRole =
67
+ hasRoleByName(member, 'otto', guild) || hasRoleByName(member, 'otto', guild)
68
+ return isOwner || isAdmin || canManageServer || hasOttoRole
69
+ }
70
+
71
+ // Keep legacy name as alias for callers not yet updated
72
+ export const hasKimakiBotPermission = hasOttoBotPermission
73
+
74
+ function hasRoleByName(
75
+ member: GuildMemberType | APIInteractionGuildMember,
76
+ roleName: string,
77
+ guild?: Guild | null,
78
+ ): boolean {
79
+ const target = roleName.toLowerCase()
80
+
81
+ if (member instanceof GuildMember) {
82
+ return member.roles.cache.some((role) => role.name.toLowerCase() === target)
83
+ }
84
+
85
+ if (!guild) {
86
+ return false
87
+ }
88
+
89
+ const roleIds = Array.isArray(member.roles) ? member.roles : []
90
+ for (const roleId of roleIds) {
91
+ const role = guild.roles.cache.get(roleId)
92
+ if (role?.name.toLowerCase() === target) {
93
+ return true
94
+ }
95
+ }
96
+ return false
97
+ }
98
+
99
+ /**
100
+ * Check if the member has the "no-otto" (or legacy "no-otto") role that blocks bot access.
101
+ * Separate from hasOttoBotPermission so callers can show a specific error message.
102
+ */
103
+ export function hasNoOttoRole(member: GuildMemberType | null): boolean {
104
+ if (!member?.roles?.cache) {
105
+ return false
106
+ }
107
+ return member.roles.cache.some(
108
+ (role) =>
109
+ role.name.toLowerCase() === 'no-otto' ||
110
+ role.name.toLowerCase() === 'no-otto',
111
+ )
112
+ }
113
+
114
+ // Keep legacy name as alias
115
+ export const hasNoKimakiRole = hasNoOttoRole
116
+
117
+ /**
118
+ * React to a thread's starter message with an emoji.
119
+ * Thread ID equals the starter message ID in Discord.
120
+ */
121
+ export async function reactToThread({
122
+ rest,
123
+ threadId,
124
+ channelId,
125
+ emoji,
126
+ }: {
127
+ rest: RESTType
128
+ threadId: string
129
+ /** Parent channel ID where the thread starter message lives.
130
+ * If not provided, fetches the thread info from Discord API to resolve it. */
131
+ channelId?: string
132
+ emoji: string
133
+ }): Promise<void> {
134
+ const parentChannelId = await (async () => {
135
+ if (channelId) {
136
+ return channelId
137
+ }
138
+ // Fetch the thread to get its parent channel ID
139
+ const threadResult = await errore.tryAsync(() => {
140
+ return rest.get(Routes.channel(threadId)) as Promise<{
141
+ parent_id?: string
142
+ }>
143
+ })
144
+ if (threadResult instanceof Error) {
145
+ discordLogger.warn(
146
+ `Failed to fetch thread ${threadId}:`,
147
+ threadResult.message,
148
+ )
149
+ return null
150
+ }
151
+ return threadResult.parent_id || null
152
+ })()
153
+
154
+ if (!parentChannelId) {
155
+ discordLogger.warn(
156
+ `Could not resolve parent channel for thread ${threadId}`,
157
+ )
158
+ return
159
+ }
160
+
161
+ // React to the thread starter message in the parent channel.
162
+ // Thread ID equals the starter message ID for threads created from messages.
163
+ const result = await errore.tryAsync(() => {
164
+ return rest.put(
165
+ Routes.channelMessageOwnReaction(
166
+ parentChannelId,
167
+ threadId,
168
+ encodeURIComponent(emoji),
169
+ ),
170
+ )
171
+ })
172
+ if (result instanceof Error) {
173
+ discordLogger.warn(
174
+ `Failed to react to thread ${threadId} with ${emoji}:`,
175
+ result.message,
176
+ )
177
+ }
178
+ }
179
+
180
+ export async function archiveThread({
181
+ rest,
182
+ threadId,
183
+ parentChannelId,
184
+ sessionId,
185
+ client,
186
+ archiveDelay = 0,
187
+ }: {
188
+ rest: RESTType
189
+ threadId: string
190
+ parentChannelId?: string
191
+ sessionId?: string
192
+ client?: OpencodeClient | null
193
+ archiveDelay?: number
194
+ }): Promise<void> {
195
+ await reactToThread({
196
+ rest,
197
+ threadId,
198
+ channelId: parentChannelId,
199
+ emoji: '📁',
200
+ })
201
+
202
+ if (client && sessionId) {
203
+ await softArchiveSession({ client, sessionId })
204
+ }
205
+
206
+ if (archiveDelay > 0) {
207
+ await new Promise<void>((resolve) => {
208
+ setTimeout(() => {
209
+ resolve()
210
+ }, archiveDelay)
211
+ })
212
+ }
213
+
214
+ await rest.patch(Routes.channel(threadId), {
215
+ body: { archived: true },
216
+ })
217
+ }
218
+
219
+ export async function softArchiveSession({
220
+ client,
221
+ sessionId,
222
+ }: {
223
+ client: OpencodeClient
224
+ sessionId: string
225
+ }): Promise<void> {
226
+ const updateResult = await errore.tryAsync({
227
+ try: async () => {
228
+ const sessionResponse = await client.session.get({
229
+ sessionID: sessionId,
230
+ })
231
+ if (!sessionResponse.data) {
232
+ return
233
+ }
234
+ const currentTitle = sessionResponse.data.title || ''
235
+ const newTitle = currentTitle.startsWith('📁')
236
+ ? currentTitle
237
+ : `📁 ${currentTitle}`.trim()
238
+ await client.session.update({
239
+ sessionID: sessionId,
240
+ title: newTitle,
241
+ })
242
+ },
243
+ catch: (e) => new Error('Failed to update session title', { cause: e }),
244
+ })
245
+ if (updateResult instanceof Error) {
246
+ discordLogger.warn(`[archive-thread] ${updateResult.message}`)
247
+ }
248
+
249
+ const abortResult = await errore.tryAsync({
250
+ try: async () => {
251
+ await client.session.abort({ sessionID: sessionId })
252
+ },
253
+ catch: (e) => new Error('Failed to abort session', { cause: e }),
254
+ })
255
+ if (abortResult instanceof Error) {
256
+ discordLogger.warn(`[archive-thread] ${abortResult.message}`)
257
+ }
258
+ }
259
+
260
+ export async function applyThreadDeletionSyncPolicy({
261
+ client,
262
+ sessionId,
263
+ mode,
264
+ }: {
265
+ client: OpencodeClient
266
+ sessionId: string
267
+ mode: 'soft' | 'hard'
268
+ }): Promise<'soft' | 'hard'> {
269
+ if (mode === 'hard') {
270
+ await client.session.delete({ sessionID: sessionId })
271
+ return 'hard'
272
+ }
273
+ await softArchiveSession({ client, sessionId })
274
+ return 'soft'
275
+ }
276
+
277
+ /** Remove Discord mentions from text so they don't appear in thread titles */
278
+ export function stripMentions(text: string): string {
279
+ return text
280
+ .replace(/<@!?\d+>/g, '') // user mentions
281
+ .replace(/<@&\d+>/g, '') // role mentions
282
+ .replace(/<#\d+>/g, '') // channel mentions
283
+ .replace(/\s+/g, ' ')
284
+ .trim()
285
+ }
286
+
287
+ export const SILENT_MESSAGE_FLAGS = 4 | 4096
288
+ // Same as SILENT but without SuppressNotifications - triggers badge/notification
289
+ export const NOTIFY_MESSAGE_FLAGS = 4
290
+
291
+ export function escapeBackticksInCodeBlocks(markdown: string): string {
292
+ const lexer = new Lexer()
293
+ const tokens = lexer.lex(markdown)
294
+
295
+ let result = ''
296
+
297
+ for (const token of tokens) {
298
+ if (token.type === 'code') {
299
+ const escapedCode = token.text.replace(/`/g, '\\`')
300
+ result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n'
301
+ } else {
302
+ result += token.raw
303
+ }
304
+ }
305
+
306
+ return result
307
+ }
308
+
309
+ type LineInfo = {
310
+ text: string
311
+ inCodeBlock: boolean
312
+ lang: string
313
+ isOpeningFence: boolean
314
+ isClosingFence: boolean
315
+ }
316
+
317
+ export function splitMarkdownForDiscord({
318
+ content,
319
+ maxLength,
320
+ }: {
321
+ content: string
322
+ maxLength: number
323
+ }): string[] {
324
+ if (content.length <= maxLength) {
325
+ return [content]
326
+ }
327
+
328
+ const lexer = new Lexer()
329
+ const tokens = lexer.lex(content)
330
+
331
+ const lines: LineInfo[] = []
332
+ const ensureNewlineBeforeCode = (): void => {
333
+ const last = lines[lines.length - 1]
334
+ if (!last) {
335
+ return
336
+ }
337
+ if (last.text.endsWith('\n')) {
338
+ return
339
+ }
340
+ lines.push({
341
+ text: '\n',
342
+ inCodeBlock: false,
343
+ lang: '',
344
+ isOpeningFence: false,
345
+ isClosingFence: false,
346
+ })
347
+ }
348
+ for (const token of tokens) {
349
+ if (token.type === 'code') {
350
+ ensureNewlineBeforeCode()
351
+ const lang = token.lang || ''
352
+ lines.push({
353
+ text: '```' + lang + '\n',
354
+ inCodeBlock: false,
355
+ lang,
356
+ isOpeningFence: true,
357
+ isClosingFence: false,
358
+ })
359
+ const codeLines = token.text.split('\n')
360
+ for (const codeLine of codeLines) {
361
+ lines.push({
362
+ text: codeLine + '\n',
363
+ inCodeBlock: true,
364
+ lang,
365
+ isOpeningFence: false,
366
+ isClosingFence: false,
367
+ })
368
+ }
369
+ lines.push({
370
+ text: '```\n',
371
+ inCodeBlock: false,
372
+ lang: '',
373
+ isOpeningFence: false,
374
+ isClosingFence: true,
375
+ })
376
+ } else {
377
+ const rawLines = token.raw.split('\n')
378
+ for (let i = 0; i < rawLines.length; i++) {
379
+ const isLast = i === rawLines.length - 1
380
+ const text = isLast ? rawLines[i]! : rawLines[i]! + '\n'
381
+ if (text) {
382
+ lines.push({
383
+ text,
384
+ inCodeBlock: false,
385
+ lang: '',
386
+ isOpeningFence: false,
387
+ isClosingFence: false,
388
+ })
389
+ }
390
+ }
391
+ }
392
+ }
393
+
394
+ const chunks: string[] = []
395
+ let currentChunk = ''
396
+ let currentLang: string | null = null
397
+
398
+ // helper to split a long line into smaller pieces at word boundaries or hard breaks
399
+ const splitLongLine = (
400
+ text: string,
401
+ available: number,
402
+ inCode: boolean,
403
+ ): string[] => {
404
+ const pieces: string[] = []
405
+ let remaining = text
406
+
407
+ while (remaining.length > available) {
408
+ let splitAt = available
409
+ // for non-code, try to split at word boundary
410
+ if (!inCode) {
411
+ const lastSpace = remaining.lastIndexOf(' ', available)
412
+ if (lastSpace > available * 0.5) {
413
+ splitAt = lastSpace + 1
414
+ }
415
+ }
416
+ pieces.push(remaining.slice(0, splitAt))
417
+ remaining = remaining.slice(splitAt)
418
+ }
419
+ if (remaining) {
420
+ pieces.push(remaining)
421
+ }
422
+ return pieces
423
+ }
424
+
425
+ const closingFence = '```\n'
426
+
427
+ for (const line of lines) {
428
+ // openingFenceSize accounts for the fence text when starting a fresh chunk
429
+ const openingFenceSize =
430
+ currentChunk.length === 0 && (line.inCodeBlock || line.isOpeningFence)
431
+ ? ('```' + line.lang + '\n').length
432
+ : 0
433
+ // When opening fence starts a fresh chunk, its size is in openingFenceSize.
434
+ // Otherwise count it normally so the overflow check doesn't miss the fence text.
435
+ const lineLength =
436
+ line.isOpeningFence && currentChunk.length === 0 ? 0 : line.text.length
437
+ const activeFenceOverhead =
438
+ currentLang !== null || openingFenceSize > 0 ? closingFence.length : 0
439
+ const wouldExceed =
440
+ currentChunk.length +
441
+ openingFenceSize +
442
+ lineLength +
443
+ activeFenceOverhead >
444
+ maxLength
445
+
446
+ if (wouldExceed) {
447
+ // handle case where single line is longer than maxLength
448
+ if (line.text.length > maxLength) {
449
+ // first, flush current chunk if any
450
+ if (currentChunk) {
451
+ if (currentLang !== null) {
452
+ currentChunk += '```\n'
453
+ }
454
+ chunks.push(currentChunk)
455
+ currentChunk = ''
456
+ }
457
+
458
+ // calculate overhead for code block markers
459
+ const codeBlockOverhead = line.inCodeBlock
460
+ ? ('```' + line.lang + '\n').length + '```\n'.length
461
+ : 0
462
+ // ensure at least 10 chars available, even if maxLength is very small
463
+ const availablePerChunk = Math.max(
464
+ 10,
465
+ maxLength - codeBlockOverhead - 50,
466
+ )
467
+
468
+ const pieces = splitLongLine(
469
+ line.text,
470
+ availablePerChunk,
471
+ line.inCodeBlock,
472
+ )
473
+
474
+ for (let i = 0; i < pieces.length; i++) {
475
+ const piece = pieces[i]!
476
+ if (line.inCodeBlock) {
477
+ chunks.push('```' + line.lang + '\n' + piece + '```\n')
478
+ } else {
479
+ chunks.push(piece)
480
+ }
481
+ }
482
+
483
+ currentLang = null
484
+ continue
485
+ }
486
+
487
+ // normal case: line fits in a chunk but current chunk would overflow
488
+ if (currentChunk) {
489
+ if (currentLang !== null) {
490
+ currentChunk += '```\n'
491
+ }
492
+ chunks.push(currentChunk)
493
+
494
+ if (line.isClosingFence && currentLang !== null) {
495
+ currentChunk = ''
496
+ currentLang = null
497
+ continue
498
+ }
499
+
500
+ if (line.inCodeBlock || line.isOpeningFence) {
501
+ const lang = line.lang
502
+ currentChunk = '```' + lang + '\n'
503
+ if (!line.isOpeningFence) {
504
+ currentChunk += line.text
505
+ }
506
+ currentLang = lang
507
+ } else {
508
+ currentChunk = line.text
509
+ currentLang = null
510
+ }
511
+ } else {
512
+ // currentChunk is empty but line still exceeds - shouldn't happen after above check
513
+ const openingFence = line.inCodeBlock || line.isOpeningFence
514
+ const openingFenceSize = openingFence
515
+ ? ('```' + line.lang + '\n').length
516
+ : 0
517
+ if (
518
+ line.text.length + openingFenceSize + activeFenceOverhead >
519
+ maxLength
520
+ ) {
521
+ const fencedOverhead = openingFence
522
+ ? ('```' + line.lang + '\n').length + closingFence.length
523
+ : 0
524
+ const availablePerChunk = Math.max(
525
+ 10,
526
+ maxLength - fencedOverhead - 50,
527
+ )
528
+ const pieces = splitLongLine(
529
+ line.text,
530
+ availablePerChunk,
531
+ line.inCodeBlock,
532
+ )
533
+ for (const piece of pieces) {
534
+ if (openingFence) {
535
+ chunks.push('```' + line.lang + '\n' + piece + closingFence)
536
+ } else {
537
+ chunks.push(piece)
538
+ }
539
+ }
540
+ currentChunk = ''
541
+ currentLang = null
542
+ } else {
543
+ if (openingFence) {
544
+ currentChunk = '```' + line.lang + '\n'
545
+ if (!line.isOpeningFence) {
546
+ currentChunk += line.text
547
+ }
548
+ currentLang = line.lang
549
+ } else {
550
+ currentChunk = line.text
551
+ currentLang = null
552
+ }
553
+ }
554
+ }
555
+ } else {
556
+ currentChunk += line.text
557
+ if (line.inCodeBlock || line.isOpeningFence) {
558
+ currentLang = line.lang
559
+ } else if (line.isClosingFence) {
560
+ currentLang = null
561
+ }
562
+ }
563
+ }
564
+
565
+ if (currentChunk) {
566
+ if (currentLang !== null) {
567
+ currentChunk += closingFence
568
+ }
569
+ chunks.push(currentChunk)
570
+ }
571
+
572
+ return chunks
573
+ }
574
+
575
+ export async function sendThreadMessage(
576
+ thread: ThreadChannel,
577
+ content: string,
578
+ options?: { flags?: number },
579
+ ): Promise<Message> {
580
+ const MAX_LENGTH = 2000
581
+
582
+ // Split content into text and CV2 component segments (tables → Container components)
583
+ const segments = splitTablesFromMarkdown(content)
584
+ const baseFlags = options?.flags ?? SILENT_MESSAGE_FLAGS
585
+
586
+ let firstMessage: Message | undefined
587
+
588
+ for (const segment of segments) {
589
+ if (segment.type === 'components') {
590
+ const message = await thread.send({
591
+ components: segment.components,
592
+ flags: MessageFlags.IsComponentsV2 | baseFlags,
593
+ })
594
+ if (!firstMessage) {
595
+ firstMessage = message
596
+ }
597
+ continue
598
+ }
599
+
600
+ // Apply text transformations to text segments
601
+ let text = segment.text
602
+ text = unnestCodeBlocksFromLists(text)
603
+ text = limitHeadingDepth(text)
604
+ text = escapeBackticksInCodeBlocks(text)
605
+
606
+ if (!text.trim()) {
607
+ continue
608
+ }
609
+
610
+ const sendFlags = options?.flags ?? SILENT_MESSAGE_FLAGS
611
+ const chunks = splitMarkdownForDiscord({
612
+ content: text,
613
+ maxLength: MAX_LENGTH,
614
+ })
615
+
616
+ if (chunks.length > 1) {
617
+ discordLogger.log(
618
+ `MESSAGE: Splitting ${text.length} chars into ${chunks.length} messages`,
619
+ )
620
+ }
621
+
622
+ for (let chunk of chunks) {
623
+ if (!chunk) {
624
+ continue
625
+ }
626
+ // Safety net: hard-truncate if splitting still produced an oversized chunk
627
+ if (chunk.length > MAX_LENGTH) {
628
+ chunk = chunk.slice(0, MAX_LENGTH - 4) + '...'
629
+ }
630
+ const message = await thread.send({ content: chunk, flags: sendFlags })
631
+ if (!firstMessage) {
632
+ firstMessage = message
633
+ }
634
+ }
635
+ }
636
+
637
+ return firstMessage!
638
+ }
639
+
640
+ export async function resolveTextChannel(
641
+ channel: TextChannel | ThreadChannel | null | undefined,
642
+ ): Promise<TextChannel | null> {
643
+ if (!channel) {
644
+ return null
645
+ }
646
+
647
+ if (channel.type === ChannelType.GuildText) {
648
+ return channel as TextChannel
649
+ }
650
+
651
+ if (
652
+ channel.type === ChannelType.PublicThread ||
653
+ channel.type === ChannelType.PrivateThread ||
654
+ channel.type === ChannelType.AnnouncementThread
655
+ ) {
656
+ const parentId = channel.parentId
657
+ if (parentId) {
658
+ const parent = await channel.guild.channels.fetch(parentId)
659
+ if (parent?.type === ChannelType.GuildText) {
660
+ return parent as TextChannel
661
+ }
662
+ }
663
+ }
664
+
665
+ return null
666
+ }
667
+
668
+ export function escapeDiscordFormatting(text: string): string {
669
+ return text.replace(/```/g, '\\`\\`\\`').replace(/````/g, '\\`\\`\\`\\`')
670
+ }
671
+
672
+ export async function getOttoMetadata(
673
+ textChannel: TextChannel | null,
674
+ ): Promise<{
675
+ projectDirectory?: string
676
+ }> {
677
+ if (!textChannel) {
678
+ return {}
679
+ }
680
+
681
+ const channelConfig = await getChannelDirectory(textChannel.id)
682
+
683
+ if (!channelConfig) {
684
+ return {}
685
+ }
686
+
687
+ return {
688
+ projectDirectory: channelConfig.directory,
689
+ }
690
+ }
691
+
692
+ // Legacy alias kept for backward compat
693
+ export const getKimakiMetadata = getOttoMetadata
694
+
695
+ /**
696
+ * Resolve project directory from an autocomplete interaction.
697
+ * Uses interaction.channelId (always available from raw payload) instead of
698
+ * interaction.channel (cache-based getter, often null with gateway-proxy).
699
+ * Checks the channel ID directly in DB, then tries thread worktree lookup,
700
+ * then falls back to fetching the channel to resolve thread parent.
701
+ */
702
+ export async function resolveProjectDirectoryFromAutocomplete(
703
+ interaction: Pick<AutocompleteInteraction, 'channelId' | 'channel' | 'client'>,
704
+ ): Promise<string | undefined> {
705
+ const channelId = interaction.channelId
706
+
707
+ // Direct channel lookup — works when the command is run from a project text channel
708
+ const channelConfig = await getChannelDirectory(channelId)
709
+ if (channelConfig) {
710
+ return channelConfig.directory
711
+ }
712
+
713
+ // If we're in a thread, try worktree info first (has project_directory)
714
+ const worktreeInfo = await getThreadWorktree(channelId)
715
+ if (worktreeInfo?.project_directory) {
716
+ return worktreeInfo.project_directory
717
+ }
718
+
719
+ // Thread fallback: resolve parent channel ID and look up its directory.
720
+ // Try cached channel first, then fetch if cache misses (gateway-proxy scenario).
721
+ const cachedParentId = interaction.channel?.isThread() ? interaction.channel.parentId : null
722
+ if (cachedParentId) {
723
+ const parentConfig = await getChannelDirectory(cachedParentId)
724
+ if (parentConfig) {
725
+ return parentConfig.directory
726
+ }
727
+ }
728
+
729
+ // Last resort: fetch the channel from Discord API to get parentId for threads
730
+ // when the channel isn't cached at all (common with gateway-proxy).
731
+ if (!cachedParentId) {
732
+ const fetched = await errore.tryAsync({
733
+ try: () => { return interaction.client.channels.fetch(channelId) },
734
+ catch: (e) => { return e as Error },
735
+ })
736
+ if (!(fetched instanceof Error) && fetched?.isThread() && fetched.parentId) {
737
+ const parentConfig = await getChannelDirectory(fetched.parentId)
738
+ if (parentConfig) {
739
+ return parentConfig.directory
740
+ }
741
+ }
742
+ }
743
+
744
+ return undefined
745
+ }
746
+
747
+ /**
748
+ * Resolve the working directory for a channel or thread.
749
+ * Returns both the base project directory (for server init) and the working directory
750
+ * (worktree directory if in a worktree thread, otherwise same as projectDirectory).
751
+ * This prevents commands from accidentally running in the base project dir when a
752
+ * worktree is active — the bug that caused /diff, /compact, etc. to use wrong cwd.
753
+ */
754
+ export async function resolveWorkingDirectory({
755
+ channel,
756
+ }: {
757
+ channel: TextChannel | ThreadChannel
758
+ }): Promise<
759
+ | {
760
+ projectDirectory: string
761
+ workingDirectory: string
762
+ }
763
+ | undefined
764
+ > {
765
+ const isThread = [
766
+ ChannelType.PublicThread,
767
+ ChannelType.PrivateThread,
768
+ ChannelType.AnnouncementThread,
769
+ ].includes(channel.type)
770
+
771
+ const textChannel = isThread
772
+ ? await resolveTextChannel(channel as ThreadChannel)
773
+ : (channel as TextChannel)
774
+
775
+ const metadata = await getOttoMetadata(textChannel)
776
+ if (!metadata.projectDirectory) {
777
+ return undefined
778
+ }
779
+
780
+ let workingDirectory = metadata.projectDirectory
781
+ if (isThread) {
782
+ const worktreeInfo = await getThreadWorktree(channel.id)
783
+ if (worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory) {
784
+ workingDirectory = worktreeInfo.worktree_directory
785
+ }
786
+ }
787
+
788
+ return {
789
+ projectDirectory: metadata.projectDirectory,
790
+ workingDirectory,
791
+ }
792
+ }
793
+
794
+ /**
795
+ * Upload files to a Discord thread/channel in a single message.
796
+ * Sending all files in one message causes Discord to display images in a grid layout.
797
+ */
798
+ export async function uploadFilesToDiscord({
799
+ threadId,
800
+ botToken,
801
+ files,
802
+ }: {
803
+ threadId: string
804
+ botToken: string
805
+ files: string[]
806
+ }): Promise<void> {
807
+ if (files.length === 0) {
808
+ return
809
+ }
810
+
811
+ // Build attachments array for all files
812
+ const attachments = files.map((file, index) => ({
813
+ id: index,
814
+ filename: path.basename(file),
815
+ }))
816
+
817
+ const formData = new FormData()
818
+ formData.append('payload_json', JSON.stringify({ attachments }))
819
+
820
+ // Append each file with its array index, with correct MIME type for grid display
821
+ files.forEach((file, index) => {
822
+ const buffer = fs.readFileSync(file)
823
+ const mimeType = mime.getType(file) || 'application/octet-stream'
824
+ formData.append(
825
+ `files[${index}]`,
826
+ new Blob([buffer], { type: mimeType }),
827
+ path.basename(file),
828
+ )
829
+ })
830
+
831
+ const response = await fetch(
832
+ discordApiUrl(`/channels/${threadId}/messages`),
833
+ {
834
+ method: 'POST',
835
+ headers: {
836
+ Authorization: `Bot ${botToken}`,
837
+ },
838
+ body: formData,
839
+ },
840
+ )
841
+
842
+ if (!response.ok) {
843
+ const error = await response.text()
844
+ throw new Error(`Discord API error: ${response.status} - ${error}`)
845
+ }
846
+ }