@otto-assistant/otto 0.1.2 → 0.7.16

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