@brianli/kimaki 0.4.72-brianli.1

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 (328) hide show
  1. package/bin.js +2 -0
  2. package/dist/ai-tool-to-genai.js +233 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/ai-tool.js +6 -0
  5. package/dist/bin.js +87 -0
  6. package/dist/bot-token.js +121 -0
  7. package/dist/bot-token.test.js +134 -0
  8. package/dist/channel-management.js +101 -0
  9. package/dist/cli-parsing.test.js +89 -0
  10. package/dist/cli.js +2529 -0
  11. package/dist/commands/abort.js +82 -0
  12. package/dist/commands/action-buttons.js +257 -0
  13. package/dist/commands/add-project.js +114 -0
  14. package/dist/commands/agent.js +291 -0
  15. package/dist/commands/ask-question.js +223 -0
  16. package/dist/commands/compact.js +120 -0
  17. package/dist/commands/context-usage.js +140 -0
  18. package/dist/commands/create-new-project.js +118 -0
  19. package/dist/commands/diff.js +128 -0
  20. package/dist/commands/file-upload.js +275 -0
  21. package/dist/commands/fork.js +217 -0
  22. package/dist/commands/gemini-apikey.js +70 -0
  23. package/dist/commands/login.js +490 -0
  24. package/dist/commands/mention-mode.js +51 -0
  25. package/dist/commands/merge-worktree.js +124 -0
  26. package/dist/commands/model.js +694 -0
  27. package/dist/commands/permissions.js +163 -0
  28. package/dist/commands/queue.js +217 -0
  29. package/dist/commands/remove-project.js +115 -0
  30. package/dist/commands/restart-opencode-server.js +116 -0
  31. package/dist/commands/resume.js +159 -0
  32. package/dist/commands/run-command.js +79 -0
  33. package/dist/commands/session-id.js +78 -0
  34. package/dist/commands/session.js +192 -0
  35. package/dist/commands/share.js +80 -0
  36. package/dist/commands/types.js +2 -0
  37. package/dist/commands/undo-redo.js +159 -0
  38. package/dist/commands/unset-model.js +152 -0
  39. package/dist/commands/upgrade.js +42 -0
  40. package/dist/commands/user-command.js +148 -0
  41. package/dist/commands/verbosity.js +60 -0
  42. package/dist/commands/worktree-settings.js +50 -0
  43. package/dist/commands/worktree.js +299 -0
  44. package/dist/condense-memory.js +33 -0
  45. package/dist/config.js +110 -0
  46. package/dist/database.js +1050 -0
  47. package/dist/db.js +159 -0
  48. package/dist/db.test.js +49 -0
  49. package/dist/discord-api.js +28 -0
  50. package/dist/discord-auth.js +231 -0
  51. package/dist/discord-auth.test.js +80 -0
  52. package/dist/discord-bot.js +997 -0
  53. package/dist/discord-utils.js +560 -0
  54. package/dist/discord-utils.test.js +115 -0
  55. package/dist/errors.js +167 -0
  56. package/dist/escape-backticks.test.js +429 -0
  57. package/dist/format-tables.js +122 -0
  58. package/dist/format-tables.test.js +199 -0
  59. package/dist/forum-sync/config.js +79 -0
  60. package/dist/forum-sync/discord-operations.js +154 -0
  61. package/dist/forum-sync/index.js +5 -0
  62. package/dist/forum-sync/markdown.js +117 -0
  63. package/dist/forum-sync/sync-to-discord.js +417 -0
  64. package/dist/forum-sync/sync-to-files.js +190 -0
  65. package/dist/forum-sync/types.js +53 -0
  66. package/dist/forum-sync/watchers.js +307 -0
  67. package/dist/gateway-consumer.js +232 -0
  68. package/dist/gateway-consumer.test.js +18 -0
  69. package/dist/genai-worker-wrapper.js +111 -0
  70. package/dist/genai-worker.js +311 -0
  71. package/dist/genai.js +232 -0
  72. package/dist/generated/browser.js +17 -0
  73. package/dist/generated/client.js +35 -0
  74. package/dist/generated/commonInputTypes.js +10 -0
  75. package/dist/generated/enums.js +30 -0
  76. package/dist/generated/internal/class.js +41 -0
  77. package/dist/generated/internal/prismaNamespace.js +239 -0
  78. package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
  79. package/dist/generated/models/bot_api_keys.js +1 -0
  80. package/dist/generated/models/bot_tokens.js +1 -0
  81. package/dist/generated/models/channel_agents.js +1 -0
  82. package/dist/generated/models/channel_directories.js +1 -0
  83. package/dist/generated/models/channel_mention_mode.js +1 -0
  84. package/dist/generated/models/channel_models.js +1 -0
  85. package/dist/generated/models/channel_verbosity.js +1 -0
  86. package/dist/generated/models/channel_worktrees.js +1 -0
  87. package/dist/generated/models/forum_sync_configs.js +1 -0
  88. package/dist/generated/models/global_models.js +1 -0
  89. package/dist/generated/models/ipc_requests.js +1 -0
  90. package/dist/generated/models/part_messages.js +1 -0
  91. package/dist/generated/models/scheduled_tasks.js +1 -0
  92. package/dist/generated/models/session_agents.js +1 -0
  93. package/dist/generated/models/session_models.js +1 -0
  94. package/dist/generated/models/session_start_sources.js +1 -0
  95. package/dist/generated/models/thread_sessions.js +1 -0
  96. package/dist/generated/models/thread_worktrees.js +1 -0
  97. package/dist/generated/models.js +1 -0
  98. package/dist/heap-monitor.js +95 -0
  99. package/dist/hrana-server.js +416 -0
  100. package/dist/hrana-server.test.js +368 -0
  101. package/dist/image-utils.js +112 -0
  102. package/dist/interaction-handler.js +327 -0
  103. package/dist/ipc-polling.js +251 -0
  104. package/dist/kimaki-digital-twin.e2e.test.js +165 -0
  105. package/dist/limit-heading-depth.js +25 -0
  106. package/dist/limit-heading-depth.test.js +105 -0
  107. package/dist/logger.js +160 -0
  108. package/dist/markdown.js +342 -0
  109. package/dist/markdown.test.js +253 -0
  110. package/dist/message-formatting.js +433 -0
  111. package/dist/message-formatting.test.js +73 -0
  112. package/dist/openai-realtime.js +228 -0
  113. package/dist/opencode-plugin-loading.e2e.test.js +91 -0
  114. package/dist/opencode-plugin.js +536 -0
  115. package/dist/opencode-plugin.test.js +98 -0
  116. package/dist/opencode.js +409 -0
  117. package/dist/privacy-sanitizer.js +105 -0
  118. package/dist/runtime-mode.js +51 -0
  119. package/dist/runtime-mode.test.js +115 -0
  120. package/dist/sentry.js +127 -0
  121. package/dist/session-handler/state.js +151 -0
  122. package/dist/session-handler.js +1874 -0
  123. package/dist/session-search.js +100 -0
  124. package/dist/session-search.test.js +40 -0
  125. package/dist/startup-service.js +153 -0
  126. package/dist/system-message.js +499 -0
  127. package/dist/task-runner.js +282 -0
  128. package/dist/task-schedule.js +191 -0
  129. package/dist/task-schedule.test.js +71 -0
  130. package/dist/thinking-utils.js +35 -0
  131. package/dist/thread-message-queue.e2e.test.js +781 -0
  132. package/dist/tools.js +359 -0
  133. package/dist/unnest-code-blocks.js +136 -0
  134. package/dist/unnest-code-blocks.test.js +641 -0
  135. package/dist/upgrade.js +114 -0
  136. package/dist/utils.js +109 -0
  137. package/dist/voice-handler.js +606 -0
  138. package/dist/voice.js +304 -0
  139. package/dist/voice.test.js +187 -0
  140. package/dist/wait-session.js +94 -0
  141. package/dist/worker-types.js +4 -0
  142. package/dist/worktree-utils.js +727 -0
  143. package/dist/xml.js +92 -0
  144. package/dist/xml.test.js +32 -0
  145. package/package.json +82 -0
  146. package/schema.prisma +246 -0
  147. package/skills/batch/SKILL.md +87 -0
  148. package/skills/critique/SKILL.md +129 -0
  149. package/skills/errore/SKILL.md +589 -0
  150. package/skills/goke/.prettierrc +5 -0
  151. package/skills/goke/CHANGELOG.md +40 -0
  152. package/skills/goke/LICENSE +21 -0
  153. package/skills/goke/README.md +666 -0
  154. package/skills/goke/SKILL.md +458 -0
  155. package/skills/goke/package.json +43 -0
  156. package/skills/goke/src/__test__/coerce.test.ts +411 -0
  157. package/skills/goke/src/__test__/index.test.ts +1798 -0
  158. package/skills/goke/src/__test__/types.test-d.ts +111 -0
  159. package/skills/goke/src/coerce.ts +547 -0
  160. package/skills/goke/src/goke.ts +1362 -0
  161. package/skills/goke/src/index.ts +16 -0
  162. package/skills/goke/src/mri.ts +164 -0
  163. package/skills/goke/tsconfig.json +15 -0
  164. package/skills/jitter/EDITOR.md +219 -0
  165. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  166. package/skills/jitter/SKILL.md +158 -0
  167. package/skills/jitter/jitter-clipboard.json +1042 -0
  168. package/skills/jitter/package.json +14 -0
  169. package/skills/jitter/tsconfig.json +15 -0
  170. package/skills/jitter/utils/actions.ts +212 -0
  171. package/skills/jitter/utils/export.ts +114 -0
  172. package/skills/jitter/utils/index.ts +141 -0
  173. package/skills/jitter/utils/snapshot.ts +154 -0
  174. package/skills/jitter/utils/traverse.ts +246 -0
  175. package/skills/jitter/utils/types.ts +279 -0
  176. package/skills/jitter/utils/wait.ts +133 -0
  177. package/skills/playwriter/SKILL.md +31 -0
  178. package/skills/security-review/SKILL.md +208 -0
  179. package/skills/simplify/SKILL.md +58 -0
  180. package/skills/termcast/SKILL.md +945 -0
  181. package/skills/tuistory/SKILL.md +250 -0
  182. package/skills/zustand-centralized-state/SKILL.md +582 -0
  183. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  184. package/src/__snapshots__/compact-session-context.md +41 -0
  185. package/src/__snapshots__/first-session-no-info.md +17 -0
  186. package/src/__snapshots__/first-session-with-info.md +23 -0
  187. package/src/__snapshots__/session-1.md +17 -0
  188. package/src/__snapshots__/session-2.md +5871 -0
  189. package/src/__snapshots__/session-3.md +17 -0
  190. package/src/__snapshots__/session-with-tools.md +5871 -0
  191. package/src/ai-tool-to-genai.test.ts +296 -0
  192. package/src/ai-tool-to-genai.ts +282 -0
  193. package/src/ai-tool.ts +39 -0
  194. package/src/bin.ts +108 -0
  195. package/src/bot-token.test.ts +171 -0
  196. package/src/bot-token.ts +159 -0
  197. package/src/channel-management.ts +172 -0
  198. package/src/cli-parsing.test.ts +132 -0
  199. package/src/cli.ts +3605 -0
  200. package/src/commands/abort.ts +112 -0
  201. package/src/commands/action-buttons.ts +376 -0
  202. package/src/commands/add-project.ts +152 -0
  203. package/src/commands/agent.ts +404 -0
  204. package/src/commands/ask-question.ts +330 -0
  205. package/src/commands/compact.ts +157 -0
  206. package/src/commands/context-usage.ts +199 -0
  207. package/src/commands/create-new-project.ts +179 -0
  208. package/src/commands/diff.ts +165 -0
  209. package/src/commands/file-upload.ts +389 -0
  210. package/src/commands/fork.ts +320 -0
  211. package/src/commands/gemini-apikey.ts +104 -0
  212. package/src/commands/login.ts +634 -0
  213. package/src/commands/mention-mode.ts +77 -0
  214. package/src/commands/merge-worktree.ts +177 -0
  215. package/src/commands/model.ts +961 -0
  216. package/src/commands/permissions.ts +261 -0
  217. package/src/commands/queue.ts +296 -0
  218. package/src/commands/remove-project.ts +155 -0
  219. package/src/commands/restart-opencode-server.ts +162 -0
  220. package/src/commands/resume.ts +242 -0
  221. package/src/commands/run-command.ts +123 -0
  222. package/src/commands/session-id.ts +109 -0
  223. package/src/commands/session.ts +250 -0
  224. package/src/commands/share.ts +106 -0
  225. package/src/commands/types.ts +25 -0
  226. package/src/commands/undo-redo.ts +221 -0
  227. package/src/commands/unset-model.ts +189 -0
  228. package/src/commands/upgrade.ts +52 -0
  229. package/src/commands/user-command.ts +193 -0
  230. package/src/commands/verbosity.ts +88 -0
  231. package/src/commands/worktree-settings.ts +79 -0
  232. package/src/commands/worktree.ts +431 -0
  233. package/src/condense-memory.ts +36 -0
  234. package/src/config.ts +148 -0
  235. package/src/database.ts +1530 -0
  236. package/src/db.test.ts +60 -0
  237. package/src/db.ts +190 -0
  238. package/src/discord-api.ts +35 -0
  239. package/src/discord-bot.ts +1316 -0
  240. package/src/discord-utils.test.ts +132 -0
  241. package/src/discord-utils.ts +767 -0
  242. package/src/errors.ts +213 -0
  243. package/src/escape-backticks.test.ts +469 -0
  244. package/src/format-tables.test.ts +223 -0
  245. package/src/format-tables.ts +145 -0
  246. package/src/forum-sync/config.ts +92 -0
  247. package/src/forum-sync/discord-operations.ts +241 -0
  248. package/src/forum-sync/index.ts +9 -0
  249. package/src/forum-sync/markdown.ts +176 -0
  250. package/src/forum-sync/sync-to-discord.ts +595 -0
  251. package/src/forum-sync/sync-to-files.ts +294 -0
  252. package/src/forum-sync/types.ts +175 -0
  253. package/src/forum-sync/watchers.ts +454 -0
  254. package/src/genai-worker-wrapper.ts +164 -0
  255. package/src/genai-worker.ts +386 -0
  256. package/src/genai.ts +321 -0
  257. package/src/generated/browser.ts +109 -0
  258. package/src/generated/client.ts +131 -0
  259. package/src/generated/commonInputTypes.ts +512 -0
  260. package/src/generated/enums.ts +46 -0
  261. package/src/generated/internal/class.ts +362 -0
  262. package/src/generated/internal/prismaNamespace.ts +2251 -0
  263. package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
  264. package/src/generated/models/bot_api_keys.ts +1288 -0
  265. package/src/generated/models/bot_tokens.ts +1577 -0
  266. package/src/generated/models/channel_agents.ts +1256 -0
  267. package/src/generated/models/channel_directories.ts +2104 -0
  268. package/src/generated/models/channel_mention_mode.ts +1300 -0
  269. package/src/generated/models/channel_models.ts +1288 -0
  270. package/src/generated/models/channel_verbosity.ts +1224 -0
  271. package/src/generated/models/channel_worktrees.ts +1308 -0
  272. package/src/generated/models/forum_sync_configs.ts +1452 -0
  273. package/src/generated/models/global_models.ts +1288 -0
  274. package/src/generated/models/ipc_requests.ts +1485 -0
  275. package/src/generated/models/part_messages.ts +1302 -0
  276. package/src/generated/models/scheduled_tasks.ts +2320 -0
  277. package/src/generated/models/session_agents.ts +1086 -0
  278. package/src/generated/models/session_models.ts +1114 -0
  279. package/src/generated/models/session_start_sources.ts +1408 -0
  280. package/src/generated/models/thread_sessions.ts +1599 -0
  281. package/src/generated/models/thread_worktrees.ts +1352 -0
  282. package/src/generated/models.ts +29 -0
  283. package/src/heap-monitor.ts +121 -0
  284. package/src/hrana-server.test.ts +428 -0
  285. package/src/hrana-server.ts +547 -0
  286. package/src/image-utils.ts +149 -0
  287. package/src/interaction-handler.ts +461 -0
  288. package/src/ipc-polling.ts +325 -0
  289. package/src/kimaki-digital-twin.e2e.test.ts +201 -0
  290. package/src/limit-heading-depth.test.ts +116 -0
  291. package/src/limit-heading-depth.ts +26 -0
  292. package/src/logger.ts +203 -0
  293. package/src/markdown.test.ts +360 -0
  294. package/src/markdown.ts +410 -0
  295. package/src/message-formatting.test.ts +81 -0
  296. package/src/message-formatting.ts +549 -0
  297. package/src/openai-realtime.ts +362 -0
  298. package/src/opencode-plugin-loading.e2e.test.ts +112 -0
  299. package/src/opencode-plugin.test.ts +108 -0
  300. package/src/opencode-plugin.ts +652 -0
  301. package/src/opencode.ts +554 -0
  302. package/src/privacy-sanitizer.ts +142 -0
  303. package/src/schema.sql +158 -0
  304. package/src/sentry.ts +137 -0
  305. package/src/session-handler/state.ts +232 -0
  306. package/src/session-handler.ts +2668 -0
  307. package/src/session-search.test.ts +50 -0
  308. package/src/session-search.ts +148 -0
  309. package/src/startup-service.ts +200 -0
  310. package/src/system-message.ts +568 -0
  311. package/src/task-runner.ts +425 -0
  312. package/src/task-schedule.test.ts +84 -0
  313. package/src/task-schedule.ts +287 -0
  314. package/src/thinking-utils.ts +61 -0
  315. package/src/thread-message-queue.e2e.test.ts +997 -0
  316. package/src/tools.ts +432 -0
  317. package/src/unnest-code-blocks.test.ts +679 -0
  318. package/src/unnest-code-blocks.ts +168 -0
  319. package/src/upgrade.ts +127 -0
  320. package/src/utils.ts +145 -0
  321. package/src/voice-handler.ts +852 -0
  322. package/src/voice.test.ts +219 -0
  323. package/src/voice.ts +444 -0
  324. package/src/wait-session.ts +147 -0
  325. package/src/worker-types.ts +64 -0
  326. package/src/worktree-utils.ts +988 -0
  327. package/src/xml.test.ts +38 -0
  328. package/src/xml.ts +121 -0
