@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,330 @@
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
+
5
+ import {
6
+ StringSelectMenuBuilder,
7
+ StringSelectMenuInteraction,
8
+ ActionRowBuilder,
9
+ type ThreadChannel,
10
+ MessageFlags,
11
+ } from 'discord.js'
12
+ import crypto from 'node:crypto'
13
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js'
14
+ import { getOpencodeClient } from '../opencode.js'
15
+ import { createLogger, LogPrefix } from '../logger.js'
16
+
17
+ const logger = createLogger(LogPrefix.ASK_QUESTION)
18
+
19
+ // Schema matching the question tool input
20
+ export type AskUserQuestionInput = {
21
+ questions: Array<{
22
+ question: string
23
+ header: string // max 12 chars
24
+ options: Array<{
25
+ label: string
26
+ description: string
27
+ }>
28
+ multiple?: boolean // optional, defaults to false
29
+ }>
30
+ }
31
+
32
+ type PendingQuestionContext = {
33
+ sessionId: string
34
+ directory: string
35
+ thread: ThreadChannel
36
+ requestId: string // OpenCode question request ID for replying
37
+ questions: AskUserQuestionInput['questions']
38
+ answers: Record<number, string[]> // questionIndex -> selected labels
39
+ totalQuestions: number
40
+ answeredCount: number
41
+ contextHash: string
42
+ }
43
+
44
+ // Store pending question contexts by hash
45
+ export const pendingQuestionContexts = new Map<string, PendingQuestionContext>()
46
+
47
+ /**
48
+ * Show dropdown menus for question tool input.
49
+ * Sends one message per question with the dropdown directly under the question text.
50
+ */
51
+ export async function showAskUserQuestionDropdowns({
52
+ thread,
53
+ sessionId,
54
+ directory,
55
+ requestId,
56
+ input,
57
+ }: {
58
+ thread: ThreadChannel
59
+ sessionId: string
60
+ directory: string
61
+ requestId: string // OpenCode question request ID
62
+ input: AskUserQuestionInput
63
+ }): Promise<void> {
64
+ const contextHash = crypto.randomBytes(8).toString('hex')
65
+
66
+ const context: PendingQuestionContext = {
67
+ sessionId,
68
+ directory,
69
+ thread,
70
+ requestId,
71
+ questions: input.questions,
72
+ answers: {},
73
+ totalQuestions: input.questions.length,
74
+ answeredCount: 0,
75
+ contextHash,
76
+ }
77
+
78
+ pendingQuestionContexts.set(contextHash, context)
79
+
80
+ // Send one message per question with its dropdown directly underneath
81
+ for (let i = 0; i < input.questions.length; i++) {
82
+ const q = input.questions[i]!
83
+
84
+ // Map options to Discord select menu options
85
+ // Discord max: 25 options per select menu
86
+ const options = [
87
+ ...q.options.slice(0, 24).map((opt, optIdx) => ({
88
+ label: opt.label.slice(0, 100),
89
+ value: `${optIdx}`,
90
+ description: opt.description.slice(0, 100),
91
+ })),
92
+ {
93
+ label: 'Other',
94
+ value: 'other',
95
+ description: 'Provide a custom answer in chat',
96
+ },
97
+ ]
98
+
99
+ const placeholder =
100
+ options.find((x) => x.label)?.label || 'Select an option'
101
+ const selectMenu = new StringSelectMenuBuilder()
102
+ .setCustomId(`ask_question:${contextHash}:${i}`)
103
+ .setPlaceholder(placeholder)
104
+ .addOptions(options)
105
+
106
+ // Enable multi-select if the question supports it
107
+ if (q.multiple) {
108
+ selectMenu.setMinValues(1)
109
+ selectMenu.setMaxValues(options.length)
110
+ }
111
+
112
+ const actionRow =
113
+ new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
114
+
115
+ await thread.send({
116
+ content: `**${(q.header || '').slice(0, 200)}**\n${q.question.slice(0, 1700)}`,
117
+ components: [actionRow],
118
+ flags: NOTIFY_MESSAGE_FLAGS,
119
+ })
120
+ }
121
+
122
+ logger.log(
123
+ `Showed ${input.questions.length} question dropdown(s) for session ${sessionId}`,
124
+ )
125
+ }
126
+
127
+ /**
128
+ * Handle dropdown selection for AskUserQuestion.
129
+ */
130
+ export async function handleAskQuestionSelectMenu(
131
+ interaction: StringSelectMenuInteraction,
132
+ ): Promise<void> {
133
+ const customId = interaction.customId
134
+
135
+ if (!customId.startsWith('ask_question:')) {
136
+ return
137
+ }
138
+
139
+ const parts = customId.split(':')
140
+ const contextHash = parts[1]
141
+ const questionIndex = parseInt(parts[2]!, 10)
142
+
143
+ if (!contextHash) {
144
+ await interaction.reply({
145
+ content: 'Invalid selection.',
146
+ flags: MessageFlags.Ephemeral,
147
+ })
148
+ return
149
+ }
150
+
151
+ const context = pendingQuestionContexts.get(contextHash)
152
+
153
+ if (!context) {
154
+ await interaction.reply({
155
+ content: 'This question has expired. Please ask the AI again.',
156
+ flags: MessageFlags.Ephemeral,
157
+ })
158
+ return
159
+ }
160
+
161
+ await interaction.deferUpdate()
162
+
163
+ const selectedValues = interaction.values
164
+ const question = context.questions[questionIndex]
165
+
166
+ if (!question) {
167
+ logger.error(`Question index ${questionIndex} not found in context`)
168
+ return
169
+ }
170
+
171
+ // Check if "other" was selected
172
+ if (selectedValues.includes('other')) {
173
+ // User wants to provide custom answer
174
+ // For now, mark as "Other" - they can type in chat
175
+ context.answers[questionIndex] = ['Other (please type your answer in chat)']
176
+ } else {
177
+ // Map value indices back to option labels
178
+ context.answers[questionIndex] = selectedValues.map((v) => {
179
+ const optIdx = parseInt(v, 10)
180
+ return question.options[optIdx]?.label || `Option ${optIdx + 1}`
181
+ })
182
+ }
183
+
184
+ context.answeredCount++
185
+
186
+ // Update this question's message: show answer and remove dropdown
187
+ const answeredText = context.answers[questionIndex]!.join(', ')
188
+ await interaction.editReply({
189
+ content: `**${question.header}**\n${question.question}\n✓ _${answeredText}_`,
190
+ components: [], // Remove the dropdown
191
+ })
192
+
193
+ // Check if all questions are answered
194
+ if (context.answeredCount >= context.totalQuestions) {
195
+ // All questions answered - send result back to session
196
+ await submitQuestionAnswers(context)
197
+ pendingQuestionContexts.delete(contextHash)
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Submit all collected answers back to the OpenCode session.
203
+ * Uses the question.reply API to provide answers to the waiting tool.
204
+ */
205
+ async function submitQuestionAnswers(
206
+ context: PendingQuestionContext,
207
+ ): Promise<void> {
208
+ try {
209
+ const client = getOpencodeClient(context.directory)
210
+ if (!client) {
211
+ throw new Error('OpenCode server not found for directory')
212
+ }
213
+
214
+ // Build answers array: each element is an array of selected labels for that question
215
+ const answers = context.questions.map((_, i) => {
216
+ return context.answers[i] || []
217
+ })
218
+
219
+ await client.question.reply({
220
+ requestID: context.requestId,
221
+ directory: context.directory,
222
+ answers,
223
+ })
224
+
225
+ logger.log(
226
+ `Submitted answers for question ${context.requestId} in session ${context.sessionId}`,
227
+ )
228
+ } catch (error) {
229
+ logger.error('Failed to submit answers:', error)
230
+ await sendThreadMessage(
231
+ context.thread,
232
+ `✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`,
233
+ )
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Check if a tool part is an AskUserQuestion tool.
239
+ * Returns the parsed input if valid, null otherwise.
240
+ */
241
+ export function parseAskUserQuestionTool(part: {
242
+ type: string
243
+ tool?: string
244
+ state?: { input?: unknown }
245
+ }): AskUserQuestionInput | null {
246
+ if (part.type !== 'tool') {
247
+ return null
248
+ }
249
+
250
+ // Check for the tool name (case-insensitive)
251
+ const toolName = part.tool?.toLowerCase()
252
+ if (toolName !== 'question') {
253
+ return null
254
+ }
255
+
256
+ const input = part.state?.input as AskUserQuestionInput | undefined
257
+
258
+ if (
259
+ !input?.questions ||
260
+ !Array.isArray(input.questions) ||
261
+ input.questions.length === 0
262
+ ) {
263
+ return null
264
+ }
265
+
266
+ // Validate structure
267
+ for (const q of input.questions) {
268
+ if (
269
+ typeof q.question !== 'string' ||
270
+ typeof q.header !== 'string' ||
271
+ !Array.isArray(q.options) ||
272
+ q.options.length < 2
273
+ ) {
274
+ return null
275
+ }
276
+ }
277
+
278
+ return input
279
+ }
280
+
281
+ /**
282
+ * Cancel a pending question for a thread (e.g., when user sends a new message).
283
+ * Sends the user's message as the answer to OpenCode so the model sees their actual response.
284
+ */
285
+ export async function cancelPendingQuestion(
286
+ threadId: string,
287
+ userMessage?: string,
288
+ ): Promise<boolean> {
289
+ // Find pending question for this thread
290
+ let contextHash: string | undefined
291
+ let context: PendingQuestionContext | undefined
292
+ for (const [hash, ctx] of pendingQuestionContexts) {
293
+ if (ctx.thread.id === threadId) {
294
+ contextHash = hash
295
+ context = ctx
296
+ break
297
+ }
298
+ }
299
+
300
+ if (!contextHash || !context) {
301
+ return false
302
+ }
303
+
304
+ try {
305
+ const client = getOpencodeClient(context.directory)
306
+ if (!client) {
307
+ throw new Error('OpenCode server not found for directory')
308
+ }
309
+
310
+ // Use user's message as answer if provided, otherwise mark as "Other"
311
+ const customAnswer = userMessage || 'Other'
312
+ const answers = context.questions.map((_, i) => {
313
+ return context.answers[i] || [customAnswer]
314
+ })
315
+
316
+ await client.question.reply({
317
+ requestID: context.requestId,
318
+ directory: context.directory,
319
+ answers,
320
+ })
321
+
322
+ logger.log(`Answered question ${context.requestId} with user message`)
323
+ } catch (error) {
324
+ logger.error('Failed to answer question:', error)
325
+ }
326
+
327
+ // Clean up regardless of whether the API call succeeded
328
+ pendingQuestionContexts.delete(contextHash)
329
+ return true
330
+ }
@@ -0,0 +1,157 @@
1
+ // /compact command - Trigger context compaction (summarization) for the current session.
2
+
3
+ import {
4
+ ChannelType,
5
+ MessageFlags,
6
+ type TextChannel,
7
+ type ThreadChannel,
8
+ } from 'discord.js'
9
+ import type { CommandContext } from './types.js'
10
+ import { getThreadSession } from '../database.js'
11
+ import {
12
+ initializeOpencodeForDirectory,
13
+ getOpencodeClient,
14
+ } from '../opencode.js'
15
+ import {
16
+ resolveWorkingDirectory,
17
+ SILENT_MESSAGE_FLAGS,
18
+ } from '../discord-utils.js'
19
+ import { createLogger, LogPrefix } from '../logger.js'
20
+
21
+ const logger = createLogger(LogPrefix.COMPACT)
22
+
23
+ export async function handleCompactCommand({
24
+ command,
25
+ }: CommandContext): Promise<void> {
26
+ const channel = command.channel
27
+
28
+ if (!channel) {
29
+ await command.reply({
30
+ content: 'This command can only be used in a channel',
31
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
32
+ })
33
+ return
34
+ }
35
+
36
+ const isThread = [
37
+ ChannelType.PublicThread,
38
+ ChannelType.PrivateThread,
39
+ ChannelType.AnnouncementThread,
40
+ ].includes(channel.type)
41
+
42
+ if (!isThread) {
43
+ await command.reply({
44
+ content:
45
+ 'This command can only be used in a thread with an active session',
46
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
47
+ })
48
+ return
49
+ }
50
+
51
+ const resolved = await resolveWorkingDirectory({
52
+ channel: channel as TextChannel | ThreadChannel,
53
+ })
54
+
55
+ if (!resolved) {
56
+ await command.reply({
57
+ content: 'Could not determine project directory for this channel',
58
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
59
+ })
60
+ return
61
+ }
62
+
63
+ const { projectDirectory, workingDirectory } = resolved
64
+
65
+ const sessionId = await getThreadSession(channel.id)
66
+
67
+ if (!sessionId) {
68
+ await command.reply({
69
+ content: 'No active session in this thread',
70
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
71
+ })
72
+ return
73
+ }
74
+
75
+ // Ensure server is running for the base project directory
76
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
77
+ if (getClient instanceof Error) {
78
+ await command.reply({
79
+ content: `Failed to compact: ${getClient.message}`,
80
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
81
+ })
82
+ return
83
+ }
84
+
85
+ const client = getOpencodeClient(projectDirectory)
86
+ if (!client) {
87
+ await command.reply({
88
+ content: 'Failed to get OpenCode client',
89
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
90
+ })
91
+ return
92
+ }
93
+
94
+ // Defer reply since compaction may take a moment
95
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
96
+
97
+ try {
98
+ // Get session messages to find the model from the last user message
99
+ const messagesResult = await client.session.messages({
100
+ sessionID: sessionId,
101
+ directory: workingDirectory,
102
+ })
103
+
104
+ if (messagesResult.error || !messagesResult.data) {
105
+ logger.error('[COMPACT] Failed to get messages:', messagesResult.error)
106
+ await command.editReply({
107
+ content: 'Failed to compact: Could not retrieve session messages',
108
+ })
109
+ return
110
+ }
111
+
112
+ // Find the last user message to get the model
113
+ const lastUserMessage = [...messagesResult.data]
114
+ .reverse()
115
+ .find((msg) => msg.info.role === 'user')
116
+
117
+ if (!lastUserMessage || lastUserMessage.info.role !== 'user') {
118
+ await command.editReply({
119
+ content: 'Failed to compact: No user message found in session',
120
+ })
121
+ return
122
+ }
123
+
124
+ const { providerID, modelID } = lastUserMessage.info.model
125
+
126
+ const result = await client.session.summarize({
127
+ sessionID: sessionId,
128
+ directory: workingDirectory,
129
+ providerID,
130
+ modelID,
131
+ auto: false,
132
+ })
133
+
134
+ if (result.error) {
135
+ logger.error('[COMPACT] Error:', result.error)
136
+ const errorMessage =
137
+ 'data' in result.error && result.error.data
138
+ ? (result.error.data as { message?: string }).message ||
139
+ 'Unknown error'
140
+ : 'Unknown error'
141
+ await command.editReply({
142
+ content: `Failed to compact: ${errorMessage}`,
143
+ })
144
+ return
145
+ }
146
+
147
+ await command.editReply({
148
+ content: `📦 Session **compacted** successfully`,
149
+ })
150
+ logger.log(`Session ${sessionId} compacted by user`)
151
+ } catch (error) {
152
+ logger.error('[COMPACT] Error:', error)
153
+ await command.editReply({
154
+ content: `Failed to compact: ${error instanceof Error ? error.message : 'Unknown error'}`,
155
+ })
156
+ }
157
+ }
@@ -0,0 +1,199 @@
1
+ // /context-usage command - Show token usage and context window percentage for the current session.
2
+
3
+ import {
4
+ ChannelType,
5
+ MessageFlags,
6
+ type TextChannel,
7
+ type ThreadChannel,
8
+ } from 'discord.js'
9
+ import type { CommandContext } from './types.js'
10
+ import { getThreadSession } from '../database.js'
11
+ import { initializeOpencodeForDirectory } from '../opencode.js'
12
+ import {
13
+ resolveWorkingDirectory,
14
+ SILENT_MESSAGE_FLAGS,
15
+ } from '../discord-utils.js'
16
+ import { createLogger, LogPrefix } from '../logger.js'
17
+ import * as errore from 'errore'
18
+
19
+ const logger = createLogger(LogPrefix.SESSION)
20
+
21
+ function getTokenTotal({
22
+ input,
23
+ output,
24
+ reasoning,
25
+ cache,
26
+ }: {
27
+ input: number
28
+ output: number
29
+ reasoning: number
30
+ cache: { read: number; write: number }
31
+ }): number {
32
+ return input + output + reasoning + cache.read + cache.write
33
+ }
34
+
35
+ export async function handleContextUsageCommand({
36
+ command,
37
+ }: CommandContext): Promise<void> {
38
+ const channel = command.channel
39
+
40
+ if (!channel) {
41
+ await command.reply({
42
+ content: 'This command can only be used in a channel',
43
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
44
+ })
45
+ return
46
+ }
47
+
48
+ const isThread = [
49
+ ChannelType.PublicThread,
50
+ ChannelType.PrivateThread,
51
+ ChannelType.AnnouncementThread,
52
+ ].includes(channel.type)
53
+
54
+ if (!isThread) {
55
+ await command.reply({
56
+ content:
57
+ 'This command can only be used in a thread with an active session',
58
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
59
+ })
60
+ return
61
+ }
62
+
63
+ const resolved = await resolveWorkingDirectory({
64
+ channel: channel as TextChannel | ThreadChannel,
65
+ })
66
+
67
+ if (!resolved) {
68
+ await command.reply({
69
+ content: 'Could not determine project directory for this channel',
70
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
71
+ })
72
+ return
73
+ }
74
+
75
+ const { projectDirectory, workingDirectory } = resolved
76
+
77
+ const sessionId = await getThreadSession(channel.id)
78
+
79
+ if (!sessionId) {
80
+ await command.reply({
81
+ content: 'No active session in this thread',
82
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
83
+ })
84
+ return
85
+ }
86
+
87
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
88
+ if (getClient instanceof Error) {
89
+ await command.reply({
90
+ content: `Failed to get context usage: ${getClient.message}`,
91
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
92
+ })
93
+ return
94
+ }
95
+
96
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
97
+
98
+ try {
99
+ const messagesResponse = await getClient().session.messages({
100
+ sessionID: sessionId,
101
+ directory: workingDirectory,
102
+ })
103
+
104
+ const messages = messagesResponse.data || []
105
+ const assistantMessages = messages.filter(
106
+ (m) => m.info.role === 'assistant',
107
+ )
108
+
109
+ if (assistantMessages.length === 0) {
110
+ await command.editReply({
111
+ content: 'No assistant messages in this session yet',
112
+ })
113
+ return
114
+ }
115
+
116
+ const lastAssistant = [...assistantMessages].reverse().find((m) => {
117
+ if (m.info.role !== 'assistant') {
118
+ return false
119
+ }
120
+ if (!('tokens' in m.info) || !m.info.tokens) {
121
+ return false
122
+ }
123
+ return getTokenTotal(m.info.tokens) > 0
124
+ })
125
+
126
+ if (!lastAssistant || lastAssistant.info.role !== 'assistant') {
127
+ await command.editReply({
128
+ content: 'Token usage not available for this session yet',
129
+ })
130
+ return
131
+ }
132
+
133
+ const { tokens, modelID, providerID } = lastAssistant.info
134
+ const totalTokens = getTokenTotal(tokens)
135
+
136
+ // Sum cost across all assistant messages for accurate session total
137
+ // (AssistantMessage.cost is per-message, not cumulative)
138
+ const totalCost = assistantMessages.reduce((sum, m) => {
139
+ if (m.info.role === 'assistant' && 'cost' in m.info) {
140
+ return sum + (m.info.cost || 0)
141
+ }
142
+ return sum
143
+ }, 0)
144
+
145
+ // Fetch model context limit from provider API
146
+ let contextLimit: number | undefined
147
+ const providersResult = await errore.tryAsync(() => {
148
+ return getClient().provider.list({ directory: workingDirectory })
149
+ })
150
+ if (providersResult instanceof Error) {
151
+ logger.error(
152
+ '[CONTEXT-USAGE] Failed to fetch provider info:',
153
+ providersResult,
154
+ )
155
+ } else {
156
+ const provider = providersResult.data?.all?.find(
157
+ (p) => p.id === providerID,
158
+ )
159
+ const model = provider?.models?.[modelID]
160
+ if (model?.limit?.context) {
161
+ contextLimit = model.limit.context
162
+ }
163
+ }
164
+
165
+ const formattedTokens = totalTokens.toLocaleString('en-US')
166
+ const formattedCost = totalCost > 0 ? `$${totalCost.toFixed(4)}` : '$0.00'
167
+
168
+ const lines: string[] = []
169
+
170
+ if (contextLimit) {
171
+ const percentage = Math.round((totalTokens / contextLimit) * 100)
172
+ const formattedLimit = contextLimit.toLocaleString('en-US')
173
+ lines.push(
174
+ `**Context usage:** ${percentage}%, ${formattedTokens} / ${formattedLimit} tokens`,
175
+ )
176
+ } else {
177
+ lines.push(
178
+ `**Context usage:** ${formattedTokens} tokens (context limit unavailable)`,
179
+ )
180
+ }
181
+
182
+ if (modelID) {
183
+ lines.push(`**Model:** ${modelID}`)
184
+ }
185
+ if (totalCost > 0) {
186
+ lines.push(`**Session cost:** ${formattedCost}`)
187
+ }
188
+
189
+ await command.editReply({ content: lines.join('\n') })
190
+ logger.log(
191
+ `Context usage shown for session ${sessionId}: ${totalTokens} tokens`,
192
+ )
193
+ } catch (error) {
194
+ logger.error('[CONTEXT-USAGE] Error:', error)
195
+ await command.editReply({
196
+ content: `Failed to get context usage: ${error instanceof Error ? error.message : 'Unknown error'}`,
197
+ })
198
+ }
199
+ }