@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,767 @@
1
+ // Discord-specific utility functions.
2
+ // Handles markdown splitting for Discord's 2000-char limit, code block escaping,
3
+ // thread message sending, and channel metadata extraction from topic tags.
4
+
5
+ import {
6
+ type APIInteractionGuildMember,
7
+ ChannelType,
8
+ GuildMember,
9
+ MessageFlags,
10
+ PermissionsBitField,
11
+ type Guild,
12
+ type Message,
13
+ type TextChannel,
14
+ type ThreadChannel,
15
+ } from 'discord.js'
16
+ import { REST, Routes } from 'discord.js'
17
+ import type { OpencodeClient } from '@opencode-ai/sdk/v2'
18
+ import { Lexer } from 'marked'
19
+ import { splitTablesFromMarkdown } from './format-tables.js'
20
+ import { getChannelDirectory, getThreadWorktree } from './database.js'
21
+ import { getDiscordApiV10BaseUrl } from './discord-api.js'
22
+ import { limitHeadingDepth } from './limit-heading-depth.js'
23
+ import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
24
+ import { createLogger, LogPrefix } from './logger.js'
25
+ import * as errore from 'errore'
26
+ import mime from 'mime'
27
+ import fs from 'node:fs'
28
+ import path from 'node:path'
29
+
30
+ const discordLogger = createLogger(LogPrefix.DISCORD)
31
+ const guildFilterId = process.env.KIMAKI_GUILD?.trim()
32
+
33
+ export function isGuildAllowed({
34
+ guildId,
35
+ }: {
36
+ guildId?: string | null
37
+ }): boolean {
38
+ if (!guildFilterId) {
39
+ return true
40
+ }
41
+ if (!guildId) {
42
+ return false
43
+ }
44
+ return guildId === guildFilterId
45
+ }
46
+
47
+ /**
48
+ * Centralized permission check for Kimaki bot access.
49
+ * Returns true if the member has permission to use the bot:
50
+ * - Server owner, Administrator, Manage Server, or "Kimaki" role (case-insensitive).
51
+ * Returns false if member is null or has the "no-kimaki" role (overrides all).
52
+ */
53
+ export function hasKimakiBotPermission(
54
+ member: GuildMember | APIInteractionGuildMember | null,
55
+ guild?: Guild | null,
56
+ ): boolean {
57
+ if (!member) {
58
+ return false
59
+ }
60
+ const hasNoKimakiRole = hasRoleByName(member, 'no-kimaki', guild)
61
+ if (hasNoKimakiRole) {
62
+ return false
63
+ }
64
+ const memberPermissions =
65
+ member instanceof GuildMember
66
+ ? member.permissions
67
+ : new PermissionsBitField(BigInt(member.permissions))
68
+ const ownerId = member instanceof GuildMember ? member.guild.ownerId : guild?.ownerId
69
+ const memberId = member instanceof GuildMember ? member.id : member.user.id
70
+ const isOwner = ownerId ? memberId === ownerId : false
71
+ const isAdmin = memberPermissions.has(PermissionsBitField.Flags.Administrator)
72
+ const canManageServer = memberPermissions.has(PermissionsBitField.Flags.ManageGuild)
73
+ const hasKimakiRole = hasRoleByName(member, 'kimaki', guild)
74
+ return isOwner || isAdmin || canManageServer || hasKimakiRole
75
+ }
76
+
77
+ function hasRoleByName(
78
+ member: GuildMember | APIInteractionGuildMember,
79
+ roleName: string,
80
+ guild?: Guild | null,
81
+ ): boolean {
82
+ const target = roleName.toLowerCase()
83
+
84
+ if (member instanceof GuildMember) {
85
+ return member.roles.cache.some((role) => role.name.toLowerCase() === target)
86
+ }
87
+
88
+ if (!guild) {
89
+ return false
90
+ }
91
+
92
+ const roleIds = Array.isArray(member.roles) ? member.roles : []
93
+ for (const roleId of roleIds) {
94
+ const role = guild.roles.cache.get(roleId)
95
+ if (role?.name.toLowerCase() === target) {
96
+ return true
97
+ }
98
+ }
99
+ return false
100
+ }
101
+
102
+ /**
103
+ * Check if the member has the "no-kimaki" role that blocks bot access.
104
+ * Separate from hasKimakiBotPermission so callers can show a specific error message.
105
+ */
106
+ export function hasNoKimakiRole(member: GuildMember | null): boolean {
107
+ if (!member?.roles?.cache) {
108
+ return false
109
+ }
110
+ return member.roles.cache.some(
111
+ (role) => role.name.toLowerCase() === 'no-kimaki',
112
+ )
113
+ }
114
+
115
+ /**
116
+ * React to a thread's starter message with an emoji.
117
+ * Thread ID equals the starter message ID in Discord.
118
+ */
119
+ export async function reactToThread({
120
+ rest,
121
+ threadId,
122
+ channelId,
123
+ emoji,
124
+ }: {
125
+ rest: REST
126
+ threadId: string
127
+ /** Parent channel ID where the thread starter message lives.
128
+ * If not provided, fetches the thread info from Discord API to resolve it. */
129
+ channelId?: string
130
+ emoji: string
131
+ }): Promise<void> {
132
+ const parentChannelId = await (async () => {
133
+ if (channelId) {
134
+ return channelId
135
+ }
136
+ // Fetch the thread to get its parent channel ID
137
+ const threadResult = await errore.tryAsync(() => {
138
+ return rest.get(Routes.channel(threadId)) as Promise<{
139
+ parent_id?: string
140
+ }>
141
+ })
142
+ if (threadResult instanceof Error) {
143
+ discordLogger.warn(
144
+ `Failed to fetch thread ${threadId}:`,
145
+ threadResult.message,
146
+ )
147
+ return null
148
+ }
149
+ return threadResult.parent_id || null
150
+ })()
151
+
152
+ if (!parentChannelId) {
153
+ discordLogger.warn(
154
+ `Could not resolve parent channel for thread ${threadId}`,
155
+ )
156
+ return
157
+ }
158
+
159
+ // React to the thread starter message in the parent channel.
160
+ // Thread ID equals the starter message ID for threads created from messages.
161
+ const result = await errore.tryAsync(() => {
162
+ return rest.put(
163
+ Routes.channelMessageOwnReaction(
164
+ parentChannelId,
165
+ threadId,
166
+ encodeURIComponent(emoji),
167
+ ),
168
+ )
169
+ })
170
+ if (result instanceof Error) {
171
+ discordLogger.warn(
172
+ `Failed to react to thread ${threadId} with ${emoji}:`,
173
+ result.message,
174
+ )
175
+ }
176
+ }
177
+
178
+ export async function archiveThread({
179
+ rest,
180
+ threadId,
181
+ parentChannelId,
182
+ sessionId,
183
+ client,
184
+ archiveDelay = 0,
185
+ }: {
186
+ rest: REST
187
+ threadId: string
188
+ parentChannelId?: string
189
+ sessionId?: string
190
+ client?: OpencodeClient | null
191
+ archiveDelay?: number
192
+ }): Promise<void> {
193
+ await reactToThread({
194
+ rest,
195
+ threadId,
196
+ channelId: parentChannelId,
197
+ emoji: '📁',
198
+ })
199
+
200
+ if (client && sessionId) {
201
+ const updateResult = await errore.tryAsync({
202
+ try: async () => {
203
+ const sessionResponse = await client.session.get({
204
+ sessionID: sessionId,
205
+ })
206
+ if (!sessionResponse.data) {
207
+ return
208
+ }
209
+ const currentTitle = sessionResponse.data.title || ''
210
+ const newTitle = currentTitle.startsWith('📁')
211
+ ? currentTitle
212
+ : `📁 ${currentTitle}`.trim()
213
+ await client.session.update({
214
+ sessionID: sessionId,
215
+ title: newTitle,
216
+ })
217
+ },
218
+ catch: (e) => new Error('Failed to update session title', { cause: e }),
219
+ })
220
+ if (updateResult instanceof Error) {
221
+ discordLogger.warn(`[archive-thread] ${updateResult.message}`)
222
+ }
223
+
224
+ const abortResult = await errore.tryAsync({
225
+ try: async () => {
226
+ await client.session.abort({ sessionID: sessionId })
227
+ },
228
+ catch: (e) => new Error('Failed to abort session', { cause: e }),
229
+ })
230
+ if (abortResult instanceof Error) {
231
+ discordLogger.warn(`[archive-thread] ${abortResult.message}`)
232
+ }
233
+ }
234
+
235
+ if (archiveDelay > 0) {
236
+ await new Promise<void>((resolve) => {
237
+ setTimeout(() => {
238
+ resolve()
239
+ }, archiveDelay)
240
+ })
241
+ }
242
+
243
+ await rest.patch(Routes.channel(threadId), {
244
+ body: { archived: true },
245
+ })
246
+ }
247
+
248
+ /** Remove Discord mentions from text so they don't appear in thread titles */
249
+ export function stripMentions(text: string): string {
250
+ return text
251
+ .replace(/<@!?\d+>/g, '') // user mentions
252
+ .replace(/<@&\d+>/g, '') // role mentions
253
+ .replace(/<#\d+>/g, '') // channel mentions
254
+ .replace(/\s+/g, ' ')
255
+ .trim()
256
+ }
257
+
258
+ export const SILENT_MESSAGE_FLAGS = 4 | 4096
259
+ // Same as SILENT but without SuppressNotifications - triggers badge/notification
260
+ export const NOTIFY_MESSAGE_FLAGS = 4
261
+
262
+ export function escapeBackticksInCodeBlocks(markdown: string): string {
263
+ const lexer = new Lexer()
264
+ const tokens = lexer.lex(markdown)
265
+
266
+ let result = ''
267
+
268
+ for (const token of tokens) {
269
+ if (token.type === 'code') {
270
+ const escapedCode = token.text.replace(/`/g, '\\`')
271
+ result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n'
272
+ } else {
273
+ result += token.raw
274
+ }
275
+ }
276
+
277
+ return result
278
+ }
279
+
280
+ type LineInfo = {
281
+ text: string
282
+ inCodeBlock: boolean
283
+ lang: string
284
+ isOpeningFence: boolean
285
+ isClosingFence: boolean
286
+ }
287
+
288
+ export function splitMarkdownForDiscord({
289
+ content,
290
+ maxLength,
291
+ }: {
292
+ content: string
293
+ maxLength: number
294
+ }): string[] {
295
+ if (content.length <= maxLength) {
296
+ return [content]
297
+ }
298
+
299
+ const lexer = new Lexer()
300
+ const tokens = lexer.lex(content)
301
+
302
+ const lines: LineInfo[] = []
303
+ const ensureNewlineBeforeCode = (): void => {
304
+ const last = lines[lines.length - 1]
305
+ if (!last) {
306
+ return
307
+ }
308
+ if (last.text.endsWith('\n')) {
309
+ return
310
+ }
311
+ lines.push({
312
+ text: '\n',
313
+ inCodeBlock: false,
314
+ lang: '',
315
+ isOpeningFence: false,
316
+ isClosingFence: false,
317
+ })
318
+ }
319
+ for (const token of tokens) {
320
+ if (token.type === 'code') {
321
+ ensureNewlineBeforeCode()
322
+ const lang = token.lang || ''
323
+ lines.push({
324
+ text: '```' + lang + '\n',
325
+ inCodeBlock: false,
326
+ lang,
327
+ isOpeningFence: true,
328
+ isClosingFence: false,
329
+ })
330
+ const codeLines = token.text.split('\n')
331
+ for (const codeLine of codeLines) {
332
+ lines.push({
333
+ text: codeLine + '\n',
334
+ inCodeBlock: true,
335
+ lang,
336
+ isOpeningFence: false,
337
+ isClosingFence: false,
338
+ })
339
+ }
340
+ lines.push({
341
+ text: '```\n',
342
+ inCodeBlock: false,
343
+ lang: '',
344
+ isOpeningFence: false,
345
+ isClosingFence: true,
346
+ })
347
+ } else {
348
+ const rawLines = token.raw.split('\n')
349
+ for (let i = 0; i < rawLines.length; i++) {
350
+ const isLast = i === rawLines.length - 1
351
+ const text = isLast ? rawLines[i]! : rawLines[i]! + '\n'
352
+ if (text) {
353
+ lines.push({
354
+ text,
355
+ inCodeBlock: false,
356
+ lang: '',
357
+ isOpeningFence: false,
358
+ isClosingFence: false,
359
+ })
360
+ }
361
+ }
362
+ }
363
+ }
364
+
365
+ const chunks: string[] = []
366
+ let currentChunk = ''
367
+ let currentLang: string | null = null
368
+
369
+ // helper to split a long line into smaller pieces at word boundaries or hard breaks
370
+ const splitLongLine = (
371
+ text: string,
372
+ available: number,
373
+ inCode: boolean,
374
+ ): string[] => {
375
+ const pieces: string[] = []
376
+ let remaining = text
377
+
378
+ while (remaining.length > available) {
379
+ let splitAt = available
380
+ // for non-code, try to split at word boundary
381
+ if (!inCode) {
382
+ const lastSpace = remaining.lastIndexOf(' ', available)
383
+ if (lastSpace > available * 0.5) {
384
+ splitAt = lastSpace + 1
385
+ }
386
+ }
387
+ pieces.push(remaining.slice(0, splitAt))
388
+ remaining = remaining.slice(splitAt)
389
+ }
390
+ if (remaining) {
391
+ pieces.push(remaining)
392
+ }
393
+ return pieces
394
+ }
395
+
396
+ const closingFence = '```\n'
397
+
398
+ for (const line of lines) {
399
+ // openingFenceSize accounts for the fence text when starting a fresh chunk
400
+ const openingFenceSize =
401
+ currentChunk.length === 0 && (line.inCodeBlock || line.isOpeningFence)
402
+ ? ('```' + line.lang + '\n').length
403
+ : 0
404
+ // When opening fence starts a fresh chunk, its size is in openingFenceSize.
405
+ // Otherwise count it normally so the overflow check doesn't miss the fence text.
406
+ const lineLength =
407
+ line.isOpeningFence && currentChunk.length === 0 ? 0 : line.text.length
408
+ const activeFenceOverhead =
409
+ currentLang !== null || openingFenceSize > 0 ? closingFence.length : 0
410
+ const wouldExceed =
411
+ currentChunk.length +
412
+ openingFenceSize +
413
+ lineLength +
414
+ activeFenceOverhead >
415
+ maxLength
416
+
417
+ if (wouldExceed) {
418
+ // handle case where single line is longer than maxLength
419
+ if (line.text.length > maxLength) {
420
+ // first, flush current chunk if any
421
+ if (currentChunk) {
422
+ if (currentLang !== null) {
423
+ currentChunk += '```\n'
424
+ }
425
+ chunks.push(currentChunk)
426
+ currentChunk = ''
427
+ }
428
+
429
+ // calculate overhead for code block markers
430
+ const codeBlockOverhead = line.inCodeBlock
431
+ ? ('```' + line.lang + '\n').length + '```\n'.length
432
+ : 0
433
+ // ensure at least 10 chars available, even if maxLength is very small
434
+ const availablePerChunk = Math.max(
435
+ 10,
436
+ maxLength - codeBlockOverhead - 50,
437
+ )
438
+
439
+ const pieces = splitLongLine(
440
+ line.text,
441
+ availablePerChunk,
442
+ line.inCodeBlock,
443
+ )
444
+
445
+ for (let i = 0; i < pieces.length; i++) {
446
+ const piece = pieces[i]!
447
+ if (line.inCodeBlock) {
448
+ chunks.push('```' + line.lang + '\n' + piece + '```\n')
449
+ } else {
450
+ chunks.push(piece)
451
+ }
452
+ }
453
+
454
+ currentLang = null
455
+ continue
456
+ }
457
+
458
+ // normal case: line fits in a chunk but current chunk would overflow
459
+ if (currentChunk) {
460
+ if (currentLang !== null) {
461
+ currentChunk += '```\n'
462
+ }
463
+ chunks.push(currentChunk)
464
+
465
+ if (line.isClosingFence && currentLang !== null) {
466
+ currentChunk = ''
467
+ currentLang = null
468
+ continue
469
+ }
470
+
471
+ if (line.inCodeBlock || line.isOpeningFence) {
472
+ const lang = line.lang
473
+ currentChunk = '```' + lang + '\n'
474
+ if (!line.isOpeningFence) {
475
+ currentChunk += line.text
476
+ }
477
+ currentLang = lang
478
+ } else {
479
+ currentChunk = line.text
480
+ currentLang = null
481
+ }
482
+ } else {
483
+ // currentChunk is empty but line still exceeds - shouldn't happen after above check
484
+ const openingFence = line.inCodeBlock || line.isOpeningFence
485
+ const openingFenceSize = openingFence
486
+ ? ('```' + line.lang + '\n').length
487
+ : 0
488
+ if (
489
+ line.text.length + openingFenceSize + activeFenceOverhead >
490
+ maxLength
491
+ ) {
492
+ const fencedOverhead = openingFence
493
+ ? ('```' + line.lang + '\n').length + closingFence.length
494
+ : 0
495
+ const availablePerChunk = Math.max(
496
+ 10,
497
+ maxLength - fencedOverhead - 50,
498
+ )
499
+ const pieces = splitLongLine(
500
+ line.text,
501
+ availablePerChunk,
502
+ line.inCodeBlock,
503
+ )
504
+ for (const piece of pieces) {
505
+ if (openingFence) {
506
+ chunks.push('```' + line.lang + '\n' + piece + closingFence)
507
+ } else {
508
+ chunks.push(piece)
509
+ }
510
+ }
511
+ currentChunk = ''
512
+ currentLang = null
513
+ } else {
514
+ if (openingFence) {
515
+ currentChunk = '```' + line.lang + '\n'
516
+ if (!line.isOpeningFence) {
517
+ currentChunk += line.text
518
+ }
519
+ currentLang = line.lang
520
+ } else {
521
+ currentChunk = line.text
522
+ currentLang = null
523
+ }
524
+ }
525
+ }
526
+ } else {
527
+ currentChunk += line.text
528
+ if (line.inCodeBlock || line.isOpeningFence) {
529
+ currentLang = line.lang
530
+ } else if (line.isClosingFence) {
531
+ currentLang = null
532
+ }
533
+ }
534
+ }
535
+
536
+ if (currentChunk) {
537
+ if (currentLang !== null) {
538
+ currentChunk += closingFence
539
+ }
540
+ chunks.push(currentChunk)
541
+ }
542
+
543
+ return chunks
544
+ }
545
+
546
+ export async function sendThreadMessage(
547
+ thread: ThreadChannel,
548
+ content: string,
549
+ options?: { flags?: number },
550
+ ): Promise<Message> {
551
+ const MAX_LENGTH = 2000
552
+
553
+ // Split content into text and CV2 component segments (tables → Container components)
554
+ const segments = splitTablesFromMarkdown(content)
555
+ const baseFlags = options?.flags ?? SILENT_MESSAGE_FLAGS
556
+
557
+ let firstMessage: Message | undefined
558
+
559
+ for (const segment of segments) {
560
+ if (segment.type === 'components') {
561
+ const message = await thread.send({
562
+ components: segment.components,
563
+ flags: MessageFlags.IsComponentsV2 | baseFlags,
564
+ })
565
+ if (!firstMessage) {
566
+ firstMessage = message
567
+ }
568
+ continue
569
+ }
570
+
571
+ // Apply text transformations to text segments
572
+ let text = segment.text
573
+ text = unnestCodeBlocksFromLists(text)
574
+ text = limitHeadingDepth(text)
575
+ text = escapeBackticksInCodeBlocks(text)
576
+
577
+ if (!text.trim()) {
578
+ continue
579
+ }
580
+
581
+ const sendFlags = options?.flags ?? SILENT_MESSAGE_FLAGS
582
+ const chunks = splitMarkdownForDiscord({
583
+ content: text,
584
+ maxLength: MAX_LENGTH,
585
+ })
586
+
587
+ if (chunks.length > 1) {
588
+ discordLogger.log(
589
+ `MESSAGE: Splitting ${text.length} chars into ${chunks.length} messages`,
590
+ )
591
+ }
592
+
593
+ for (let chunk of chunks) {
594
+ if (!chunk) {
595
+ continue
596
+ }
597
+ // Safety net: hard-truncate if splitting still produced an oversized chunk
598
+ if (chunk.length > MAX_LENGTH) {
599
+ chunk = chunk.slice(0, MAX_LENGTH - 4) + '...'
600
+ }
601
+ const message = await thread.send({ content: chunk, flags: sendFlags })
602
+ if (!firstMessage) {
603
+ firstMessage = message
604
+ }
605
+ }
606
+ }
607
+
608
+ return firstMessage!
609
+ }
610
+
611
+ export async function resolveTextChannel(
612
+ channel: TextChannel | ThreadChannel | null | undefined,
613
+ ): Promise<TextChannel | null> {
614
+ if (!channel) {
615
+ return null
616
+ }
617
+
618
+ if (channel.type === ChannelType.GuildText) {
619
+ return channel as TextChannel
620
+ }
621
+
622
+ if (
623
+ channel.type === ChannelType.PublicThread ||
624
+ channel.type === ChannelType.PrivateThread ||
625
+ channel.type === ChannelType.AnnouncementThread
626
+ ) {
627
+ const parentId = channel.parentId
628
+ if (parentId) {
629
+ const parent = await channel.guild.channels.fetch(parentId)
630
+ if (parent?.type === ChannelType.GuildText) {
631
+ return parent as TextChannel
632
+ }
633
+ }
634
+ }
635
+
636
+ return null
637
+ }
638
+
639
+ export function escapeDiscordFormatting(text: string): string {
640
+ return text.replace(/```/g, '\\`\\`\\`').replace(/````/g, '\\`\\`\\`\\`')
641
+ }
642
+
643
+ export async function getKimakiMetadata(
644
+ textChannel: TextChannel | null,
645
+ ): Promise<{
646
+ projectDirectory?: string
647
+ channelAppId?: string
648
+ }> {
649
+ if (!textChannel) {
650
+ return {}
651
+ }
652
+
653
+ const channelConfig = await getChannelDirectory(textChannel.id)
654
+
655
+ if (!channelConfig) {
656
+ return {}
657
+ }
658
+
659
+ return {
660
+ projectDirectory: channelConfig.directory,
661
+ channelAppId: channelConfig.appId || undefined,
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Resolve the working directory for a channel or thread.
667
+ * Returns both the base project directory (for server init) and the working directory
668
+ * (worktree directory if in a worktree thread, otherwise same as projectDirectory).
669
+ * This prevents commands from accidentally running in the base project dir when a
670
+ * worktree is active — the bug that caused /diff, /compact, etc. to use wrong cwd.
671
+ */
672
+ export async function resolveWorkingDirectory({
673
+ channel,
674
+ }: {
675
+ channel: TextChannel | ThreadChannel
676
+ }): Promise<
677
+ | {
678
+ projectDirectory: string
679
+ workingDirectory: string
680
+ channelAppId?: string
681
+ }
682
+ | undefined
683
+ > {
684
+ const isThread = [
685
+ ChannelType.PublicThread,
686
+ ChannelType.PrivateThread,
687
+ ChannelType.AnnouncementThread,
688
+ ].includes(channel.type)
689
+
690
+ const textChannel = isThread
691
+ ? await resolveTextChannel(channel as ThreadChannel)
692
+ : (channel as TextChannel)
693
+
694
+ const metadata = await getKimakiMetadata(textChannel)
695
+ if (!metadata.projectDirectory) {
696
+ return undefined
697
+ }
698
+
699
+ let workingDirectory = metadata.projectDirectory
700
+ if (isThread) {
701
+ const worktreeInfo = await getThreadWorktree(channel.id)
702
+ if (worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory) {
703
+ workingDirectory = worktreeInfo.worktree_directory
704
+ }
705
+ }
706
+
707
+ return {
708
+ projectDirectory: metadata.projectDirectory,
709
+ workingDirectory,
710
+ channelAppId: metadata.channelAppId,
711
+ }
712
+ }
713
+
714
+ /**
715
+ * Upload files to a Discord thread/channel in a single message.
716
+ * Sending all files in one message causes Discord to display images in a grid layout.
717
+ */
718
+ export async function uploadFilesToDiscord({
719
+ threadId,
720
+ botToken,
721
+ files,
722
+ }: {
723
+ threadId: string
724
+ botToken: string
725
+ files: string[]
726
+ }): Promise<void> {
727
+ if (files.length === 0) {
728
+ return
729
+ }
730
+ const apiV10BaseUrl = getDiscordApiV10BaseUrl()
731
+
732
+ // Build attachments array for all files
733
+ const attachments = files.map((file, index) => ({
734
+ id: index,
735
+ filename: path.basename(file),
736
+ }))
737
+
738
+ const formData = new FormData()
739
+ formData.append('payload_json', JSON.stringify({ attachments }))
740
+
741
+ // Append each file with its array index, with correct MIME type for grid display
742
+ files.forEach((file, index) => {
743
+ const buffer = fs.readFileSync(file)
744
+ const mimeType = mime.getType(file) || 'application/octet-stream'
745
+ formData.append(
746
+ `files[${index}]`,
747
+ new Blob([buffer], { type: mimeType }),
748
+ path.basename(file),
749
+ )
750
+ })
751
+
752
+ const response = await fetch(
753
+ `${apiV10BaseUrl}/channels/${threadId}/messages`,
754
+ {
755
+ method: 'POST',
756
+ headers: {
757
+ Authorization: `Bot ${botToken}`,
758
+ },
759
+ body: formData,
760
+ },
761
+ )
762
+
763
+ if (!response.ok) {
764
+ const error = await response.text()
765
+ throw new Error(`Discord API error: ${response.status} - ${error}`)
766
+ }
767
+ }