kimaki 0.4.25 → 0.4.27

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 (52) hide show
  1. package/dist/acp-client.test.js +149 -0
  2. package/dist/channel-management.js +11 -9
  3. package/dist/cli.js +58 -18
  4. package/dist/commands/add-project.js +1 -0
  5. package/dist/commands/agent.js +152 -0
  6. package/dist/commands/ask-question.js +184 -0
  7. package/dist/commands/model.js +23 -4
  8. package/dist/commands/permissions.js +101 -105
  9. package/dist/commands/session.js +1 -3
  10. package/dist/commands/user-command.js +145 -0
  11. package/dist/database.js +51 -0
  12. package/dist/discord-bot.js +32 -32
  13. package/dist/discord-utils.js +71 -14
  14. package/dist/interaction-handler.js +25 -8
  15. package/dist/logger.js +43 -5
  16. package/dist/markdown.js +104 -0
  17. package/dist/markdown.test.js +31 -1
  18. package/dist/message-formatting.js +72 -22
  19. package/dist/message-formatting.test.js +73 -0
  20. package/dist/opencode.js +70 -16
  21. package/dist/session-handler.js +142 -66
  22. package/dist/system-message.js +4 -51
  23. package/dist/voice-handler.js +18 -8
  24. package/dist/voice.js +28 -12
  25. package/package.json +14 -13
  26. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  27. package/src/__snapshots__/compact-session-context.md +47 -0
  28. package/src/channel-management.ts +20 -8
  29. package/src/cli.ts +73 -19
  30. package/src/commands/add-project.ts +1 -0
  31. package/src/commands/agent.ts +201 -0
  32. package/src/commands/ask-question.ts +277 -0
  33. package/src/commands/fork.ts +1 -2
  34. package/src/commands/model.ts +24 -4
  35. package/src/commands/permissions.ts +139 -114
  36. package/src/commands/session.ts +1 -3
  37. package/src/commands/user-command.ts +178 -0
  38. package/src/database.ts +61 -0
  39. package/src/discord-bot.ts +36 -33
  40. package/src/discord-utils.ts +76 -14
  41. package/src/interaction-handler.ts +31 -10
  42. package/src/logger.ts +47 -10
  43. package/src/markdown.test.ts +45 -1
  44. package/src/markdown.ts +132 -0
  45. package/src/message-formatting.test.ts +81 -0
  46. package/src/message-formatting.ts +93 -25
  47. package/src/opencode.ts +80 -21
  48. package/src/session-handler.ts +190 -97
  49. package/src/system-message.ts +4 -51
  50. package/src/voice-handler.ts +20 -9
  51. package/src/voice.ts +32 -13
  52. package/LICENSE +0 -21
