@otto-assistant/bridge 0.4.92

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 (483) 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-auth-plugin.js +728 -0
  7. package/dist/anthropic-auth-plugin.test.js +125 -0
  8. package/dist/anthropic-auth-state.js +231 -0
  9. package/dist/bin.js +90 -0
  10. package/dist/channel-management.js +227 -0
  11. package/dist/cli-parsing.test.js +137 -0
  12. package/dist/cli-send-thread.e2e.test.js +356 -0
  13. package/dist/cli.js +3276 -0
  14. package/dist/commands/abort.js +65 -0
  15. package/dist/commands/action-buttons.js +245 -0
  16. package/dist/commands/add-project.js +113 -0
  17. package/dist/commands/agent.js +335 -0
  18. package/dist/commands/ask-question.js +274 -0
  19. package/dist/commands/btw.js +116 -0
  20. package/dist/commands/compact.js +120 -0
  21. package/dist/commands/context-usage.js +140 -0
  22. package/dist/commands/create-new-project.js +130 -0
  23. package/dist/commands/diff.js +63 -0
  24. package/dist/commands/file-upload.js +275 -0
  25. package/dist/commands/fork.js +220 -0
  26. package/dist/commands/gemini-apikey.js +70 -0
  27. package/dist/commands/login.js +885 -0
  28. package/dist/commands/mcp.js +239 -0
  29. package/dist/commands/memory-snapshot.js +24 -0
  30. package/dist/commands/mention-mode.js +44 -0
  31. package/dist/commands/merge-worktree.js +159 -0
  32. package/dist/commands/model-variant.js +364 -0
  33. package/dist/commands/model.js +776 -0
  34. package/dist/commands/new-worktree.js +366 -0
  35. package/dist/commands/paginated-select.js +57 -0
  36. package/dist/commands/permissions.js +274 -0
  37. package/dist/commands/queue.js +206 -0
  38. package/dist/commands/remove-project.js +115 -0
  39. package/dist/commands/restart-opencode-server.js +127 -0
  40. package/dist/commands/resume.js +149 -0
  41. package/dist/commands/run-command.js +79 -0
  42. package/dist/commands/screenshare.js +303 -0
  43. package/dist/commands/screenshare.test.js +20 -0
  44. package/dist/commands/session-id.js +78 -0
  45. package/dist/commands/session.js +176 -0
  46. package/dist/commands/share.js +80 -0
  47. package/dist/commands/tasks.js +205 -0
  48. package/dist/commands/types.js +2 -0
  49. package/dist/commands/undo-redo.js +305 -0
  50. package/dist/commands/unset-model.js +138 -0
  51. package/dist/commands/upgrade.js +42 -0
  52. package/dist/commands/user-command.js +155 -0
  53. package/dist/commands/verbosity.js +125 -0
  54. package/dist/commands/worktree-settings.js +43 -0
  55. package/dist/commands/worktrees.js +410 -0
  56. package/dist/condense-memory.js +33 -0
  57. package/dist/config.js +94 -0
  58. package/dist/context-awareness-plugin.js +363 -0
  59. package/dist/context-awareness-plugin.test.js +124 -0
  60. package/dist/critique-utils.js +95 -0
  61. package/dist/database.js +1310 -0
  62. package/dist/db.js +251 -0
  63. package/dist/db.test.js +138 -0
  64. package/dist/debounce-timeout.js +28 -0
  65. package/dist/debounced-process-flush.js +77 -0
  66. package/dist/discord-bot.js +1008 -0
  67. package/dist/discord-command-registration.js +524 -0
  68. package/dist/discord-urls.js +81 -0
  69. package/dist/discord-utils.js +591 -0
  70. package/dist/discord-utils.test.js +134 -0
  71. package/dist/errors.js +157 -0
  72. package/dist/escape-backticks.test.js +429 -0
  73. package/dist/event-stream-real-capture.e2e.test.js +533 -0
  74. package/dist/eventsource-parser.test.js +327 -0
  75. package/dist/exec-async.js +26 -0
  76. package/dist/external-opencode-sync.js +480 -0
  77. package/dist/format-tables.js +302 -0
  78. package/dist/format-tables.test.js +308 -0
  79. package/dist/forum-sync/config.js +79 -0
  80. package/dist/forum-sync/discord-operations.js +154 -0
  81. package/dist/forum-sync/index.js +5 -0
  82. package/dist/forum-sync/markdown.js +113 -0
  83. package/dist/forum-sync/sync-to-discord.js +417 -0
  84. package/dist/forum-sync/sync-to-files.js +190 -0
  85. package/dist/forum-sync/types.js +53 -0
  86. package/dist/forum-sync/watchers.js +307 -0
  87. package/dist/gateway-proxy-reconnect.e2e.test.js +394 -0
  88. package/dist/gateway-proxy.e2e.test.js +483 -0
  89. package/dist/genai-worker-wrapper.js +111 -0
  90. package/dist/genai-worker.js +311 -0
  91. package/dist/genai.js +232 -0
  92. package/dist/generated/browser.js +17 -0
  93. package/dist/generated/client.js +37 -0
  94. package/dist/generated/commonInputTypes.js +10 -0
  95. package/dist/generated/enums.js +52 -0
  96. package/dist/generated/internal/class.js +49 -0
  97. package/dist/generated/internal/prismaNamespace.js +253 -0
  98. package/dist/generated/internal/prismaNamespaceBrowser.js +223 -0
  99. package/dist/generated/models/bot_api_keys.js +1 -0
  100. package/dist/generated/models/bot_tokens.js +1 -0
  101. package/dist/generated/models/channel_agents.js +1 -0
  102. package/dist/generated/models/channel_directories.js +1 -0
  103. package/dist/generated/models/channel_mention_mode.js +1 -0
  104. package/dist/generated/models/channel_models.js +1 -0
  105. package/dist/generated/models/channel_verbosity.js +1 -0
  106. package/dist/generated/models/channel_worktrees.js +1 -0
  107. package/dist/generated/models/forum_sync_configs.js +1 -0
  108. package/dist/generated/models/global_models.js +1 -0
  109. package/dist/generated/models/ipc_requests.js +1 -0
  110. package/dist/generated/models/part_messages.js +1 -0
  111. package/dist/generated/models/scheduled_tasks.js +1 -0
  112. package/dist/generated/models/session_agents.js +1 -0
  113. package/dist/generated/models/session_events.js +1 -0
  114. package/dist/generated/models/session_models.js +1 -0
  115. package/dist/generated/models/session_start_sources.js +1 -0
  116. package/dist/generated/models/thread_sessions.js +1 -0
  117. package/dist/generated/models/thread_worktrees.js +1 -0
  118. package/dist/generated/models.js +1 -0
  119. package/dist/heap-monitor.js +122 -0
  120. package/dist/hrana-server.js +263 -0
  121. package/dist/hrana-server.test.js +370 -0
  122. package/dist/html-actions.js +123 -0
  123. package/dist/html-actions.test.js +70 -0
  124. package/dist/html-components.js +117 -0
  125. package/dist/html-components.test.js +34 -0
  126. package/dist/image-optimizer-plugin.js +153 -0
  127. package/dist/image-utils.js +112 -0
  128. package/dist/interaction-handler.js +397 -0
  129. package/dist/ipc-polling.js +252 -0
  130. package/dist/ipc-tools-plugin.js +193 -0
  131. package/dist/kimaki-digital-twin.e2e.test.js +161 -0
  132. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +87 -0
  133. package/dist/kimaki-opencode-plugin.js +17 -0
  134. package/dist/kimaki-opencode-plugin.test.js +98 -0
  135. package/dist/limit-heading-depth.js +25 -0
  136. package/dist/limit-heading-depth.test.js +105 -0
  137. package/dist/logger.js +165 -0
  138. package/dist/markdown.js +342 -0
  139. package/dist/markdown.test.js +257 -0
  140. package/dist/message-finish-field.e2e.test.js +165 -0
  141. package/dist/message-formatting.js +413 -0
  142. package/dist/message-formatting.test.js +73 -0
  143. package/dist/message-preprocessing.js +330 -0
  144. package/dist/onboarding-tutorial.js +172 -0
  145. package/dist/onboarding-welcome.js +37 -0
  146. package/dist/openai-realtime.js +224 -0
  147. package/dist/opencode-command-detection.js +65 -0
  148. package/dist/opencode-command-detection.test.js +240 -0
  149. package/dist/opencode-command.js +129 -0
  150. package/dist/opencode-command.test.js +48 -0
  151. package/dist/opencode-interrupt-plugin.js +361 -0
  152. package/dist/opencode-interrupt-plugin.test.js +458 -0
  153. package/dist/opencode.js +861 -0
  154. package/dist/otto/branding.js +22 -0
  155. package/dist/otto/index.js +21 -0
  156. package/dist/parse-permission-rules.test.js +117 -0
  157. package/dist/patch-text-parser.js +97 -0
  158. package/dist/plugin-logger.js +59 -0
  159. package/dist/privacy-sanitizer.js +105 -0
  160. package/dist/queue-advanced-abort.e2e.test.js +293 -0
  161. package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
  162. package/dist/queue-advanced-e2e-setup.js +786 -0
  163. package/dist/queue-advanced-footer.e2e.test.js +472 -0
  164. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  165. package/dist/queue-advanced-permissions-typing.e2e.test.js +180 -0
  166. package/dist/queue-advanced-question.e2e.test.js +261 -0
  167. package/dist/queue-advanced-typing-interrupt.e2e.test.js +114 -0
  168. package/dist/queue-advanced-typing.e2e.test.js +153 -0
  169. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  170. package/dist/queue-interrupt-drain.e2e.test.js +135 -0
  171. package/dist/queue-question-select-drain.e2e.test.js +120 -0
  172. package/dist/runtime-idle-sweeper.js +52 -0
  173. package/dist/runtime-lifecycle.e2e.test.js +508 -0
  174. package/dist/sentry.js +23 -0
  175. package/dist/session-handler/agent-utils.js +67 -0
  176. package/dist/session-handler/event-stream-state.js +420 -0
  177. package/dist/session-handler/event-stream-state.test.js +563 -0
  178. package/dist/session-handler/model-utils.js +124 -0
  179. package/dist/session-handler/opencode-session-event-log.js +94 -0
  180. package/dist/session-handler/thread-runtime-state.js +104 -0
  181. package/dist/session-handler/thread-session-runtime.js +3258 -0
  182. package/dist/session-handler.js +9 -0
  183. package/dist/session-search.js +100 -0
  184. package/dist/session-search.test.js +40 -0
  185. package/dist/session-title-rename.test.js +80 -0
  186. package/dist/startup-service.js +153 -0
  187. package/dist/startup-time.e2e.test.js +296 -0
  188. package/dist/store.js +17 -0
  189. package/dist/system-message.js +613 -0
  190. package/dist/system-message.test.js +602 -0
  191. package/dist/task-runner.js +295 -0
  192. package/dist/task-schedule.js +209 -0
  193. package/dist/task-schedule.test.js +71 -0
  194. package/dist/test-utils.js +299 -0
  195. package/dist/thinking-utils.js +35 -0
  196. package/dist/thread-message-queue.e2e.test.js +999 -0
  197. package/dist/tools.js +357 -0
  198. package/dist/undo-redo.e2e.test.js +161 -0
  199. package/dist/unnest-code-blocks.js +146 -0
  200. package/dist/unnest-code-blocks.test.js +673 -0
  201. package/dist/upgrade.js +114 -0
  202. package/dist/utils.js +144 -0
  203. package/dist/voice-attachment.js +34 -0
  204. package/dist/voice-handler.js +646 -0
  205. package/dist/voice-message.e2e.test.js +1021 -0
  206. package/dist/voice.js +447 -0
  207. package/dist/voice.test.js +235 -0
  208. package/dist/wait-session.js +94 -0
  209. package/dist/websockify.js +69 -0
  210. package/dist/worker-types.js +4 -0
  211. package/dist/worktree-lifecycle.e2e.test.js +308 -0
  212. package/dist/worktree-utils.js +3 -0
  213. package/dist/worktrees.js +929 -0
  214. package/dist/worktrees.test.js +189 -0
  215. package/dist/xml.js +92 -0
  216. package/dist/xml.test.js +32 -0
  217. package/package.json +98 -0
  218. package/schema.prisma +295 -0
  219. package/skills/batch/SKILL.md +87 -0
  220. package/skills/critique/SKILL.md +112 -0
  221. package/skills/egaki/SKILL.md +100 -0
  222. package/skills/errore/SKILL.md +647 -0
  223. package/skills/event-sourcing-state/SKILL.md +252 -0
  224. package/skills/gitchamber/SKILL.md +93 -0
  225. package/skills/goke/SKILL.md +644 -0
  226. package/skills/jitter/EDITOR.md +219 -0
  227. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  228. package/skills/jitter/SKILL.md +158 -0
  229. package/skills/jitter/jitter-clipboard.json +1042 -0
  230. package/skills/jitter/package.json +14 -0
  231. package/skills/jitter/tsconfig.json +15 -0
  232. package/skills/jitter/utils/actions.ts +212 -0
  233. package/skills/jitter/utils/export.ts +114 -0
  234. package/skills/jitter/utils/index.ts +141 -0
  235. package/skills/jitter/utils/snapshot.ts +154 -0
  236. package/skills/jitter/utils/traverse.ts +246 -0
  237. package/skills/jitter/utils/types.ts +279 -0
  238. package/skills/jitter/utils/wait.ts +133 -0
  239. package/skills/lintcn/SKILL.md +873 -0
  240. package/skills/new-skill/SKILL.md +211 -0
  241. package/skills/npm-package/SKILL.md +239 -0
  242. package/skills/playwriter/SKILL.md +35 -0
  243. package/skills/proxyman/SKILL.md +215 -0
  244. package/skills/security-review/SKILL.md +208 -0
  245. package/skills/simplify/SKILL.md +58 -0
  246. package/skills/spiceflow/SKILL.md +14 -0
  247. package/skills/termcast/SKILL.md +945 -0
  248. package/skills/tuistory/SKILL.md +250 -0
  249. package/skills/usecomputer/SKILL.md +264 -0
  250. package/skills/x-articles/SKILL.md +554 -0
  251. package/skills/zele/SKILL.md +112 -0
  252. package/skills/zustand-centralized-state/SKILL.md +1004 -0
  253. package/src/agent-model.e2e.test.ts +976 -0
  254. package/src/ai-tool-to-genai.test.ts +296 -0
  255. package/src/ai-tool-to-genai.ts +283 -0
  256. package/src/ai-tool.ts +39 -0
  257. package/src/anthropic-auth-plugin.test.ts +159 -0
  258. package/src/anthropic-auth-plugin.ts +861 -0
  259. package/src/anthropic-auth-state.ts +282 -0
  260. package/src/bin.ts +111 -0
  261. package/src/channel-management.ts +334 -0
  262. package/src/cli-parsing.test.ts +195 -0
  263. package/src/cli-send-thread.e2e.test.ts +464 -0
  264. package/src/cli.ts +4581 -0
  265. package/src/commands/abort.ts +89 -0
  266. package/src/commands/action-buttons.ts +364 -0
  267. package/src/commands/add-project.ts +149 -0
  268. package/src/commands/agent.ts +473 -0
  269. package/src/commands/ask-question.ts +390 -0
  270. package/src/commands/btw.ts +164 -0
  271. package/src/commands/compact.ts +157 -0
  272. package/src/commands/context-usage.ts +199 -0
  273. package/src/commands/create-new-project.ts +190 -0
  274. package/src/commands/diff.ts +91 -0
  275. package/src/commands/file-upload.ts +389 -0
  276. package/src/commands/fork.ts +321 -0
  277. package/src/commands/gemini-apikey.ts +104 -0
  278. package/src/commands/login.ts +1173 -0
  279. package/src/commands/mcp.ts +307 -0
  280. package/src/commands/memory-snapshot.ts +30 -0
  281. package/src/commands/mention-mode.ts +68 -0
  282. package/src/commands/merge-worktree.ts +223 -0
  283. package/src/commands/model-variant.ts +483 -0
  284. package/src/commands/model.ts +1053 -0
  285. package/src/commands/new-worktree.ts +510 -0
  286. package/src/commands/paginated-select.ts +81 -0
  287. package/src/commands/permissions.ts +397 -0
  288. package/src/commands/queue.ts +271 -0
  289. package/src/commands/remove-project.ts +155 -0
  290. package/src/commands/restart-opencode-server.ts +162 -0
  291. package/src/commands/resume.ts +230 -0
  292. package/src/commands/run-command.ts +123 -0
  293. package/src/commands/screenshare.test.ts +30 -0
  294. package/src/commands/screenshare.ts +366 -0
  295. package/src/commands/session-id.ts +109 -0
  296. package/src/commands/session.ts +227 -0
  297. package/src/commands/share.ts +106 -0
  298. package/src/commands/tasks.ts +293 -0
  299. package/src/commands/types.ts +25 -0
  300. package/src/commands/undo-redo.ts +386 -0
  301. package/src/commands/unset-model.ts +173 -0
  302. package/src/commands/upgrade.ts +52 -0
  303. package/src/commands/user-command.ts +198 -0
  304. package/src/commands/verbosity.ts +173 -0
  305. package/src/commands/worktree-settings.ts +70 -0
  306. package/src/commands/worktrees.ts +552 -0
  307. package/src/condense-memory.ts +36 -0
  308. package/src/config.ts +111 -0
  309. package/src/context-awareness-plugin.test.ts +142 -0
  310. package/src/context-awareness-plugin.ts +510 -0
  311. package/src/critique-utils.ts +139 -0
  312. package/src/database.ts +1876 -0
  313. package/src/db.test.ts +162 -0
  314. package/src/db.ts +286 -0
  315. package/src/debounce-timeout.ts +43 -0
  316. package/src/debounced-process-flush.ts +104 -0
  317. package/src/discord-bot.ts +1330 -0
  318. package/src/discord-command-registration.ts +693 -0
  319. package/src/discord-urls.ts +88 -0
  320. package/src/discord-utils.test.ts +153 -0
  321. package/src/discord-utils.ts +800 -0
  322. package/src/errors.ts +201 -0
  323. package/src/escape-backticks.test.ts +469 -0
  324. package/src/event-stream-real-capture.e2e.test.ts +692 -0
  325. package/src/eventsource-parser.test.ts +351 -0
  326. package/src/exec-async.ts +35 -0
  327. package/src/external-opencode-sync.ts +685 -0
  328. package/src/format-tables.test.ts +335 -0
  329. package/src/format-tables.ts +445 -0
  330. package/src/forum-sync/config.ts +92 -0
  331. package/src/forum-sync/discord-operations.ts +241 -0
  332. package/src/forum-sync/index.ts +9 -0
  333. package/src/forum-sync/markdown.ts +172 -0
  334. package/src/forum-sync/sync-to-discord.ts +595 -0
  335. package/src/forum-sync/sync-to-files.ts +294 -0
  336. package/src/forum-sync/types.ts +175 -0
  337. package/src/forum-sync/watchers.ts +454 -0
  338. package/src/gateway-proxy-reconnect.e2e.test.ts +523 -0
  339. package/src/gateway-proxy.e2e.test.ts +640 -0
  340. package/src/genai-worker-wrapper.ts +164 -0
  341. package/src/genai-worker.ts +386 -0
  342. package/src/genai.ts +321 -0
  343. package/src/generated/browser.ts +114 -0
  344. package/src/generated/client.ts +138 -0
  345. package/src/generated/commonInputTypes.ts +736 -0
  346. package/src/generated/enums.ts +88 -0
  347. package/src/generated/internal/class.ts +384 -0
  348. package/src/generated/internal/prismaNamespace.ts +2386 -0
  349. package/src/generated/internal/prismaNamespaceBrowser.ts +326 -0
  350. package/src/generated/models/bot_api_keys.ts +1288 -0
  351. package/src/generated/models/bot_tokens.ts +1656 -0
  352. package/src/generated/models/channel_agents.ts +1256 -0
  353. package/src/generated/models/channel_directories.ts +1859 -0
  354. package/src/generated/models/channel_mention_mode.ts +1300 -0
  355. package/src/generated/models/channel_models.ts +1288 -0
  356. package/src/generated/models/channel_verbosity.ts +1228 -0
  357. package/src/generated/models/channel_worktrees.ts +1300 -0
  358. package/src/generated/models/forum_sync_configs.ts +1452 -0
  359. package/src/generated/models/global_models.ts +1288 -0
  360. package/src/generated/models/ipc_requests.ts +1485 -0
  361. package/src/generated/models/part_messages.ts +1302 -0
  362. package/src/generated/models/scheduled_tasks.ts +2320 -0
  363. package/src/generated/models/session_agents.ts +1086 -0
  364. package/src/generated/models/session_events.ts +1439 -0
  365. package/src/generated/models/session_models.ts +1114 -0
  366. package/src/generated/models/session_start_sources.ts +1408 -0
  367. package/src/generated/models/thread_sessions.ts +1781 -0
  368. package/src/generated/models/thread_worktrees.ts +1356 -0
  369. package/src/generated/models.ts +30 -0
  370. package/src/heap-monitor.ts +152 -0
  371. package/src/hrana-server.test.ts +434 -0
  372. package/src/hrana-server.ts +314 -0
  373. package/src/html-actions.test.ts +87 -0
  374. package/src/html-actions.ts +174 -0
  375. package/src/html-components.test.ts +38 -0
  376. package/src/html-components.ts +181 -0
  377. package/src/image-optimizer-plugin.ts +194 -0
  378. package/src/image-utils.ts +149 -0
  379. package/src/interaction-handler.ts +576 -0
  380. package/src/ipc-polling.ts +326 -0
  381. package/src/ipc-tools-plugin.ts +236 -0
  382. package/src/kimaki-digital-twin.e2e.test.ts +199 -0
  383. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +109 -0
  384. package/src/kimaki-opencode-plugin.test.ts +108 -0
  385. package/src/kimaki-opencode-plugin.ts +18 -0
  386. package/src/limit-heading-depth.test.ts +116 -0
  387. package/src/limit-heading-depth.ts +26 -0
  388. package/src/logger.ts +208 -0
  389. package/src/markdown.test.ts +308 -0
  390. package/src/markdown.ts +410 -0
  391. package/src/message-finish-field.e2e.test.ts +192 -0
  392. package/src/message-formatting.test.ts +81 -0
  393. package/src/message-formatting.ts +533 -0
  394. package/src/message-preprocessing.ts +455 -0
  395. package/src/onboarding-tutorial.ts +176 -0
  396. package/src/onboarding-welcome.ts +49 -0
  397. package/src/openai-realtime.ts +358 -0
  398. package/src/opencode-command-detection.test.ts +307 -0
  399. package/src/opencode-command-detection.ts +76 -0
  400. package/src/opencode-command.test.ts +70 -0
  401. package/src/opencode-command.ts +188 -0
  402. package/src/opencode-interrupt-plugin.test.ts +677 -0
  403. package/src/opencode-interrupt-plugin.ts +477 -0
  404. package/src/opencode.ts +1110 -0
  405. package/src/otto/branding.ts +23 -0
  406. package/src/otto/index.ts +22 -0
  407. package/src/parse-permission-rules.test.ts +127 -0
  408. package/src/patch-text-parser.ts +107 -0
  409. package/src/plugin-logger.ts +68 -0
  410. package/src/privacy-sanitizer.ts +142 -0
  411. package/src/queue-advanced-abort.e2e.test.ts +382 -0
  412. package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
  413. package/src/queue-advanced-e2e-setup.ts +873 -0
  414. package/src/queue-advanced-footer.e2e.test.ts +576 -0
  415. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  416. package/src/queue-advanced-permissions-typing.e2e.test.ts +245 -0
  417. package/src/queue-advanced-question.e2e.test.ts +316 -0
  418. package/src/queue-advanced-typing-interrupt.e2e.test.ts +146 -0
  419. package/src/queue-advanced-typing.e2e.test.ts +199 -0
  420. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  421. package/src/queue-interrupt-drain.e2e.test.ts +166 -0
  422. package/src/queue-question-select-drain.e2e.test.ts +152 -0
  423. package/src/runtime-idle-sweeper.ts +76 -0
  424. package/src/runtime-lifecycle.e2e.test.ts +641 -0
  425. package/src/schema.sql +173 -0
  426. package/src/sentry.ts +26 -0
  427. package/src/session-handler/agent-utils.ts +97 -0
  428. package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
  429. package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
  430. package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
  431. package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
  432. package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
  433. package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
  434. package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
  435. package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
  436. package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
  437. package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
  438. package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
  439. package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
  440. package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
  441. package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
  442. package/src/session-handler/event-stream-state.test.ts +645 -0
  443. package/src/session-handler/event-stream-state.ts +608 -0
  444. package/src/session-handler/model-utils.ts +183 -0
  445. package/src/session-handler/opencode-session-event-log.ts +130 -0
  446. package/src/session-handler/thread-runtime-state.ts +212 -0
  447. package/src/session-handler/thread-session-runtime.ts +4281 -0
  448. package/src/session-handler.ts +15 -0
  449. package/src/session-search.test.ts +50 -0
  450. package/src/session-search.ts +148 -0
  451. package/src/session-title-rename.test.ts +112 -0
  452. package/src/startup-service.ts +200 -0
  453. package/src/startup-time.e2e.test.ts +373 -0
  454. package/src/store.ts +122 -0
  455. package/src/system-message.test.ts +612 -0
  456. package/src/system-message.ts +723 -0
  457. package/src/task-runner.ts +421 -0
  458. package/src/task-schedule.test.ts +84 -0
  459. package/src/task-schedule.ts +311 -0
  460. package/src/test-utils.ts +435 -0
  461. package/src/thinking-utils.ts +61 -0
  462. package/src/thread-message-queue.e2e.test.ts +1219 -0
  463. package/src/tools.ts +430 -0
  464. package/src/undici.d.ts +12 -0
  465. package/src/undo-redo.e2e.test.ts +209 -0
  466. package/src/unnest-code-blocks.test.ts +713 -0
  467. package/src/unnest-code-blocks.ts +185 -0
  468. package/src/upgrade.ts +127 -0
  469. package/src/utils.ts +212 -0
  470. package/src/voice-attachment.ts +51 -0
  471. package/src/voice-handler.ts +908 -0
  472. package/src/voice-message.e2e.test.ts +1255 -0
  473. package/src/voice.test.ts +281 -0
  474. package/src/voice.ts +627 -0
  475. package/src/wait-session.ts +147 -0
  476. package/src/websockify.ts +101 -0
  477. package/src/worker-types.ts +64 -0
  478. package/src/worktree-lifecycle.e2e.test.ts +391 -0
  479. package/src/worktree-utils.ts +4 -0
  480. package/src/worktrees.test.ts +223 -0
  481. package/src/worktrees.ts +1294 -0
  482. package/src/xml.test.ts +38 -0
  483. package/src/xml.ts +121 -0
