@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,1876 @@
1
+ // SQLite database manager for persistent bot state using Prisma.
2
+ // Stores thread-session mappings, bot tokens, channel directories,
3
+ // API keys, and model preferences in <dataDir>/discord-sessions.db.
4
+
5
+ import { getPrisma, closePrisma } from './db.js'
6
+ import type { Prisma, session_events, BotMode, VerbosityLevel, WorktreeStatus, ChannelType as PrismaChannelType, ThreadSessionSource } from './generated/client.js'
7
+ import crypto from 'node:crypto'
8
+
9
+ import { store } from './store.js'
10
+ import { createLogger, LogPrefix } from './logger.js'
11
+
12
+ const dbLogger = createLogger(LogPrefix.DB)
13
+
14
+ // Re-export Prisma utilities
15
+ export { getPrisma, closePrisma }
16
+
17
+ /**
18
+ * Initialize the database.
19
+ * Returns the Prisma client.
20
+ */
21
+ export async function initDatabase() {
22
+ const prisma = await getPrisma()
23
+ dbLogger.log('Database initialized')
24
+ return prisma
25
+ }
26
+
27
+ /**
28
+ * Close the database connection.
29
+ */
30
+ export async function closeDatabase() {
31
+ await closePrisma()
32
+ }
33
+
34
+ // Re-export enum types from generated Prisma client
35
+ export type { VerbosityLevel }
36
+ export type { WorktreeStatus }
37
+ export type { PrismaChannelType }
38
+
39
+ export type ThreadWorktree = {
40
+ thread_id: string
41
+ worktree_name: string
42
+ worktree_directory: string | null
43
+ project_directory: string
44
+ status: WorktreeStatus
45
+ error_message: string | null
46
+ created_at: Date | null
47
+ }
48
+
49
+ export type ScheduledTaskStatus =
50
+ | 'planned'
51
+ | 'running'
52
+ | 'completed'
53
+ | 'cancelled'
54
+ | 'failed'
55
+ export type ScheduledTaskScheduleKind = 'at' | 'cron'
56
+
57
+ export type ScheduledTask = {
58
+ id: number
59
+ status: ScheduledTaskStatus
60
+ schedule_kind: ScheduledTaskScheduleKind
61
+ run_at: Date | null
62
+ cron_expr: string | null
63
+ timezone: string | null
64
+ next_run_at: Date
65
+ running_started_at: Date | null
66
+ last_run_at: Date | null
67
+ last_error: string | null
68
+ attempts: number
69
+ payload_json: string
70
+ prompt_preview: string
71
+ channel_id: string | null
72
+ thread_id: string | null
73
+ session_id: string | null
74
+ project_directory: string | null
75
+ created_at: Date | null
76
+ updated_at: Date | null
77
+ }
78
+
79
+ export type SessionStartSource = {
80
+ session_id: string
81
+ schedule_kind: ScheduledTaskScheduleKind
82
+ scheduled_task_id: number | null
83
+ created_at: Date | null
84
+ updated_at: Date | null
85
+ }
86
+
87
+ function toScheduledTask(row: {
88
+ id: number
89
+ status: string
90
+ schedule_kind: string
91
+ run_at: Date | null
92
+ cron_expr: string | null
93
+ timezone: string | null
94
+ next_run_at: Date
95
+ running_started_at: Date | null
96
+ last_run_at: Date | null
97
+ last_error: string | null
98
+ attempts: number
99
+ payload_json: string
100
+ prompt_preview: string
101
+ channel_id: string | null
102
+ thread_id: string | null
103
+ session_id: string | null
104
+ project_directory: string | null
105
+ created_at: Date | null
106
+ updated_at: Date | null
107
+ }): ScheduledTask {
108
+ return {
109
+ id: row.id,
110
+ status: row.status as ScheduledTaskStatus,
111
+ schedule_kind: row.schedule_kind as ScheduledTaskScheduleKind,
112
+ run_at: row.run_at,
113
+ cron_expr: row.cron_expr,
114
+ timezone: row.timezone,
115
+ next_run_at: row.next_run_at,
116
+ running_started_at: row.running_started_at,
117
+ last_run_at: row.last_run_at,
118
+ last_error: row.last_error,
119
+ attempts: row.attempts,
120
+ payload_json: row.payload_json,
121
+ prompt_preview: row.prompt_preview,
122
+ channel_id: row.channel_id,
123
+ thread_id: row.thread_id,
124
+ session_id: row.session_id,
125
+ project_directory: row.project_directory,
126
+ created_at: row.created_at,
127
+ updated_at: row.updated_at,
128
+ }
129
+ }
130
+
131
+ function toSessionStartSource(row: {
132
+ session_id: string
133
+ schedule_kind: string
134
+ scheduled_task_id: number | null
135
+ created_at: Date | null
136
+ updated_at: Date | null
137
+ }): SessionStartSource {
138
+ return {
139
+ session_id: row.session_id,
140
+ schedule_kind: row.schedule_kind as ScheduledTaskScheduleKind,
141
+ scheduled_task_id: row.scheduled_task_id,
142
+ created_at: row.created_at,
143
+ updated_at: row.updated_at,
144
+ }
145
+ }
146
+
147
+ // ============================================================================
148
+ // Scheduled Task Functions
149
+ // ============================================================================
150
+
151
+ export async function createScheduledTask({
152
+ scheduleKind,
153
+ runAt,
154
+ cronExpr,
155
+ timezone,
156
+ nextRunAt,
157
+ payloadJson,
158
+ promptPreview,
159
+ channelId,
160
+ threadId,
161
+ sessionId,
162
+ projectDirectory,
163
+ }: {
164
+ scheduleKind: ScheduledTaskScheduleKind
165
+ runAt?: Date | null
166
+ cronExpr?: string | null
167
+ timezone?: string | null
168
+ nextRunAt: Date
169
+ payloadJson: string
170
+ promptPreview: string
171
+ channelId?: string | null
172
+ threadId?: string | null
173
+ sessionId?: string | null
174
+ projectDirectory?: string | null
175
+ }): Promise<number> {
176
+ const prisma = await getPrisma()
177
+ const row = await prisma.scheduled_tasks.create({
178
+ data: {
179
+ status: 'planned',
180
+ schedule_kind: scheduleKind,
181
+ run_at: runAt ?? null,
182
+ cron_expr: cronExpr ?? null,
183
+ timezone: timezone ?? null,
184
+ next_run_at: nextRunAt,
185
+ payload_json: payloadJson,
186
+ prompt_preview: promptPreview,
187
+ channel_id: channelId ?? null,
188
+ thread_id: threadId ?? null,
189
+ session_id: sessionId ?? null,
190
+ project_directory: projectDirectory ?? null,
191
+ },
192
+ select: { id: true },
193
+ })
194
+ return row.id
195
+ }
196
+
197
+ export async function listScheduledTasks({
198
+ statuses,
199
+ }: {
200
+ statuses?: ScheduledTaskStatus[]
201
+ } = {}): Promise<ScheduledTask[]> {
202
+ const prisma = await getPrisma()
203
+ const rows = await prisma.scheduled_tasks.findMany({
204
+ where:
205
+ statuses && statuses.length > 0
206
+ ? { status: { in: statuses } }
207
+ : undefined,
208
+ orderBy: [{ next_run_at: 'asc' }, { id: 'asc' }],
209
+ })
210
+ return rows.map((row) => toScheduledTask(row))
211
+ }
212
+
213
+ export async function getScheduledTask(
214
+ taskId: number,
215
+ ): Promise<ScheduledTask | null> {
216
+ const prisma = await getPrisma()
217
+ const row = await prisma.scheduled_tasks.findUnique({
218
+ where: { id: taskId },
219
+ })
220
+ return row ? toScheduledTask(row) : null
221
+ }
222
+
223
+ export async function updateScheduledTask({
224
+ taskId,
225
+ payloadJson,
226
+ promptPreview,
227
+ scheduleKind,
228
+ runAt,
229
+ cronExpr,
230
+ timezone,
231
+ nextRunAt,
232
+ }: {
233
+ taskId: number
234
+ payloadJson: string
235
+ promptPreview: string
236
+ scheduleKind?: ScheduledTaskScheduleKind
237
+ runAt?: Date | null
238
+ cronExpr?: string | null
239
+ timezone?: string | null
240
+ nextRunAt?: Date
241
+ }): Promise<boolean> {
242
+ const prisma = await getPrisma()
243
+ const data: Record<string, unknown> = {
244
+ payload_json: payloadJson,
245
+ prompt_preview: promptPreview,
246
+ }
247
+ if (scheduleKind !== undefined) {
248
+ data.schedule_kind = scheduleKind
249
+ }
250
+ if (runAt !== undefined) {
251
+ data.run_at = runAt
252
+ }
253
+ if (cronExpr !== undefined) {
254
+ data.cron_expr = cronExpr
255
+ }
256
+ if (timezone !== undefined) {
257
+ data.timezone = timezone
258
+ }
259
+ if (nextRunAt !== undefined) {
260
+ data.next_run_at = nextRunAt
261
+ }
262
+ const result = await prisma.scheduled_tasks.updateMany({
263
+ where: {
264
+ id: taskId,
265
+ status: 'planned',
266
+ },
267
+ data,
268
+ })
269
+ return result.count > 0
270
+ }
271
+
272
+ export async function cancelScheduledTask(taskId: number): Promise<boolean> {
273
+ const prisma = await getPrisma()
274
+ const result = await prisma.scheduled_tasks.updateMany({
275
+ where: {
276
+ id: taskId,
277
+ status: {
278
+ in: ['planned', 'running'],
279
+ },
280
+ },
281
+ data: {
282
+ status: 'cancelled',
283
+ running_started_at: null,
284
+ },
285
+ })
286
+ return result.count > 0
287
+ }
288
+
289
+ export async function getDuePlannedScheduledTasks({
290
+ now,
291
+ limit,
292
+ }: {
293
+ now: Date
294
+ limit: number
295
+ }): Promise<ScheduledTask[]> {
296
+ const prisma = await getPrisma()
297
+ const rows = await prisma.scheduled_tasks.findMany({
298
+ where: {
299
+ status: 'planned',
300
+ next_run_at: {
301
+ lte: now,
302
+ },
303
+ },
304
+ orderBy: [{ next_run_at: 'asc' }, { id: 'asc' }],
305
+ take: limit,
306
+ })
307
+ return rows.map((row) => toScheduledTask(row))
308
+ }
309
+
310
+ export async function claimScheduledTaskRunning({
311
+ taskId,
312
+ startedAt,
313
+ }: {
314
+ taskId: number
315
+ startedAt: Date
316
+ }): Promise<boolean> {
317
+ const prisma = await getPrisma()
318
+ const result = await prisma.scheduled_tasks.updateMany({
319
+ where: {
320
+ id: taskId,
321
+ status: 'planned',
322
+ },
323
+ data: {
324
+ status: 'running',
325
+ running_started_at: startedAt,
326
+ },
327
+ })
328
+ return result.count > 0
329
+ }
330
+
331
+ export async function recoverStaleRunningScheduledTasks({
332
+ staleBefore,
333
+ }: {
334
+ staleBefore: Date
335
+ }): Promise<number> {
336
+ const prisma = await getPrisma()
337
+ const result = await prisma.scheduled_tasks.updateMany({
338
+ where: {
339
+ status: 'running',
340
+ running_started_at: {
341
+ lte: staleBefore,
342
+ },
343
+ },
344
+ data: {
345
+ status: 'planned',
346
+ running_started_at: null,
347
+ },
348
+ })
349
+ return result.count
350
+ }
351
+
352
+ export async function markScheduledTaskOneShotCompleted({
353
+ taskId,
354
+ completedAt,
355
+ }: {
356
+ taskId: number
357
+ completedAt: Date
358
+ }): Promise<void> {
359
+ const prisma = await getPrisma()
360
+ await prisma.scheduled_tasks.update({
361
+ where: { id: taskId },
362
+ data: {
363
+ status: 'completed',
364
+ last_run_at: completedAt,
365
+ running_started_at: null,
366
+ last_error: null,
367
+ },
368
+ })
369
+ }
370
+
371
+ export async function markScheduledTaskCronRescheduled({
372
+ taskId,
373
+ completedAt,
374
+ nextRunAt,
375
+ }: {
376
+ taskId: number
377
+ completedAt: Date
378
+ nextRunAt: Date
379
+ }): Promise<void> {
380
+ const prisma = await getPrisma()
381
+ await prisma.scheduled_tasks.update({
382
+ where: { id: taskId },
383
+ data: {
384
+ status: 'planned',
385
+ last_run_at: completedAt,
386
+ running_started_at: null,
387
+ last_error: null,
388
+ next_run_at: nextRunAt,
389
+ },
390
+ })
391
+ }
392
+
393
+ export async function markScheduledTaskFailed({
394
+ taskId,
395
+ failedAt,
396
+ errorMessage,
397
+ }: {
398
+ taskId: number
399
+ failedAt: Date
400
+ errorMessage: string
401
+ }): Promise<void> {
402
+ const prisma = await getPrisma()
403
+ await prisma.scheduled_tasks.update({
404
+ where: { id: taskId },
405
+ data: {
406
+ status: 'failed',
407
+ last_run_at: failedAt,
408
+ running_started_at: null,
409
+ last_error: errorMessage,
410
+ attempts: {
411
+ increment: 1,
412
+ },
413
+ },
414
+ })
415
+ }
416
+
417
+ export async function markScheduledTaskCronRetry({
418
+ taskId,
419
+ failedAt,
420
+ errorMessage,
421
+ nextRunAt,
422
+ }: {
423
+ taskId: number
424
+ failedAt: Date
425
+ errorMessage: string
426
+ nextRunAt: Date
427
+ }): Promise<void> {
428
+ const prisma = await getPrisma()
429
+ await prisma.scheduled_tasks.update({
430
+ where: { id: taskId },
431
+ data: {
432
+ status: 'planned',
433
+ next_run_at: nextRunAt,
434
+ last_run_at: failedAt,
435
+ running_started_at: null,
436
+ last_error: errorMessage,
437
+ attempts: {
438
+ increment: 1,
439
+ },
440
+ },
441
+ })
442
+ }
443
+
444
+ export async function setSessionStartSource({
445
+ sessionId,
446
+ scheduleKind,
447
+ scheduledTaskId,
448
+ }: {
449
+ sessionId: string
450
+ scheduleKind: ScheduledTaskScheduleKind
451
+ scheduledTaskId?: number
452
+ }): Promise<void> {
453
+ const prisma = await getPrisma()
454
+ await prisma.session_start_sources.upsert({
455
+ where: { session_id: sessionId },
456
+ create: {
457
+ session_id: sessionId,
458
+ schedule_kind: scheduleKind,
459
+ scheduled_task_id: scheduledTaskId ?? null,
460
+ },
461
+ update: {
462
+ schedule_kind: scheduleKind,
463
+ scheduled_task_id: scheduledTaskId ?? null,
464
+ },
465
+ })
466
+ }
467
+
468
+ export async function getSessionStartSourcesBySessionIds(
469
+ sessionIds: string[],
470
+ ): Promise<Map<string, SessionStartSource>> {
471
+ if (sessionIds.length === 0) {
472
+ return new Map<string, SessionStartSource>()
473
+ }
474
+ const prisma = await getPrisma()
475
+ const chunkSize = 500
476
+ const chunks: string[][] = []
477
+ for (let index = 0; index < sessionIds.length; index += chunkSize) {
478
+ chunks.push(sessionIds.slice(index, index + chunkSize))
479
+ }
480
+
481
+ const rowGroups = await Promise.all(
482
+ chunks.map((chunkSessionIds) => {
483
+ return prisma.session_start_sources.findMany({
484
+ where: {
485
+ session_id: {
486
+ in: chunkSessionIds,
487
+ },
488
+ },
489
+ })
490
+ }),
491
+ )
492
+ const rows = rowGroups.flatMap((group) => group)
493
+ return new Map(rows.map((row) => [row.session_id, toSessionStartSource(row)]))
494
+ }
495
+
496
+ // ============================================================================
497
+ // Channel Model Functions
498
+ // ============================================================================
499
+
500
+ export type ModelPreference = { modelId: string; variant: string | null }
501
+
502
+ /**
503
+ * Get the model preference for a channel.
504
+ * @returns Model ID in format "provider_id/model_id" + optional variant, or undefined
505
+ */
506
+ export async function getChannelModel(
507
+ channelId: string,
508
+ ): Promise<ModelPreference | undefined> {
509
+ const prisma = await getPrisma()
510
+ const row = await prisma.channel_models.findUnique({
511
+ where: { channel_id: channelId },
512
+ })
513
+ if (!row) {
514
+ return undefined
515
+ }
516
+ return { modelId: row.model_id, variant: row.variant }
517
+ }
518
+
519
+ /**
520
+ * Set the model preference for a channel.
521
+ * @param modelId Model ID in format "provider_id/model_id"
522
+ * @param variant Optional thinking/reasoning variant name
523
+ */
524
+ export async function setChannelModel({
525
+ channelId,
526
+ modelId,
527
+ variant,
528
+ }: {
529
+ channelId: string
530
+ modelId: string
531
+ variant?: string | null
532
+ }): Promise<void> {
533
+ const prisma = await getPrisma()
534
+ await prisma.channel_models.upsert({
535
+ where: { channel_id: channelId },
536
+ create: {
537
+ channel_id: channelId,
538
+ model_id: modelId,
539
+ variant: variant ?? null,
540
+ },
541
+ update: {
542
+ model_id: modelId,
543
+ variant: variant ?? null,
544
+ updated_at: new Date(),
545
+ },
546
+ })
547
+ }
548
+
549
+ // ============================================================================
550
+ // Global Model Functions
551
+ // ============================================================================
552
+
553
+ /**
554
+ * Get the global default model for a bot.
555
+ * @returns Model ID in format "provider_id/model_id" + optional variant, or undefined
556
+ */
557
+ export async function getGlobalModel(
558
+ appId: string,
559
+ ): Promise<ModelPreference | undefined> {
560
+ const prisma = await getPrisma()
561
+ const row = await prisma.global_models.findUnique({
562
+ where: { app_id: appId },
563
+ })
564
+ if (!row) {
565
+ return undefined
566
+ }
567
+ return { modelId: row.model_id, variant: row.variant }
568
+ }
569
+
570
+ /**
571
+ * Set the global default model for a bot.
572
+ * @param modelId Model ID in format "provider_id/model_id"
573
+ * @param variant Optional thinking/reasoning variant name
574
+ */
575
+ export async function setGlobalModel({
576
+ appId,
577
+ modelId,
578
+ variant,
579
+ }: {
580
+ appId: string
581
+ modelId: string
582
+ variant?: string | null
583
+ }): Promise<void> {
584
+ const prisma = await getPrisma()
585
+ await prisma.global_models.upsert({
586
+ where: { app_id: appId },
587
+ create: { app_id: appId, model_id: modelId, variant: variant ?? null },
588
+ update: {
589
+ model_id: modelId,
590
+ variant: variant ?? null,
591
+ updated_at: new Date(),
592
+ },
593
+ })
594
+ }
595
+
596
+ // ============================================================================
597
+ // Session Model Functions
598
+ // ============================================================================
599
+
600
+ /**
601
+ * Get the model preference for a session.
602
+ * @returns Model ID in format "provider_id/model_id" + optional variant, or undefined
603
+ */
604
+ export async function getSessionModel(
605
+ sessionId: string,
606
+ ): Promise<ModelPreference | undefined> {
607
+ const prisma = await getPrisma()
608
+ const row = await prisma.session_models.findUnique({
609
+ where: { session_id: sessionId },
610
+ })
611
+ if (!row) {
612
+ return undefined
613
+ }
614
+ return { modelId: row.model_id, variant: row.variant }
615
+ }
616
+
617
+ /**
618
+ * Set the model preference for a session.
619
+ * @param modelId Model ID in format "provider_id/model_id"
620
+ * @param variant Optional thinking/reasoning variant name
621
+ */
622
+ export async function setSessionModel({
623
+ sessionId,
624
+ modelId,
625
+ variant,
626
+ }: {
627
+ sessionId: string
628
+ modelId: string
629
+ variant?: string | null
630
+ }): Promise<void> {
631
+ const prisma = await getPrisma()
632
+ await prisma.session_models.upsert({
633
+ where: { session_id: sessionId },
634
+ create: {
635
+ session_id: sessionId,
636
+ model_id: modelId,
637
+ variant: variant ?? null,
638
+ },
639
+ update: { model_id: modelId, variant: variant ?? null },
640
+ })
641
+ }
642
+
643
+ /**
644
+ * Clear the model preference for a session.
645
+ * Used when switching agents so the agent's model takes effect.
646
+ */
647
+ export async function clearSessionModel(sessionId: string): Promise<void> {
648
+ const prisma = await getPrisma()
649
+ await prisma.session_models.deleteMany({
650
+ where: { session_id: sessionId },
651
+ })
652
+ }
653
+
654
+ // ============================================================================
655
+ // Variant Cascade Resolution
656
+ // ============================================================================
657
+
658
+ /**
659
+ * Resolve the variant (thinking level) using the session → channel → global cascade.
660
+ * Returns the first non-null variant found, or undefined if none set at any level.
661
+ */
662
+ export async function getVariantCascade({
663
+ sessionId,
664
+ channelId,
665
+ appId,
666
+ }: {
667
+ sessionId?: string
668
+ channelId?: string
669
+ appId?: string
670
+ }): Promise<string | undefined> {
671
+ if (sessionId) {
672
+ const session = await getSessionModel(sessionId)
673
+ if (session?.variant) {
674
+ return session.variant
675
+ }
676
+ }
677
+ if (channelId) {
678
+ const channel = await getChannelModel(channelId)
679
+ if (channel?.variant) {
680
+ return channel.variant
681
+ }
682
+ }
683
+ if (appId) {
684
+ const global = await getGlobalModel(appId)
685
+ if (global?.variant) {
686
+ return global.variant
687
+ }
688
+ }
689
+ return undefined
690
+ }
691
+
692
+ // ============================================================================
693
+ // Channel Agent Functions
694
+ // ============================================================================
695
+
696
+ /**
697
+ * Get the agent preference for a channel.
698
+ */
699
+ export async function getChannelAgent(
700
+ channelId: string,
701
+ ): Promise<string | undefined> {
702
+ const prisma = await getPrisma()
703
+ const row = await prisma.channel_agents.findUnique({
704
+ where: { channel_id: channelId },
705
+ })
706
+ return row?.agent_name
707
+ }
708
+
709
+ /**
710
+ * Set the agent preference for a channel.
711
+ */
712
+ export async function setChannelAgent(
713
+ channelId: string,
714
+ agentName: string,
715
+ ): Promise<void> {
716
+ const prisma = await getPrisma()
717
+ await prisma.channel_agents.upsert({
718
+ where: { channel_id: channelId },
719
+ create: { channel_id: channelId, agent_name: agentName },
720
+ update: { agent_name: agentName, updated_at: new Date() },
721
+ })
722
+ }
723
+
724
+ // ============================================================================
725
+ // Session Agent Functions
726
+ // ============================================================================
727
+
728
+ /**
729
+ * Get the agent preference for a session.
730
+ */
731
+ export async function getSessionAgent(
732
+ sessionId: string,
733
+ ): Promise<string | undefined> {
734
+ const prisma = await getPrisma()
735
+ const row = await prisma.session_agents.findUnique({
736
+ where: { session_id: sessionId },
737
+ })
738
+ return row?.agent_name
739
+ }
740
+
741
+ /**
742
+ * Set the agent preference for a session.
743
+ */
744
+ export async function setSessionAgent(
745
+ sessionId: string,
746
+ agentName: string,
747
+ ): Promise<void> {
748
+ const prisma = await getPrisma()
749
+ await prisma.session_agents.upsert({
750
+ where: { session_id: sessionId },
751
+ create: { session_id: sessionId, agent_name: agentName },
752
+ update: { agent_name: agentName },
753
+ })
754
+ }
755
+
756
+ // ============================================================================
757
+ // Thread Worktree Functions
758
+ // ============================================================================
759
+
760
+ /**
761
+ * Get the worktree info for a thread.
762
+ */
763
+ export async function getThreadWorktree(
764
+ threadId: string,
765
+ ): Promise<ThreadWorktree | undefined> {
766
+ const prisma = await getPrisma()
767
+ return (await prisma.thread_worktrees.findUnique({
768
+ where: { thread_id: threadId },
769
+ })) ?? undefined
770
+ }
771
+
772
+ /**
773
+ * Create a pending worktree entry for a thread.
774
+ * Ensures the parent thread_sessions row exists first (with empty session_id)
775
+ * to satisfy the FK constraint. The real session_id is set later by setThreadSession().
776
+ */
777
+ export async function createPendingWorktree({
778
+ threadId,
779
+ worktreeName,
780
+ projectDirectory,
781
+ }: {
782
+ threadId: string
783
+ worktreeName: string
784
+ projectDirectory: string
785
+ }): Promise<void> {
786
+ const prisma = await getPrisma()
787
+ await prisma.$transaction([
788
+ prisma.thread_sessions.upsert({
789
+ where: { thread_id: threadId },
790
+ create: { thread_id: threadId, session_id: '' },
791
+ update: {},
792
+ }),
793
+ prisma.thread_worktrees.upsert({
794
+ where: { thread_id: threadId },
795
+ create: {
796
+ thread_id: threadId,
797
+ worktree_name: worktreeName,
798
+ project_directory: projectDirectory,
799
+ status: 'pending',
800
+ },
801
+ update: {
802
+ worktree_name: worktreeName,
803
+ project_directory: projectDirectory,
804
+ status: 'pending',
805
+ worktree_directory: null,
806
+ error_message: null,
807
+ },
808
+ }),
809
+ ])
810
+ }
811
+
812
+ /**
813
+ * Mark a worktree as ready with its directory.
814
+ */
815
+ export async function setWorktreeReady({
816
+ threadId,
817
+ worktreeDirectory,
818
+ }: {
819
+ threadId: string
820
+ worktreeDirectory: string
821
+ }): Promise<void> {
822
+ const prisma = await getPrisma()
823
+ await prisma.thread_worktrees.update({
824
+ where: { thread_id: threadId },
825
+ data: {
826
+ worktree_directory: worktreeDirectory,
827
+ status: 'ready',
828
+ },
829
+ })
830
+ }
831
+
832
+ /**
833
+ * Mark a worktree as failed with error message.
834
+ */
835
+ export async function setWorktreeError({
836
+ threadId,
837
+ errorMessage,
838
+ }: {
839
+ threadId: string
840
+ errorMessage: string
841
+ }): Promise<void> {
842
+ const prisma = await getPrisma()
843
+ await prisma.thread_worktrees.update({
844
+ where: { thread_id: threadId },
845
+ data: {
846
+ status: 'error',
847
+ error_message: errorMessage,
848
+ },
849
+ })
850
+ }
851
+
852
+ /**
853
+ * Delete the worktree info for a thread.
854
+ */
855
+ export async function deleteThreadWorktree(threadId: string): Promise<void> {
856
+ const prisma = await getPrisma()
857
+ await prisma.thread_worktrees.deleteMany({
858
+ where: { thread_id: threadId },
859
+ })
860
+ }
861
+
862
+ // ============================================================================
863
+ // Channel Verbosity Functions
864
+ // ============================================================================
865
+
866
+ /**
867
+ * Get the verbosity setting for a channel.
868
+ * Falls back to the global default set via --verbosity CLI flag if no per-channel override exists.
869
+ */
870
+ export async function getChannelVerbosity(
871
+ channelId: string,
872
+ ): Promise<VerbosityLevel> {
873
+ const prisma = await getPrisma()
874
+ const row = await prisma.channel_verbosity.findUnique({
875
+ where: { channel_id: channelId },
876
+ })
877
+ if (row?.verbosity) {
878
+ return row.verbosity as VerbosityLevel
879
+ }
880
+ return store.getState().defaultVerbosity
881
+ }
882
+
883
+ /**
884
+ * Set the verbosity setting for a channel.
885
+ */
886
+ export async function setChannelVerbosity(
887
+ channelId: string,
888
+ verbosity: VerbosityLevel,
889
+ ): Promise<void> {
890
+ const prisma = await getPrisma()
891
+ await prisma.channel_verbosity.upsert({
892
+ where: { channel_id: channelId },
893
+ create: { channel_id: channelId, verbosity },
894
+ update: { verbosity, updated_at: new Date() },
895
+ })
896
+ }
897
+
898
+ // ============================================================================
899
+ // Channel Mention Mode Functions
900
+ // ============================================================================
901
+
902
+ /**
903
+ * Get the mention mode setting for a channel.
904
+ * Falls back to the global default set via --mention-mode CLI flag if no per-channel override exists.
905
+ */
906
+ export async function getChannelMentionMode(
907
+ channelId: string,
908
+ ): Promise<boolean> {
909
+ const prisma = await getPrisma()
910
+ const row = await prisma.channel_mention_mode.findUnique({
911
+ where: { channel_id: channelId },
912
+ })
913
+ if (row) {
914
+ return row.enabled === 1
915
+ }
916
+ return store.getState().defaultMentionMode
917
+ }
918
+
919
+ /**
920
+ * Set the mention mode setting for a channel.
921
+ */
922
+ export async function setChannelMentionMode(
923
+ channelId: string,
924
+ enabled: boolean,
925
+ ): Promise<void> {
926
+ const prisma = await getPrisma()
927
+ await prisma.channel_mention_mode.upsert({
928
+ where: { channel_id: channelId },
929
+ create: { channel_id: channelId, enabled: enabled ? 1 : 0 },
930
+ update: { enabled: enabled ? 1 : 0, updated_at: new Date() },
931
+ })
932
+ }
933
+
934
+ // ============================================================================
935
+ // Channel Worktree Settings Functions
936
+ // ============================================================================
937
+
938
+ /**
939
+ * Check if automatic worktree creation is enabled for a channel.
940
+ */
941
+ export async function getChannelWorktreesEnabled(
942
+ channelId: string,
943
+ ): Promise<boolean> {
944
+ const prisma = await getPrisma()
945
+ const row = await prisma.channel_worktrees.findUnique({
946
+ where: { channel_id: channelId },
947
+ })
948
+ return row?.enabled === 1
949
+ }
950
+
951
+ /**
952
+ * Enable or disable automatic worktree creation for a channel.
953
+ */
954
+ export async function setChannelWorktreesEnabled(
955
+ channelId: string,
956
+ enabled: boolean,
957
+ ): Promise<void> {
958
+ const prisma = await getPrisma()
959
+ await prisma.channel_worktrees.upsert({
960
+ where: { channel_id: channelId },
961
+ create: { channel_id: channelId, enabled: enabled ? 1 : 0 },
962
+ update: { enabled: enabled ? 1 : 0, updated_at: new Date() },
963
+ })
964
+ }
965
+
966
+ // ============================================================================
967
+ // Channel Directory Functions
968
+ // ============================================================================
969
+
970
+ /**
971
+ * Get the directory for a channel from the database.
972
+ * This is the single source of truth for channel-project mappings.
973
+ */
974
+ export async function getChannelDirectory(channelId: string): Promise<
975
+ | {
976
+ directory: string
977
+ }
978
+ | undefined
979
+ > {
980
+ const prisma = await getPrisma()
981
+ const row = await prisma.channel_directories.findUnique({
982
+ where: { channel_id: channelId },
983
+ })
984
+
985
+ if (!row) {
986
+ return undefined
987
+ }
988
+
989
+ return {
990
+ directory: row.directory,
991
+ }
992
+ }
993
+
994
+ // ============================================================================
995
+ // Thread Session Functions
996
+ // ============================================================================
997
+
998
+ /**
999
+ * Get the session ID for a thread.
1000
+ */
1001
+ export async function getThreadSession(
1002
+ threadId: string,
1003
+ ): Promise<string | undefined> {
1004
+ const prisma = await getPrisma()
1005
+ const row = await prisma.thread_sessions.findUnique({
1006
+ where: { thread_id: threadId },
1007
+ })
1008
+ return row?.session_id
1009
+ }
1010
+
1011
+ /**
1012
+ * Set the session ID for a thread.
1013
+ */
1014
+ export async function setThreadSession(
1015
+ threadId: string,
1016
+ sessionId: string,
1017
+ ): Promise<void> {
1018
+ await upsertThreadSession({
1019
+ threadId,
1020
+ sessionId,
1021
+ source: 'kimaki',
1022
+ })
1023
+ }
1024
+
1025
+ export async function upsertThreadSession({
1026
+ threadId,
1027
+ sessionId,
1028
+ source,
1029
+ }: {
1030
+ threadId: string
1031
+ sessionId: string
1032
+ source: ThreadSessionSource
1033
+ }): Promise<void> {
1034
+ const prisma = await getPrisma()
1035
+ await prisma.thread_sessions.upsert({
1036
+ where: { thread_id: threadId },
1037
+ create: {
1038
+ thread_id: threadId,
1039
+ session_id: sessionId,
1040
+ source,
1041
+ },
1042
+ update: {
1043
+ session_id: sessionId,
1044
+ source,
1045
+ },
1046
+ })
1047
+ }
1048
+
1049
+ export async function getThreadSessionSource(
1050
+ threadId: string,
1051
+ ): Promise<ThreadSessionSource | undefined> {
1052
+ const prisma = await getPrisma()
1053
+ const row = await prisma.thread_sessions.findUnique({
1054
+ where: { thread_id: threadId },
1055
+ select: { source: true },
1056
+ })
1057
+ return row?.source
1058
+ }
1059
+
1060
+ /**
1061
+ * Get the thread ID for a session.
1062
+ */
1063
+ export async function getThreadIdBySessionId(
1064
+ sessionId: string,
1065
+ ): Promise<string | undefined> {
1066
+ const prisma = await getPrisma()
1067
+ const row = await prisma.thread_sessions.findFirst({
1068
+ where: { session_id: sessionId },
1069
+ })
1070
+ return row?.thread_id
1071
+ }
1072
+
1073
+ /**
1074
+ * Get all session IDs that are associated with threads.
1075
+ */
1076
+ export async function getAllThreadSessionIds(): Promise<string[]> {
1077
+ const prisma = await getPrisma()
1078
+ const rows = await prisma.thread_sessions.findMany({
1079
+ select: { session_id: true },
1080
+ })
1081
+ return rows.map((row) => row.session_id).filter((id) => id !== '')
1082
+ }
1083
+
1084
+ export async function appendSessionEventsSinceLastTimestamp({
1085
+ sessionId,
1086
+ events,
1087
+ }: {
1088
+ sessionId: string
1089
+ events: Prisma.session_eventsCreateManyInput[]
1090
+ }): Promise<number> {
1091
+ if (events.length === 0) {
1092
+ return 0
1093
+ }
1094
+
1095
+ const prisma = await getPrisma()
1096
+ const sortedEvents = [...events]
1097
+ .sort((a, b) => {
1098
+ if (a.timestamp < b.timestamp) {
1099
+ return -1
1100
+ }
1101
+ if (a.timestamp > b.timestamp) {
1102
+ return 1
1103
+ }
1104
+ if (a.event_index < b.event_index) {
1105
+ return -1
1106
+ }
1107
+ if (a.event_index > b.event_index) {
1108
+ return 1
1109
+ }
1110
+ return 0
1111
+ })
1112
+
1113
+ const latestPersisted = await prisma.session_events.findFirst({
1114
+ where: {
1115
+ session_id: sessionId,
1116
+ },
1117
+ orderBy: [{ timestamp: 'desc' }, { event_index: 'desc' }, { id: 'desc' }],
1118
+ select: {
1119
+ timestamp: true,
1120
+ event_index: true,
1121
+ },
1122
+ })
1123
+
1124
+ const eventsToInsert = sortedEvents.filter((event) => {
1125
+ if (!latestPersisted) {
1126
+ return true
1127
+ }
1128
+ if (event.timestamp > latestPersisted.timestamp) {
1129
+ return true
1130
+ }
1131
+ if (event.timestamp < latestPersisted.timestamp) {
1132
+ return false
1133
+ }
1134
+ return event.event_index > latestPersisted.event_index
1135
+ })
1136
+
1137
+ if (eventsToInsert.length === 0) {
1138
+ return 0
1139
+ }
1140
+
1141
+ await prisma.$transaction(async (tx) => {
1142
+ await tx.session_events.createMany({
1143
+ data: eventsToInsert,
1144
+ })
1145
+
1146
+ const staleRows = await tx.session_events.findMany({
1147
+ where: {
1148
+ session_id: sessionId,
1149
+ },
1150
+ orderBy: [{ timestamp: 'desc' }, { event_index: 'desc' }, { id: 'desc' }],
1151
+ skip: 1000,
1152
+ select: {
1153
+ id: true,
1154
+ },
1155
+ })
1156
+ if (staleRows.length === 0) {
1157
+ return
1158
+ }
1159
+
1160
+ await tx.session_events.deleteMany({
1161
+ where: {
1162
+ id: {
1163
+ in: staleRows.map((row) => {
1164
+ return row.id
1165
+ }),
1166
+ },
1167
+ },
1168
+ })
1169
+ })
1170
+
1171
+ return eventsToInsert.length
1172
+ }
1173
+
1174
+ export async function getSessionEventSnapshot({
1175
+ sessionId,
1176
+ }: {
1177
+ sessionId: string
1178
+ }): Promise<session_events[]> {
1179
+ const prisma = await getPrisma()
1180
+ return prisma.session_events.findMany({
1181
+ where: {
1182
+ session_id: sessionId,
1183
+ },
1184
+ orderBy: [{ timestamp: 'asc' }, { event_index: 'asc' }, { id: 'asc' }],
1185
+ take: 1000,
1186
+ })
1187
+ }
1188
+
1189
+ // ============================================================================
1190
+ // Part Messages Functions
1191
+ // ============================================================================
1192
+
1193
+ /**
1194
+ * Get all part IDs for a thread.
1195
+ */
1196
+ export async function getPartMessageIds(threadId: string): Promise<string[]> {
1197
+ const prisma = await getPrisma()
1198
+ const rows = await prisma.part_messages.findMany({
1199
+ where: { thread_id: threadId },
1200
+ select: { part_id: true },
1201
+ })
1202
+ return rows.map((row) => row.part_id)
1203
+ }
1204
+
1205
+ /**
1206
+ * Store a part-message mapping.
1207
+ * Note: The thread must already have a session (via setThreadSession) before calling this.
1208
+ */
1209
+ export async function setPartMessage(
1210
+ partId: string,
1211
+ messageId: string,
1212
+ threadId: string,
1213
+ ): Promise<void> {
1214
+ const prisma = await getPrisma()
1215
+ await prisma.part_messages.upsert({
1216
+ where: { part_id: partId },
1217
+ create: { part_id: partId, message_id: messageId, thread_id: threadId },
1218
+ update: { message_id: messageId, thread_id: threadId },
1219
+ })
1220
+ }
1221
+
1222
+ /**
1223
+ * Store multiple part-message mappings in a transaction.
1224
+ * More efficient and atomic for batch operations.
1225
+ * Note: The thread must already have a session (via setThreadSession) before calling this.
1226
+ */
1227
+ export async function setPartMessagesBatch(
1228
+ partMappings: Array<{ partId: string; messageId: string; threadId: string }>,
1229
+ ): Promise<void> {
1230
+ if (partMappings.length === 0) {
1231
+ return
1232
+ }
1233
+ const prisma = await getPrisma()
1234
+ await prisma.$transaction(
1235
+ partMappings.map(({ partId, messageId, threadId }) => {
1236
+ return prisma.part_messages.upsert({
1237
+ where: { part_id: partId },
1238
+ create: { part_id: partId, message_id: messageId, thread_id: threadId },
1239
+ update: { message_id: messageId, thread_id: threadId },
1240
+ })
1241
+ }),
1242
+ )
1243
+ }
1244
+
1245
+ // ============================================================================
1246
+ // Bot Token Functions
1247
+ // ============================================================================
1248
+
1249
+ /**
1250
+ * Get the bot token to use, with mode info, in a single query.
1251
+ *
1252
+ * Selection logic (when multiple bot rows exist):
1253
+ * - If only one bot exists, use it regardless of mode.
1254
+ * - Picks the bot row with the most recent `last_used_at` timestamp, which is
1255
+ * set by the `run()` command when the bot starts. This ensures subcommands
1256
+ * in separate processes (send, project list, etc.) automatically use
1257
+ * whichever bot mode (gateway or self-hosted) was last started.
1258
+ * - Falls back to `created_at` ordering when no row has `last_used_at` set
1259
+ * (backward compat for existing DBs before this column was added).
1260
+ *
1261
+ * For gateway mode, the token is derived from client_id:client_secret
1262
+ * and REST routing is automatically enabled (idempotent env var set).
1263
+ * This ensures every code path that reads credentials gets correct routing
1264
+ * without needing to set discordBaseUrl separately.
1265
+ */
1266
+ export async function getBotTokenWithMode(): Promise<
1267
+ | {
1268
+ appId: string
1269
+ token: string
1270
+ gatewayToken: string
1271
+ mode: BotMode
1272
+ clientId: string | null
1273
+ clientSecret: string | null
1274
+ proxyUrl: string | null
1275
+ }
1276
+ | undefined
1277
+ > {
1278
+ const prisma = await getPrisma()
1279
+ // Pick the bot that was most recently started via run(). last_used_at is the
1280
+ // cross-process source of truth — no in-memory flags needed.
1281
+ // Fall back to created_at for DBs that predate the last_used_at column.
1282
+ const allBots = await prisma.bot_tokens.findMany({
1283
+ orderBy: [{ last_used_at: 'desc' }, { created_at: 'desc' }],
1284
+ })
1285
+ const row = allBots[0]
1286
+ if (!row) {
1287
+ return undefined
1288
+ }
1289
+ const gatewayToken = await ensureServiceAuthToken({ appId: row.app_id })
1290
+ const serviceParts = splitServiceAuthToken({ token: gatewayToken })
1291
+ const mode: BotMode = row.bot_mode === 'gateway' ? 'gateway' : 'self_hosted'
1292
+ const token = (mode === 'gateway' && serviceParts)
1293
+ ? gatewayToken
1294
+ : row.token
1295
+ // Always reset discordBaseUrl on every read so a mode switch within
1296
+ // the same process (e.g. DB has gateway row but user proceeds self-hosted)
1297
+ // doesn't leave a stale proxy URL in the store.
1298
+ const discordBaseUrl = (mode === 'gateway' && row.proxy_url)
1299
+ ? row.proxy_url
1300
+ : 'https://discord.com'
1301
+ store.setState({ discordBaseUrl, gatewayToken })
1302
+ return {
1303
+ appId: row.app_id,
1304
+ token,
1305
+ gatewayToken,
1306
+ mode,
1307
+ clientId: serviceParts?.clientId || row.client_id,
1308
+ clientSecret: serviceParts?.clientSecret || row.client_secret,
1309
+ proxyUrl: row.proxy_url,
1310
+ }
1311
+ }
1312
+
1313
+ function splitServiceAuthToken({ token }: { token: string }): { clientId: string; clientSecret: string } | null {
1314
+ const separatorIndex = token.indexOf(':')
1315
+ if (separatorIndex <= 0 || separatorIndex >= token.length - 1) {
1316
+ return null
1317
+ }
1318
+ return {
1319
+ clientId: token.slice(0, separatorIndex),
1320
+ clientSecret: token.slice(separatorIndex + 1),
1321
+ }
1322
+ }
1323
+
1324
+ function createServiceCredentials(): { clientId: string; clientSecret: string } {
1325
+ return {
1326
+ clientId: crypto.randomUUID(),
1327
+ clientSecret: crypto.randomBytes(32).toString('hex'),
1328
+ }
1329
+ }
1330
+
1331
+ export async function ensureServiceAuthToken({
1332
+ appId,
1333
+ preferredGatewayToken,
1334
+ }: {
1335
+ appId: string
1336
+ preferredGatewayToken?: string
1337
+ }): Promise<string> {
1338
+ const prisma = await getPrisma()
1339
+ const row = await prisma.bot_tokens.findUnique({
1340
+ where: { app_id: appId },
1341
+ })
1342
+ if (!row) {
1343
+ throw new Error(`Bot token row not found for app_id ${appId}`)
1344
+ }
1345
+
1346
+ const preferred = preferredGatewayToken
1347
+ ? splitServiceAuthToken({ token: preferredGatewayToken })
1348
+ : null
1349
+ const existing = (row.client_id && row.client_secret)
1350
+ ? { clientId: row.client_id, clientSecret: row.client_secret }
1351
+ : null
1352
+ const fromStoredToken = splitServiceAuthToken({ token: row.token })
1353
+ const resolved = preferred || existing || fromStoredToken || createServiceCredentials()
1354
+
1355
+ if (row.client_id !== resolved.clientId || row.client_secret !== resolved.clientSecret) {
1356
+ await prisma.bot_tokens.update({
1357
+ where: { app_id: appId },
1358
+ data: {
1359
+ client_id: resolved.clientId,
1360
+ client_secret: resolved.clientSecret,
1361
+ },
1362
+ })
1363
+ }
1364
+
1365
+ return `${resolved.clientId}:${resolved.clientSecret}`
1366
+ }
1367
+
1368
+ /**
1369
+ * Store a bot token.
1370
+ */
1371
+ export async function setBotToken(appId: string, token: string): Promise<void> {
1372
+ const prisma = await getPrisma()
1373
+ const generated = createServiceCredentials()
1374
+ await prisma.bot_tokens.upsert({
1375
+ where: { app_id: appId },
1376
+ create: {
1377
+ app_id: appId,
1378
+ token,
1379
+ client_id: generated.clientId,
1380
+ client_secret: generated.clientSecret,
1381
+ },
1382
+ update: { token },
1383
+ })
1384
+ await ensureServiceAuthToken({ appId })
1385
+ }
1386
+
1387
+ export type { BotMode }
1388
+
1389
+ /**
1390
+ * Persist gateway bot mode credentials.
1391
+ * Upserts the row so a prior setBotToken call is not needed.
1392
+ */
1393
+ export async function setBotMode({
1394
+ appId,
1395
+ mode,
1396
+ clientId,
1397
+ clientSecret,
1398
+ proxyUrl,
1399
+ }: {
1400
+ appId: string
1401
+ mode: BotMode
1402
+ clientId?: string | null
1403
+ clientSecret?: string | null
1404
+ proxyUrl?: string | null
1405
+ }): Promise<void> {
1406
+ const prisma = await getPrisma()
1407
+ const data = {
1408
+ bot_mode: mode,
1409
+ client_id: clientId ?? null,
1410
+ client_secret: clientSecret ?? null,
1411
+ proxy_url: proxyUrl ?? null,
1412
+ }
1413
+ const createToken = (clientId && clientSecret) ? `${clientId}:${clientSecret}` : ''
1414
+ await prisma.bot_tokens.upsert({
1415
+ where: { app_id: appId },
1416
+ create: { app_id: appId, token: createToken, ...data },
1417
+ update: data,
1418
+ })
1419
+ await ensureServiceAuthToken({
1420
+ appId,
1421
+ preferredGatewayToken: (clientId && clientSecret) ? `${clientId}:${clientSecret}` : undefined,
1422
+ })
1423
+ }
1424
+
1425
+
1426
+
1427
+ // ============================================================================
1428
+ // Bot API Keys Functions
1429
+ // ============================================================================
1430
+
1431
+ /**
1432
+ * Get the Gemini API key for a bot.
1433
+ */
1434
+ export async function getGeminiApiKey(appId: string): Promise<string | null> {
1435
+ const prisma = await getPrisma()
1436
+ const row = await prisma.bot_api_keys.findUnique({
1437
+ where: { app_id: appId },
1438
+ })
1439
+ return row?.gemini_api_key ?? null
1440
+ }
1441
+
1442
+ /**
1443
+ * Set the Gemini API key for a bot.
1444
+ * Note: The bot must already have a token (via setBotToken) before calling this.
1445
+ */
1446
+ export async function setGeminiApiKey(
1447
+ appId: string,
1448
+ apiKey: string,
1449
+ ): Promise<void> {
1450
+ const prisma = await getPrisma()
1451
+ await prisma.bot_api_keys.upsert({
1452
+ where: { app_id: appId },
1453
+ create: { app_id: appId, gemini_api_key: apiKey },
1454
+ update: { gemini_api_key: apiKey },
1455
+ })
1456
+ }
1457
+
1458
+ /**
1459
+ * Get the OpenAI API key for a bot.
1460
+ */
1461
+ export async function getOpenAIApiKey(appId: string): Promise<string | null> {
1462
+ const prisma = await getPrisma()
1463
+ const row = await prisma.bot_api_keys.findUnique({
1464
+ where: { app_id: appId },
1465
+ })
1466
+ return row?.openai_api_key ?? null
1467
+ }
1468
+
1469
+ /**
1470
+ * Set the OpenAI API key for a bot.
1471
+ */
1472
+ export async function setOpenAIApiKey(
1473
+ appId: string,
1474
+ apiKey: string,
1475
+ ): Promise<void> {
1476
+ const prisma = await getPrisma()
1477
+ await prisma.bot_api_keys.upsert({
1478
+ where: { app_id: appId },
1479
+ create: { app_id: appId, openai_api_key: apiKey },
1480
+ update: { openai_api_key: apiKey },
1481
+ })
1482
+ }
1483
+
1484
+ /**
1485
+ * Get the best available transcription API key for a bot.
1486
+ * Prefers OpenAI, falls back to Gemini.
1487
+ */
1488
+ export async function getTranscriptionApiKey(
1489
+ appId: string,
1490
+ ): Promise<{ provider: 'openai' | 'gemini'; apiKey: string } | null> {
1491
+ const prisma = await getPrisma()
1492
+ const row = await prisma.bot_api_keys.findUnique({
1493
+ where: { app_id: appId },
1494
+ })
1495
+ if (!row) return null
1496
+ if (row.openai_api_key) {
1497
+ return { provider: 'openai', apiKey: row.openai_api_key }
1498
+ }
1499
+ if (row.gemini_api_key) {
1500
+ return { provider: 'gemini', apiKey: row.gemini_api_key }
1501
+ }
1502
+ return null
1503
+ }
1504
+
1505
+ // ============================================================================
1506
+ // Channel Directory CRUD Functions
1507
+ // ============================================================================
1508
+
1509
+ /**
1510
+ * Store a channel-directory mapping.
1511
+ * @param skipIfExists If true, behaves like INSERT OR IGNORE - skips if record exists.
1512
+ * If false (default), behaves like INSERT OR REPLACE - updates if exists.
1513
+ */
1514
+ export async function setChannelDirectory({
1515
+ channelId,
1516
+ directory,
1517
+ channelType,
1518
+ skipIfExists = false,
1519
+ }: {
1520
+ channelId: string
1521
+ directory: string
1522
+ channelType: PrismaChannelType
1523
+ skipIfExists?: boolean
1524
+ }): Promise<void> {
1525
+ const prisma = await getPrisma()
1526
+ if (skipIfExists) {
1527
+ // INSERT OR IGNORE semantics - only insert if not exists
1528
+ const existing = await prisma.channel_directories.findUnique({
1529
+ where: { channel_id: channelId },
1530
+ })
1531
+ if (existing) {
1532
+ return
1533
+ }
1534
+ await prisma.channel_directories.create({
1535
+ data: {
1536
+ channel_id: channelId,
1537
+ directory,
1538
+ channel_type: channelType,
1539
+ },
1540
+ })
1541
+ } else {
1542
+ // INSERT OR REPLACE semantics - upsert
1543
+ await prisma.channel_directories.upsert({
1544
+ where: { channel_id: channelId },
1545
+ create: {
1546
+ channel_id: channelId,
1547
+ directory,
1548
+ channel_type: channelType,
1549
+ },
1550
+ update: {
1551
+ directory,
1552
+ channel_type: channelType,
1553
+ },
1554
+ })
1555
+ }
1556
+ }
1557
+
1558
+ /**
1559
+ * Find channels by directory path.
1560
+ */
1561
+ export async function findChannelsByDirectory({
1562
+ directory,
1563
+ channelType,
1564
+ }: {
1565
+ directory?: string
1566
+ channelType?: PrismaChannelType
1567
+ }): Promise<
1568
+ Array<{ channel_id: string; directory: string; channel_type: string }>
1569
+ > {
1570
+ const prisma = await getPrisma()
1571
+ const where: {
1572
+ directory?: string
1573
+ channel_type?: PrismaChannelType
1574
+ } = {}
1575
+ if (directory) {
1576
+ where.directory = directory
1577
+ }
1578
+ if (channelType) {
1579
+ where.channel_type = channelType
1580
+ }
1581
+ const rows = await prisma.channel_directories.findMany({
1582
+ where,
1583
+ select: { channel_id: true, directory: true, channel_type: true },
1584
+ })
1585
+ return rows
1586
+ }
1587
+
1588
+ /**
1589
+ * Get all distinct directories with text channels.
1590
+ */
1591
+ export async function getAllTextChannelDirectories(): Promise<string[]> {
1592
+ const prisma = await getPrisma()
1593
+ const rows = await prisma.channel_directories.findMany({
1594
+ where: { channel_type: 'text' },
1595
+ select: { directory: true },
1596
+ distinct: ['directory'],
1597
+ })
1598
+ return rows.map((row) => row.directory)
1599
+ }
1600
+
1601
+ export async function listTrackedTextChannels(): Promise<
1602
+ Array<{ channel_id: string; directory: string; created_at: Date | null }>
1603
+ > {
1604
+ const prisma = await getPrisma()
1605
+ return prisma.channel_directories.findMany({
1606
+ where: { channel_type: 'text' },
1607
+ orderBy: [{ created_at: 'asc' }, { channel_id: 'asc' }],
1608
+ select: { channel_id: true, directory: true, created_at: true },
1609
+ })
1610
+ }
1611
+
1612
+ /**
1613
+ * Delete all channel directories for a specific directory.
1614
+ */
1615
+ export async function deleteChannelDirectoriesByDirectory(
1616
+ directory: string,
1617
+ ): Promise<void> {
1618
+ const prisma = await getPrisma()
1619
+ await prisma.channel_directories.deleteMany({
1620
+ where: { directory },
1621
+ })
1622
+ }
1623
+
1624
+ /**
1625
+ * Delete a single channel_directories row and all its child rows
1626
+ * (channel_models, channel_agents, channel_worktrees, channel_verbosity,
1627
+ * channel_mention_mode) in a single transaction. scheduled_tasks has
1628
+ * onDelete:SetNull so Prisma handles it automatically.
1629
+ */
1630
+ export async function deleteChannelDirectoryById(
1631
+ channelId: string,
1632
+ ): Promise<boolean> {
1633
+ const prisma = await getPrisma()
1634
+ const deletedCount = await prisma.$transaction(async (tx) => {
1635
+ await tx.channel_models.deleteMany({ where: { channel_id: channelId } })
1636
+ await tx.channel_agents.deleteMany({ where: { channel_id: channelId } })
1637
+ await tx.channel_worktrees.deleteMany({ where: { channel_id: channelId } })
1638
+ await tx.channel_verbosity.deleteMany({ where: { channel_id: channelId } })
1639
+ await tx.channel_mention_mode.deleteMany({ where: { channel_id: channelId } })
1640
+ const result = await tx.channel_directories.deleteMany({
1641
+ where: { channel_id: channelId },
1642
+ })
1643
+ return result.count
1644
+ })
1645
+ return deletedCount > 0
1646
+ }
1647
+
1648
+ /**
1649
+ * Get the directory for a voice channel.
1650
+ */
1651
+ export async function getVoiceChannelDirectory(
1652
+ channelId: string,
1653
+ ): Promise<string | undefined> {
1654
+ const prisma = await getPrisma()
1655
+ const row = await prisma.channel_directories.findFirst({
1656
+ where: { channel_id: channelId, channel_type: 'voice' },
1657
+ })
1658
+ return row?.directory
1659
+ }
1660
+
1661
+ /**
1662
+ * Find the text channel ID that shares the same directory as a voice channel.
1663
+ * Used to send error messages to text channels from voice handlers.
1664
+ */
1665
+ export async function findTextChannelByVoiceChannel(
1666
+ voiceChannelId: string,
1667
+ ): Promise<string | undefined> {
1668
+ const prisma = await getPrisma()
1669
+ // First get the directory for the voice channel
1670
+ const voiceChannel = await prisma.channel_directories.findFirst({
1671
+ where: { channel_id: voiceChannelId, channel_type: 'voice' },
1672
+ })
1673
+ if (!voiceChannel) {
1674
+ return undefined
1675
+ }
1676
+ // Then find the text channel with the same directory
1677
+ const textChannel = await prisma.channel_directories.findFirst({
1678
+ where: { directory: voiceChannel.directory, channel_type: 'text' },
1679
+ })
1680
+ return textChannel?.channel_id
1681
+ }
1682
+
1683
+ // ============================================================================
1684
+ // Forum Sync Config Functions
1685
+ // ============================================================================
1686
+
1687
+ export type ForumSyncConfigRow = {
1688
+ appId: string
1689
+ forumChannelId: string
1690
+ outputDir: string
1691
+ direction: string
1692
+ }
1693
+
1694
+ export async function getForumSyncConfigs({
1695
+ appId,
1696
+ }: {
1697
+ appId: string
1698
+ }): Promise<ForumSyncConfigRow[]> {
1699
+ const prisma = await getPrisma()
1700
+ const rows = await prisma.forum_sync_configs.findMany({
1701
+ where: { app_id: appId },
1702
+ })
1703
+ return rows.map((row) => ({
1704
+ appId: row.app_id,
1705
+ forumChannelId: row.forum_channel_id,
1706
+ outputDir: row.output_dir,
1707
+ direction: row.direction,
1708
+ }))
1709
+ }
1710
+
1711
+ export async function upsertForumSyncConfig({
1712
+ appId,
1713
+ forumChannelId,
1714
+ outputDir,
1715
+ direction = 'bidirectional',
1716
+ }: {
1717
+ appId: string
1718
+ forumChannelId: string
1719
+ outputDir: string
1720
+ direction?: string
1721
+ }) {
1722
+ const prisma = await getPrisma()
1723
+ await prisma.forum_sync_configs.upsert({
1724
+ where: {
1725
+ app_id_forum_channel_id: {
1726
+ app_id: appId,
1727
+ forum_channel_id: forumChannelId,
1728
+ },
1729
+ },
1730
+ update: { output_dir: outputDir, direction },
1731
+ create: {
1732
+ app_id: appId,
1733
+ forum_channel_id: forumChannelId,
1734
+ output_dir: outputDir,
1735
+ direction,
1736
+ },
1737
+ })
1738
+ }
1739
+
1740
+ export async function deleteForumSyncConfig({
1741
+ appId,
1742
+ forumChannelId,
1743
+ }: {
1744
+ appId: string
1745
+ forumChannelId: string
1746
+ }) {
1747
+ const prisma = await getPrisma()
1748
+ await prisma.forum_sync_configs.deleteMany({
1749
+ where: { app_id: appId, forum_channel_id: forumChannelId },
1750
+ })
1751
+ }
1752
+
1753
+ /** Delete forum sync configs that share the same outputDir but have a different forumChannelId.
1754
+ * This cleans up stale entries left behind when a forum channel is deleted and recreated. */
1755
+ export async function deleteStaleForumSyncConfigs({
1756
+ appId,
1757
+ forumChannelId,
1758
+ outputDir,
1759
+ }: {
1760
+ appId: string
1761
+ forumChannelId: string
1762
+ outputDir: string
1763
+ }) {
1764
+ const prisma = await getPrisma()
1765
+ await prisma.forum_sync_configs.deleteMany({
1766
+ where: {
1767
+ app_id: appId,
1768
+ output_dir: outputDir,
1769
+ NOT: { forum_channel_id: forumChannelId },
1770
+ },
1771
+ })
1772
+ }
1773
+
1774
+ // ═══════════════════════════════════════════════════════════════════════════
1775
+ // IPC REQUESTS - plugin <-> bot communication via DB polling
1776
+ // ═══════════════════════════════════════════════════════════════════════════
1777
+
1778
+ export async function createIpcRequest({
1779
+ type,
1780
+ sessionId,
1781
+ threadId,
1782
+ payload,
1783
+ }: {
1784
+ type: import('./generated/client.js').ipc_request_type
1785
+ sessionId: string
1786
+ threadId: string
1787
+ payload: string
1788
+ }) {
1789
+ const prisma = await getPrisma()
1790
+ return prisma.ipc_requests.create({
1791
+ data: {
1792
+ type,
1793
+ session_id: sessionId,
1794
+ thread_id: threadId,
1795
+ payload,
1796
+ },
1797
+ })
1798
+ }
1799
+
1800
+ /**
1801
+ * Atomically claim pending IPC requests by updating status to 'processing'
1802
+ * only for rows that are still 'pending'. Returns the claimed rows.
1803
+ * This prevents duplicate dispatch when poll ticks overlap.
1804
+ */
1805
+ export async function claimPendingIpcRequests() {
1806
+ const prisma = await getPrisma()
1807
+ const pending = await prisma.ipc_requests.findMany({
1808
+ where: { status: 'pending' },
1809
+ orderBy: { created_at: 'asc' },
1810
+ })
1811
+ if (pending.length === 0) return pending
1812
+
1813
+ // Atomically claim each one (updateMany with status guard)
1814
+ const claimed: typeof pending = []
1815
+ for (const req of pending) {
1816
+ const result = await prisma.ipc_requests.updateMany({
1817
+ where: { id: req.id, status: 'pending' },
1818
+ data: { status: 'processing' },
1819
+ })
1820
+ if (result.count > 0) {
1821
+ claimed.push(req)
1822
+ }
1823
+ }
1824
+ return claimed
1825
+ }
1826
+
1827
+ export async function completeIpcRequest({
1828
+ id,
1829
+ response,
1830
+ }: {
1831
+ id: string
1832
+ response: string
1833
+ }) {
1834
+ const prisma = await getPrisma()
1835
+ return prisma.ipc_requests.update({
1836
+ where: { id },
1837
+ data: { response, status: 'completed' as const },
1838
+ })
1839
+ }
1840
+
1841
+ export async function getIpcRequestById({ id }: { id: string }) {
1842
+ const prisma = await getPrisma()
1843
+ return prisma.ipc_requests.findUnique({ where: { id } })
1844
+ }
1845
+
1846
+ /** Cancel IPC requests stuck in 'processing' longer than the TTL (e.g. hung file upload). */
1847
+ export async function cancelStaleProcessingRequests({
1848
+ ttlMs,
1849
+ }: {
1850
+ ttlMs: number
1851
+ }) {
1852
+ const prisma = await getPrisma()
1853
+ const cutoff = new Date(Date.now() - ttlMs)
1854
+ return prisma.ipc_requests.updateMany({
1855
+ where: {
1856
+ status: 'processing',
1857
+ updated_at: { lt: cutoff },
1858
+ },
1859
+ data: {
1860
+ status: 'cancelled' as const,
1861
+ response: JSON.stringify({ error: 'Request timed out' }),
1862
+ },
1863
+ })
1864
+ }
1865
+
1866
+ /** Cancel all pending IPC requests (on startup cleanup and shutdown). */
1867
+ export async function cancelAllPendingIpcRequests() {
1868
+ const prisma = await getPrisma()
1869
+ await prisma.ipc_requests.updateMany({
1870
+ where: { status: { in: ['pending', 'processing'] } },
1871
+ data: {
1872
+ status: 'cancelled' as const,
1873
+ response: JSON.stringify({ error: 'Bot shutting down' }),
1874
+ },
1875
+ })
1876
+ }