@@ -0,0 +1,149 @@
1
+ // ACP client integration test for the OpenCode ACP server.
2
+ import { afterAll, beforeAll, expect, test } from 'vitest';
3
+ import { spawn } from 'node:child_process';
4
+ import { Readable, Writable } from 'node:stream';
5
+ import { ClientSideConnection, PROTOCOL_VERSION, ndJsonStream, } from '@agentclientprotocol/sdk';
6
+ const UPDATE_TIMEOUT_MS = 5000;
7
+ class TestClient {
8
+ updates = [];
9
+ listeners = [];
10
+ async requestPermission(params) {
11
+ const firstOption = params.options[0];
12
+ if (!firstOption) {
13
+ return { outcome: { outcome: 'cancelled' } };
14
+ }
15
+ return {
16
+ outcome: {
17
+ outcome: 'selected',
18
+ optionId: firstOption.optionId,
19
+ },
20
+ };
21
+ }
22
+ async sessionUpdate(params) {
23
+ this.updates.push(params);
24
+ const matching = this.listeners.filter((listener) => {
25
+ return listener.predicate(params);
26
+ });
27
+ this.listeners = this.listeners.filter((listener) => {
28
+ return !matching.includes(listener);
29
+ });
30
+ matching.forEach((listener) => {
31
+ clearTimeout(listener.timeout);
32
+ listener.resolve(params);
33
+ });
34
+ }
35
+ resetUpdates() {
36
+ this.updates = [];
37
+ }
38
+ waitForUpdate({ predicate, timeoutMs = UPDATE_TIMEOUT_MS, }) {
39
+ const existing = this.updates.find((update) => {
40
+ return predicate(update);
41
+ });
42
+ if (existing) {
43
+ return Promise.resolve(existing);
44
+ }
45
+ return new Promise((resolve, reject) => {
46
+ const timeout = setTimeout(() => {
47
+ this.listeners = this.listeners.filter((listener) => {
48
+ return listener.resolve !== resolve;
49
+ });
50
+ reject(new Error('Timed out waiting for session update'));
51
+ }, timeoutMs);
52
+ const listener = {
53
+ predicate,
54
+ resolve,
55
+ reject,
56
+ timeout,
57
+ };
58
+ this.listeners = [...this.listeners, listener];
59
+ });
60
+ }
61
+ }
62
+ let serverProcess = null;
63
+ let connection = null;
64
+ let client = null;
65
+ let initResult = null;
66
+ let sessionId = null;
67
+ beforeAll(async () => {
68
+ serverProcess = spawn('opencode', ['acp', '--port', '0', '--cwd', process.cwd()], {
69
+ stdio: ['pipe', 'pipe', 'pipe'],
70
+ });
71
+ if (!serverProcess.stdin || !serverProcess.stdout) {
72
+ throw new Error('Failed to open ACP stdio streams');
73
+ }
74
+ const input = Writable.toWeb(serverProcess.stdin);
75
+ const output = Readable.toWeb(serverProcess.stdout);
76
+ const localClient = new TestClient();
77
+ client = localClient;
78
+ const stream = ndJsonStream(input, output);
79
+ connection = new ClientSideConnection(() => localClient, stream);
80
+ initResult = await connection.initialize({
81
+ protocolVersion: PROTOCOL_VERSION,
82
+ clientCapabilities: {
83
+ fs: {
84
+ readTextFile: false,
85
+ writeTextFile: false,
86
+ },
87
+ terminal: false,
88
+ },
89
+ });
90
+ }, 30000);
91
+ afterAll(async () => {
92
+ if (serverProcess) {
93
+ serverProcess.kill('SIGTERM');
94
+ await new Promise((resolve) => {
95
+ setTimeout(resolve, 500);
96
+ });
97
+ if (!serverProcess.killed) {
98
+ serverProcess.kill('SIGKILL');
99
+ }
100
+ }
101
+ });
102
+ test('creates a session and receives prompt events', async () => {
103
+ if (!connection || !client) {
104
+ throw new Error('ACP connection not initialized');
105
+ }
106
+ const sessionResponse = await connection.newSession({
107
+ cwd: process.cwd(),
108
+ mcpServers: [],
109
+ });
110
+ sessionId = sessionResponse.sessionId;
111
+ const promptResponse = await connection.prompt({
112
+ sessionId,
113
+ prompt: [{ type: 'text', text: 'Hello from ACP test.' }],
114
+ });
115
+ const update = await client.waitForUpdate({
116
+ predicate: (notification) => {
117
+ return (notification.sessionId === sessionId &&
118
+ notification.update.sessionUpdate === 'agent_message_chunk');
119
+ },
120
+ });
121
+ expect(promptResponse.stopReason).toBeTruthy();
122
+ expect(update.update.sessionUpdate).toBe('agent_message_chunk');
123
+ }, 30000);
124
+ test('loads an existing session and streams history', async () => {
125
+ if (!connection || !client) {
126
+ throw new Error('ACP connection not initialized');
127
+ }
128
+ if (!initResult?.agentCapabilities?.loadSession) {
129
+ expect(true).toBe(true);
130
+ return;
131
+ }
132
+ if (!sessionId) {
133
+ throw new Error('Missing session ID from previous test');
134
+ }
135
+ client.resetUpdates();
136
+ const loadPromise = connection.loadSession({
137
+ sessionId,
138
+ cwd: process.cwd(),
139
+ mcpServers: [],
140
+ });
141
+ const update = await client.waitForUpdate({
142
+ predicate: (notification) => {
143
+ return (notification.sessionId === sessionId &&
144
+ notification.update.sessionUpdate === 'user_message_chunk');
145
+ },
146
+ });
147
+ await loadPromise;
148
+ expect(update.update.sessionUpdate).toBe('user_message_chunk');
149
+ }, 30000);
@@ -5,44 +5,46 @@ import { ChannelType, } from 'discord.js';
5
5
  import path from 'node:path';
