@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,560 @@
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
+ import { ChannelType, GuildMember, MessageFlags, PermissionsBitField, } from 'discord.js';
5
+ import { REST, Routes } from 'discord.js';
6
+ import { Lexer } from 'marked';
7
+ import { splitTablesFromMarkdown } from './format-tables.js';
8
+ import { getChannelDirectory, getThreadWorktree } from './database.js';
9
+ import { getDiscordApiV10BaseUrl } from './discord-api.js';
10
+ import { limitHeadingDepth } from './limit-heading-depth.js';
11
+ import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
12
+ import { createLogger, LogPrefix } from './logger.js';
13
+ import * as errore from 'errore';
14
+ import mime from 'mime';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ const discordLogger = createLogger(LogPrefix.DISCORD);
18
+ const guildFilterId = process.env.KIMAKI_GUILD?.trim();
19
+ export function isGuildAllowed({ guildId, }) {
20
+ if (!guildFilterId) {
21
+ return true;
22
+ }
23
+ if (!guildId) {
24
+ return false;
25
+ }
26
+ return guildId === guildFilterId;
27
+ }
28
+ /**
29
+ * Centralized permission check for Kimaki bot access.
30
+ * Returns true if the member has permission to use the bot:
31
+ * - Server owner, Administrator, Manage Server, or "Kimaki" role (case-insensitive).
32
+ * Returns false if member is null or has the "no-kimaki" role (overrides all).
33
+ */
34
+ export function hasKimakiBotPermission(member, guild) {
35
+ if (!member) {
36
+ return false;
37
+ }
38
+ const hasNoKimakiRole = hasRoleByName(member, 'no-kimaki', guild);
39
+ if (hasNoKimakiRole) {
40
+ return false;
41
+ }
42
+ const memberPermissions = member instanceof GuildMember
43
+ ? member.permissions
44
+ : new PermissionsBitField(BigInt(member.permissions));
45
+ const ownerId = member instanceof GuildMember ? member.guild.ownerId : guild?.ownerId;
46
+ const memberId = member instanceof GuildMember ? member.id : member.user.id;
47
+ const isOwner = ownerId ? memberId === ownerId : false;
48
+ const isAdmin = memberPermissions.has(PermissionsBitField.Flags.Administrator);
49
+ const canManageServer = memberPermissions.has(PermissionsBitField.Flags.ManageGuild);
50
+ const hasKimakiRole = hasRoleByName(member, 'kimaki', guild);
51
+ return isOwner || isAdmin || canManageServer || hasKimakiRole;
52
+ }
53
+ function hasRoleByName(member, roleName, guild) {
54
+ const target = roleName.toLowerCase();
55
+ if (member instanceof GuildMember) {
56
+ return member.roles.cache.some((role) => role.name.toLowerCase() === target);
57
+ }
58
+ if (!guild) {
59
+ return false;
60
+ }
61
+ const roleIds = Array.isArray(member.roles) ? member.roles : [];
62
+ for (const roleId of roleIds) {
63
+ const role = guild.roles.cache.get(roleId);
64
+ if (role?.name.toLowerCase() === target) {
65
+ return true;
66
+ }
67
+ }
68
+ return false;
69
+ }
70
+ /**
71
+ * Check if the member has the "no-kimaki" role that blocks bot access.
72
+ * Separate from hasKimakiBotPermission so callers can show a specific error message.
73
+ */
74
+ export function hasNoKimakiRole(member) {
75
+ if (!member?.roles?.cache) {
76
+ return false;
77
+ }
78
+ return member.roles.cache.some((role) => role.name.toLowerCase() === 'no-kimaki');
79
+ }
80
+ /**
81
+ * React to a thread's starter message with an emoji.
82
+ * Thread ID equals the starter message ID in Discord.
83
+ */
84
+ export async function reactToThread({ rest, threadId, channelId, emoji, }) {
85
+ const parentChannelId = await (async () => {
86
+ if (channelId) {
87
+ return channelId;
88
+ }
89
+ // Fetch the thread to get its parent channel ID
90
+ const threadResult = await errore.tryAsync(() => {
91
+ return rest.get(Routes.channel(threadId));
92
+ });
93
+ if (threadResult instanceof Error) {
94
+ discordLogger.warn(`Failed to fetch thread ${threadId}:`, threadResult.message);
95
+ return null;
96
+ }
97
+ return threadResult.parent_id || null;
98
+ })();
99
+ if (!parentChannelId) {
100
+ discordLogger.warn(`Could not resolve parent channel for thread ${threadId}`);
101
+ return;
102
+ }
103
+ // React to the thread starter message in the parent channel.
104
+ // Thread ID equals the starter message ID for threads created from messages.
105
+ const result = await errore.tryAsync(() => {
106
+ return rest.put(Routes.channelMessageOwnReaction(parentChannelId, threadId, encodeURIComponent(emoji)));
107
+ });
108
+ if (result instanceof Error) {
109
+ discordLogger.warn(`Failed to react to thread ${threadId} with ${emoji}:`, result.message);
110
+ }
111
+ }
112
+ export async function archiveThread({ rest, threadId, parentChannelId, sessionId, client, archiveDelay = 0, }) {
113
+ await reactToThread({
114
+ rest,
115
+ threadId,
116
+ channelId: parentChannelId,
117
+ emoji: '📁',
118
+ });
119
+ if (client && sessionId) {
120
+ const updateResult = await errore.tryAsync({
121
+ try: async () => {
122
+ const sessionResponse = await client.session.get({
123
+ sessionID: sessionId,
124
+ });
125
+ if (!sessionResponse.data) {
126
+ return;
127
+ }
128
+ const currentTitle = sessionResponse.data.title || '';
129
+ const newTitle = currentTitle.startsWith('📁')
130
+ ? currentTitle
131
+ : `📁 ${currentTitle}`.trim();
132
+ await client.session.update({
133
+ sessionID: sessionId,
134
+ title: newTitle,
135
+ });
136
+ },
137
+ catch: (e) => new Error('Failed to update session title', { cause: e }),
138
+ });
139
+ if (updateResult instanceof Error) {
140
+ discordLogger.warn(`[archive-thread] ${updateResult.message}`);
141
+ }
142
+ const abortResult = await errore.tryAsync({
143
+ try: async () => {
144
+ await client.session.abort({ sessionID: sessionId });
145
+ },
146
+ catch: (e) => new Error('Failed to abort session', { cause: e }),
147
+ });
148
+ if (abortResult instanceof Error) {
149
+ discordLogger.warn(`[archive-thread] ${abortResult.message}`);
150
+ }
151
+ }
152
+ if (archiveDelay > 0) {
153
+ await new Promise((resolve) => {
154
+ setTimeout(() => {
155
+ resolve();
156
+ }, archiveDelay);
157
+ });
158
+ }
159
+ await rest.patch(Routes.channel(threadId), {
160
+ body: { archived: true },
161
+ });
162
+ }
163
+ /** Remove Discord mentions from text so they don't appear in thread titles */
164
+ export function stripMentions(text) {
165
+ return text
166
+ .replace(/<@!?\d+>/g, '') // user mentions
167
+ .replace(/<@&\d+>/g, '') // role mentions
168
+ .replace(/<#\d+>/g, '') // channel mentions
169
+ .replace(/\s+/g, ' ')
170
+ .trim();
171
+ }
172
+ export const SILENT_MESSAGE_FLAGS = 4 | 4096;
173
+ // Same as SILENT but without SuppressNotifications - triggers badge/notification
174
+ export const NOTIFY_MESSAGE_FLAGS = 4;
175
+ export function escapeBackticksInCodeBlocks(markdown) {
176
+ const lexer = new Lexer();
177
+ const tokens = lexer.lex(markdown);
178
+ let result = '';
179
+ for (const token of tokens) {
180
+ if (token.type === 'code') {
181
+ const escapedCode = token.text.replace(/`/g, '\\`');
182
+ result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n';
183
+ }
184
+ else {
185
+ result += token.raw;
186
+ }
187
+ }
188
+ return result;
189
+ }
190
+ export function splitMarkdownForDiscord({ content, maxLength, }) {
191
+ if (content.length <= maxLength) {
192
+ return [content];
193
+ }
194
+ const lexer = new Lexer();
195
+ const tokens = lexer.lex(content);
196
+ const lines = [];
197
+ const ensureNewlineBeforeCode = () => {
198
+ const last = lines[lines.length - 1];
199
+ if (!last) {
200
+ return;
201
+ }
202
+ if (last.text.endsWith('\n')) {
203
+ return;
204
+ }
205
+ lines.push({
206
+ text: '\n',
207
+ inCodeBlock: false,
208
+ lang: '',
209
+ isOpeningFence: false,
210
+ isClosingFence: false,
211
+ });
212
+ };
213
+ for (const token of tokens) {
214
+ if (token.type === 'code') {
215
+ ensureNewlineBeforeCode();
216
+ const lang = token.lang || '';
217
+ lines.push({
218
+ text: '```' + lang + '\n',
219
+ inCodeBlock: false,
220
+ lang,
221
+ isOpeningFence: true,
222
+ isClosingFence: false,
223
+ });
224
+ const codeLines = token.text.split('\n');
225
+ for (const codeLine of codeLines) {
226
+ lines.push({
227
+ text: codeLine + '\n',
228
+ inCodeBlock: true,
229
+ lang,
230
+ isOpeningFence: false,
231
+ isClosingFence: false,
232
+ });
233
+ }
234
+ lines.push({
235
+ text: '```\n',
236
+ inCodeBlock: false,
237
+ lang: '',
238
+ isOpeningFence: false,
239
+ isClosingFence: true,
240
+ });
241
+ }
242
+ else {
243
+ const rawLines = token.raw.split('\n');
244
+ for (let i = 0; i < rawLines.length; i++) {
245
+ const isLast = i === rawLines.length - 1;
246
+ const text = isLast ? rawLines[i] : rawLines[i] + '\n';
247
+ if (text) {
248
+ lines.push({
249
+ text,
250
+ inCodeBlock: false,
251
+ lang: '',
252
+ isOpeningFence: false,
253
+ isClosingFence: false,
254
+ });
255
+ }
256
+ }
257
+ }
258
+ }
259
+ const chunks = [];
260
+ let currentChunk = '';
261
+ let currentLang = null;
262
+ // helper to split a long line into smaller pieces at word boundaries or hard breaks
263
+ const splitLongLine = (text, available, inCode) => {
264
+ const pieces = [];
265
+ let remaining = text;
266
+ while (remaining.length > available) {
267
+ let splitAt = available;
268
+ // for non-code, try to split at word boundary
269
+ if (!inCode) {
270
+ const lastSpace = remaining.lastIndexOf(' ', available);
271
+ if (lastSpace > available * 0.5) {
272
+ splitAt = lastSpace + 1;
273
+ }
274
+ }
275
+ pieces.push(remaining.slice(0, splitAt));
276
+ remaining = remaining.slice(splitAt);
277
+ }
278
+ if (remaining) {
279
+ pieces.push(remaining);
280
+ }
281
+ return pieces;
282
+ };
283
+ const closingFence = '```\n';
284
+ for (const line of lines) {
285
+ // openingFenceSize accounts for the fence text when starting a fresh chunk
286
+ const openingFenceSize = currentChunk.length === 0 && (line.inCodeBlock || line.isOpeningFence)
287
+ ? ('```' + line.lang + '\n').length
288
+ : 0;
289
+ // When opening fence starts a fresh chunk, its size is in openingFenceSize.
290
+ // Otherwise count it normally so the overflow check doesn't miss the fence text.
291
+ const lineLength = line.isOpeningFence && currentChunk.length === 0 ? 0 : line.text.length;
292
+ const activeFenceOverhead = currentLang !== null || openingFenceSize > 0 ? closingFence.length : 0;
293
+ const wouldExceed = currentChunk.length +
294
+ openingFenceSize +
295
+ lineLength +
296
+ activeFenceOverhead >
297
+ maxLength;
298
+ if (wouldExceed) {
299
+ // handle case where single line is longer than maxLength
300
+ if (line.text.length > maxLength) {
301
+ // first, flush current chunk if any
302
+ if (currentChunk) {
303
+ if (currentLang !== null) {
304
+ currentChunk += '```\n';
305
+ }
306
+ chunks.push(currentChunk);
307
+ currentChunk = '';
308
+ }
309
+ // calculate overhead for code block markers
310
+ const codeBlockOverhead = line.inCodeBlock
311
+ ? ('```' + line.lang + '\n').length + '```\n'.length
312
+ : 0;
313
+ // ensure at least 10 chars available, even if maxLength is very small
314
+ const availablePerChunk = Math.max(10, maxLength - codeBlockOverhead - 50);
315
+ const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
316
+ for (let i = 0; i < pieces.length; i++) {
317
+ const piece = pieces[i];
318
+ if (line.inCodeBlock) {
319
+ chunks.push('```' + line.lang + '\n' + piece + '```\n');
320
+ }
321
+ else {
322
+ chunks.push(piece);
323
+ }
324
+ }
325
+ currentLang = null;
326
+ continue;
327
+ }
328
+ // normal case: line fits in a chunk but current chunk would overflow
329
+ if (currentChunk) {
330
+ if (currentLang !== null) {
331
+ currentChunk += '```\n';
332
+ }
333
+ chunks.push(currentChunk);
334
+ if (line.isClosingFence && currentLang !== null) {
335
+ currentChunk = '';
336
+ currentLang = null;
337
+ continue;
338
+ }
339
+ if (line.inCodeBlock || line.isOpeningFence) {
340
+ const lang = line.lang;
341
+ currentChunk = '```' + lang + '\n';
342
+ if (!line.isOpeningFence) {
343
+ currentChunk += line.text;
344
+ }
345
+ currentLang = lang;
346
+ }
347
+ else {
348
+ currentChunk = line.text;
349
+ currentLang = null;
350
+ }
351
+ }
352
+ else {
353
+ // currentChunk is empty but line still exceeds - shouldn't happen after above check
354
+ const openingFence = line.inCodeBlock || line.isOpeningFence;
355
+ const openingFenceSize = openingFence
356
+ ? ('```' + line.lang + '\n').length
357
+ : 0;
358
+ if (line.text.length + openingFenceSize + activeFenceOverhead >
359
+ maxLength) {
360
+ const fencedOverhead = openingFence
361
+ ? ('```' + line.lang + '\n').length + closingFence.length
362
+ : 0;
363
+ const availablePerChunk = Math.max(10, maxLength - fencedOverhead - 50);
364
+ const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
365
+ for (const piece of pieces) {
366
+ if (openingFence) {
367
+ chunks.push('```' + line.lang + '\n' + piece + closingFence);
368
+ }
369
+ else {
370
+ chunks.push(piece);
371
+ }
372
+ }
373
+ currentChunk = '';
374
+ currentLang = null;
375
+ }
376
+ else {
377
+ if (openingFence) {
378
+ currentChunk = '```' + line.lang + '\n';
379
+ if (!line.isOpeningFence) {
380
+ currentChunk += line.text;
381
+ }
382
+ currentLang = line.lang;
383
+ }
384
+ else {
385
+ currentChunk = line.text;
386
+ currentLang = null;
387
+ }
388
+ }
389
+ }
390
+ }
391
+ else {
392
+ currentChunk += line.text;
393
+ if (line.inCodeBlock || line.isOpeningFence) {
394
+ currentLang = line.lang;
395
+ }
396
+ else if (line.isClosingFence) {
397
+ currentLang = null;
398
+ }
399
+ }
400
+ }
401
+ if (currentChunk) {
402
+ if (currentLang !== null) {
403
+ currentChunk += closingFence;
404
+ }
405
+ chunks.push(currentChunk);
406
+ }
407
+ return chunks;
408
+ }
409
+ export async function sendThreadMessage(thread, content, options) {
410
+ const MAX_LENGTH = 2000;
411
+ // Split content into text and CV2 component segments (tables → Container components)
412
+ const segments = splitTablesFromMarkdown(content);
413
+ const baseFlags = options?.flags ?? SILENT_MESSAGE_FLAGS;
414
+ let firstMessage;
415
+ for (const segment of segments) {
416
+ if (segment.type === 'components') {
417
+ const message = await thread.send({
418
+ components: segment.components,
419
+ flags: MessageFlags.IsComponentsV2 | baseFlags,
420
+ });
421
+ if (!firstMessage) {
422
+ firstMessage = message;
423
+ }
424
+ continue;
425
+ }
426
+ // Apply text transformations to text segments
427
+ let text = segment.text;
428
+ text = unnestCodeBlocksFromLists(text);
429
+ text = limitHeadingDepth(text);
430
+ text = escapeBackticksInCodeBlocks(text);
431
+ if (!text.trim()) {
432
+ continue;
433
+ }
434
+ const sendFlags = options?.flags ?? SILENT_MESSAGE_FLAGS;
435
+ const chunks = splitMarkdownForDiscord({
436
+ content: text,
437
+ maxLength: MAX_LENGTH,
438
+ });
439
+ if (chunks.length > 1) {
440
+ discordLogger.log(`MESSAGE: Splitting ${text.length} chars into ${chunks.length} messages`);
441
+ }
442
+ for (let chunk of chunks) {
443
+ if (!chunk) {
444
+ continue;
445
+ }
446
+ // Safety net: hard-truncate if splitting still produced an oversized chunk
447
+ if (chunk.length > MAX_LENGTH) {
448
+ chunk = chunk.slice(0, MAX_LENGTH - 4) + '...';
449
+ }
450
+ const message = await thread.send({ content: chunk, flags: sendFlags });
451
+ if (!firstMessage) {
452
+ firstMessage = message;
453
+ }
454
+ }
455
+ }
456
+ return firstMessage;
457
+ }
458
+ export async function resolveTextChannel(channel) {
459
+ if (!channel) {
460
+ return null;
461
+ }
462
+ if (channel.type === ChannelType.GuildText) {
463
+ return channel;
464
+ }
465
+ if (channel.type === ChannelType.PublicThread ||
466
+ channel.type === ChannelType.PrivateThread ||
467
+ channel.type === ChannelType.AnnouncementThread) {
468
+ const parentId = channel.parentId;
469
+ if (parentId) {
470
+ const parent = await channel.guild.channels.fetch(parentId);
471
+ if (parent?.type === ChannelType.GuildText) {
472
+ return parent;
473
+ }
474
+ }
475
+ }
476
+ return null;
477
+ }
478
+ export function escapeDiscordFormatting(text) {
479
+ return text.replace(/```/g, '\\`\\`\\`').replace(/````/g, '\\`\\`\\`\\`');
480
+ }
481
+ export async function getKimakiMetadata(textChannel) {
482
+ if (!textChannel) {
483
+ return {};
484
+ }
485
+ const channelConfig = await getChannelDirectory(textChannel.id);
486
+ if (!channelConfig) {
487
+ return {};
488
+ }
489
+ return {
490
+ projectDirectory: channelConfig.directory,
491
+ channelAppId: channelConfig.appId || undefined,
492
+ };
493
+ }
494
+ /**
495
+ * Resolve the working directory for a channel or thread.
496
+ * Returns both the base project directory (for server init) and the working directory
497
+ * (worktree directory if in a worktree thread, otherwise same as projectDirectory).
498
+ * This prevents commands from accidentally running in the base project dir when a
499
+ * worktree is active — the bug that caused /diff, /compact, etc. to use wrong cwd.
500
+ */
501
+ export async function resolveWorkingDirectory({ channel, }) {
502
+ const isThread = [
503
+ ChannelType.PublicThread,
504
+ ChannelType.PrivateThread,
505
+ ChannelType.AnnouncementThread,
506
+ ].includes(channel.type);
507
+ const textChannel = isThread
508
+ ? await resolveTextChannel(channel)
509
+ : channel;
510
+ const metadata = await getKimakiMetadata(textChannel);
511
+ if (!metadata.projectDirectory) {
512
+ return undefined;
513
+ }
514
+ let workingDirectory = metadata.projectDirectory;
515
+ if (isThread) {
516
+ const worktreeInfo = await getThreadWorktree(channel.id);
517
+ if (worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory) {
518
+ workingDirectory = worktreeInfo.worktree_directory;
519
+ }
520
+ }
521
+ return {
522
+ projectDirectory: metadata.projectDirectory,
523
+ workingDirectory,
524
+ channelAppId: metadata.channelAppId,
525
+ };
526
+ }
527
+ /**
528
+ * Upload files to a Discord thread/channel in a single message.
529
+ * Sending all files in one message causes Discord to display images in a grid layout.
530
+ */
531
+ export async function uploadFilesToDiscord({ threadId, botToken, files, }) {
532
+ if (files.length === 0) {
533
+ return;
534
+ }
535
+ const apiV10BaseUrl = getDiscordApiV10BaseUrl();
536
+ // Build attachments array for all files
537
+ const attachments = files.map((file, index) => ({
538
+ id: index,
539
+ filename: path.basename(file),
540
+ }));
541
+ const formData = new FormData();
542
+ formData.append('payload_json', JSON.stringify({ attachments }));
543
+ // Append each file with its array index, with correct MIME type for grid display
544
+ files.forEach((file, index) => {
545
+ const buffer = fs.readFileSync(file);
546
+ const mimeType = mime.getType(file) || 'application/octet-stream';
547
+ formData.append(`files[${index}]`, new Blob([buffer], { type: mimeType }), path.basename(file));
548
+ });
549
+ const response = await fetch(`${apiV10BaseUrl}/channels/${threadId}/messages`, {
550
+ method: 'POST',
551
+ headers: {
552
+ Authorization: `Bot ${botToken}`,
553
+ },
554
+ body: formData,
555
+ });
556
+ if (!response.ok) {
557
+ const error = await response.text();
558
+ throw new Error(`Discord API error: ${response.status} - ${error}`);
559
+ }
560
+ }
@@ -0,0 +1,115 @@
1
+ import { PermissionsBitField } from 'discord.js';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { hasKimakiBotPermission, splitMarkdownForDiscord } from './discord-utils.js';
4
+ describe('splitMarkdownForDiscord', () => {
5
+ test('never returns chunks over the max length with code fences', () => {
6
+ const maxLength = 2000;
7
+ const header = '## Summary of Current Architecture\n\n';
8
+ const codeFenceStart = '```\n';
9
+ const codeFenceEnd = '\n```\n';
10
+ const codeLine = 'x'.repeat(180);
11
+ const codeBlock = Array.from({ length: 20 })
12
+ .map(() => codeLine)
13
+ .join('\n');
14
+ const markdown = `${header}${codeFenceStart}${codeBlock}${codeFenceEnd}`;
15
+ const chunks = splitMarkdownForDiscord({ content: markdown, maxLength });
16
+ expect(chunks.length).toBeGreaterThan(1);
17
+ for (const chunk of chunks) {
18
+ expect(chunk.length).toBeLessThanOrEqual(maxLength);
19
+ }
20
+ });
21
+ // Without the lineLength fix for opening fences on non-empty chunks, the opening
22
+ // fence text "```\n" gets appended without being counted in the overflow check.
23
+ // When the chunk is later flushed with a closing fence, it exceeds maxLength.
24
+ test('opening fence on non-empty chunk is counted in overflow check', () => {
25
+ const maxLength = 60;
26
+ // 55 chars of text + paragraph break, then a code block.
27
+ // The text fills the chunk to ~57 chars. The opening fence "```\n" (4 chars)
28
+ // would push to 61 if not counted, then flushing adds "```\n" (4 more) = 65.
29
+ const markdown = 'a'.repeat(55) + '\n\n```\nshort code\n```\n';
30
+ const chunks = splitMarkdownForDiscord({ content: markdown, maxLength });
31
+ for (const chunk of chunks) {
32
+ expect(chunk.length).toBeLessThanOrEqual(maxLength);
33
+ }
34
+ });
35
+ test('list item code block keeps newline before fence when splitting', () => {
36
+ const content = `- File: playwriter/src/aria-snapshot.ts
37
+ - Add helper function (~line 477, after isTextRole):
38
+ \`\`\`ts
39
+ function isSubstringOfAny(needle: string, haystack: Set<string>): boolean {
40
+ for (const str of haystack) {
41
+ if (str.includes(needle)) {
42
+ return true
43
+ }
44
+ }
45
+ return false
46
+ }
47
+ \`\`\`
48
+ `;
49
+ const result = splitMarkdownForDiscord({ content, maxLength: 80 });
50
+ expect(result).toMatchInlineSnapshot(`
51
+ [
52
+ "- File: playwriter/src/aria-snapshot.ts
53
+ ",
54
+ "- Add helper function (~line 477, after isTextRole):
55
+ \`\`\`ts
56
+ ",
57
+ " function isSubstringOfAny(needle: string, haystack: Set<string>): boolean {
58
+ ",
59
+ " for (const str of haystack) {
60
+ if (str.includes(needle)) {
61
+ ",
62
+ " return true
63
+ }
64
+ }
65
+ return false
66
+ }
67
+ \`\`\`
68
+ ",
69
+ ]
70
+ `);
71
+ });
72
+ });
73
+ describe('hasKimakiBotPermission', () => {
74
+ test('allows API interaction member when kimaki role exists', () => {
75
+ const kimakiRoleId = '111';
76
+ const guild = {
77
+ ownerId: 'owner-id',
78
+ roles: {
79
+ cache: new Map([
80
+ [kimakiRoleId, { id: kimakiRoleId, name: 'Kimaki' }],
81
+ ]),
82
+ },
83
+ };
84
+ const member = {
85
+ user: { id: 'member-id' },
86
+ permissions: '0',
87
+ roles: [kimakiRoleId],
88
+ };
89
+ expect(hasKimakiBotPermission(member, guild)).toBe(true);
90
+ });
91
+ test('allows API interaction member with ManageGuild permission', () => {
92
+ const guild = {
93
+ ownerId: 'owner-id',
94
+ roles: { cache: new Map() },
95
+ };
96
+ const member = {
97
+ user: { id: 'member-id' },
98
+ permissions: PermissionsBitField.Flags.ManageGuild.toString(),
99
+ roles: [],
100
+ };
101
+ expect(hasKimakiBotPermission(member, guild)).toBe(true);
102
+ });
103
+ test('denies API interaction member with no role, owner, or admin rights', () => {
104
+ const guild = {
105
+ ownerId: 'owner-id',
106
+ roles: { cache: new Map() },
107
+ };
108
+ const member = {
109
+ user: { id: 'member-id' },
110
+ permissions: '0',
111
+ roles: [],
112
+ };
113
+ expect(hasKimakiBotPermission(member, guild)).toBe(false);
114
+ });
115
+ });