@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,291 @@
1
+ // /agent command - Set the preferred agent for this channel or session.
2
+ // Also provides quick agent commands like /plan-agent, /build-agent that switch instantly.
3
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
4
+ import crypto from 'node:crypto';
5
+ import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, } from '../database.js';
6
+ import { initializeOpencodeForDirectory } from '../opencode.js';
7
+ import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
8
+ import { createLogger, LogPrefix } from '../logger.js';
9
+ const agentLogger = createLogger(LogPrefix.AGENT);
10
+ const pendingAgentContexts = new Map();
11
+ /**
12
+ * Get the current agent info for a channel/session, including where it comes from.
13
+ * Priority: session > channel > none
14
+ */
15
+ export async function getCurrentAgentInfo({ sessionId, channelId, }) {
16
+ if (sessionId) {
17
+ const sessionAgent = await getSessionAgent(sessionId);
18
+ if (sessionAgent) {
19
+ return { type: 'session', agent: sessionAgent };
20
+ }
21
+ }
22
+ if (channelId) {
23
+ const channelAgent = await getChannelAgent(channelId);
24
+ if (channelAgent) {
25
+ return { type: 'channel', agent: channelAgent };
26
+ }
27
+ }
28
+ return { type: 'none' };
29
+ }
30
+ /**
31
+ * Sanitize an agent name to be a valid Discord command name component.
32
+ * Lowercase, alphanumeric and hyphens only.
33
+ */
34
+ export function sanitizeAgentName(name) {
35
+ return name
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9-]/g, '-')
38
+ .replace(/-+/g, '-')
39
+ .replace(/^-|-$/g, '');
40
+ }
41
+ /**
42
+ * Resolve the context for an agent command (directory, channel, session).
43
+ * Returns null if the command cannot be executed in this context.
44
+ */
45
+ export async function resolveAgentCommandContext({ interaction, appId, }) {
46
+ const channel = interaction.channel;
47
+ if (!channel) {
48
+ await interaction.editReply({
49
+ content: 'This command can only be used in a channel',
50
+ });
51
+ return null;
52
+ }
53
+ const isThread = [
54
+ ChannelType.PublicThread,
55
+ ChannelType.PrivateThread,
56
+ ChannelType.AnnouncementThread,
57
+ ].includes(channel.type);
58
+ let projectDirectory;
59
+ let channelAppId;
60
+ let targetChannelId;
61
+ let sessionId;
62
+ if (isThread) {
63
+ const thread = channel;
64
+ const textChannel = await resolveTextChannel(thread);
65
+ const metadata = await getKimakiMetadata(textChannel);
66
+ projectDirectory = metadata.projectDirectory;
67
+ channelAppId = metadata.channelAppId;
68
+ targetChannelId = textChannel?.id || channel.id;
69
+ sessionId = await getThreadSession(thread.id);
70
+ }
71
+ else if (channel.type === ChannelType.GuildText) {
72
+ const textChannel = channel;
73
+ const metadata = await getKimakiMetadata(textChannel);
74
+ projectDirectory = metadata.projectDirectory;
75
+ channelAppId = metadata.channelAppId;
76
+ targetChannelId = channel.id;
77
+ }
78
+ else {
79
+ await interaction.editReply({
80
+ content: 'This command can only be used in text channels or threads',
81
+ });
82
+ return null;
83
+ }
84
+ if (channelAppId && channelAppId !== appId) {
85
+ await interaction.editReply({
86
+ content: 'This channel is not configured for this bot',
87
+ });
88
+ return null;
89
+ }
90
+ if (!projectDirectory) {
91
+ await interaction.editReply({
92
+ content: 'This channel is not configured with a project directory',
93
+ });
94
+ return null;
95
+ }
96
+ return {
97
+ dir: projectDirectory,
98
+ channelId: targetChannelId,
99
+ sessionId,
100
+ isThread,
101
+ };
102
+ }
103
+ /**
104
+ * Set the agent preference for a context (session or channel).
105
+ * When switching agents for a session, clears session model preference
106
+ * so the new agent's model takes effect (agent model > channel model).
107
+ */
108
+ export async function setAgentForContext({ context, agentName, }) {
109
+ if (context.isThread && context.sessionId) {
110
+ await setSessionAgent(context.sessionId, agentName);
111
+ // Clear session model so the new agent's model takes effect
112
+ await clearSessionModel(context.sessionId);
113
+ agentLogger.log(`Set agent ${agentName} for session ${context.sessionId} (cleared session model)`);
114
+ }
115
+ else {
116
+ await setChannelAgent(context.channelId, agentName);
117
+ agentLogger.log(`Set agent ${agentName} for channel ${context.channelId}`);
118
+ }
119
+ }
120
+ export async function handleAgentCommand({ interaction, appId, }) {
121
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
122
+ const context = await resolveAgentCommandContext({ interaction, appId });
123
+ if (!context) {
124
+ return;
125
+ }
126
+ try {
127
+ const getClient = await initializeOpencodeForDirectory(context.dir);
128
+ if (getClient instanceof Error) {
129
+ await interaction.editReply({ content: getClient.message });
130
+ return;
131
+ }
132
+ const agentsResponse = await getClient().app.agents({
133
+ directory: context.dir,
134
+ });
135
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
136
+ await interaction.editReply({ content: 'No agents available' });
137
+ return;
138
+ }
139
+ const agents = agentsResponse.data
140
+ .filter((agent) => {
141
+ const hidden = agent.hidden;
142
+ return (agent.mode === 'primary' || agent.mode === 'all') && !hidden;
143
+ })
144
+ .slice(0, 25);
145
+ if (agents.length === 0) {
146
+ await interaction.editReply({ content: 'No primary agents available' });
147
+ return;
148
+ }
149
+ const currentAgentInfo = await getCurrentAgentInfo({
150
+ sessionId: context.sessionId,
151
+ channelId: context.channelId,
152
+ });
153
+ const currentAgentText = (() => {
154
+ switch (currentAgentInfo.type) {
155
+ case 'session':
156
+ return `**Current (session override):** \`${currentAgentInfo.agent}\``;
157
+ case 'channel':
158
+ return `**Current (channel override):** \`${currentAgentInfo.agent}\``;
159
+ case 'none':
160
+ return '**Current:** none';
161
+ }
162
+ })();
163
+ const contextHash = crypto.randomBytes(8).toString('hex');
164
+ pendingAgentContexts.set(contextHash, context);
165
+ const options = agents.map((agent) => ({
166
+ label: agent.name.slice(0, 100),
167
+ value: agent.name,
168
+ description: (agent.description || `${agent.mode} agent`).slice(0, 100),
169
+ }));
170
+ const selectMenu = new StringSelectMenuBuilder()
171
+ .setCustomId(`agent_select:${contextHash}`)
172
+ .setPlaceholder('Select an agent')
173
+ .addOptions(options);
174
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
175
+ await interaction.editReply({
176
+ content: `**Set Agent Preference**\n${currentAgentText}\nSelect an agent:`,
177
+ components: [actionRow],
178
+ });
179
+ }
180
+ catch (error) {
181
+ agentLogger.error('Error loading agents:', error);
182
+ await interaction.editReply({
183
+ content: `Failed to load agents: ${error instanceof Error ? error.message : 'Unknown error'}`,
184
+ });
185
+ }
186
+ }
187
+ export async function handleAgentSelectMenu(interaction) {
188
+ const customId = interaction.customId;
189
+ if (!customId.startsWith('agent_select:')) {
190
+ return;
191
+ }
192
+ await interaction.deferUpdate();
193
+ const contextHash = customId.replace('agent_select:', '');
194
+ const context = pendingAgentContexts.get(contextHash);
195
+ if (!context) {
196
+ await interaction.editReply({
197
+ content: 'Selection expired. Please run /agent again.',
198
+ components: [],
199
+ });
200
+ return;
201
+ }
202
+ const selectedAgent = interaction.values[0];
203
+ if (!selectedAgent) {
204
+ await interaction.editReply({
205
+ content: 'No agent selected',
206
+ components: [],
207
+ });
208
+ return;
209
+ }
210
+ try {
211
+ await setAgentForContext({ context, agentName: selectedAgent });
212
+ if (context.isThread && context.sessionId) {
213
+ await interaction.editReply({
214
+ content: `Agent preference set for this session: **${selectedAgent}**`,
215
+ components: [],
216
+ });
217
+ }
218
+ else {
219
+ await interaction.editReply({
220
+ content: `Agent preference set for this channel: **${selectedAgent}**\nAll new sessions in this channel will use this agent.`,
221
+ components: [],
222
+ });
223
+ }
224
+ pendingAgentContexts.delete(contextHash);
225
+ }
226
+ catch (error) {
227
+ agentLogger.error('Error saving agent preference:', error);
228
+ await interaction.editReply({
229
+ content: `Failed to save agent preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
230
+ components: [],
231
+ });
232
+ }
233
+ }
234
+ /**
235
+ * Handle quick agent commands like /plan-agent, /build-agent.
236
+ * These instantly switch to the specified agent without showing a dropdown.
237
+ *
238
+ * Optimized for speed: skips opencode server entirely.
239
+ * The agent name is already validated at command registration time (cli.ts registers
240
+ * commands from the agents list), so we trust the command name directly instead of
241
+ * calling initializeOpencodeForDirectory + app.agents() to re-validate.
242
+ * This eliminates two HTTP round-trips (~400-1000ms) that the old implementation had.
243
+ */
244
+ export async function handleQuickAgentCommand({ command, appId, }) {
245
+ // Extract agent name from command: "plan-agent" → "plan"
246
+ const sanitizedAgentName = command.commandName.replace(/-agent$/, '');
247
+ await command.deferReply({ flags: MessageFlags.Ephemeral });
248
+ const context = await resolveAgentCommandContext({
249
+ interaction: command,
250
+ appId,
251
+ });
252
+ if (!context) {
253
+ return;
254
+ }
255
+ try {
256
+ // Check current agent and set new one.
257
+ // getCurrentAgentInfo is fast (DB only), use it for the "was X" text.
258
+ const previousAgent = await getCurrentAgentInfo({
259
+ sessionId: context.sessionId,
260
+ channelId: context.channelId,
261
+ });
262
+ const previousAgentName = previousAgent.type !== 'none' ? previousAgent.agent : undefined;
263
+ if (previousAgentName === sanitizedAgentName) {
264
+ await command.editReply({
265
+ content: `Already using **${sanitizedAgentName}** agent`,
266
+ });
267
+ return;
268
+ }
269
+ // Set the agent preference (DB write only, no HTTP calls)
270
+ await setAgentForContext({ context, agentName: sanitizedAgentName });
271
+ const previousText = previousAgentName
272
+ ? ` (was **${previousAgentName}**)`
273
+ : '';
274
+ if (context.isThread && context.sessionId) {
275
+ await command.editReply({
276
+ content: `Switched to **${sanitizedAgentName}** agent for this session${previousText}`,
277
+ });
278
+ }
279
+ else {
280
+ await command.editReply({
281
+ content: `Switched to **${sanitizedAgentName}** agent for this channel${previousText}\nAll new sessions will use this agent.`,
282
+ });
283
+ }
284
+ }
285
+ catch (error) {
286
+ agentLogger.error('Error in quick agent command:', error);
287
+ await command.editReply({
288
+ content: `Failed to switch agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
289
+ });
290
+ }
291
+ }
@@ -0,0 +1,223 @@
1
+ // AskUserQuestion tool handler - Shows Discord dropdowns for AI questions.
2
+ // When the AI uses the AskUserQuestion tool, this module renders dropdowns
3
+ // for each question and collects user responses.
4
+ import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder, MessageFlags, } from 'discord.js';
5
+ import crypto from 'node:crypto';
6
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
7
+ import { getOpencodeClient } from '../opencode.js';
8
+ import { createLogger, LogPrefix } from '../logger.js';
9
+ const logger = createLogger(LogPrefix.ASK_QUESTION);
10
+ // Store pending question contexts by hash
11
+ export const pendingQuestionContexts = new Map();
12
+ /**
13
+ * Show dropdown menus for question tool input.
14
+ * Sends one message per question with the dropdown directly under the question text.
15
+ */
16
+ export async function showAskUserQuestionDropdowns({ thread, sessionId, directory, requestId, input, }) {
17
+ const contextHash = crypto.randomBytes(8).toString('hex');
18
+ const context = {
19
+ sessionId,
20
+ directory,
21
+ thread,
22
+ requestId,
23
+ questions: input.questions,
24
+ answers: {},
25
+ totalQuestions: input.questions.length,
26
+ answeredCount: 0,
27
+ contextHash,
28
+ };
29
+ pendingQuestionContexts.set(contextHash, context);
30
+ // Send one message per question with its dropdown directly underneath
31
+ for (let i = 0; i < input.questions.length; i++) {
32
+ const q = input.questions[i];
33
+ // Map options to Discord select menu options
34
+ // Discord max: 25 options per select menu
35
+ const options = [
36
+ ...q.options.slice(0, 24).map((opt, optIdx) => ({
37
+ label: opt.label.slice(0, 100),
38
+ value: `${optIdx}`,
39
+ description: opt.description.slice(0, 100),
40
+ })),
41
+ {
42
+ label: 'Other',
43
+ value: 'other',
44
+ description: 'Provide a custom answer in chat',
45
+ },
46
+ ];
47
+ const placeholder = options.find((x) => x.label)?.label || 'Select an option';
48
+ const selectMenu = new StringSelectMenuBuilder()
49
+ .setCustomId(`ask_question:${contextHash}:${i}`)
50
+ .setPlaceholder(placeholder)
51
+ .addOptions(options);
52
+ // Enable multi-select if the question supports it
53
+ if (q.multiple) {
54
+ selectMenu.setMinValues(1);
55
+ selectMenu.setMaxValues(options.length);
56
+ }
57
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
58
+ await thread.send({
59
+ content: `**${(q.header || '').slice(0, 200)}**\n${q.question.slice(0, 1700)}`,
60
+ components: [actionRow],
61
+ flags: NOTIFY_MESSAGE_FLAGS,
62
+ });
63
+ }
64
+ logger.log(`Showed ${input.questions.length} question dropdown(s) for session ${sessionId}`);
65
+ }
66
+ /**
67
+ * Handle dropdown selection for AskUserQuestion.
68
+ */
69
+ export async function handleAskQuestionSelectMenu(interaction) {
70
+ const customId = interaction.customId;
71
+ if (!customId.startsWith('ask_question:')) {
72
+ return;
73
+ }
74
+ const parts = customId.split(':');
75
+ const contextHash = parts[1];
76
+ const questionIndex = parseInt(parts[2], 10);
77
+ if (!contextHash) {
78
+ await interaction.reply({
79
+ content: 'Invalid selection.',
80
+ flags: MessageFlags.Ephemeral,
81
+ });
82
+ return;
83
+ }
84
+ const context = pendingQuestionContexts.get(contextHash);
85
+ if (!context) {
86
+ await interaction.reply({
87
+ content: 'This question has expired. Please ask the AI again.',
88
+ flags: MessageFlags.Ephemeral,
89
+ });
90
+ return;
91
+ }
92
+ await interaction.deferUpdate();
93
+ const selectedValues = interaction.values;
94
+ const question = context.questions[questionIndex];
95
+ if (!question) {
96
+ logger.error(`Question index ${questionIndex} not found in context`);
97
+ return;
98
+ }
99
+ // Check if "other" was selected
100
+ if (selectedValues.includes('other')) {
101
+ // User wants to provide custom answer
102
+ // For now, mark as "Other" - they can type in chat
103
+ context.answers[questionIndex] = ['Other (please type your answer in chat)'];
104
+ }
105
+ else {
106
+ // Map value indices back to option labels
107
+ context.answers[questionIndex] = selectedValues.map((v) => {
108
+ const optIdx = parseInt(v, 10);
109
+ return question.options[optIdx]?.label || `Option ${optIdx + 1}`;
110
+ });
111
+ }
112
+ context.answeredCount++;
113
+ // Update this question's message: show answer and remove dropdown
114
+ const answeredText = context.answers[questionIndex].join(', ');
115
+ await interaction.editReply({
116
+ content: `**${question.header}**\n${question.question}\n✓ _${answeredText}_`,
117
+ components: [], // Remove the dropdown
118
+ });
119
+ // Check if all questions are answered
120
+ if (context.answeredCount >= context.totalQuestions) {
121
+ // All questions answered - send result back to session
122
+ await submitQuestionAnswers(context);
123
+ pendingQuestionContexts.delete(contextHash);
124
+ }
125
+ }
126
+ /**
127
+ * Submit all collected answers back to the OpenCode session.
128
+ * Uses the question.reply API to provide answers to the waiting tool.
129
+ */
130
+ async function submitQuestionAnswers(context) {
131
+ try {
132
+ const client = getOpencodeClient(context.directory);
133
+ if (!client) {
134
+ throw new Error('OpenCode server not found for directory');
135
+ }
136
+ // Build answers array: each element is an array of selected labels for that question
137
+ const answers = context.questions.map((_, i) => {
138
+ return context.answers[i] || [];
139
+ });
140
+ await client.question.reply({
141
+ requestID: context.requestId,
142
+ directory: context.directory,
143
+ answers,
144
+ });
145
+ logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
146
+ }
147
+ catch (error) {
148
+ logger.error('Failed to submit answers:', error);
149
+ await sendThreadMessage(context.thread, `✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`);
150
+ }
151
+ }
152
+ /**
153
+ * Check if a tool part is an AskUserQuestion tool.
154
+ * Returns the parsed input if valid, null otherwise.
155
+ */
156
+ export function parseAskUserQuestionTool(part) {
157
+ if (part.type !== 'tool') {
158
+ return null;
159
+ }
160
+ // Check for the tool name (case-insensitive)
161
+ const toolName = part.tool?.toLowerCase();
162
+ if (toolName !== 'question') {
163
+ return null;
164
+ }
165
+ const input = part.state?.input;
166
+ if (!input?.questions ||
167
+ !Array.isArray(input.questions) ||
168
+ input.questions.length === 0) {
169
+ return null;
170
+ }
171
+ // Validate structure
172
+ for (const q of input.questions) {
173
+ if (typeof q.question !== 'string' ||
174
+ typeof q.header !== 'string' ||
175
+ !Array.isArray(q.options) ||
176
+ q.options.length < 2) {
177
+ return null;
178
+ }
179
+ }
180
+ return input;
181
+ }
182
+ /**
183
+ * Cancel a pending question for a thread (e.g., when user sends a new message).
184
+ * Sends the user's message as the answer to OpenCode so the model sees their actual response.
185
+ */
186
+ export async function cancelPendingQuestion(threadId, userMessage) {
187
+ // Find pending question for this thread
188
+ let contextHash;
189
+ let context;
190
+ for (const [hash, ctx] of pendingQuestionContexts) {
191
+ if (ctx.thread.id === threadId) {
192
+ contextHash = hash;
193
+ context = ctx;
194
+ break;
195
+ }
196
+ }
197
+ if (!contextHash || !context) {
198
+ return false;
199
+ }
200
+ try {
201
+ const client = getOpencodeClient(context.directory);
202
+ if (!client) {
203
+ throw new Error('OpenCode server not found for directory');
204
+ }
205
+ // Use user's message as answer if provided, otherwise mark as "Other"
206
+ const customAnswer = userMessage || 'Other';
207
+ const answers = context.questions.map((_, i) => {
208
+ return context.answers[i] || [customAnswer];
209
+ });
210
+ await client.question.reply({
211
+ requestID: context.requestId,
212
+ directory: context.directory,
213
+ answers,
214
+ });
215
+ logger.log(`Answered question ${context.requestId} with user message`);
216
+ }
217
+ catch (error) {
218
+ logger.error('Failed to answer question:', error);
219
+ }
220
+ // Clean up regardless of whether the API call succeeded
221
+ pendingQuestionContexts.delete(contextHash);
222
+ return true;
223
+ }
@@ -0,0 +1,120 @@
1
+ // /compact command - Trigger context compaction (summarization) for the current session.
2
+ import { ChannelType, MessageFlags, } from 'discord.js';
3
+ import { getThreadSession } from '../database.js';
4
+ import { initializeOpencodeForDirectory, getOpencodeClient, } from '../opencode.js';
5
+ import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
6
+ import { createLogger, LogPrefix } from '../logger.js';
7
+ const logger = createLogger(LogPrefix.COMPACT);
8
+ export async function handleCompactCommand({ command, }) {
9
+ const channel = command.channel;
10
+ if (!channel) {
11
+ await command.reply({
12
+ content: 'This command can only be used in a channel',
13
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
14
+ });
15
+ return;
16
+ }
17
+ const isThread = [
18
+ ChannelType.PublicThread,
19
+ ChannelType.PrivateThread,
20
+ ChannelType.AnnouncementThread,
21
+ ].includes(channel.type);
22
+ if (!isThread) {
23
+ await command.reply({
24
+ content: 'This command can only be used in a thread with an active session',
25
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
26
+ });
27
+ return;
28
+ }
29
+ const resolved = await resolveWorkingDirectory({
30
+ channel: channel,
31
+ });
32
+ if (!resolved) {
33
+ await command.reply({
34
+ content: 'Could not determine project directory for this channel',
35
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
36
+ });
37
+ return;
38
+ }
39
+ const { projectDirectory, workingDirectory } = resolved;
40
+ const sessionId = await getThreadSession(channel.id);
41
+ if (!sessionId) {
42
+ await command.reply({
43
+ content: 'No active session in this thread',
44
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
45
+ });
46
+ return;
47
+ }
48
+ // Ensure server is running for the base project directory
49
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
50
+ if (getClient instanceof Error) {
51
+ await command.reply({
52
+ content: `Failed to compact: ${getClient.message}`,
53
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
54
+ });
55
+ return;
56
+ }
57
+ const client = getOpencodeClient(projectDirectory);
58
+ if (!client) {
59
+ await command.reply({
60
+ content: 'Failed to get OpenCode client',
61
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
62
+ });
63
+ return;
64
+ }
65
+ // Defer reply since compaction may take a moment
66
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
67
+ try {
68
+ // Get session messages to find the model from the last user message
69
+ const messagesResult = await client.session.messages({
70
+ sessionID: sessionId,
71
+ directory: workingDirectory,
72
+ });
73
+ if (messagesResult.error || !messagesResult.data) {
74
+ logger.error('[COMPACT] Failed to get messages:', messagesResult.error);
75
+ await command.editReply({
76
+ content: 'Failed to compact: Could not retrieve session messages',
77
+ });
78
+ return;
79
+ }
80
+ // Find the last user message to get the model
81
+ const lastUserMessage = [...messagesResult.data]
82
+ .reverse()
83
+ .find((msg) => msg.info.role === 'user');
84
+ if (!lastUserMessage || lastUserMessage.info.role !== 'user') {
85
+ await command.editReply({
86
+ content: 'Failed to compact: No user message found in session',
87
+ });
88
+ return;
89
+ }
90
+ const { providerID, modelID } = lastUserMessage.info.model;
91
+ const result = await client.session.summarize({
92
+ sessionID: sessionId,
93
+ directory: workingDirectory,
94
+ providerID,
95
+ modelID,
96
+ auto: false,
97
+ });
98
+ if (result.error) {
99
+ logger.error('[COMPACT] Error:', result.error);
100
+ const errorMessage = 'data' in result.error && result.error.data
101
+ ? result.error.data.message ||
102
+ 'Unknown error'
103
+ : 'Unknown error';
104
+ await command.editReply({
105
+ content: `Failed to compact: ${errorMessage}`,
106
+ });
107
+ return;
108
+ }
109
+ await command.editReply({
110
+ content: `📦 Session **compacted** successfully`,
111
+ });
112
+ logger.log(`Session ${sessionId} compacted by user`);
113
+ }
114
+ catch (error) {
115
+ logger.error('[COMPACT] Error:', error);
116
+ await command.editReply({
117
+ content: `Failed to compact: ${error instanceof Error ? error.message : 'Unknown error'}`,
118
+ });
119
+ }
120
+ }