@@ -0,0 +1,1530 @@
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 { getDefaultVerbosity, getDefaultMentionMode } from './config.js'
7
+ import { hydrateBotTokenCache, isAuthModeEnabled } from './bot-token.js'
8
+ import { createLogger, LogPrefix } from './logger.js'
9
+
10
+ const dbLogger = createLogger(LogPrefix.DB)
11
+
12
+ // Re-export Prisma utilities
13
+ export { getPrisma, closePrisma }
14
+
15
+ /**
16
+ * Initialize the database.
17
+ * Returns the Prisma client.
18
+ */
19
+ export async function initDatabase() {
20
+ const prisma = await getPrisma()
21
+ const botRow = await prisma.bot_tokens.findFirst({
22
+ orderBy: { created_at: 'desc' },
23
+ })
24
+ hydrateBotTokenCache(
25
+ botRow ? { app_id: botRow.app_id, token: botRow.token } : null,
26
+ )
27
+ dbLogger.log('Database initialized')
28
+ return prisma
29
+ }
30
+
31
+ /**
32
+ * Close the database connection.
33
+ */
34
+ export async function closeDatabase() {
35
+ await closePrisma()
36
+ }
37
+
38
+ // Verbosity levels for controlling output detail
39
+ // - tools-and-text: shows all output including tool executions
40
+ // - text-and-essential-tools: shows text + edits + custom MCP tools, hides read/search/navigation tools
41
+ // - text-only: only shows text responses (⬥ diamond parts)
42
+ export type VerbosityLevel =
43
+ | 'tools-and-text'
44
+ | 'text-and-essential-tools'
45
+ | 'text-only'
46
+
47
+ // Worktree status types
48
+ export type WorktreeStatus = 'pending' | 'ready' | 'error'
49
+
50
+ export type ThreadWorktree = {
51
+ thread_id: string
52
+ worktree_name: string
53
+ worktree_directory: string | null
54
+ project_directory: string
55
+ status: WorktreeStatus
56
+ error_message: string | 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 cancelScheduledTask(taskId: number): Promise<boolean> {
224
+ const prisma = await getPrisma()
225
+ const result = await prisma.scheduled_tasks.updateMany({
226
+ where: {
227
+ id: taskId,
228
+ status: {
229
+ in: ['planned', 'running'],
230
+ },
231
+ },
232
+ data: {
233
+ status: 'cancelled',
234
+ running_started_at: null,
235
+ },
236
+ })
237
+ return result.count > 0
238
+ }
239
+
240
+ export async function getDuePlannedScheduledTasks({
241
+ now,
242
+ limit,
243
+ }: {
244
+ now: Date
245
+ limit: number
246
+ }): Promise<ScheduledTask[]> {
247
+ const prisma = await getPrisma()
248
+ const rows = await prisma.scheduled_tasks.findMany({
249
+ where: {
250
+ status: 'planned',
251
+ next_run_at: {
252
+ lte: now,
253
+ },
254
+ },
255
+ orderBy: [{ next_run_at: 'asc' }, { id: 'asc' }],
256
+ take: limit,
257
+ })
258
+ return rows.map((row) => toScheduledTask(row))
259
+ }
260
+
261
+ export async function claimScheduledTaskRunning({
262
+ taskId,
263
+ startedAt,
264
+ }: {
265
+ taskId: number
266
+ startedAt: Date
267
+ }): Promise<boolean> {
268
+ const prisma = await getPrisma()
269
+ const result = await prisma.scheduled_tasks.updateMany({
270
+ where: {
271
+ id: taskId,
272
+ status: 'planned',
273
+ },
274
+ data: {
275
+ status: 'running',
276
+ running_started_at: startedAt,
277
+ },
278
+ })
279
+ return result.count > 0
280
+ }
281
+
282
+ export async function recoverStaleRunningScheduledTasks({
283
+ staleBefore,
284
+ }: {
285
+ staleBefore: Date
286
+ }): Promise<number> {
287
+ const prisma = await getPrisma()
288
+ const result = await prisma.scheduled_tasks.updateMany({
289
+ where: {
290
+ status: 'running',
291
+ running_started_at: {
292
+ lte: staleBefore,
293
+ },
294
+ },
295
+ data: {
296
+ status: 'planned',
297
+ running_started_at: null,
298
+ },
299
+ })
300
+ return result.count
301
+ }
302
+
303
+ export async function markScheduledTaskOneShotCompleted({
304
+ taskId,
305
+ completedAt,
306
+ }: {
307
+ taskId: number
308
+ completedAt: Date
309
+ }): Promise<void> {
310
+ const prisma = await getPrisma()
311
+ await prisma.scheduled_tasks.update({
312
+ where: { id: taskId },
313
+ data: {
314
+ status: 'completed',
315
+ last_run_at: completedAt,
316
+ running_started_at: null,
317
+ last_error: null,
318
+ },
319
+ })
320
+ }
321
+
322
+ export async function markScheduledTaskCronRescheduled({
323
+ taskId,
324
+ completedAt,
325
+ nextRunAt,
326
+ }: {
327
+ taskId: number
328
+ completedAt: Date
329
+ nextRunAt: Date
330
+ }): Promise<void> {
331
+ const prisma = await getPrisma()
332
+ await prisma.scheduled_tasks.update({
333
+ where: { id: taskId },
334
+ data: {
335
+ status: 'planned',
336
+ last_run_at: completedAt,
337
+ running_started_at: null,
338
+ last_error: null,
339
+ next_run_at: nextRunAt,
340
+ },
341
+ })
342
+ }
343
+
344
+ export async function markScheduledTaskFailed({
345
+ taskId,
346
+ failedAt,
347
+ errorMessage,
348
+ }: {
349
+ taskId: number
350
+ failedAt: Date
351
+ errorMessage: string
352
+ }): Promise<void> {
353
+ const prisma = await getPrisma()
354
+ await prisma.scheduled_tasks.update({
355
+ where: { id: taskId },
356
+ data: {
357
+ status: 'failed',
358
+ last_run_at: failedAt,
359
+ running_started_at: null,
360
+ last_error: errorMessage,
361
+ attempts: {
362
+ increment: 1,
363
+ },
364
+ },
365
+ })
366
+ }
367
+
368
+ export async function markScheduledTaskCronRetry({
369
+ taskId,
370
+ failedAt,
371
+ errorMessage,
372
+ nextRunAt,
373
+ }: {
374
+ taskId: number
375
+ failedAt: Date
376
+ errorMessage: string
377
+ nextRunAt: Date
378
+ }): Promise<void> {
379
+ const prisma = await getPrisma()
380
+ await prisma.scheduled_tasks.update({
381
+ where: { id: taskId },
382
+ data: {
383
+ status: 'planned',
384
+ next_run_at: nextRunAt,
385
+ last_run_at: failedAt,
386
+ running_started_at: null,
387
+ last_error: errorMessage,
388
+ attempts: {
389
+ increment: 1,
390
+ },
391
+ },
392
+ })
393
+ }
394
+
395
+ export async function setSessionStartSource({
396
+ sessionId,
397
+ scheduleKind,
398
+ scheduledTaskId,
399
+ }: {
400
+ sessionId: string
401
+ scheduleKind: ScheduledTaskScheduleKind
402
+ scheduledTaskId?: number
403
+ }): Promise<void> {
404
+ const prisma = await getPrisma()
405
+ await prisma.session_start_sources.upsert({
406
+ where: { session_id: sessionId },
407
+ create: {
408
+ session_id: sessionId,
409
+ schedule_kind: scheduleKind,
410
+ scheduled_task_id: scheduledTaskId ?? null,
411
+ },
412
+ update: {
413
+ schedule_kind: scheduleKind,
414
+ scheduled_task_id: scheduledTaskId ?? null,
415
+ },
416
+ })
417
+ }
418
+
419
+ export async function getSessionStartSourcesBySessionIds(
420
+ sessionIds: string[],
421
+ ): Promise<Map<string, SessionStartSource>> {
422
+ if (sessionIds.length === 0) {
423
+ return new Map<string, SessionStartSource>()
424
+ }
425
+ const prisma = await getPrisma()
426
+ const chunkSize = 500
427
+ const chunks: string[][] = []
428
+ for (let index = 0; index < sessionIds.length; index += chunkSize) {
429
+ chunks.push(sessionIds.slice(index, index + chunkSize))
430
+ }
431
+
432
+ const rowGroups = await Promise.all(
433
+ chunks.map((chunkSessionIds) => {
434
+ return prisma.session_start_sources.findMany({
435
+ where: {
436
+ session_id: {
437
+ in: chunkSessionIds,
438
+ },
439
+ },
440
+ })
441
+ }),
442
+ )
443
+ const rows = rowGroups.flatMap((group) => group)
444
+ return new Map(rows.map((row) => [row.session_id, toSessionStartSource(row)]))
445
+ }
446
+
447
+ // ============================================================================
448
+ // Channel Model Functions
449
+ // ============================================================================
450
+
451
+ export type ModelPreference = { modelId: string; variant: string | null }
452
+
453
+ /**
454
+ * Get the model preference for a channel.
455
+ * @returns Model ID in format "provider_id/model_id" + optional variant, or undefined
456
+ */
457
+ export async function getChannelModel(
458
+ channelId: string,
459
+ ): Promise<ModelPreference | undefined> {
460
+ const prisma = await getPrisma()
461
+ const row = await prisma.channel_models.findUnique({
462
+ where: { channel_id: channelId },
463
+ })
464
+ if (!row) {
465
+ return undefined
466
+ }
467
+ return { modelId: row.model_id, variant: row.variant }
468
+ }
469
+
470
+ /**
471
+ * Set the model preference for a channel.
472
+ * @param modelId Model ID in format "provider_id/model_id"
473
+ * @param variant Optional thinking/reasoning variant name
474
+ */
475
+ export async function setChannelModel({
476
+ channelId,
477
+ modelId,
478
+ variant,
479
+ }: {
480
+ channelId: string
481
+ modelId: string
482
+ variant?: string | null
483
+ }): Promise<void> {
484
+ const prisma = await getPrisma()
485
+ await prisma.channel_models.upsert({
486
+ where: { channel_id: channelId },
487
+ create: {
488
+ channel_id: channelId,
489
+ model_id: modelId,
490
+ variant: variant ?? null,
491
+ },
492
+ update: {
493
+ model_id: modelId,
494
+ variant: variant ?? null,
495
+ updated_at: new Date(),
496
+ },
497
+ })
498
+ }
499
+
500
+ // ============================================================================
501
+ // Global Model Functions
502
+ // ============================================================================
503
+
504
+ /**
505
+ * Get the global default model for a bot.
506
+ * @returns Model ID in format "provider_id/model_id" + optional variant, or undefined
507
+ */
508
+ export async function getGlobalModel(
509
+ appId: string,
510
+ ): Promise<ModelPreference | undefined> {
511
+ const prisma = await getPrisma()
512
+ const row = await prisma.global_models.findUnique({
513
+ where: { app_id: appId },
514
+ })
515
+ if (!row) {
516
+ return undefined
517
+ }
518
+ return { modelId: row.model_id, variant: row.variant }
519
+ }
520
+
521
+ /**
522
+ * Set the global default model for a bot.
523
+ * @param modelId Model ID in format "provider_id/model_id"
524
+ * @param variant Optional thinking/reasoning variant name
525
+ */
526
+ export async function setGlobalModel({
527
+ appId,
528
+ modelId,
529
+ variant,
530
+ }: {
531
+ appId: string
532
+ modelId: string
533
+ variant?: string | null
534
+ }): Promise<void> {
535
+ const prisma = await getPrisma()
536
+ await prisma.global_models.upsert({
537
+ where: { app_id: appId },
538
+ create: { app_id: appId, model_id: modelId, variant: variant ?? null },
539
+ update: {
540
+ model_id: modelId,
541
+ variant: variant ?? null,
542
+ updated_at: new Date(),
543
+ },
544
+ })
545
+ }
546
+
547
+ // ============================================================================
548
+ // Session Model Functions
549
+ // ============================================================================
550
+
551
+ /**
552
+ * Get the model preference for a session.
553
+ * @returns Model ID in format "provider_id/model_id" + optional variant, or undefined
554
+ */
555
+ export async function getSessionModel(
556
+ sessionId: string,
557
+ ): Promise<ModelPreference | undefined> {
558
+ const prisma = await getPrisma()
559
+ const row = await prisma.session_models.findUnique({
560
+ where: { session_id: sessionId },
561
+ })
562
+ if (!row) {
563
+ return undefined
564
+ }
565
+ return { modelId: row.model_id, variant: row.variant }
566
+ }
567
+
568
+ /**
569
+ * Set the model preference for a session.
570
+ * @param modelId Model ID in format "provider_id/model_id"
571
+ * @param variant Optional thinking/reasoning variant name
572
+ */
573
+ export async function setSessionModel({
574
+ sessionId,
575
+ modelId,
576
+ variant,
577
+ }: {
578
+ sessionId: string
579
+ modelId: string
580
+ variant?: string | null
581
+ }): Promise<void> {
582
+ const prisma = await getPrisma()
583
+ await prisma.session_models.upsert({
584
+ where: { session_id: sessionId },
585
+ create: {
586
+ session_id: sessionId,
587
+ model_id: modelId,
588
+ variant: variant ?? null,
589
+ },
590
+ update: { model_id: modelId, variant: variant ?? null },
591
+ })
592
+ }
593
+
594
+ /**
595
+ * Clear the model preference for a session.
596
+ * Used when switching agents so the agent's model takes effect.
597
+ */
598
+ export async function clearSessionModel(sessionId: string): Promise<void> {
599
+ const prisma = await getPrisma()
600
+ await prisma.session_models.deleteMany({
601
+ where: { session_id: sessionId },
602
+ })
603
+ }
604
+
605
+ // ============================================================================
606
+ // Variant Cascade Resolution
607
+ // ============================================================================
608
+
609
+ /**
610
+ * Resolve the variant (thinking level) using the session → channel → global cascade.
611
+ * Returns the first non-null variant found, or undefined if none set at any level.
612
+ */
613
+ export async function getVariantCascade({
614
+ sessionId,
615
+ channelId,
616
+ appId,
617
+ }: {
618
+ sessionId?: string
619
+ channelId?: string
620
+ appId?: string
621
+ }): Promise<string | undefined> {
622
+ if (sessionId) {
623
+ const session = await getSessionModel(sessionId)
624
+ if (session?.variant) {
625
+ return session.variant
626
+ }
627
+ }
628
+ if (channelId) {
629
+ const channel = await getChannelModel(channelId)
630
+ if (channel?.variant) {
631
+ return channel.variant
632
+ }
633
+ }
634
+ if (appId) {
635
+ const global = await getGlobalModel(appId)
636
+ if (global?.variant) {
637
+ return global.variant
638
+ }
639
+ }
640
+ return undefined
641
+ }
642
+
643
+ // ============================================================================
644
+ // Channel Agent Functions
645
+ // ============================================================================
646
+
647
+ /**
648
+ * Get the agent preference for a channel.
649
+ */
650
+ export async function getChannelAgent(
651
+ channelId: string,
652
+ ): Promise<string | undefined> {
653
+ const prisma = await getPrisma()
654
+ const row = await prisma.channel_agents.findUnique({
655
+ where: { channel_id: channelId },
656
+ })
657
+ return row?.agent_name
658
+ }
659
+
660
+ /**
661
+ * Set the agent preference for a channel.
662
+ */
663
+ export async function setChannelAgent(
664
+ channelId: string,
665
+ agentName: string,
666
+ ): Promise<void> {
667
+ const prisma = await getPrisma()
668
+ await prisma.channel_agents.upsert({
669
+ where: { channel_id: channelId },
670
+ create: { channel_id: channelId, agent_name: agentName },
671
+ update: { agent_name: agentName, updated_at: new Date() },
672
+ })
673
+ }
674
+
675
+ // ============================================================================
676
+ // Session Agent Functions
677
+ // ============================================================================
678
+
679
+ /**
680
+ * Get the agent preference for a session.
681
+ */
682
+ export async function getSessionAgent(
683
+ sessionId: string,
684
+ ): Promise<string | undefined> {
685
+ const prisma = await getPrisma()
686
+ const row = await prisma.session_agents.findUnique({
687
+ where: { session_id: sessionId },
688
+ })
689
+ return row?.agent_name
690
+ }
691
+
692
+ /**
693
+ * Set the agent preference for a session.
694
+ */
695
+ export async function setSessionAgent(
696
+ sessionId: string,
697
+ agentName: string,
698
+ ): Promise<void> {
699
+ const prisma = await getPrisma()
700
+ await prisma.session_agents.upsert({
701
+ where: { session_id: sessionId },
702
+ create: { session_id: sessionId, agent_name: agentName },
703
+ update: { agent_name: agentName },
704
+ })
705
+ }
706
+
707
+ // ============================================================================
708
+ // Thread Worktree Functions
709
+ // ============================================================================
710
+
711
+ /**
712
+ * Get the worktree info for a thread.
713
+ */
714
+ export async function getThreadWorktree(
715
+ threadId: string,
716
+ ): Promise<ThreadWorktree | undefined> {
717
+ const prisma = await getPrisma()
718
+ const row = await prisma.thread_worktrees.findUnique({
719
+ where: { thread_id: threadId },
720
+ })
721
+ if (!row) {
722
+ return undefined
723
+ }
724
+ return {
725
+ thread_id: row.thread_id,
726
+ worktree_name: row.worktree_name,
727
+ worktree_directory: row.worktree_directory,
728
+ project_directory: row.project_directory,
729
+ status: row.status as WorktreeStatus,
730
+ error_message: row.error_message,
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Create a pending worktree entry for a thread.
736
+ * Ensures the parent thread_sessions row exists first (with empty session_id)
737
+ * to satisfy the FK constraint. The real session_id is set later by setThreadSession().
738
+ */
739
+ export async function createPendingWorktree({
740
+ threadId,
741
+ worktreeName,
742
+ projectDirectory,
743
+ }: {
744
+ threadId: string
745
+ worktreeName: string
746
+ projectDirectory: string
747
+ }): Promise<void> {
748
+ const prisma = await getPrisma()
749
+ await prisma.$transaction([
750
+ prisma.thread_sessions.upsert({
751
+ where: { thread_id: threadId },
752
+ create: { thread_id: threadId, session_id: '' },
753
+ update: {},
754
+ }),
755
+ prisma.thread_worktrees.upsert({
756
+ where: { thread_id: threadId },
757
+ create: {
758
+ thread_id: threadId,
759
+ worktree_name: worktreeName,
760
+ project_directory: projectDirectory,
761
+ status: 'pending',
762
+ },
763
+ update: {
764
+ worktree_name: worktreeName,
765
+ project_directory: projectDirectory,
766
+ status: 'pending',
767
+ worktree_directory: null,
768
+ error_message: null,
769
+ },
770
+ }),
771
+ ])
772
+ }
773
+
774
+ /**
775
+ * Mark a worktree as ready with its directory.
776
+ */
777
+ export async function setWorktreeReady({
778
+ threadId,
779
+ worktreeDirectory,
780
+ }: {
781
+ threadId: string
782
+ worktreeDirectory: string
783
+ }): Promise<void> {
784
+ const prisma = await getPrisma()
785
+ await prisma.thread_worktrees.update({
786
+ where: { thread_id: threadId },
787
+ data: {
788
+ worktree_directory: worktreeDirectory,
789
+ status: 'ready',
790
+ },
791
+ })
792
+ }
793
+
794
+ /**
795
+ * Mark a worktree as failed with error message.
796
+ */
797
+ export async function setWorktreeError({
798
+ threadId,
799
+ errorMessage,
800
+ }: {
801
+ threadId: string
802
+ errorMessage: string
803
+ }): Promise<void> {
804
+ const prisma = await getPrisma()
805
+ await prisma.thread_worktrees.update({
806
+ where: { thread_id: threadId },
807
+ data: {
808
+ status: 'error',
809
+ error_message: errorMessage,
810
+ },
811
+ })
812
+ }
813
+
814
+ /**
815
+ * Delete the worktree info for a thread.
816
+ */
817
+ export async function deleteThreadWorktree(threadId: string): Promise<void> {
818
+ const prisma = await getPrisma()
819
+ await prisma.thread_worktrees.deleteMany({
820
+ where: { thread_id: threadId },
821
+ })
822
+ }
823
+
824
+ // ============================================================================
825
+ // Channel Verbosity Functions
826
+ // ============================================================================
827
+
828
+ /**
829
+ * Get the verbosity setting for a channel.
830
+ * Falls back to the global default set via --verbosity CLI flag if no per-channel override exists.
831
+ */
832
+ export async function getChannelVerbosity(
833
+ channelId: string,
834
+ ): Promise<VerbosityLevel> {
835
+ const prisma = await getPrisma()
836
+ const row = await prisma.channel_verbosity.findUnique({
837
+ where: { channel_id: channelId },
838
+ })
839
+ if (row?.verbosity) {
840
+ return row.verbosity as VerbosityLevel
841
+ }
842
+ return getDefaultVerbosity()
843
+ }
844
+
845
+ /**
846
+ * Set the verbosity setting for a channel.
847
+ */
848
+ export async function setChannelVerbosity(
849
+ channelId: string,
850
+ verbosity: VerbosityLevel,
851
+ ): Promise<void> {
852
+ const prisma = await getPrisma()
853
+ await prisma.channel_verbosity.upsert({
854
+ where: { channel_id: channelId },
855
+ create: { channel_id: channelId, verbosity },
856
+ update: { verbosity, updated_at: new Date() },
857
+ })
858
+ }
859
+
860
+ // ============================================================================
861
+ // Channel Mention Mode Functions
862
+ // ============================================================================
863
+
864
+ /**
865
+ * Get the mention mode setting for a channel.
866
+ * Falls back to the global default set via --mention-mode CLI flag if no per-channel override exists.
867
+ */
868
+ export async function getChannelMentionMode(
869
+ channelId: string,
870
+ ): Promise<boolean> {
871
+ const prisma = await getPrisma()
872
+ const row = await prisma.channel_mention_mode.findUnique({
873
+ where: { channel_id: channelId },
874
+ })
875
+ if (row) {
876
+ return row.enabled === 1
877
+ }
878
+ return getDefaultMentionMode()
879
+ }
880
+
881
+ /**
882
+ * Set the mention mode setting for a channel.
883
+ */
884
+ export async function setChannelMentionMode(
885
+ channelId: string,
886
+ enabled: boolean,
887
+ ): Promise<void> {
888
+ const prisma = await getPrisma()
889
+ await prisma.channel_mention_mode.upsert({
890
+ where: { channel_id: channelId },
891
+ create: { channel_id: channelId, enabled: enabled ? 1 : 0 },
892
+ update: { enabled: enabled ? 1 : 0, updated_at: new Date() },
893
+ })
894
+ }
895
+
896
+ // ============================================================================
897
+ // Channel Worktree Settings Functions
898
+ // ============================================================================
899
+
900
+ /**
901
+ * Check if automatic worktree creation is enabled for a channel.
902
+ */
903
+ export async function getChannelWorktreesEnabled(
904
+ channelId: string,
905
+ ): Promise<boolean> {
906
+ const prisma = await getPrisma()
907
+ const row = await prisma.channel_worktrees.findUnique({
908
+ where: { channel_id: channelId },
909
+ })
910
+ return row?.enabled === 1
911
+ }
912
+
913
+ /**
914
+ * Enable or disable automatic worktree creation for a channel.
915
+ */
916
+ export async function setChannelWorktreesEnabled(
917
+ channelId: string,
918
+ enabled: boolean,
919
+ ): Promise<void> {
920
+ const prisma = await getPrisma()
921
+ await prisma.channel_worktrees.upsert({
922
+ where: { channel_id: channelId },
923
+ create: { channel_id: channelId, enabled: enabled ? 1 : 0 },
924
+ update: { enabled: enabled ? 1 : 0, updated_at: new Date() },
925
+ })
926
+ }
927
+
928
+ // ============================================================================
929
+ // Channel Directory Functions
930
+ // ============================================================================
931
+
932
+ /**
933
+ * Get the directory and app_id for a channel from the database.
934
+ * This is the single source of truth for channel-project mappings.
935
+ */
936
+ export async function getChannelDirectory(channelId: string): Promise<
937
+ | {
938
+ directory: string
939
+ appId: string | null
940
+ }
941
+ | undefined
942
+ > {
943
+ const prisma = await getPrisma()
944
+ const row = await prisma.channel_directories.findUnique({
945
+ where: { channel_id: channelId },
946
+ })
947
+
948
+ if (!row) {
949
+ return undefined
950
+ }
951
+
952
+ return {
953
+ directory: row.directory,
954
+ appId: row.app_id,
955
+ }
956
+ }
957
+
958
+ // ============================================================================
959
+ // Thread Session Functions
960
+ // ============================================================================
961
+
962
+ /**
963
+ * Get the session ID for a thread.
964
+ */
965
+ export async function getThreadSession(
966
+ threadId: string,
967
+ ): Promise<string | undefined> {
968
+ const prisma = await getPrisma()
969
+ const row = await prisma.thread_sessions.findUnique({
970
+ where: { thread_id: threadId },
971
+ })
972
+ return row?.session_id
973
+ }
974
+
975
+ /**
976
+ * Set the session ID for a thread.
977
+ */
978
+ export async function setThreadSession(
979
+ threadId: string,
980
+ sessionId: string,
981
+ ): Promise<void> {
982
+ const prisma = await getPrisma()
983
+ await prisma.thread_sessions.upsert({
984
+ where: { thread_id: threadId },
985
+ create: { thread_id: threadId, session_id: sessionId },
986
+ update: { session_id: sessionId },
987
+ })
988
+ }
989
+
990
+ /**
991
+ * Get the thread ID for a session.
992
+ */
993
+ export async function getThreadIdBySessionId(
994
+ sessionId: string,
995
+ ): Promise<string | undefined> {
996
+ const prisma = await getPrisma()
997
+ const row = await prisma.thread_sessions.findFirst({
998
+ where: { session_id: sessionId },
999
+ })
1000
+ return row?.thread_id
1001
+ }
1002
+
1003
+ /**
1004
+ * Get all session IDs that are associated with threads.
1005
+ */
1006
+ export async function getAllThreadSessionIds(): Promise<string[]> {
1007
+ const prisma = await getPrisma()
1008
+ const rows = await prisma.thread_sessions.findMany({
1009
+ select: { session_id: true },
1010
+ })
1011
+ return rows.map((row) => row.session_id).filter((id) => id !== '')
1012
+ }
1013
+
1014
+ // ============================================================================
1015
+ // Part Messages Functions
1016
+ // ============================================================================
1017
+
1018
+ /**
1019
+ * Get all part IDs for a thread.
1020
+ */
1021
+ export async function getPartMessageIds(threadId: string): Promise<string[]> {
1022
+ const prisma = await getPrisma()
1023
+ const rows = await prisma.part_messages.findMany({
1024
+ where: { thread_id: threadId },
1025
+ select: { part_id: true },
1026
+ })
1027
+ return rows.map((row) => row.part_id)
1028
+ }
1029
+
1030
+ /**
1031
+ * Store a part-message mapping.
1032
+ * Note: The thread must already have a session (via setThreadSession) before calling this.
1033
+ */
1034
+ export async function setPartMessage(
1035
+ partId: string,
1036
+ messageId: string,
1037
+ threadId: string,
1038
+ ): Promise<void> {
1039
+ const prisma = await getPrisma()
1040
+ await prisma.part_messages.upsert({
1041
+ where: { part_id: partId },
1042
+ create: { part_id: partId, message_id: messageId, thread_id: threadId },
1043
+ update: { message_id: messageId, thread_id: threadId },
1044
+ })
1045
+ }
1046
+
1047
+ /**
1048
+ * Store multiple part-message mappings in a transaction.
1049
+ * More efficient and atomic for batch operations.
1050
+ * Note: The thread must already have a session (via setThreadSession) before calling this.
1051
+ */
1052
+ export async function setPartMessagesBatch(
1053
+ partMappings: Array<{ partId: string; messageId: string; threadId: string }>,
1054
+ ): Promise<void> {
1055
+ if (partMappings.length === 0) {
1056
+ return
1057
+ }
1058
+ const prisma = await getPrisma()
1059
+ await prisma.$transaction(
1060
+ partMappings.map(({ partId, messageId, threadId }) => {
1061
+ return prisma.part_messages.upsert({
1062
+ where: { part_id: partId },
1063
+ create: { part_id: partId, message_id: messageId, thread_id: threadId },
1064
+ update: { message_id: messageId, thread_id: threadId },
1065
+ })
1066
+ }),
1067
+ )
1068
+ }
1069
+
1070
+ // ============================================================================
1071
+ // Bot Token Functions
1072
+ // ============================================================================
1073
+
1074
+ /**
1075
+ * Store a bot token.
1076
+ */
1077
+ export async function setBotToken(appId: string, token: string): Promise<void> {
1078
+ if (isAuthModeEnabled()) {
1079
+ return
1080
+ }
1081
+ const prisma = await getPrisma()
1082
+ await prisma.bot_tokens.upsert({
1083
+ where: { app_id: appId },
1084
+ create: { app_id: appId, token },
1085
+ update: { token },
1086
+ })
1087
+ hydrateBotTokenCache({ app_id: appId, token })
1088
+ }
1089
+
1090
+ // ============================================================================
1091
+ // Bot API Keys Functions
1092
+ // ============================================================================
1093
+
1094
+ /**
1095
+ * Get the Gemini API key for a bot.
1096
+ */
1097
+ export async function getGeminiApiKey(appId: string): Promise<string | null> {
1098
+ const prisma = await getPrisma()
1099
+ const row = await prisma.bot_api_keys.findUnique({
1100
+ where: { app_id: appId },
1101
+ })
1102
+ return row?.gemini_api_key ?? null
1103
+ }
1104
+
1105
+ /**
1106
+ * Set the Gemini API key for a bot.
1107
+ * Note: The bot must already have a token (via setBotToken) before calling this.
1108
+ */
1109
+ export async function setGeminiApiKey(
1110
+ appId: string,
1111
+ apiKey: string,
1112
+ ): Promise<void> {
1113
+ const prisma = await getPrisma()
1114
+ await prisma.bot_api_keys.upsert({
1115
+ where: { app_id: appId },
1116
+ create: { app_id: appId, gemini_api_key: apiKey },
1117
+ update: { gemini_api_key: apiKey },
1118
+ })
1119
+ }
1120
+
1121
+ /**
1122
+ * Get the OpenAI API key for a bot.
1123
+ */
1124
+ export async function getOpenAIApiKey(appId: string): Promise<string | null> {
1125
+ const prisma = await getPrisma()
1126
+ const row = await prisma.bot_api_keys.findUnique({
1127
+ where: { app_id: appId },
1128
+ })
1129
+ return row?.openai_api_key ?? null
1130
+ }
1131
+
1132
+ /**
1133
+ * Set the OpenAI API key for a bot.
1134
+ */
1135
+ export async function setOpenAIApiKey(
1136
+ appId: string,
1137
+ apiKey: string,
1138
+ ): Promise<void> {
1139
+ const prisma = await getPrisma()
1140
+ await prisma.bot_api_keys.upsert({
1141
+ where: { app_id: appId },
1142
+ create: { app_id: appId, openai_api_key: apiKey },
1143
+ update: { openai_api_key: apiKey },
1144
+ })
1145
+ }
1146
+
1147
+ /**
1148
+ * Get the best available transcription API key for a bot.
1149
+ * Prefers OpenAI, falls back to Gemini.
1150
+ */
1151
+ export async function getTranscriptionApiKey(
1152
+ appId: string,
1153
+ ): Promise<{ provider: 'openai' | 'gemini'; apiKey: string } | null> {
1154
+ const prisma = await getPrisma()
1155
+ const row = await prisma.bot_api_keys.findUnique({
1156
+ where: { app_id: appId },
1157
+ })
1158
+ if (!row) return null
1159
+ if (row.openai_api_key) {
1160
+ return { provider: 'openai', apiKey: row.openai_api_key }
1161
+ }
1162
+ if (row.gemini_api_key) {
1163
+ return { provider: 'gemini', apiKey: row.gemini_api_key }
1164
+ }
1165
+ return null
1166
+ }
1167
+
1168
+ // ============================================================================
1169
+ // Channel Directory CRUD Functions
1170
+ // ============================================================================
1171
+
1172
+ /**
1173
+ * Store a channel-directory mapping.
1174
+ * @param skipIfExists If true, behaves like INSERT OR IGNORE - skips if record exists.
1175
+ * If false (default), behaves like INSERT OR REPLACE - updates if exists.
1176
+ */
1177
+ export async function setChannelDirectory({
1178
+ channelId,
1179
+ directory,
1180
+ channelType,
1181
+ appId,
1182
+ skipIfExists = false,
1183
+ }: {
1184
+ channelId: string
1185
+ directory: string
1186
+ channelType: 'text' | 'voice'
1187
+ appId?: string | null
1188
+ skipIfExists?: boolean
1189
+ }): Promise<void> {
1190
+ const prisma = await getPrisma()
1191
+ if (skipIfExists) {
1192
+ // INSERT OR IGNORE semantics - only insert if not exists
1193
+ const existing = await prisma.channel_directories.findUnique({
1194
+ where: { channel_id: channelId },
1195
+ })
1196
+ if (existing) {
1197
+ return
1198
+ }
1199
+ await prisma.channel_directories.create({
1200
+ data: {
1201
+ channel_id: channelId,
1202
+ directory,
1203
+ channel_type: channelType,
1204
+ app_id: appId ?? null,
1205
+ },
1206
+ })
1207
+ } else {
1208
+ // INSERT OR REPLACE semantics - upsert
1209
+ await prisma.channel_directories.upsert({
1210
+ where: { channel_id: channelId },
1211
+ create: {
1212
+ channel_id: channelId,
1213
+ directory,
1214
+ channel_type: channelType,
1215
+ app_id: appId ?? null,
1216
+ },
1217
+ update: {
1218
+ directory,
1219
+ channel_type: channelType,
1220
+ app_id: appId ?? null,
1221
+ },
1222
+ })
1223
+ }
1224
+ }
1225
+
1226
+ /**
1227
+ * Find channels by directory path.
1228
+ */
1229
+ export async function findChannelsByDirectory({
1230
+ directory,
1231
+ channelType,
1232
+ appId,
1233
+ }: {
1234
+ directory?: string
1235
+ channelType?: 'text' | 'voice'
1236
+ appId?: string
1237
+ }): Promise<
1238
+ Array<{ channel_id: string; directory: string; channel_type: string }>
1239
+ > {
1240
+ const prisma = await getPrisma()
1241
+ const where: {
1242
+ directory?: string
1243
+ channel_type?: string
1244
+ app_id?: string
1245
+ } = {}
1246
+ if (directory) {
1247
+ where.directory = directory
1248
+ }
1249
+ if (channelType) {
1250
+ where.channel_type = channelType
1251
+ }
1252
+ if (appId) {
1253
+ where.app_id = appId
1254
+ }
1255
+ const rows = await prisma.channel_directories.findMany({
1256
+ where,
1257
+ select: { channel_id: true, directory: true, channel_type: true },
1258
+ })
1259
+ return rows
1260
+ }
1261
+
1262
+ /**
1263
+ * Get all distinct directories with text channels.
1264
+ */
1265
+ export async function getAllTextChannelDirectories(): Promise<string[]> {
1266
+ const prisma = await getPrisma()
1267
+ const rows = await prisma.channel_directories.findMany({
1268
+ where: { channel_type: 'text' },
1269
+ select: { directory: true },
1270
+ distinct: ['directory'],
1271
+ })
1272
+ return rows.map((row) => row.directory)
1273
+ }
1274
+
1275
+ /**
1276
+ * Delete all channel directories for a specific directory.
1277
+ */
1278
+ export async function deleteChannelDirectoriesByDirectory(
1279
+ directory: string,
1280
+ ): Promise<void> {
1281
+ const prisma = await getPrisma()
1282
+ await prisma.channel_directories.deleteMany({
1283
+ where: { directory },
1284
+ })
1285
+ }
1286
+
1287
+ /**
1288
+ * Find a channel by app ID.
1289
+ */
1290
+ export async function findChannelByAppId(
1291
+ appId: string,
1292
+ ): Promise<string | undefined> {
1293
+ const prisma = await getPrisma()
1294
+ const row = await prisma.channel_directories.findFirst({
1295
+ where: { app_id: appId },
1296
+ orderBy: { created_at: 'desc' },
1297
+ select: { channel_id: true },
1298
+ })
1299
+ return row?.channel_id
1300
+ }
1301
+
1302
+ /**
1303
+ * Get the directory for a voice channel.
1304
+ */
1305
+ export async function getVoiceChannelDirectory(
1306
+ channelId: string,
1307
+ ): Promise<string | undefined> {
1308
+ const prisma = await getPrisma()
1309
+ const row = await prisma.channel_directories.findFirst({
1310
+ where: { channel_id: channelId, channel_type: 'voice' },
1311
+ })
1312
+ return row?.directory
1313
+ }
1314
+
1315
+ /**
1316
+ * Find the text channel ID that shares the same directory as a voice channel.
1317
+ * Used to send error messages to text channels from voice handlers.
1318
+ */
1319
+ export async function findTextChannelByVoiceChannel(
1320
+ voiceChannelId: string,
1321
+ ): Promise<string | undefined> {
1322
+ const prisma = await getPrisma()
1323
+ // First get the directory for the voice channel
1324
+ const voiceChannel = await prisma.channel_directories.findFirst({
1325
+ where: { channel_id: voiceChannelId, channel_type: 'voice' },
1326
+ })
1327
+ if (!voiceChannel) {
1328
+ return undefined
1329
+ }
1330
+ // Then find the text channel with the same directory
1331
+ const textChannel = await prisma.channel_directories.findFirst({
1332
+ where: { directory: voiceChannel.directory, channel_type: 'text' },
1333
+ })
1334
+ return textChannel?.channel_id
1335
+ }
1336
+
1337
+ // ============================================================================
1338
+ // Forum Sync Config Functions
1339
+ // ============================================================================
1340
+
1341
+ export type ForumSyncConfigRow = {
1342
+ appId: string
1343
+ forumChannelId: string
1344
+ outputDir: string
1345
+ direction: string
1346
+ }
1347
+
1348
+ export async function getForumSyncConfigs({
1349
+ appId,
1350
+ }: {
1351
+ appId: string
1352
+ }): Promise<ForumSyncConfigRow[]> {
1353
+ const prisma = await getPrisma()
1354
+ const rows = await prisma.forum_sync_configs.findMany({
1355
+ where: { app_id: appId },
1356
+ })
1357
+ return rows.map((row) => ({
1358
+ appId: row.app_id,
1359
+ forumChannelId: row.forum_channel_id,
1360
+ outputDir: row.output_dir,
1361
+ direction: row.direction,
1362
+ }))
1363
+ }
1364
+
1365
+ export async function upsertForumSyncConfig({
1366
+ appId,
1367
+ forumChannelId,
1368
+ outputDir,
1369
+ direction = 'bidirectional',
1370
+ }: {
1371
+ appId: string
1372
+ forumChannelId: string
1373
+ outputDir: string
1374
+ direction?: string
1375
+ }) {
1376
+ const prisma = await getPrisma()
1377
+ await prisma.forum_sync_configs.upsert({
1378
+ where: {
1379
+ app_id_forum_channel_id: {
1380
+ app_id: appId,
1381
+ forum_channel_id: forumChannelId,
1382
+ },
1383
+ },
1384
+ update: { output_dir: outputDir, direction },
1385
+ create: {
1386
+ app_id: appId,
1387
+ forum_channel_id: forumChannelId,
1388
+ output_dir: outputDir,
1389
+ direction,
1390
+ },
1391
+ })
1392
+ }
1393
+
1394
+ export async function deleteForumSyncConfig({
1395
+ appId,
1396
+ forumChannelId,
1397
+ }: {
1398
+ appId: string
1399
+ forumChannelId: string
1400
+ }) {
1401
+ const prisma = await getPrisma()
1402
+ await prisma.forum_sync_configs.deleteMany({
1403
+ where: { app_id: appId, forum_channel_id: forumChannelId },
1404
+ })
1405
+ }
1406
+
1407
+ /** Delete forum sync configs that share the same outputDir but have a different forumChannelId.
1408
+ * This cleans up stale entries left behind when a forum channel is deleted and recreated. */
1409
+ export async function deleteStaleForumSyncConfigs({
1410
+ appId,
1411
+ forumChannelId,
1412
+ outputDir,
1413
+ }: {
1414
+ appId: string
1415
+ forumChannelId: string
1416
+ outputDir: string
1417
+ }) {
1418
+ const prisma = await getPrisma()
1419
+ await prisma.forum_sync_configs.deleteMany({
1420
+ where: {
1421
+ app_id: appId,
1422
+ output_dir: outputDir,
1423
+ NOT: { forum_channel_id: forumChannelId },
1424
+ },
1425
+ })
1426
+ }
1427
+
1428
+ // ═══════════════════════════════════════════════════════════════════════════
1429
+ // IPC REQUESTS - plugin <-> bot communication via DB polling
1430
+ // ═══════════════════════════════════════════════════════════════════════════
1431
+
1432
+ export async function createIpcRequest({
1433
+ type,
1434
+ sessionId,
1435
+ threadId,
1436
+ payload,
1437
+ }: {
1438
+ type: import('./generated/client.js').ipc_request_type
1439
+ sessionId: string
1440
+ threadId: string
1441
+ payload: string
1442
+ }) {
1443
+ const prisma = await getPrisma()
1444
+ return prisma.ipc_requests.create({
1445
+ data: {
1446
+ type,
1447
+ session_id: sessionId,
1448
+ thread_id: threadId,
1449
+ payload,
1450
+ },
1451
+ })
1452
+ }
1453
+
1454
+ /**
1455
+ * Atomically claim pending IPC requests by updating status to 'processing'
1456
+ * only for rows that are still 'pending'. Returns the claimed rows.
1457
+ * This prevents duplicate dispatch when poll ticks overlap.
1458
+ */
1459
+ export async function claimPendingIpcRequests() {
1460
+ const prisma = await getPrisma()
1461
+ const pending = await prisma.ipc_requests.findMany({
1462
+ where: { status: 'pending' },
1463
+ orderBy: { created_at: 'asc' },
1464
+ })
1465
+ if (pending.length === 0) return pending
1466
+
1467
+ // Atomically claim each one (updateMany with status guard)
1468
+ const claimed: typeof pending = []
1469
+ for (const req of pending) {
1470
+ const result = await prisma.ipc_requests.updateMany({
1471
+ where: { id: req.id, status: 'pending' },
1472
+ data: { status: 'processing' },
1473
+ })
1474
+ if (result.count > 0) {
1475
+ claimed.push(req)
1476
+ }
1477
+ }
1478
+ return claimed
1479
+ }
1480
+
1481
+ export async function completeIpcRequest({
1482
+ id,
1483
+ response,
1484
+ }: {
1485
+ id: string
1486
+ response: string
1487
+ }) {
1488
+ const prisma = await getPrisma()
1489
+ return prisma.ipc_requests.update({
1490
+ where: { id },
1491
+ data: { response, status: 'completed' as const },
1492
+ })
1493
+ }
1494
+
1495
+ export async function getIpcRequestById({ id }: { id: string }) {
1496
+ const prisma = await getPrisma()
1497
+ return prisma.ipc_requests.findUnique({ where: { id } })
1498
+ }
1499
+
1500
+ /** Cancel IPC requests stuck in 'processing' longer than the TTL (e.g. hung file upload). */
1501
+ export async function cancelStaleProcessingRequests({
1502
+ ttlMs,
1503
+ }: {
1504
+ ttlMs: number
1505
+ }) {
1506
+ const prisma = await getPrisma()
1507
+ const cutoff = new Date(Date.now() - ttlMs)
1508
+ return prisma.ipc_requests.updateMany({
1509
+ where: {
1510
+ status: 'processing',
1511
+ updated_at: { lt: cutoff },
1512
+ },
1513
+ data: {
1514
+ status: 'cancelled' as const,
1515
+ response: JSON.stringify({ error: 'Request timed out' }),
1516
+ },
1517
+ })
1518
+ }
1519
+
1520
+ /** Cancel all pending IPC requests (on startup cleanup and shutdown). */
1521
+ export async function cancelAllPendingIpcRequests() {
1522
+ const prisma = await getPrisma()
1523
+ await prisma.ipc_requests.updateMany({
1524
+ where: { status: { in: ['pending', 'processing'] } },
1525
+ data: {
1526
+ status: 'cancelled' as const,
1527
+ response: JSON.stringify({ error: 'Bot shutting down' }),
1528
+ },
1529
+ })
1530
+ }