6
6
  import { getDatabase } from './database.js';
7
7
  import { extractTagsArrays } from './xml.js';
8
- export async function ensureKimakiCategory(guild) {
8
+ export async function ensureKimakiCategory(guild, botName) {
9
+ const categoryName = botName ? `Kimaki ${botName}` : 'Kimaki';
9
10
  const existingCategory = guild.channels.cache.find((channel) => {
10
11
  if (channel.type !== ChannelType.GuildCategory) {
11
12
  return false;
12
13
  }
13
- return channel.name.toLowerCase() === 'kimaki';
14
+ return channel.name.toLowerCase() === categoryName.toLowerCase();
14
15
  });
15
16
  if (existingCategory) {
16
17
  return existingCategory;
17
18
  }
18
19
  return guild.channels.create({
19
- name: 'Kimaki',
20
+ name: categoryName,
20
21
  type: ChannelType.GuildCategory,
21
22
  });
22
23
  }
23
- export async function ensureKimakiAudioCategory(guild) {
24
+ export async function ensureKimakiAudioCategory(guild, botName) {
25
+ const categoryName = botName ? `Kimaki Audio ${botName}` : 'Kimaki Audio';
24
26
  const existingCategory = guild.channels.cache.find((channel) => {
25
27
  if (channel.type !== ChannelType.GuildCategory) {
26
28
  return false;
27
29
  }
28
- return channel.name.toLowerCase() === 'kimaki audio';
30
+ return channel.name.toLowerCase() === categoryName.toLowerCase();
29
31
  });
30
32
  if (existingCategory) {
31
33
  return existingCategory;
32
34
  }
33
35
  return guild.channels.create({
34
- name: 'Kimaki Audio',
36
+ name: categoryName,
35
37
  type: ChannelType.GuildCategory,
36
38
  });
37
39
  }
38
- export async function createProjectChannels({ guild, projectDirectory, appId, }) {
40
+ export async function createProjectChannels({ guild, projectDirectory, appId, botName, }) {
39
41
  const baseName = path.basename(projectDirectory);
40
42
  const channelName = `${baseName}`
41
43
  .toLowerCase()
42
44
  .replace(/[^a-z0-9-]/g, '-')
43
45
  .slice(0, 100);
44
- const kimakiCategory = await ensureKimakiCategory(guild);
45
- const kimakiAudioCategory = await ensureKimakiAudioCategory(guild);
46
+ const kimakiCategory = await ensureKimakiCategory(guild, botName);
47
+ const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName);
46
48
  const textChannel = await guild.channels.create({
47
49
  name: channelName,
48
50
  type: ChannelType.GuildText,
package/dist/cli.js CHANGED
@@ -45,14 +45,15 @@ async function killProcessOnPort(port) {
45
45
  // Filter out our own PID and take the first (oldest)
46
46
  const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
47
47
  if (targetPid) {
48
- cliLogger.log(`Killing existing kimaki process (PID: ${targetPid})`);
49
- process.kill(parseInt(targetPid, 10), 'SIGKILL');
48
+ const pid = parseInt(targetPid, 10);
49
+ cliLogger.log(`Stopping existing kimaki process (PID: ${pid})`);
50
+ process.kill(pid, 'SIGKILL');
50
51
  return true;
51
52
  }
52
53
  }
53
54
  }
54
- catch {
55
- // Failed to kill, continue anyway
55
+ catch (e) {
56
+ cliLogger.debug(`Failed to kill process on port ${port}:`, e);
56
57
  }
57
58
  return false;
58
59
  }
@@ -69,7 +70,7 @@ async function checkSingleInstance() {
69
70
  }
70
71
  }
71
72
  catch {
72
- // Connection refused means no instance running, continue
73
+ cliLogger.debug('No other kimaki instance detected on lock port');
73
74
  }
74
75
  }
75
76
  async function startLockServer() {
@@ -97,7 +98,9 @@ async function startLockServer() {
97
98
  });
98
99
  }
99
100
  const EXIT_NO_RESTART = 64;
100
- async function registerCommands(token, appId) {
101
+ // Commands to skip when registering user commands (reserved names)
102
+ const SKIP_USER_COMMANDS = ['init'];
103
+ async function registerCommands(token, appId, userCommands = []) {
101
104
  const commands = [
102
105
  new SlashCommandBuilder()
103
106
  .setName('resume')
@@ -154,19 +157,11 @@ async function registerCommands(token, appId) {
154
157
  })
155
158
  .toJSON(),
156
159
  new SlashCommandBuilder()
157
- .setName('accept')
158
- .setDescription('Accept a pending permission request (this request only)')
159
- .toJSON(),
160
- new SlashCommandBuilder()
161
- .setName('accept-always')
162
- .setDescription('Accept and auto-approve future requests matching this pattern')
163
- .toJSON(),
164
- new SlashCommandBuilder()
165
- .setName('reject')
166
- .setDescription('Reject a pending permission request')
160
+ .setName('abort')
161
+ .setDescription('Abort the current OpenCode request in this thread')
167
162
  .toJSON(),
168
163
  new SlashCommandBuilder()
169
- .setName('abort')
164
+ .setName('stop')
170
165
  .setDescription('Abort the current OpenCode request in this thread')
171
166
  .toJSON(),
172
167
  new SlashCommandBuilder()
@@ -181,6 +176,10 @@ async function registerCommands(token, appId) {
181
176
  .setName('model')
182
177
  .setDescription('Set the preferred model for this channel or session')
183
178
  .toJSON(),
179
+ new SlashCommandBuilder()
180
+ .setName('agent')
181
+ .setDescription('Set the preferred agent for this channel or session')
182
+ .toJSON(),
184
183
  new SlashCommandBuilder()
185
184
  .setName('queue')
186
185
  .setDescription('Queue a message to be sent after the current response finishes')
@@ -205,6 +204,25 @@ async function registerCommands(token, appId) {
205
204
  .setDescription('Redo previously undone changes')
206
205
  .toJSON(),
207
206
  ];
207
+ // Add user-defined commands with -cmd suffix
208
+ for (const cmd of userCommands) {
209
+ if (SKIP_USER_COMMANDS.includes(cmd.name)) {
210
+ continue;
211
+ }
212
+ const commandName = `${cmd.name}-cmd`;
213
+ const description = cmd.description || `Run /${cmd.name} command`;
214
+ commands.push(new SlashCommandBuilder()
215
+ .setName(commandName)
216
+ .setDescription(description.slice(0, 100)) // Discord limits to 100 chars
217
+ .addStringOption((option) => {
218
+ option
219
+ .setName('arguments')
220
+ .setDescription('Arguments to pass to the command')
221
+ .setRequired(false);
222
+ return option;
223
+ })
224
+ .toJSON());
225
+ }
208
226
  const rest = new REST().setToken(token);
209
227
  try {
210
228
  const data = (await rest.put(Routes.applicationCommands(appId), {
@@ -493,6 +511,7 @@ async function run({ restart, addChannels }) {
493
511
  guild: targetGuild,
494
512
  projectDirectory: project.worktree,
495
513
  appId,
514
+ botName: discordClient.user?.username,
496
515
  });
497
516
  createdChannels.push({
498
517
  name: channelName,
@@ -510,8 +529,29 @@ async function run({ restart, addChannels }) {
510
529
  }
511
530
  }
512
531
  }
532
+ // Fetch user-defined commands using the already-running server
533
+ const allUserCommands = [];
534
+ try {
535
+ const commandsResponse = await getClient().command.list({
536
+ query: { directory: currentDir },
537
+ });
538
+ if (commandsResponse.data) {
539
+ allUserCommands.push(...commandsResponse.data);
540
+ }
541
+ }
542
+ catch {
543
+ // Ignore errors fetching commands
544
+ }
545
+ // Log available user commands
546
+ const registrableCommands = allUserCommands.filter((cmd) => !SKIP_USER_COMMANDS.includes(cmd.name));
547
+ if (registrableCommands.length > 0) {
548
+ const commandList = registrableCommands
549
+ .map((cmd) => ` /${cmd.name}-cmd - ${cmd.description || 'No description'}`)
550
+ .join('\n');
551
+ note(`Found ${registrableCommands.length} user-defined command(s):\n${commandList}`, 'OpenCode Commands');
552
+ }
513
553
  cliLogger.log('Registering slash commands asynchronously...');
514
- void registerCommands(token, appId)
554
+ void registerCommands(token, appId, allUserCommands)
515
555
  .then(() => {
516
556
  cliLogger.log('Slash commands registered!');
517
557
  })
@@ -44,6 +44,7 @@ export async function handleAddProjectCommand({ command, appId, }) {
44
44
  guild,
45
45
  projectDirectory: directory,
46
46
  appId,
47
+ botName: command.client.user?.username,
47
48
  });
48
49
  await command.editReply(`✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``);
49
50
  logger.log(`Created channels for project ${channelName} at ${directory}`);
@@ -0,0 +1,152 @@
1
+ // /agent command - Set the preferred agent for this channel or session.
2
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, } from 'discord.js';
3
+ import crypto from 'node:crypto';
4
+ import { getDatabase, setChannelAgent, setSessionAgent, runModelMigrations } from '../database.js';
5
+ import { initializeOpencodeForDirectory } from '../opencode.js';
6
+ import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
+ import { createLogger } from '../logger.js';
8
+ const agentLogger = createLogger('AGENT');
9
+ const pendingAgentContexts = new Map();
10
+ export async function handleAgentCommand({ interaction, appId, }) {
11
+ await interaction.deferReply({ ephemeral: true });
12
+ runModelMigrations();
13
+ const channel = interaction.channel;
14
+ if (!channel) {
15
+ await interaction.editReply({ content: 'This command can only be used in a channel' });
16
+ return;
17
+ }
18
+ const isThread = [
19
+ ChannelType.PublicThread,
20
+ ChannelType.PrivateThread,
21
+ ChannelType.AnnouncementThread,
22
+ ].includes(channel.type);
23
+ let projectDirectory;
24
+ let channelAppId;
25
+ let targetChannelId;
26
+ let sessionId;
27
+ if (isThread) {
28
+ const thread = channel;
29
+ const textChannel = await resolveTextChannel(thread);
30
+ const metadata = getKimakiMetadata(textChannel);
31
+ projectDirectory = metadata.projectDirectory;
32
+ channelAppId = metadata.channelAppId;
33
+ targetChannelId = textChannel?.id || channel.id;
34
+ const row = getDatabase()
35
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
36
+ .get(thread.id);
37
+ sessionId = row?.session_id;
38
+ }
39
+ else if (channel.type === ChannelType.GuildText) {
40
+ const textChannel = channel;
41
+ const metadata = getKimakiMetadata(textChannel);
42
+ projectDirectory = metadata.projectDirectory;
43
+ channelAppId = metadata.channelAppId;
44
+ targetChannelId = channel.id;
45
+ }
46
+ else {
47
+ await interaction.editReply({ content: 'This command can only be used in text channels or threads' });
48
+ return;
49
+ }
50
+ if (channelAppId && channelAppId !== appId) {
51
+ await interaction.editReply({ content: 'This channel is not configured for this bot' });
52
+ return;
53
+ }
54
+ if (!projectDirectory) {
55
+ await interaction.editReply({ content: 'This channel is not configured with a project directory' });
56
+ return;
57
+ }
58
+ try {
59
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
60
+ const agentsResponse = await getClient().app.agents({
61
+ query: { directory: projectDirectory },
62
+ });
63
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
64
+ await interaction.editReply({ content: 'No agents available' });
65
+ return;
66
+ }
67
+ const agents = agentsResponse.data
68
+ .filter((a) => a.mode === 'primary' || a.mode === 'all')
69
+ .slice(0, 25);
70
+ if (agents.length === 0) {
71
+ await interaction.editReply({ content: 'No primary agents available' });
72
+ return;
73
+ }
74
+ const contextHash = crypto.randomBytes(8).toString('hex');
75
+ pendingAgentContexts.set(contextHash, {
76
+ dir: projectDirectory,
77
+ channelId: targetChannelId,
78
+ sessionId,
79
+ isThread,
80
+ });
81
+ const options = agents.map((agent) => ({
82
+ label: agent.name.slice(0, 100),
83
+ value: agent.name,
84
+ description: (agent.description || `${agent.mode} agent`).slice(0, 100),
85
+ }));
86
+ const selectMenu = new StringSelectMenuBuilder()
87
+ .setCustomId(`agent_select:${contextHash}`)
88
+ .setPlaceholder('Select an agent')
89
+ .addOptions(options);
90
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
91
+ await interaction.editReply({
92
+ content: '**Set Agent Preference**\nSelect an agent:',
93
+ components: [actionRow],
94
+ });
95
+ }
96
+ catch (error) {
97
+ agentLogger.error('Error loading agents:', error);
98
+ await interaction.editReply({
99
+ content: `Failed to load agents: ${error instanceof Error ? error.message : 'Unknown error'}`,
100
+ });
101
+ }
102
+ }
103
+ export async function handleAgentSelectMenu(interaction) {
104
+ const customId = interaction.customId;
105
+ if (!customId.startsWith('agent_select:')) {
106
+ return;
107
+ }
108
+ await interaction.deferUpdate();
109
+ const contextHash = customId.replace('agent_select:', '');
110
+ const context = pendingAgentContexts.get(contextHash);
111
+ if (!context) {
112
+ await interaction.editReply({
113
+ content: 'Selection expired. Please run /agent again.',
114
+ components: [],
115
+ });
116
+ return;
117
+ }
118
+ const selectedAgent = interaction.values[0];
119
+ if (!selectedAgent) {
120
+ await interaction.editReply({
121
+ content: 'No agent selected',
122
+ components: [],
123
+ });
124
+ return;
125
+ }
126
+ try {
127
+ if (context.isThread && context.sessionId) {
128
+ setSessionAgent(context.sessionId, selectedAgent);
129
+ agentLogger.log(`Set agent ${selectedAgent} for session ${context.sessionId}`);
130
+ await interaction.editReply({
131
+ content: `Agent preference set for this session: **${selectedAgent}**`,
132
+ components: [],
133
+ });
134
+ }
135
+ else {
136
+ setChannelAgent(context.channelId, selectedAgent);
137
+ agentLogger.log(`Set agent ${selectedAgent} for channel ${context.channelId}`);
138
+ await interaction.editReply({
139
+ content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
140
+ components: [],
141
+ });
142
+ }
143
+ pendingAgentContexts.delete(contextHash);
144
+ }
145
+ catch (error) {
146
+ agentLogger.error('Error saving agent preference:', error);
147
+ await interaction.editReply({
148
+ content: `Failed to save agent preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
149
+ components: [],
150
+ });
151
+ }
152
+ }
@@ -0,0 +1,184 @@
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, } from 'discord.js';
5
+ import crypto from 'node:crypto';
6
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
7
+ import { getOpencodeServerPort } from '../opencode.js';
8
+ import { createLogger } from '../logger.js';
9
+ const logger = createLogger('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 selectMenu = new StringSelectMenuBuilder()
48
+ .setCustomId(`ask_question:${contextHash}:${i}`)
49
+ .setPlaceholder(`Select an option`)
50
+ .addOptions(options);
51
+ // Enable multi-select if the question supports it
52
+ if (q.multiple) {
53
+ selectMenu.setMinValues(1);
54
+ selectMenu.setMaxValues(options.length);
55
+ }
56
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
57
+ await thread.send({
58
+ content: `**${q.header}**\n${q.question}`,
59
+ components: [actionRow],
60
+ flags: NOTIFY_MESSAGE_FLAGS,
61
+ });
62
+ }
63
+ logger.log(`Showed ${input.questions.length} question dropdown(s) for session ${sessionId}`);
64
+ }
65
+ /**
66
+ * Handle dropdown selection for AskUserQuestion.
67
+ */
68
+ export async function handleAskQuestionSelectMenu(interaction) {
69
+ const customId = interaction.customId;
70
+ if (!customId.startsWith('ask_question:')) {
71
+ return;
72
+ }
73
+ const parts = customId.split(':');
74
+ const contextHash = parts[1];
75
+ const questionIndex = parseInt(parts[2], 10);
76
+ if (!contextHash) {
77
+ await interaction.reply({
78
+ content: 'Invalid selection.',
79
+ ephemeral: true,
80
+ });
81
+ return;
82
+ }
83
+ const context = pendingQuestionContexts.get(contextHash);
84
+ if (!context) {
85
+ await interaction.reply({
86
+ content: 'This question has expired. Please ask the AI again.',
87
+ ephemeral: true,
88
+ });
89
+ return;
90
+ }
91
+ await interaction.deferUpdate();
92
+ const selectedValues = interaction.values;
93
+ const question = context.questions[questionIndex];
94
+ if (!question) {
95
+ logger.error(`Question index ${questionIndex} not found in context`);
96
+ return;
97
+ }
98
+ // Check if "other" was selected
99
+ if (selectedValues.includes('other')) {
100
+ // User wants to provide custom answer
101
+ // For now, mark as "Other" - they can type in chat
102
+ context.answers[questionIndex] = ['Other (please type your answer in chat)'];
103
+ }
104
+ else {
105
+ // Map value indices back to option labels
106
+ context.answers[questionIndex] = selectedValues.map((v) => {
107
+ const optIdx = parseInt(v, 10);
108
+ return question.options[optIdx]?.label || `Option ${optIdx + 1}`;
109
+ });
110
+ }
111
+ context.answeredCount++;
112
+ // Update this question's message: show answer and remove dropdown
113
+ const answeredText = context.answers[questionIndex].join(', ');
114
+ await interaction.editReply({
115
+ content: `**${question.header}**\n${question.question}\n✓ _${answeredText}_`,
116
+ components: [], // Remove the dropdown
117
+ });
118
+ // Check if all questions are answered
119
+ if (context.answeredCount >= context.totalQuestions) {
120
+ // All questions answered - send result back to session
121
+ await submitQuestionAnswers(context);
122
+ pendingQuestionContexts.delete(contextHash);
123
+ }
124
+ }
125
+ /**
126
+ * Submit all collected answers back to the OpenCode session.
127
+ * Uses the question.reply API to provide answers to the waiting tool.
128
+ */
129
+ async function submitQuestionAnswers(context) {
130
+ try {
131
+ // Build answers array: each element is an array of selected labels for that question
132
+ const answersPayload = context.questions.map((_, i) => {
133
+ return context.answers[i] || [];
134
+ });
135
+ // Reply to the question using direct HTTP call to OpenCode API
136
+ // (v1 SDK doesn't have question.reply, so we call it directly)
137
+ const port = getOpencodeServerPort(context.directory);
138
+ if (!port) {
139
+ throw new Error('OpenCode server not found for directory');
140
+ }
141
+ const response = await fetch(`http://127.0.0.1:${port}/question/${context.requestId}/reply`, {
142
+ method: 'POST',
143
+ headers: { 'Content-Type': 'application/json' },
144
+ body: JSON.stringify({ answers: answersPayload }),
145
+ });
146
+ if (!response.ok) {
147
+ const text = await response.text();
148
+ throw new Error(`Failed to reply to question: ${response.status} ${text}`);
149
+ }
150
+ logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
151
+ }
152
+ catch (error) {
153
+ logger.error('Failed to submit answers:', error);
154
+ await sendThreadMessage(context.thread, `✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`);
155
+ }
156
+ }
157
+ /**
158
+ * Check if a tool part is an AskUserQuestion tool.
159
+ * Returns the parsed input if valid, null otherwise.
160
+ */
161
+ export function parseAskUserQuestionTool(part) {
162
+ if (part.type !== 'tool') {
163
+ return null;
164
+ }
165
+ // Check for the tool name (case-insensitive)
166
+ const toolName = part.tool?.toLowerCase();
167
+ if (toolName !== 'question') {
168
+ return null;
169
+ }
170
+ const input = part.state?.input;
171
+ if (!input?.questions || !Array.isArray(input.questions) || input.questions.length === 0) {
172
+ return null;
173
+ }
174
+ // Validate structure
175
+ for (const q of input.questions) {
176
+ if (typeof q.question !== 'string' ||
177
+ typeof q.header !== 'string' ||
178
+ !Array.isArray(q.options) ||
179
+ q.options.length < 2) {
180
+ return null;
181
+ }
182
+ }
183
+ return input;
184
+ }