@@ -0,0 +1,908 @@
1
+ // Discord voice channel connection and audio stream handler.
2
+ // Manages joining/leaving voice channels, captures user audio, resamples to 16kHz,
3
+ // and routes audio to the GenAI worker for real-time voice assistant interactions.
4
+ import * as errore from 'errore'
5
+
6
+ import {
7
+ VoiceConnectionStatus,
8
+ EndBehaviorType,
9
+ joinVoiceChannel,
10
+ entersState,
11
+ type VoiceConnection,
12
+ } from '@discordjs/voice'
13
+ import fs, { createWriteStream } from 'node:fs'
14
+ import { mkdir } from 'node:fs/promises'
15
+ import path from 'node:path'
16
+ import { Transform, type TransformCallback } from 'node:stream'
17
+ import * as prism from 'prism-media'
18
+ import dedent from 'string-dedent'
19
+ import {
20
+ Events,
21
+ ActionRowBuilder,
22
+ ButtonBuilder,
23
+ ButtonStyle,
24
+ type Client,
25
+ type Message,
26
+ type ThreadChannel,
27
+ type VoiceChannel,
28
+ type VoiceState,
29
+ } from 'discord.js'
30
+ import { createGenAIWorker, type GenAIWorker } from './genai-worker-wrapper.js'
31
+ import {
32
+ getVoiceChannelDirectory,
33
+ getGeminiApiKey,
34
+ getTranscriptionApiKey,
35
+ findTextChannelByVoiceChannel,
36
+ } from './database.js'
37
+ import {
38
+ sendThreadMessage,
39
+ escapeDiscordFormatting,
40
+ SILENT_MESSAGE_FLAGS,
41
+ NOTIFY_MESSAGE_FLAGS,
42
+ hasKimakiBotPermission,
43
+ } from './discord-utils.js'
44
+ import { transcribeAudio, type TranscriptionResult } from './voice.js'
45
+ import { FetchError } from './errors.js'
46
+ import { store } from './store.js'
47
+ import {
48
+ getVoiceAttachmentMatchReason,
49
+ isVoiceAttachment,
50
+ } from './voice-attachment.js'
51
+ import { execAsync } from './worktrees.js'
52
+
53
+ import { createLogger, LogPrefix } from './logger.js'
54
+ import { notifyError } from './sentry.js'
55
+
56
+ const voiceLogger = createLogger(LogPrefix.VOICE)
57
+
58
+ export type VoiceConnectionData = {
59
+ connection: VoiceConnection
60
+ genAiWorker?: GenAIWorker
61
+ userAudioStream?: fs.WriteStream
62
+ }
63
+
64
+ export const voiceConnections = new Map<string, VoiceConnectionData>()
65
+
66
+
67
+
68
+ export function convertToMono16k(buffer: Buffer): Buffer {
69
+ const inputSampleRate = 48000
70
+ const outputSampleRate = 16000
71
+ const ratio = inputSampleRate / outputSampleRate
72
+ const inputChannels = 2
73
+ const bytesPerSample = 2
74
+
75
+ const inputSamples = buffer.length / (bytesPerSample * inputChannels)
76
+ const outputSamples = Math.floor(inputSamples / ratio)
77
+ const outputBuffer = Buffer.alloc(outputSamples * bytesPerSample)
78
+
79
+ for (let i = 0; i < outputSamples; i++) {
80
+ const inputIndex = Math.floor(i * ratio) * inputChannels * bytesPerSample
81
+
82
+ if (inputIndex + 3 < buffer.length) {
83
+ const leftSample = buffer.readInt16LE(inputIndex)
84
+ const rightSample = buffer.readInt16LE(inputIndex + 2)
85
+ const monoSample = Math.round((leftSample + rightSample) / 2)
86
+
87
+ outputBuffer.writeInt16LE(monoSample, i * bytesPerSample)
88
+ }
89
+ }
90
+
91
+ return outputBuffer
92
+ }
93
+
94
+ export async function createUserAudioLogStream(
95
+ guildId: string,
96
+ channelId: string,
97
+ ): Promise<fs.WriteStream | undefined> {
98
+ if (!process.env.DEBUG) return undefined
99
+
100
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
101
+ const audioDir = path.join(
102
+ process.cwd(),
103
+ 'discord-audio-logs',
104
+ guildId,
105
+ channelId,
106
+ )
107
+
108
+ try {
109
+ await mkdir(audioDir, { recursive: true })
110
+
111
+ const inputFileName = `user_${timestamp}.16.pcm`
112
+ const inputFilePath = path.join(audioDir, inputFileName)
113
+ const inputAudioStream = createWriteStream(inputFilePath)
114
+ voiceLogger.log(`Created user audio log: ${inputFilePath}`)
115
+
116
+ return inputAudioStream
117
+ } catch (error) {
118
+ voiceLogger.error('Failed to create audio log directory:', error)
119
+ return undefined
120
+ }
121
+ }
122
+
123
+ export function frameMono16khz(): Transform {
124
+ const FRAME_BYTES = (100 * 16_000 * 1 * 2) / 1000
125
+ let stash: Buffer = Buffer.alloc(0)
126
+ let offset = 0
127
+
128
+ return new Transform({
129
+ readableObjectMode: false,
130
+ writableObjectMode: false,
131
+
132
+ transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback) {
133
+ if (offset > 0) {
134
+ stash = stash.subarray(offset)
135
+ offset = 0
136
+ }
137
+
138
+ stash = stash.length ? Buffer.concat([stash, chunk]) : chunk
139
+
140
+ while (stash.length - offset >= FRAME_BYTES) {
141
+ this.push(stash.subarray(offset, offset + FRAME_BYTES))
142
+ offset += FRAME_BYTES
143
+ }
144
+
145
+ if (offset === stash.length) {
146
+ stash = Buffer.alloc(0)
147
+ offset = 0
148
+ }
149
+
150
+ cb()
151
+ },
152
+
153
+ flush(cb: TransformCallback) {
154
+ stash = Buffer.alloc(0)
155
+ offset = 0
156
+ cb()
157
+ },
158
+ })
159
+ }
160
+
161
+ export async function setupVoiceHandling({
162
+ connection,
163
+ guildId,
164
+ channelId,
165
+ appId,
166
+ discordClient,
167
+ }: {
168
+ connection: VoiceConnection
169
+ guildId: string
170
+ channelId: string
171
+ appId: string
172
+ discordClient: Client
173
+ }) {
174
+ voiceLogger.log(
175
+ `Setting up voice handling for guild ${guildId}, channel ${channelId}`,
176
+ )
177
+
178
+ const directory = await getVoiceChannelDirectory(channelId)
179
+
180
+ if (!directory) {
181
+ voiceLogger.log(
182
+ `Voice channel ${channelId} has no associated directory, skipping setup`,
183
+ )
184
+ return
185
+ }
186
+
187
+ voiceLogger.log(`Found directory for voice channel: ${directory}`)
188
+
189
+ const voiceData = voiceConnections.get(guildId)
190
+ if (!voiceData) {
191
+ voiceLogger.error(`No voice data found for guild ${guildId}`)
192
+ return
193
+ }
194
+
195
+ voiceData.userAudioStream = await createUserAudioLogStream(guildId, channelId)
196
+
197
+ const geminiApiKey = await getGeminiApiKey(appId)
198
+
199
+ const genAiWorker = await createGenAIWorker({
200
+ directory,
201
+ guildId,
202
+ channelId,
203
+ appId,
204
+ geminiApiKey,
205
+ systemMessage: dedent`
206
+ You are Kimaki, an AI similar to Jarvis: you help your user (an engineer) controlling his coding agent, just like Jarvis controls Ironman armor and machines. Speak fast.
207
+
208
+ You should talk like Jarvis, British accent, satirical, joking and calm. Be short and concise. Speak fast.
209
+
210
+ After tool calls give a super short summary of the assistant message, you should say what the assistant message writes.
211
+
212
+ Before starting a new session ask for confirmation if it is not clear if the user finished describing it. ask "message ready, send?"
213
+
214
+ NEVER repeat the whole tool call parameters or message.
215
+
216
+ Your job is to manage many opencode agent chat instances. Opencode is the agent used to write the code, it is similar to Claude Code.
217
+
218
+ For everything the user asks it is implicit that the user is asking for you to proxy the requests to opencode sessions.
219
+
220
+ You can
221
+ - start new chats on a given project
222
+ - read the chats to report progress to the user
223
+ - submit messages to the chat
224
+ - list files for a given projects, so you can translate imprecise user prompts to precise messages that mention filename paths using @
225
+
226
+ Common patterns
227
+ - to get the last session use the listChats tool
228
+ - when user asks you to do something you submit a new session to do it. it's implicit that you proxy requests to the agents chat!
229
+ - when you submit a session assume the session will take a minute or 2 to complete the task
230
+
231
+ Rules
232
+ - never spell files by mentioning dots, letters, etc. instead give a brief description of the filename
233
+ - NEVER spell hashes or IDs
234
+ - never read session ids or other ids
235
+
236
+ Your voice is calm and monotone, NEVER excited and goofy. But you speak without jargon or bs and do veiled short jokes.
237
+ You speak like you knew something other don't. You are cool and cold.
238
+ `,
239
+ onAssistantOpusPacket(packet) {
240
+ if (connection.state.status !== VoiceConnectionStatus.Ready) {
241
+ voiceLogger.log('Skipping packet: connection not ready')
242
+ return
243
+ }
244
+
245
+ try {
246
+ connection.setSpeaking(true)
247
+ connection.playOpusPacket(Buffer.from(packet))
248
+ } catch (error) {
249
+ voiceLogger.error('Error sending packet:', error)
250
+ }
251
+ },
252
+ onAssistantStartSpeaking() {
253
+ voiceLogger.log('Assistant started speaking')
254
+ connection.setSpeaking(true)
255
+ },
256
+ onAssistantStopSpeaking() {
257
+ voiceLogger.log('Assistant stopped speaking (natural finish)')
258
+ connection.setSpeaking(false)
259
+ },
260
+ onAssistantInterruptSpeaking() {
261
+ voiceLogger.log('Assistant interrupted while speaking')
262
+ genAiWorker.interrupt()
263
+ connection.setSpeaking(false)
264
+ },
265
+ onToolCallCompleted(params) {
266
+ const errorText: string | undefined = (() => {
267
+ if (!params.error) {
268
+ return undefined
269
+ }
270
+ if (params.error instanceof Error) {
271
+ return params.error.message
272
+ }
273
+ return String(params.error)
274
+ })()
275
+
276
+ const text = params.error
277
+ ? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${errorText || 'Unknown error'}\n</systemMessage>`
278
+ : `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`
279
+
280
+ genAiWorker.sendTextInput(text)
281
+ },
282
+ async onError(error) {
283
+ voiceLogger.error('GenAI worker error:', error)
284
+ const textChannelId = await findTextChannelByVoiceChannel(channelId)
285
+
286
+ if (textChannelId) {
287
+ try {
288
+ const textChannel = await discordClient.channels.fetch(textChannelId)
289
+ if (textChannel?.isTextBased() && 'send' in textChannel) {
290
+ await textChannel.send({
291
+ content: `⚠️ Voice session error: ${String(error).slice(0, 1900)}`,
292
+ flags: NOTIFY_MESSAGE_FLAGS,
293
+ })
294
+ }
295
+ } catch (e) {
296
+ voiceLogger.error('Failed to send error to text channel:', e)
297
+ }
298
+ }
299
+ },
300
+ })
301
+
302
+ if (voiceData.genAiWorker) {
303
+ voiceLogger.log('Stopping existing GenAI worker before creating new one')
304
+ await voiceData.genAiWorker.stop()
305
+ }
306
+
307
+ genAiWorker.sendTextInput(
308
+ `<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`,
309
+ )
310
+
311
+ voiceData.genAiWorker = genAiWorker
312
+
313
+ const receiver = connection.receiver
314
+
315
+ receiver.speaking.removeAllListeners('start')
316
+
317
+ let speakingSessionCount = 0
318
+
319
+ receiver.speaking.on('start', (userId) => {
320
+ voiceLogger.log(`User ${userId} started speaking`)
321
+
322
+ speakingSessionCount++
323
+ const currentSessionCount = speakingSessionCount
324
+ voiceLogger.log(`Speaking session ${currentSessionCount} started`)
325
+
326
+ const audioStream = receiver.subscribe(userId, {
327
+ end: { behavior: EndBehaviorType.AfterSilence, duration: 500 },
328
+ })
329
+
330
+ const decoder = new prism.opus.Decoder({
331
+ rate: 48000,
332
+ channels: 2,
333
+ frameSize: 960,
334
+ })
335
+
336
+ decoder.on('error', (error) => {
337
+ voiceLogger.error(`Opus decoder error for user ${userId}:`, error)
338
+ void notifyError(error, `Opus decoder error for user ${userId}`)
339
+ })
340
+
341
+ const downsampleTransform = new Transform({
342
+ transform(chunk: Buffer, _encoding, callback) {
343
+ try {
344
+ const downsampled = convertToMono16k(chunk)
345
+ callback(null, downsampled)
346
+ } catch (error) {
347
+ callback(error as Error)
348
+ }
349
+ },
350
+ })
351
+
352
+ const framer = frameMono16khz()
353
+
354
+ const pipeline = audioStream
355
+ .pipe(decoder)
356
+ .pipe(downsampleTransform)
357
+ .pipe(framer)
358
+
359
+ pipeline
360
+ .on('data', (frame: Buffer) => {
361
+ if (currentSessionCount !== speakingSessionCount) {
362
+ return
363
+ }
364
+
365
+ if (!voiceData.genAiWorker) {
366
+ voiceLogger.warn(
367
+ `[VOICE] Received audio frame but no GenAI worker active for guild ${guildId}`,
368
+ )
369
+ return
370
+ }
371
+
372
+ voiceData.userAudioStream?.write(frame)
373
+
374
+ voiceData.genAiWorker.sendRealtimeInput({
375
+ audio: {
376
+ mimeType: 'audio/pcm;rate=16000',
377
+ data: frame.toString('base64'),
378
+ },
379
+ })
380
+ })
381
+ .on('end', () => {
382
+ if (currentSessionCount === speakingSessionCount) {
383
+ voiceLogger.log(
384
+ `User ${userId} stopped speaking (session ${currentSessionCount})`,
385
+ )
386
+ voiceData.genAiWorker?.sendRealtimeInput({
387
+ audioStreamEnd: true,
388
+ })
389
+ } else {
390
+ voiceLogger.log(
391
+ `User ${userId} stopped speaking (session ${currentSessionCount}), but skipping audioStreamEnd because newer session ${speakingSessionCount} exists`,
392
+ )
393
+ }
394
+ })
395
+ .on('error', (error) => {
396
+ voiceLogger.error(`Pipeline error for user ${userId}:`, error)
397
+ void notifyError(error, `Voice pipeline error for user ${userId}`)
398
+ })
399
+
400
+ audioStream.on('error', (error) => {
401
+ voiceLogger.error(`Audio stream error for user ${userId}:`, error)
402
+ void notifyError(error, `Audio stream error for user ${userId}`)
403
+ })
404
+
405
+ downsampleTransform.on('error', (error) => {
406
+ voiceLogger.error(`Downsample transform error for user ${userId}:`, error)
407
+ void notifyError(error, `Downsample transform error for user ${userId}`)
408
+ })
409
+
410
+ framer.on('error', (error) => {
411
+ voiceLogger.error(`Framer error for user ${userId}:`, error)
412
+ void notifyError(error, `Framer error for user ${userId}`)
413
+ })
414
+ })
415
+ }
416
+
417
+ export async function cleanupVoiceConnection(guildId: string) {
418
+ const voiceData = voiceConnections.get(guildId)
419
+ if (!voiceData) return
420
+
421
+ voiceLogger.log(`Starting cleanup for guild ${guildId}`)
422
+
423
+ try {
424
+ if (voiceData.genAiWorker) {
425
+ voiceLogger.log(`Stopping GenAI worker...`)
426
+ await voiceData.genAiWorker.stop()
427
+ voiceLogger.log(`GenAI worker stopped`)
428
+ }
429
+
430
+ if (voiceData.userAudioStream) {
431
+ voiceLogger.log(`Closing user audio stream...`)
432
+ await new Promise<void>((resolve) => {
433
+ voiceData.userAudioStream!.end(() => {
434
+ voiceLogger.log('User audio stream closed')
435
+ resolve()
436
+ })
437
+ setTimeout(resolve, 2000)
438
+ })
439
+ }
440
+
441
+ if (voiceData.connection.state.status !== VoiceConnectionStatus.Destroyed) {
442
+ voiceLogger.log(`Destroying voice connection...`)
443
+ voiceData.connection.destroy()
444
+ }
445
+
446
+ voiceConnections.delete(guildId)
447
+ voiceLogger.log(`Cleanup complete for guild ${guildId}`)
448
+ } catch (error) {
449
+ voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error)
450
+ voiceConnections.delete(guildId)
451
+ }
452
+ }
453
+
454
+ type ProcessVoiceAttachmentArgs = {
455
+ message: Message
456
+ thread: ThreadChannel
457
+ projectDirectory?: string
458
+ isNewThread?: boolean
459
+ appId?: string
460
+ currentSessionContext?: string
461
+ lastSessionContext?: string
462
+ /** Available agents for voice-based agent selection. Passed to the transcription prompt as enum values. */
463
+ agents?: Array<{ name: string; description?: string }>
464
+ }
465
+
466
+ // Per-thread serialization is handled by ThreadSessionRuntime.enqueueIncoming()
467
+ // via the runtime action queue; no local serialization is needed here.
468
+ export async function processVoiceAttachment({
469
+ message,
470
+ thread,
471
+ projectDirectory,
472
+ isNewThread = false,
473
+ appId,
474
+ currentSessionContext,
475
+ lastSessionContext,
476
+ agents,
477
+ }: ProcessVoiceAttachmentArgs): Promise<TranscriptionResult | null> {
478
+ const audioAttachment = Array.from(message.attachments.values()).find(
479
+ (attachment) => isVoiceAttachment(attachment),
480
+ )
481
+
482
+ if (!audioAttachment) return null
483
+
484
+ const attachmentMatchReason = getVoiceAttachmentMatchReason(audioAttachment)
485
+
486
+ voiceLogger.log(
487
+ `Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType || 'no contentType'}, ${attachmentMatchReason || 'unknown reason'})`,
488
+ )
489
+
490
+ await sendThreadMessage(thread, '🎤 Transcribing voice message...')
491
+
492
+ // Deterministic mode: skip audio download and AI model call entirely,
493
+ // return a canned result after an optional delay. Used by e2e tests to
494
+ // control transcription output, timing, and queueMessage deterministically.
495
+ // Only active when KIMAKI_VITEST=1 to prevent accidental activation in production.
496
+ const deterministicConfig =
497
+ process.env['KIMAKI_VITEST'] === '1'
498
+ ? store.getState().test.deterministicTranscription
499
+ : null
500
+ if (deterministicConfig) {
501
+ if (deterministicConfig.delayMs) {
502
+ await new Promise<void>((resolve) => {
503
+ setTimeout(resolve, deterministicConfig.delayMs)
504
+ })
505
+ }
506
+ const result: TranscriptionResult = {
507
+ transcription: deterministicConfig.transcription,
508
+ queueMessage: deterministicConfig.queueMessage,
509
+ agent: deterministicConfig.agent,
510
+ }
511
+ voiceLogger.log(
512
+ `[DETERMINISTIC] Returning canned transcription: "${result.transcription}"${result.queueMessage ? ' [QUEUE]' : ''}`,
513
+ )
514
+ if (isNewThread) {
515
+ const threadName = result.transcription.replace(/\s+/g, ' ').trim().slice(0, 80)
516
+ if (threadName) {
517
+ const renameResult = await errore.tryAsync({
518
+ try: () => thread.setName(threadName),
519
+ catch: (e) =>
520
+ new Error('Failed to update thread name from deterministic transcription', {
521
+ cause: e,
522
+ }),
523
+ })
524
+ if (renameResult instanceof Error) {
525
+ voiceLogger.log(`Could not update thread name:`, renameResult.message)
526
+ }
527
+ }
528
+ }
529
+ await sendThreadMessage(
530
+ thread,
531
+ `📝 **Transcribed message:** ${escapeDiscordFormatting(result.transcription)}`,
532
+ )
533
+ return result
534
+ }
535
+
536
+ const audioResponse = await errore.tryAsync({
537
+ try: () => fetch(audioAttachment.url),
538
+ catch: (e) => new FetchError({ url: audioAttachment.url, cause: e }),
539
+ })
540
+ if (audioResponse instanceof Error) {
541
+ voiceLogger.error(
542
+ `Failed to download audio attachment:`,
543
+ audioResponse.message,
544
+ )
545
+ await sendThreadMessage(
546
+ thread,
547
+ `⚠️ Failed to download audio: ${audioResponse.message}`,
548
+ { flags: NOTIFY_MESSAGE_FLAGS },
549
+ )
550
+ return null
551
+ }
552
+ const audioBuffer = Buffer.from(await audioResponse.arrayBuffer())
553
+
554
+ voiceLogger.log(`Downloaded ${audioBuffer.length} bytes, transcribing...`)
555
+
556
+ let transcriptionPrompt = 'Discord voice message transcription'
557
+
558
+ if (projectDirectory) {
559
+ try {
560
+ voiceLogger.log(`Getting project file tree from ${projectDirectory}`)
561
+ const { stdout } = await execAsync('git ls-files | tree --fromfile -a', {
562
+ cwd: projectDirectory,
563
+ })
564
+
565
+ if (stdout) {
566
+ transcriptionPrompt = `Discord voice message transcription. Project file structure:\n${stdout}\n\nPlease transcribe file names and paths accurately based on this context.`
567
+ voiceLogger.log(`Added project context to transcription prompt`)
568
+ }
569
+ } catch (e) {
570
+ voiceLogger.log(`Could not get project tree:`, e)
571
+ }
572
+ }
573
+
574
+ // Resolve transcription API key: prefer OpenAI, fall back to Gemini, then env vars
575
+ let transcriptionApiKey: string | undefined
576
+ let transcriptionProvider: 'openai' | 'gemini' | undefined
577
+ if (appId) {
578
+ const stored = await getTranscriptionApiKey(appId)
579
+ if (stored) {
580
+ transcriptionApiKey = stored.apiKey
581
+ transcriptionProvider = stored.provider
582
+ }
583
+ }
584
+ if (!transcriptionApiKey) {
585
+ if (process.env.OPENAI_API_KEY) {
586
+ transcriptionApiKey = process.env.OPENAI_API_KEY
587
+ transcriptionProvider = 'openai'
588
+ } else if (process.env.GEMINI_API_KEY) {
589
+ transcriptionApiKey = process.env.GEMINI_API_KEY
590
+ transcriptionProvider = 'gemini'
591
+ }
592
+ }
593
+
594
+ if (!transcriptionApiKey) {
595
+ if (appId) {
596
+ const button = new ButtonBuilder()
597
+ .setCustomId(`transcription_apikey:${appId}`)
598
+ .setLabel('Set Transcription API Key')
599
+ .setStyle(ButtonStyle.Primary)
600
+
601
+ const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button)
602
+
603
+ await thread.send({
604
+ content:
605
+ 'Voice transcription requires an API key (OpenAI or Gemini). Set one to enable voice message transcription.',
606
+ components: [row],
607
+ flags: SILENT_MESSAGE_FLAGS,
608
+ })
609
+ } else {
610
+ await sendThreadMessage(
611
+ thread,
612
+ 'Voice transcription requires an API key. Set OPENAI_API_KEY or GEMINI_API_KEY, or use /login in this channel.',
613
+ )
614
+ }
615
+ return null
616
+ }
617
+
618
+ const transcription = await transcribeAudio({
619
+ audio: audioBuffer,
620
+ prompt: transcriptionPrompt,
621
+ apiKey: transcriptionApiKey,
622
+ provider: transcriptionProvider,
623
+ mediaType: audioAttachment.contentType || undefined,
624
+ currentSessionContext,
625
+ lastSessionContext,
626
+ agents,
627
+ })
628
+
629
+ if (transcription instanceof Error) {
630
+ const errMsg = errore.matchError(transcription, {
631
+ ApiKeyMissingError: (e) => e.message,
632
+ InvalidAudioFormatError: (e) => e.message,
633
+ TranscriptionError: (e) => e.message,
634
+ EmptyTranscriptionError: (e) => e.message,
635
+ NoResponseContentError: (e) => e.message,
636
+ NoToolResponseError: (e) => e.message,
637
+ Error: (e) => e.message,
638
+ })
639
+ voiceLogger.error(`Transcription failed:`, transcription)
640
+ await sendThreadMessage(thread, `⚠️ Transcription failed: ${errMsg}`, {
641
+ flags: NOTIFY_MESSAGE_FLAGS,
642
+ })
643
+ return null
644
+ }
645
+
646
+ const { transcription: text, queueMessage, agent } = transcription
647
+
648
+ voiceLogger.log(
649
+ `Transcription successful: "${text.slice(0, 50)}${text.length > 50 ? '...' : ''}"${queueMessage ? ' [QUEUE]' : ''}${agent ? ` [AGENT:${agent}]` : ''}`,
650
+ )
651
+
652
+ if (isNewThread) {
653
+ const threadName = text.replace(/\s+/g, ' ').trim().slice(0, 80)
654
+ if (threadName) {
655
+ const renamed = await Promise.race([
656
+ errore.tryAsync({
657
+ try: () => thread.setName(threadName),
658
+ catch: (e) => e as Error,
659
+ }),
660
+ new Promise<null>((resolve) => {
661
+ setTimeout(() => {
662
+ resolve(null)
663
+ }, 2000)
664
+ }),
665
+ ])
666
+ if (renamed === null) {
667
+ voiceLogger.log(`Thread name update timed out`)
668
+ } else if (renamed instanceof Error) {
669
+ voiceLogger.log(`Could not update thread name:`, renamed.message)
670
+ } else {
671
+ voiceLogger.log(`Updated thread name to: "${threadName}"`)
672
+ }
673
+ }
674
+ }
675
+
676
+ await sendThreadMessage(
677
+ thread,
678
+ `📝 **Transcribed message:** ${escapeDiscordFormatting(text)}`,
679
+ )
680
+ if (agent) {
681
+ await sendThreadMessage(thread, `Detected agent: ${agent}`)
682
+ }
683
+ return transcription
684
+ }
685
+
686
+ export function registerVoiceStateHandler({
687
+ discordClient,
688
+ appId,
689
+ }: {
690
+ discordClient: Client
691
+ appId: string
692
+ }) {
693
+ discordClient.on(
694
+ Events.VoiceStateUpdate,
695
+ async (oldState: VoiceState, newState: VoiceState) => {
696
+ try {
697
+ const member = newState.member || oldState.member
698
+ if (!member) return
699
+
700
+ if (!hasKimakiBotPermission(member)) {
701
+ return
702
+ }
703
+
704
+ const guild = newState.guild || oldState.guild
705
+
706
+ if (oldState.channelId !== null && newState.channelId === null) {
707
+ voiceLogger.log(
708
+ `Permitted user ${member.user.tag} left voice channel: ${oldState.channel?.name}`,
709
+ )
710
+
711
+ const guildId = guild.id
712
+ const voiceData = voiceConnections.get(guildId)
713
+
714
+ if (
715
+ voiceData &&
716
+ voiceData.connection.joinConfig.channelId === oldState.channelId
717
+ ) {
718
+ const voiceChannel = oldState.channel as VoiceChannel
719
+ if (!voiceChannel) return
720
+
721
+ const hasOtherPermittedUsers = voiceChannel.members.some((m) => {
722
+ if (m.id === member.id || m.user.bot) {
723
+ return false
724
+ }
725
+ return hasKimakiBotPermission(m)
726
+ })
727
+
728
+ if (!hasOtherPermittedUsers) {
729
+ voiceLogger.log(
730
+ `No other permitted users in channel, bot leaving voice channel in guild: ${guild.name}`,
731
+ )
732
+
733
+ await cleanupVoiceConnection(guildId)
734
+ } else {
735
+ voiceLogger.log(
736
+ `Other permitted users still in channel, bot staying in voice channel`,
737
+ )
738
+ }
739
+ }
740
+ return
741
+ }
742
+
743
+ if (
744
+ oldState.channelId !== null &&
745
+ newState.channelId !== null &&
746
+ oldState.channelId !== newState.channelId
747
+ ) {
748
+ voiceLogger.log(
749
+ `Permitted user ${member.user.tag} moved from ${oldState.channel?.name} to ${newState.channel?.name}`,
750
+ )
751
+
752
+ const guildId = guild.id
753
+ const voiceData = voiceConnections.get(guildId)
754
+
755
+ if (
756
+ voiceData &&
757
+ voiceData.connection.joinConfig.channelId === oldState.channelId
758
+ ) {
759
+ const oldVoiceChannel = oldState.channel as VoiceChannel
760
+ if (oldVoiceChannel) {
761
+ const hasOtherPermittedUsers = oldVoiceChannel.members.some(
762
+ (m) => {
763
+ if (m.id === member.id || m.user.bot) {
764
+ return false
765
+ }
766
+ return hasKimakiBotPermission(m)
767
+ },
768
+ )
769
+
770
+ if (!hasOtherPermittedUsers) {
771
+ voiceLogger.log(
772
+ `Following admin to new channel: ${newState.channel?.name}`,
773
+ )
774
+ const voiceChannel = newState.channel as VoiceChannel
775
+ if (voiceChannel) {
776
+ voiceData.connection.rejoin({
777
+ channelId: voiceChannel.id,
778
+ selfDeaf: false,
779
+ selfMute: false,
780
+ })
781
+ }
782
+ } else {
783
+ voiceLogger.log(
784
+ `Other permitted users still in old channel, bot staying put`,
785
+ )
786
+ }
787
+ }
788
+ }
789
+ }
790
+
791
+ if (oldState.channelId === null && newState.channelId !== null) {
792
+ voiceLogger.log(
793
+ `Permitted user ${member.user.tag} joined voice channel: ${newState.channel?.name}`,
794
+ )
795
+ }
796
+
797
+ if (newState.channelId === null) return
798
+
799
+ const voiceChannel = newState.channel as VoiceChannel
800
+ if (!voiceChannel) return
801
+
802
+ const existingVoiceData = voiceConnections.get(newState.guild.id)
803
+ if (
804
+ existingVoiceData &&
805
+ existingVoiceData.connection.state.status !==
806
+ VoiceConnectionStatus.Destroyed
807
+ ) {
808
+ voiceLogger.log(
809
+ `Bot already connected to a voice channel in guild ${newState.guild.name}`,
810
+ )
811
+
812
+ if (
813
+ existingVoiceData.connection.joinConfig.channelId !==
814
+ voiceChannel.id
815
+ ) {
816
+ voiceLogger.log(
817
+ `Moving bot from channel ${existingVoiceData.connection.joinConfig.channelId} to ${voiceChannel.id}`,
818
+ )
819
+ existingVoiceData.connection.rejoin({
820
+ channelId: voiceChannel.id,
821
+ selfDeaf: false,
822
+ selfMute: false,
823
+ })
824
+ }
825
+ return
826
+ }
827
+
828
+ try {
829
+ voiceLogger.log(
830
+ `Attempting to join voice channel: ${voiceChannel.name} (${voiceChannel.id})`,
831
+ )
832
+
833
+ const connection = joinVoiceChannel({
834
+ channelId: voiceChannel.id,
835
+ guildId: newState.guild.id,
836
+ adapterCreator: newState.guild.voiceAdapterCreator,
837
+ selfDeaf: false,
838
+ debug: true,
839
+ // daveEncryption defaults to true, required by Discord since ~March 2026
840
+ selfMute: false,
841
+ })
842
+
843
+ voiceConnections.set(newState.guild.id, { connection })
844
+
845
+ await entersState(connection, VoiceConnectionStatus.Ready, 30_000)
846
+ voiceLogger.log(
847
+ `Successfully joined voice channel: ${voiceChannel.name} in guild: ${newState.guild.name}`,
848
+ )
849
+
850
+ await setupVoiceHandling({
851
+ connection,
852
+ guildId: newState.guild.id,
853
+ channelId: voiceChannel.id,
854
+ appId,
855
+ discordClient,
856
+ })
857
+
858
+ connection.on(VoiceConnectionStatus.Disconnected, async () => {
859
+ voiceLogger.log(
860
+ `Disconnected from voice channel in guild: ${newState.guild.name}`,
861
+ )
862
+ try {
863
+ await Promise.race([
864
+ entersState(
865
+ connection,
866
+ VoiceConnectionStatus.Signalling,
867
+ 5_000,
868
+ ),
869
+ entersState(
870
+ connection,
871
+ VoiceConnectionStatus.Connecting,
872
+ 5_000,
873
+ ),
874
+ ])
875
+ voiceLogger.log(`Reconnecting to voice channel`)
876
+ } catch (error) {
877
+ voiceLogger.log(`Failed to reconnect, destroying connection`)
878
+ connection.destroy()
879
+ voiceConnections.delete(newState.guild.id)
880
+ }
881
+ })
882
+
883
+ connection.on(VoiceConnectionStatus.Destroyed, async () => {
884
+ voiceLogger.log(
885
+ `Connection destroyed for guild: ${newState.guild.name}`,
886
+ )
887
+ await cleanupVoiceConnection(newState.guild.id)
888
+ })
889
+
890
+ connection.on('error', (error) => {
891
+ voiceLogger.error(
892
+ `Connection error in guild ${newState.guild.name}:`,
893
+ error,
894
+ )
895
+ void notifyError(error, `Voice connection error in guild ${newState.guild.name}`)
896
+ })
897
+ } catch (error) {
898
+ voiceLogger.error(`Failed to join voice channel:`, error)
899
+ void notifyError(error, 'Failed to join voice channel')
900
+ await cleanupVoiceConnection(newState.guild.id)
901
+ }
902
+ } catch (error) {
903
+ voiceLogger.error('Error in voice state update handler:', error)
904
+ void notifyError(error, 'Voice state update handler error')
905
+ }
906
+ },
907
+ )
908
+ }