@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,454 @@
1
+ // Runtime state management, file watchers, and Discord event listeners.
2
+ // Manages the lifecycle of forum sync: initial sync, live Discord event handling,
3
+ // file system watcher for bidirectional sync, and debounced sync scheduling.
4
+
5
+ import fs from 'node:fs'
6
+ import parcelWatcher from '@parcel/watcher'
7
+ import {
8
+ ChannelType,
9
+ Events,
10
+ type Client,
11
+ type Message,
12
+ type PartialMessage,
13
+ type ThreadChannel,
14
+ } from 'discord.js'
15
+ import { createLogger } from '../logger.js'
16
+ import { readForumSyncConfig } from './config.js'
17
+ import {
18
+ ensureDirectory,
19
+ getCanonicalThreadFilePath,
20
+ } from './discord-operations.js'
21
+ import { syncForumToFiles } from './sync-to-files.js'
22
+ import { syncFilesToForum } from './sync-to-discord.js'
23
+ import {
24
+ DEFAULT_DEBOUNCE_MS,
25
+ ForumSyncOperationError,
26
+ addIgnoredPath,
27
+ shouldIgnorePath,
28
+ type ForumRuntimeState,
29
+ type ForumSyncDirection,
30
+ type StartForumSyncOptions,
31
+ } from './types.js'
32
+
33
+ const forumLogger = createLogger('FORUM')
34
+
35
+ // ═══════════════════════════════════════════════════════════════════════════
36
+ // MODULE STATE
37
+ // ═══════════════════════════════════════════════════════════════════════════
38
+
39
+ const forumStateById = new Map<string, ForumRuntimeState>()
40
+ const watcherUnsubscribeByForumId = new Map<string, () => Promise<void>>()
41
+ let discordListenersRegistered = false
42
+
43
+ // ═══════════════════════════════════════════════════════════════════════════
44
+ // RUNTIME STATE
45
+ // ═══════════════════════════════════════════════════════════════════════════
46
+
47
+ function buildRuntimeState({
48
+ forumChannelId,
49
+ outputDir,
50
+ direction,
51
+ }: {
52
+ forumChannelId: string
53
+ outputDir: string
54
+ direction: ForumSyncDirection
55
+ }): ForumRuntimeState {
56
+ return {
57
+ forumChannelId,
58
+ outputDir,
59
+ direction,
60
+ dirtyThreadIds: new Set<string>(),
61
+ ignoredPaths: new Map<string, number>(),
62
+ queuedFileEvents: new Map<string, 'create' | 'update' | 'delete'>(),
63
+ discordDebounceTimer: null,
64
+ fileDebounceTimer: null,
65
+ }
66
+ }
67
+
68
+ // ═══════════════════════════════════════════════════════════════════════════
69
+ // FILE WATCHER EVENT HANDLING
70
+ // ═══════════════════════════════════════════════════════════════════════════
71
+
72
+ async function runQueuedFileEvents({
73
+ runtimeState,
74
+ discordClient,
75
+ }: {
76
+ runtimeState: ForumRuntimeState
77
+ discordClient: Client
78
+ }) {
79
+ const queuedEntries = Array.from(runtimeState.queuedFileEvents.entries())
80
+ runtimeState.queuedFileEvents.clear()
81
+
82
+ if (queuedEntries.length === 0) return
83
+
84
+ const changedFilePaths = queuedEntries
85
+ .filter(([, eventType]) => eventType === 'create' || eventType === 'update')
86
+ .map(([filePath]) => filePath)
87
+ const deletedFilePaths = queuedEntries
88
+ .filter(([, eventType]) => eventType === 'delete')
89
+ .map(([filePath]) => filePath)
90
+
91
+ const fileSyncResult = await syncFilesToForum({
92
+ discordClient,
93
+ forumChannelId: runtimeState.forumChannelId,
94
+ outputDir: runtimeState.outputDir,
95
+ runtimeState,
96
+ changedFilePaths,
97
+ deletedFilePaths,
98
+ })
99
+
100
+ if (fileSyncResult instanceof Error) {
101
+ forumLogger.warn(
102
+ `FS -> Discord sync failed for ${runtimeState.forumChannelId}: ${fileSyncResult.message}`,
103
+ )
104
+ return
105
+ }
106
+
107
+ if (
108
+ fileSyncResult.created + fileSyncResult.updated + fileSyncResult.deleted >
109
+ 0
110
+ ) {
111
+ forumLogger.log(
112
+ `FS -> Discord ${runtimeState.forumChannelId}: +${fileSyncResult.created} ~${fileSyncResult.updated} -${fileSyncResult.deleted} (skip ${fileSyncResult.skipped})`,
113
+ )
114
+ }
115
+
116
+ // Refresh the FS mirror for any threads that were touched
117
+ const discordSyncResult = await syncForumToFiles({
118
+ discordClient,
119
+ forumChannelId: runtimeState.forumChannelId,
120
+ outputDir: runtimeState.outputDir,
121
+ runtimeState,
122
+ forceThreadIds: runtimeState.dirtyThreadIds,
123
+ })
124
+ if (discordSyncResult instanceof Error) {
125
+ forumLogger.warn(
126
+ `Discord -> FS refresh failed for ${runtimeState.forumChannelId}: ${discordSyncResult.message}`,
127
+ )
128
+ return
129
+ }
130
+ runtimeState.dirtyThreadIds.clear()
131
+ }
132
+
133
+ function queueFileEvent({
134
+ runtimeState,
135
+ filePath,
136
+ eventType,
137
+ discordClient,
138
+ }: {
139
+ runtimeState: ForumRuntimeState
140
+ filePath: string
141
+ eventType: 'create' | 'update' | 'delete'
142
+ discordClient: Client
143
+ }) {
144
+ if (shouldIgnorePath({ runtimeState, filePath })) return
145
+
146
+ runtimeState.queuedFileEvents.set(filePath, eventType)
147
+
148
+ if (runtimeState.fileDebounceTimer) {
149
+ clearTimeout(runtimeState.fileDebounceTimer)
150
+ }
151
+
152
+ runtimeState.fileDebounceTimer = setTimeout(() => {
153
+ runtimeState.fileDebounceTimer = null
154
+ void runQueuedFileEvents({ runtimeState, discordClient })
155
+ }, DEFAULT_DEBOUNCE_MS)
156
+ }
157
+
158
+ // ═══════════════════════════════════════════════════════════════════════════
159
+ // DISCORD EVENT HANDLING
160
+ // ═══════════════════════════════════════════════════════════════════════════
161
+
162
+ function scheduleDiscordSync({
163
+ runtimeState,
164
+ threadId,
165
+ discordClient,
166
+ }: {
167
+ runtimeState: ForumRuntimeState
168
+ threadId: string
169
+ discordClient: Client
170
+ }) {
171
+ runtimeState.dirtyThreadIds.add(threadId)
172
+
173
+ if (runtimeState.discordDebounceTimer) {
174
+ clearTimeout(runtimeState.discordDebounceTimer)
175
+ }
176
+
177
+ runtimeState.discordDebounceTimer = setTimeout(() => {
178
+ runtimeState.discordDebounceTimer = null
179
+ void (async () => {
180
+ const syncResult = await syncForumToFiles({
181
+ discordClient,
182
+ forumChannelId: runtimeState.forumChannelId,
183
+ outputDir: runtimeState.outputDir,
184
+ runtimeState,
185
+ forceThreadIds: runtimeState.dirtyThreadIds,
186
+ })
187
+ if (syncResult instanceof Error) {
188
+ forumLogger.warn(
189
+ `Debounced Discord -> FS sync failed for ${runtimeState.forumChannelId}: ${syncResult.message}`,
190
+ )
191
+ return
192
+ }
193
+ runtimeState.dirtyThreadIds.clear()
194
+ })()
195
+ }, DEFAULT_DEBOUNCE_MS)
196
+ }
197
+
198
+ function getThreadEventData({
199
+ channel,
200
+ }: {
201
+ channel: ThreadChannel | null
202
+ }): { forumChannelId: string; threadId: string } | null {
203
+ if (!channel) return null
204
+ if (
205
+ channel.type !== ChannelType.PublicThread &&
206
+ channel.type !== ChannelType.PrivateThread &&
207
+ channel.type !== ChannelType.AnnouncementThread
208
+ ) {
209
+ return null
210
+ }
211
+ if (!channel.parentId) return null
212
+ return { forumChannelId: channel.parentId, threadId: channel.id }
213
+ }
214
+
215
+ function getEventThreadFromMessage({
216
+ message,
217
+ }: {
218
+ message: Message | PartialMessage
219
+ }): ThreadChannel | null {
220
+ const channel = message.channel
221
+ if (!channel || !channel.isThread()) return null
222
+ return channel
223
+ }
224
+
225
+ function tryHandleThreadEvent({
226
+ channel,
227
+ discordClient,
228
+ }: {
229
+ channel: ThreadChannel | null
230
+ discordClient: Client
231
+ }) {
232
+ const data = getThreadEventData({ channel })
233
+ if (!data) return
234
+ const runtimeState = forumStateById.get(data.forumChannelId)
235
+ if (!runtimeState) return
236
+ scheduleDiscordSync({ runtimeState, threadId: data.threadId, discordClient })
237
+ }
238
+
239
+ /**
240
+ * Find the file path for a thread, checking root and one level of subdirectories.
241
+ */
242
+ function findThreadFilePath({
243
+ outputDir,
244
+ threadId,
245
+ }: {
246
+ outputDir: string
247
+ threadId: string
248
+ }): string | null {
249
+ const rootPath = getCanonicalThreadFilePath({ outputDir, threadId })
250
+ if (fs.existsSync(rootPath)) return rootPath
251
+
252
+ const dirEntries = (() => {
253
+ try {
254
+ return fs.readdirSync(outputDir, { withFileTypes: true })
255
+ } catch {
256
+ return []
257
+ }
258
+ })()
259
+ for (const entry of dirEntries) {
260
+ if (!entry.isDirectory()) continue
261
+ const subPath = getCanonicalThreadFilePath({
262
+ outputDir,
263
+ threadId,
264
+ subfolder: entry.name,
265
+ })
266
+ if (fs.existsSync(subPath)) return subPath
267
+ }
268
+ return null
269
+ }
270
+
271
+ function registerDiscordSyncListeners({
272
+ discordClient,
273
+ }: {
274
+ discordClient: Client
275
+ }) {
276
+ if (discordListenersRegistered) return
277
+ discordListenersRegistered = true
278
+
279
+ discordClient.on(Events.MessageCreate, (message) => {
280
+ if (message.author?.bot) return
281
+ const thread = getEventThreadFromMessage({ message })
282
+ tryHandleThreadEvent({ channel: thread, discordClient })
283
+ })
284
+
285
+ discordClient.on(Events.MessageUpdate, (_oldMessage, newMessage) => {
286
+ const thread = getEventThreadFromMessage({ message: newMessage })
287
+ tryHandleThreadEvent({ channel: thread, discordClient })
288
+ })
289
+
290
+ discordClient.on(Events.ThreadUpdate, (_oldThread, newThread) => {
291
+ tryHandleThreadEvent({ channel: newThread, discordClient })
292
+ })
293
+
294
+ discordClient.on(Events.ThreadDelete, async (thread) => {
295
+ const data = getThreadEventData({ channel: thread })
296
+ if (!data) return
297
+ const runtimeState = forumStateById.get(data.forumChannelId)
298
+ if (!runtimeState) return
299
+ const targetPath = findThreadFilePath({
300
+ outputDir: runtimeState.outputDir,
301
+ threadId: data.threadId,
302
+ })
303
+ if (!targetPath) return
304
+ addIgnoredPath({ runtimeState, filePath: targetPath })
305
+ await fs.promises.unlink(targetPath).catch((cause) => {
306
+ forumLogger.warn(
307
+ `Failed to delete forum file on thread delete ${targetPath}:`,
308
+ cause,
309
+ )
310
+ })
311
+ })
312
+ }
313
+
314
+ // ═══════════════════════════════════════════════════════════════════════════
315
+ // FILE WATCHER SETUP
316
+ // ═══════════════════════════════════════════════════════════════════════════
317
+
318
+ async function startWatcherForRuntimeState({
319
+ runtimeState,
320
+ discordClient,
321
+ }: {
322
+ runtimeState: ForumRuntimeState
323
+ discordClient: Client
324
+ }): Promise<void | ForumSyncOperationError> {
325
+ if (runtimeState.direction !== 'bidirectional') return
326
+
327
+ const subscription = await parcelWatcher
328
+ .subscribe(runtimeState.outputDir, (_error, events) => {
329
+ const mdEvents = events.filter((event) => event.path.endsWith('.md'))
330
+ mdEvents
331
+ .filter(
332
+ (event) =>
333
+ event.type === 'create' ||
334
+ event.type === 'update' ||
335
+ event.type === 'delete',
336
+ )
337
+ .map((event) => {
338
+ queueFileEvent({
339
+ runtimeState,
340
+ filePath: event.path,
341
+ eventType: event.type as 'create' | 'update' | 'delete',
342
+ discordClient,
343
+ })
344
+ })
345
+ })
346
+ .catch(
347
+ (cause) =>
348
+ new ForumSyncOperationError({
349
+ forumChannelId: runtimeState.forumChannelId,
350
+ reason: `failed to subscribe watcher for ${runtimeState.outputDir}`,
351
+ cause,
352
+ }),
353
+ )
354
+
355
+ if (subscription instanceof Error) return subscription
356
+
357
+ watcherUnsubscribeByForumId.set(runtimeState.forumChannelId, () => {
358
+ return subscription.unsubscribe()
359
+ })
360
+ }
361
+
362
+ // ═══════════════════════════════════════════════════════════════════════════
363
+ // PUBLIC API
364
+ // ═══════════════════════════════════════════════════════════════════════════
365
+
366
+ export async function stopConfiguredForumSync() {
367
+ const unsubscribers = Array.from(watcherUnsubscribeByForumId.values())
368
+ watcherUnsubscribeByForumId.clear()
369
+ forumStateById.clear()
370
+
371
+ await Promise.all(
372
+ unsubscribers.map(async (unsubscribe) => {
373
+ await unsubscribe().catch((cause) => {
374
+ forumLogger.warn('Failed to unsubscribe forum watcher:', cause)
375
+ })
376
+ }),
377
+ )
378
+ }
379
+
380
+ export async function startConfiguredForumSync({
381
+ discordClient,
382
+ appId,
383
+ }: StartForumSyncOptions) {
384
+ const loadedConfig = await readForumSyncConfig({ appId })
385
+ if (loadedConfig instanceof Error) return loadedConfig
386
+
387
+ if (loadedConfig.length === 0) return
388
+
389
+ registerDiscordSyncListeners({ discordClient })
390
+
391
+ // Process each config independently so one stale/deleted forum channel
392
+ // doesn't block the watcher from starting for other valid configs.
393
+ for (const entry of loadedConfig) {
394
+ const runtimeState = buildRuntimeState({
395
+ forumChannelId: entry.forumChannelId,
396
+ outputDir: entry.outputDir,
397
+ direction: entry.direction,
398
+ })
399
+ forumStateById.set(entry.forumChannelId, runtimeState)
400
+
401
+ const ensureResult = await ensureDirectory({ directory: entry.outputDir })
402
+ if (ensureResult instanceof Error) {
403
+ forumLogger.warn(
404
+ `Skipping forum ${entry.forumChannelId}: failed to create ${entry.outputDir}`,
405
+ )
406
+ continue
407
+ }
408
+
409
+ const fileToDiscordResult = await syncFilesToForum({
410
+ discordClient,
411
+ forumChannelId: entry.forumChannelId,
412
+ outputDir: entry.outputDir,
413
+ runtimeState,
414
+ })
415
+ if (fileToDiscordResult instanceof Error) {
416
+ forumLogger.warn(
417
+ `Skipping forum ${entry.forumChannelId}: FS->Discord sync failed: ${fileToDiscordResult.message}`,
418
+ )
419
+ continue
420
+ }
421
+
422
+ const discordToFileResult = await syncForumToFiles({
423
+ discordClient,
424
+ forumChannelId: entry.forumChannelId,
425
+ outputDir: entry.outputDir,
426
+ forceFullRefresh: true,
427
+ runtimeState,
428
+ })
429
+ if (discordToFileResult instanceof Error) {
430
+ forumLogger.warn(
431
+ `Skipping forum ${entry.forumChannelId}: Discord->FS sync failed: ${discordToFileResult.message}`,
432
+ )
433
+ continue
434
+ }
435
+
436
+ const watcherResult = await startWatcherForRuntimeState({
437
+ runtimeState,
438
+ discordClient,
439
+ })
440
+ if (watcherResult instanceof Error) {
441
+ forumLogger.warn(
442
+ `Skipping forum ${entry.forumChannelId}: watcher failed: ${watcherResult.message}`,
443
+ )
444
+ continue
445
+ }
446
+
447
+ forumLogger.log(
448
+ `Forum sync started for ${entry.forumChannelId} (${entry.direction}) -> ${entry.outputDir}`,
449
+ )
450
+ forumLogger.log(
451
+ `Initial sync: Discord->FS synced ${discordToFileResult.synced}, skipped ${discordToFileResult.skipped}, deleted ${discordToFileResult.deleted}; FS->Discord created ${fileToDiscordResult.created}, updated ${fileToDiscordResult.updated}, deleted ${fileToDiscordResult.deleted}`,
452
+ )
453
+ }
454
+ }
@@ -0,0 +1,164 @@
1
+ // Main thread interface for the GenAI worker.
2
+ // Spawns and manages the worker thread, handling message passing for
3
+ // audio input/output, tool call completions, and graceful shutdown.
4
+
5
+ import { Worker } from 'node:worker_threads'
6
+ import type { WorkerInMessage, WorkerOutMessage } from './worker-types.js'
7
+ import { createLogger, LogPrefix } from './logger.js'
8
+ import { notifyError } from './sentry.js'
9
+
10
+ const genaiWorkerLogger = createLogger(LogPrefix.GENAI_WORKER)
11
+ const genaiWrapperLogger = createLogger(LogPrefix.GENAI_WORKER)
12
+
13
+ export interface GenAIWorkerOptions {
14
+ directory: string
15
+ systemMessage?: string
16
+ guildId: string
17
+ channelId: string
18
+ appId: string
19
+ geminiApiKey?: string | null
20
+ onAssistantOpusPacket: (packet: ArrayBuffer) => void
21
+ onAssistantStartSpeaking?: () => void
22
+ onAssistantStopSpeaking?: () => void
23
+ onAssistantInterruptSpeaking?: () => void
24
+ onToolCallCompleted?: (params: {
25
+ sessionId: string
26
+ messageId: string
27
+ data?: unknown
28
+ error?: unknown
29
+ markdown?: string
30
+ }) => void
31
+ onError?: (error: string) => void
32
+ }
33
+
34
+ export interface GenAIWorker {
35
+ sendRealtimeInput(params: {
36
+ audio?: { mimeType: string; data: string }
37
+ audioStreamEnd?: boolean
38
+ }): void
39
+ sendTextInput(text: string): void
40
+ interrupt(): void
41
+ stop(): Promise<void>
42
+ }
43
+
44
+ export function createGenAIWorker(
45
+ options: GenAIWorkerOptions,
46
+ ): Promise<GenAIWorker> {
47
+ return new Promise((resolve, reject) => {
48
+ const worker = new Worker(
49
+ new URL('../dist/genai-worker.js', import.meta.url),
50
+ )
51
+
52
+ // Handle messages from worker
53
+ worker.on('message', (message: WorkerOutMessage) => {
54
+ switch (message.type) {
55
+ case 'assistantOpusPacket':
56
+ options.onAssistantOpusPacket(message.packet)
57
+ break
58
+ case 'assistantStartSpeaking':
59
+ options.onAssistantStartSpeaking?.()
60
+ break
61
+ case 'assistantStopSpeaking':
62
+ options.onAssistantStopSpeaking?.()
63
+ break
64
+ case 'assistantInterruptSpeaking':
65
+ options.onAssistantInterruptSpeaking?.()
66
+ break
67
+ case 'toolCallCompleted':
68
+ options.onToolCallCompleted?.(message)
69
+ break
70
+ case 'error':
71
+ genaiWorkerLogger.error('Error:', message.error)
72
+ options.onError?.(message.error)
73
+ break
74
+ case 'ready':
75
+ genaiWorkerLogger.log('Ready')
76
+ // Resolve with the worker interface
77
+ resolve({
78
+ sendRealtimeInput({ audio, audioStreamEnd }) {
79
+ worker.postMessage({
80
+ type: 'sendRealtimeInput',
81
+ audio,
82
+ audioStreamEnd,
83
+ } satisfies WorkerInMessage)
84
+ },
85
+ sendTextInput(text) {
86
+ worker.postMessage({
87
+ type: 'sendTextInput',
88
+ text,
89
+ } satisfies WorkerInMessage)
90
+ },
91
+ interrupt() {
92
+ worker.postMessage({
93
+ type: 'interrupt',
94
+ } satisfies WorkerInMessage)
95
+ },
96
+ async stop() {
97
+ genaiWrapperLogger.log('Stopping worker...')
98
+ // Send stop message to trigger graceful shutdown
99
+ worker.postMessage({ type: 'stop' } satisfies WorkerInMessage)
100
+
101
+ // Wait for worker to exit gracefully (with timeout)
102
+ await new Promise<void>((resolve) => {
103
+ let resolved = false
104
+
105
+ // Listen for worker exit
106
+ worker.once('exit', (code) => {
107
+ if (!resolved) {
108
+ resolved = true
109
+ genaiWrapperLogger.log(
110
+ `[GENAI WORKER WRAPPER] Worker exited with code ${code}`,
111
+ )
112
+ resolve()
113
+ }
114
+ })
115
+
116
+ // Timeout after 5 seconds and force terminate
117
+ setTimeout(() => {
118
+ if (!resolved) {
119
+ resolved = true
120
+ genaiWrapperLogger.log(
121
+ '[GENAI WORKER WRAPPER] Worker did not exit gracefully, terminating...',
122
+ )
123
+ worker.terminate().then(() => {
124
+ genaiWrapperLogger.log('Worker terminated')
125
+ resolve()
126
+ })
127
+ }
128
+ }, 5000)
129
+ })
130
+ },
131
+ })
132
+ break
133
+ }
134
+ })
135
+
136
+ // Handle worker errors
137
+ worker.on('error', (error) => {
138
+ genaiWorkerLogger.error('Worker error:', error)
139
+ reject(error)
140
+ })
141
+
142
+ worker.on('exit', (code) => {
143
+ if (code !== 0) {
144
+ genaiWorkerLogger.error(`Worker stopped with exit code ${code}`)
145
+ void notifyError(
146
+ new Error(`GenAI worker exited with code ${code}`),
147
+ 'GenAI worker non-zero exit after init',
148
+ )
149
+ }
150
+ })
151
+
152
+ // Send initialization message
153
+ const initMessage: WorkerInMessage = {
154
+ type: 'init',
155
+ directory: options.directory,
156
+ systemMessage: options.systemMessage,
157
+ guildId: options.guildId,
158
+ channelId: options.channelId,
159
+ appId: options.appId,
160
+ geminiApiKey: options.geminiApiKey,
161
+ }
162
+ worker.postMessage(initMessage)
163
+ })
164
+ }