kimaki 0.4.24 → 0.4.26

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 (86) hide show
  1. package/bin.js +6 -1
  2. package/dist/acp-client.test.js +149 -0
  3. package/dist/ai-tool-to-genai.js +3 -0
  4. package/dist/channel-management.js +14 -9
  5. package/dist/cli.js +148 -17
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +54 -0
  22. package/dist/discord-bot.js +35 -32
  23. package/dist/discord-utils.js +81 -15
  24. package/dist/format-tables.js +3 -0
  25. package/dist/genai-worker-wrapper.js +3 -0
  26. package/dist/genai-worker.js +3 -0
  27. package/dist/genai.js +3 -0
  28. package/dist/interaction-handler.js +89 -695
  29. package/dist/logger.js +46 -5
  30. package/dist/markdown.js +107 -0
  31. package/dist/markdown.test.js +31 -1
  32. package/dist/message-formatting.js +113 -28
  33. package/dist/message-formatting.test.js +73 -0
  34. package/dist/opencode.js +73 -16
  35. package/dist/session-handler.js +176 -63
  36. package/dist/system-message.js +7 -38
  37. package/dist/tools.js +3 -0
  38. package/dist/utils.js +3 -0
  39. package/dist/voice-handler.js +21 -8
  40. package/dist/voice.js +31 -12
  41. package/dist/worker-types.js +3 -0
  42. package/dist/xml.js +3 -0
  43. package/package.json +3 -3
  44. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  45. package/src/__snapshots__/compact-session-context.md +47 -0
  46. package/src/ai-tool-to-genai.ts +4 -0
  47. package/src/channel-management.ts +24 -8
  48. package/src/cli.ts +163 -18
  49. package/src/commands/abort.ts +94 -0
  50. package/src/commands/add-project.ts +139 -0
  51. package/src/commands/agent.ts +201 -0
  52. package/src/commands/ask-question.ts +276 -0
  53. package/src/commands/create-new-project.ts +111 -0
  54. package/src/{fork.ts → commands/fork.ts} +40 -7
  55. package/src/{model-command.ts → commands/model.ts} +31 -9
  56. package/src/commands/permissions.ts +146 -0
  57. package/src/commands/queue.ts +181 -0
  58. package/src/commands/resume.ts +230 -0
  59. package/src/commands/session.ts +184 -0
  60. package/src/commands/share.ts +96 -0
  61. package/src/commands/types.ts +25 -0
  62. package/src/commands/undo-redo.ts +213 -0
  63. package/src/commands/user-command.ts +178 -0
  64. package/src/database.ts +65 -0
  65. package/src/discord-bot.ts +40 -33
  66. package/src/discord-utils.ts +88 -14
  67. package/src/format-tables.ts +4 -0
  68. package/src/genai-worker-wrapper.ts +4 -0
  69. package/src/genai-worker.ts +4 -0
  70. package/src/genai.ts +4 -0
  71. package/src/interaction-handler.ts +111 -924
  72. package/src/logger.ts +51 -10
  73. package/src/markdown.test.ts +45 -1
  74. package/src/markdown.ts +136 -0
  75. package/src/message-formatting.test.ts +81 -0
  76. package/src/message-formatting.ts +143 -30
  77. package/src/opencode.ts +84 -21
  78. package/src/session-handler.ts +248 -91
  79. package/src/system-message.ts +8 -38
  80. package/src/tools.ts +4 -0
  81. package/src/utils.ts +4 -0
  82. package/src/voice-handler.ts +24 -9
  83. package/src/voice.ts +36 -13
  84. package/src/worker-types.ts +4 -0
  85. package/src/xml.ts +4 -0
  86. package/README.md +0 -48
@@ -0,0 +1,313 @@
1
+ // /model command - Set the preferred model for this channel or session.
2
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, } from 'discord.js';
3
+ import crypto from 'node:crypto';
4
+ import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from '../database.js';
5
+ import { initializeOpencodeForDirectory } from '../opencode.js';
6
+ import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
+ import { abortAndRetrySession } from '../session-handler.js';
8
+ import { createLogger } from '../logger.js';
9
+ const modelLogger = createLogger('MODEL');
10
+ // Store context by hash to avoid customId length limits (Discord max: 100 chars)
11
+ const pendingModelContexts = new Map();
12
+ /**
13
+ * Handle the /model slash command.
14
+ * Shows a select menu with available providers.
15
+ */
16
+ export async function handleModelCommand({ interaction, appId, }) {
17
+ modelLogger.log('[MODEL] handleModelCommand called');
18
+ // Defer reply immediately to avoid 3-second timeout
19
+ await interaction.deferReply({ ephemeral: true });
20
+ modelLogger.log('[MODEL] Deferred reply');
21
+ // Ensure migrations are run
22
+ runModelMigrations();
23
+ const channel = interaction.channel;
24
+ if (!channel) {
25
+ await interaction.editReply({
26
+ content: 'This command can only be used in a channel',
27
+ });
28
+ return;
29
+ }
30
+ // Determine if we're in a thread or text channel
31
+ const isThread = [
32
+ ChannelType.PublicThread,
33
+ ChannelType.PrivateThread,
34
+ ChannelType.AnnouncementThread,
35
+ ].includes(channel.type);
36
+ let projectDirectory;
37
+ let channelAppId;
38
+ let targetChannelId;
39
+ let sessionId;
40
+ if (isThread) {
41
+ const thread = channel;
42
+ const textChannel = await resolveTextChannel(thread);
43
+ const metadata = getKimakiMetadata(textChannel);
44
+ projectDirectory = metadata.projectDirectory;
45
+ channelAppId = metadata.channelAppId;
46
+ targetChannelId = textChannel?.id || channel.id;
47
+ // Get session ID for this thread
48
+ const row = getDatabase()
49
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
50
+ .get(thread.id);
51
+ sessionId = row?.session_id;
52
+ }
53
+ else if (channel.type === ChannelType.GuildText) {
54
+ const textChannel = channel;
55
+ const metadata = getKimakiMetadata(textChannel);
56
+ projectDirectory = metadata.projectDirectory;
57
+ channelAppId = metadata.channelAppId;
58
+ targetChannelId = channel.id;
59
+ }
60
+ else {
61
+ await interaction.editReply({
62
+ content: 'This command can only be used in text channels or threads',
63
+ });
64
+ return;
65
+ }
66
+ if (channelAppId && channelAppId !== appId) {
67
+ await interaction.editReply({
68
+ content: 'This channel is not configured for this bot',
69
+ });
70
+ return;
71
+ }
72
+ if (!projectDirectory) {
73
+ await interaction.editReply({
74
+ content: 'This channel is not configured with a project directory',
75
+ });
76
+ return;
77
+ }
78
+ try {
79
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
80
+ const providersResponse = await getClient().provider.list({
81
+ query: { directory: projectDirectory },
82
+ });
83
+ if (!providersResponse.data) {
84
+ await interaction.editReply({
85
+ content: 'Failed to fetch providers',
86
+ });
87
+ return;
88
+ }
89
+ const { all: allProviders, connected } = providersResponse.data;
90
+ // Filter to only connected providers (have credentials)
91
+ const availableProviders = allProviders.filter((p) => {
92
+ return connected.includes(p.id);
93
+ });
94
+ if (availableProviders.length === 0) {
95
+ await interaction.editReply({
96
+ content: 'No providers with credentials found. Use `/connect` in OpenCode TUI to add provider credentials.',
97
+ });
98
+ return;
99
+ }
100
+ // Store context with a short hash key to avoid customId length limits
101
+ const context = {
102
+ dir: projectDirectory,
103
+ channelId: targetChannelId,
104
+ sessionId: sessionId,
105
+ isThread: isThread,
106
+ thread: isThread ? channel : undefined,
107
+ };
108
+ const contextHash = crypto.randomBytes(8).toString('hex');
109
+ pendingModelContexts.set(contextHash, context);
110
+ const options = availableProviders.slice(0, 25).map((provider) => {
111
+ const modelCount = Object.keys(provider.models || {}).length;
112
+ return {
113
+ label: provider.name.slice(0, 100),
114
+ value: provider.id,
115
+ description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
116
+ };
117
+ });
118
+ const selectMenu = new StringSelectMenuBuilder()
119
+ .setCustomId(`model_provider:${contextHash}`)
120
+ .setPlaceholder('Select a provider')
121
+ .addOptions(options);
122
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
123
+ await interaction.editReply({
124
+ content: '**Set Model Preference**\nSelect a provider:',
125
+ components: [actionRow],
126
+ });
127
+ }
128
+ catch (error) {
129
+ modelLogger.error('Error loading providers:', error);
130
+ await interaction.editReply({
131
+ content: `Failed to load providers: ${error instanceof Error ? error.message : 'Unknown error'}`,
132
+ });
133
+ }
134
+ }
135
+ /**
136
+ * Handle the provider select menu interaction.
137
+ * Shows a second select menu with models for the chosen provider.
138
+ */
139
+ export async function handleProviderSelectMenu(interaction) {
140
+ const customId = interaction.customId;
141
+ if (!customId.startsWith('model_provider:')) {
142
+ return;
143
+ }
144
+ // Defer update immediately to avoid timeout
145
+ await interaction.deferUpdate();
146
+ const contextHash = customId.replace('model_provider:', '');
147
+ const context = pendingModelContexts.get(contextHash);
148
+ if (!context) {
149
+ await interaction.editReply({
150
+ content: 'Selection expired. Please run /model again.',
151
+ components: [],
152
+ });
153
+ return;
154
+ }
155
+ const selectedProviderId = interaction.values[0];
156
+ if (!selectedProviderId) {
157
+ await interaction.editReply({
158
+ content: 'No provider selected',
159
+ components: [],
160
+ });
161
+ return;
162
+ }
163
+ try {
164
+ const getClient = await initializeOpencodeForDirectory(context.dir);
165
+ const providersResponse = await getClient().provider.list({
166
+ query: { directory: context.dir },
167
+ });
168
+ if (!providersResponse.data) {
169
+ await interaction.editReply({
170
+ content: 'Failed to fetch providers',
171
+ components: [],
172
+ });
173
+ return;
174
+ }
175
+ const provider = providersResponse.data.all.find((p) => p.id === selectedProviderId);
176
+ if (!provider) {
177
+ await interaction.editReply({
178
+ content: 'Provider not found',
179
+ components: [],
180
+ });
181
+ return;
182
+ }
183
+ const models = Object.entries(provider.models || {})
184
+ .map(([modelId, model]) => ({
185
+ id: modelId,
186
+ name: model.name,
187
+ releaseDate: model.release_date,
188
+ }))
189
+ // Sort by release date descending (most recent first)
190
+ .sort((a, b) => {
191
+ const dateA = a.releaseDate ? new Date(a.releaseDate).getTime() : 0;
192
+ const dateB = b.releaseDate ? new Date(b.releaseDate).getTime() : 0;
193
+ return dateB - dateA;
194
+ });
195
+ if (models.length === 0) {
196
+ await interaction.editReply({
197
+ content: `No models available for ${provider.name}`,
198
+ components: [],
199
+ });
200
+ return;
201
+ }
202
+ // Take first 25 models (most recent since sorted descending)
203
+ const recentModels = models.slice(0, 25);
204
+ // Update context with provider info and reuse the same hash
205
+ context.providerId = selectedProviderId;
206
+ context.providerName = provider.name;
207
+ pendingModelContexts.set(contextHash, context);
208
+ const options = recentModels.map((model) => {
209
+ const dateStr = model.releaseDate
210
+ ? new Date(model.releaseDate).toLocaleDateString()
211
+ : 'Unknown date';
212
+ return {
213
+ label: model.name.slice(0, 100),
214
+ value: model.id,
215
+ description: dateStr.slice(0, 100),
216
+ };
217
+ });
218
+ const selectMenu = new StringSelectMenuBuilder()
219
+ .setCustomId(`model_select:${contextHash}`)
220
+ .setPlaceholder('Select a model')
221
+ .addOptions(options);
222
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
223
+ await interaction.editReply({
224
+ content: `**Set Model Preference**\nProvider: **${provider.name}**\nSelect a model:`,
225
+ components: [actionRow],
226
+ });
227
+ }
228
+ catch (error) {
229
+ modelLogger.error('Error loading models:', error);
230
+ await interaction.editReply({
231
+ content: `Failed to load models: ${error instanceof Error ? error.message : 'Unknown error'}`,
232
+ components: [],
233
+ });
234
+ }
235
+ }
236
+ /**
237
+ * Handle the model select menu interaction.
238
+ * Stores the model preference in the database.
239
+ */
240
+ export async function handleModelSelectMenu(interaction) {
241
+ const customId = interaction.customId;
242
+ if (!customId.startsWith('model_select:')) {
243
+ return;
244
+ }
245
+ // Defer update immediately
246
+ await interaction.deferUpdate();
247
+ const contextHash = customId.replace('model_select:', '');
248
+ const context = pendingModelContexts.get(contextHash);
249
+ if (!context || !context.providerId || !context.providerName) {
250
+ await interaction.editReply({
251
+ content: 'Selection expired. Please run /model again.',
252
+ components: [],
253
+ });
254
+ return;
255
+ }
256
+ const selectedModelId = interaction.values[0];
257
+ if (!selectedModelId) {
258
+ await interaction.editReply({
259
+ content: 'No model selected',
260
+ components: [],
261
+ });
262
+ return;
263
+ }
264
+ // Build full model ID: provider_id/model_id
265
+ const fullModelId = `${context.providerId}/${selectedModelId}`;
266
+ try {
267
+ // Store in appropriate table based on context
268
+ if (context.isThread && context.sessionId) {
269
+ // Store for session
270
+ setSessionModel(context.sessionId, fullModelId);
271
+ modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`);
272
+ // Check if there's a running request and abort+retry with new model
273
+ let retried = false;
274
+ if (context.thread) {
275
+ retried = await abortAndRetrySession({
276
+ sessionId: context.sessionId,
277
+ thread: context.thread,
278
+ projectDirectory: context.dir,
279
+ });
280
+ }
281
+ if (retried) {
282
+ await interaction.editReply({
283
+ content: `Model changed for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\`\n\n_Retrying current request with new model..._`,
284
+ components: [],
285
+ });
286
+ }
287
+ else {
288
+ await interaction.editReply({
289
+ content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
290
+ components: [],
291
+ });
292
+ }
293
+ }
294
+ else {
295
+ // Store for channel
296
+ setChannelModel(context.channelId, fullModelId);
297
+ modelLogger.log(`Set model ${fullModelId} for channel ${context.channelId}`);
298
+ await interaction.editReply({
299
+ content: `Model preference set for this channel:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\`\n\nAll new sessions in this channel will use this model.`,
300
+ components: [],
301
+ });
302
+ }
303
+ // Clean up the context from memory
304
+ pendingModelContexts.delete(contextHash);
305
+ }
306
+ catch (error) {
307
+ modelLogger.error('Error saving model preference:', error);
308
+ await interaction.editReply({
309
+ content: `Failed to save model preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
310
+ components: [],
311
+ });
312
+ }
313
+ }
@@ -0,0 +1,126 @@
1
+ // Permission commands - /accept, /accept-always, /reject
2
+ import { ChannelType } from 'discord.js';
3
+ import { initializeOpencodeForDirectory } from '../opencode.js';
4
+ import { pendingPermissions } from '../session-handler.js';
5
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
+ import { createLogger } from '../logger.js';
7
+ const logger = createLogger('PERMISSIONS');
8
+ export async function handleAcceptCommand({ command, }) {
9
+ const scope = command.commandName === 'accept-always' ? 'always' : 'once';
10
+ const channel = command.channel;
11
+ if (!channel) {
12
+ await command.reply({
13
+ content: 'This command can only be used in a channel',
14
+ ephemeral: true,
15
+ flags: SILENT_MESSAGE_FLAGS,
16
+ });
17
+ return;
18
+ }
19
+ const isThread = [
20
+ ChannelType.PublicThread,
21
+ ChannelType.PrivateThread,
22
+ ChannelType.AnnouncementThread,
23
+ ].includes(channel.type);
24
+ if (!isThread) {
25
+ await command.reply({
26
+ content: 'This command can only be used in a thread with an active session',
27
+ ephemeral: true,
28
+ flags: SILENT_MESSAGE_FLAGS,
29
+ });
30
+ return;
31
+ }
32
+ const pending = pendingPermissions.get(channel.id);
33
+ if (!pending) {
34
+ await command.reply({
35
+ content: 'No pending permission request in this thread',
36
+ ephemeral: true,
37
+ flags: SILENT_MESSAGE_FLAGS,
38
+ });
39
+ return;
40
+ }
41
+ try {
42
+ const getClient = await initializeOpencodeForDirectory(pending.directory);
43
+ await getClient().postSessionIdPermissionsPermissionId({
44
+ path: {
45
+ id: pending.permission.sessionID,
46
+ permissionID: pending.permission.id,
47
+ },
48
+ body: {
49
+ response: scope,
50
+ },
51
+ });
52
+ pendingPermissions.delete(channel.id);
53
+ const msg = scope === 'always'
54
+ ? `✅ Permission **accepted** (auto-approve similar requests)`
55
+ : `✅ Permission **accepted**`;
56
+ await command.reply({ content: msg, flags: SILENT_MESSAGE_FLAGS });
57
+ logger.log(`Permission ${pending.permission.id} accepted with scope: ${scope}`);
58
+ }
59
+ catch (error) {
60
+ logger.error('[ACCEPT] Error:', error);
61
+ await command.reply({
62
+ content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
63
+ ephemeral: true,
64
+ flags: SILENT_MESSAGE_FLAGS,
65
+ });
66
+ }
67
+ }
68
+ export async function handleRejectCommand({ command, }) {
69
+ const channel = command.channel;
70
+ if (!channel) {
71
+ await command.reply({
72
+ content: 'This command can only be used in a channel',
73
+ ephemeral: true,
74
+ flags: SILENT_MESSAGE_FLAGS,
75
+ });
76
+ return;
77
+ }
78
+ const isThread = [
79
+ ChannelType.PublicThread,
80
+ ChannelType.PrivateThread,
81
+ ChannelType.AnnouncementThread,
82
+ ].includes(channel.type);
83
+ if (!isThread) {
84
+ await command.reply({
85
+ content: 'This command can only be used in a thread with an active session',
86
+ ephemeral: true,
87
+ flags: SILENT_MESSAGE_FLAGS,
88
+ });
89
+ return;
90
+ }
91
+ const pending = pendingPermissions.get(channel.id);
92
+ if (!pending) {
93
+ await command.reply({
94
+ content: 'No pending permission request in this thread',
95
+ ephemeral: true,
96
+ flags: SILENT_MESSAGE_FLAGS,
97
+ });
98
+ return;
99
+ }
100
+ try {
101
+ const getClient = await initializeOpencodeForDirectory(pending.directory);
102
+ await getClient().postSessionIdPermissionsPermissionId({
103
+ path: {
104
+ id: pending.permission.sessionID,
105
+ permissionID: pending.permission.id,
106
+ },
107
+ body: {
108
+ response: 'reject',
109
+ },
110
+ });
111
+ pendingPermissions.delete(channel.id);
112
+ await command.reply({
113
+ content: `❌ Permission **rejected**`,
114
+ flags: SILENT_MESSAGE_FLAGS,
115
+ });
116
+ logger.log(`Permission ${pending.permission.id} rejected`);
117
+ }
118
+ catch (error) {
119
+ logger.error('[REJECT] Error:', error);
120
+ await command.reply({
121
+ content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
122
+ ephemeral: true,
123
+ flags: SILENT_MESSAGE_FLAGS,
124
+ });
125
+ }
126
+ }
@@ -0,0 +1,129 @@
1
+ // Queue commands - /queue, /clear-queue
2
+ import { ChannelType } from 'discord.js';
3
+ import { getDatabase } from '../database.js';
4
+ import { resolveTextChannel, getKimakiMetadata, sendThreadMessage, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
5
+ import { handleOpencodeSession, abortControllers, addToQueue, getQueueLength, clearQueue, } from '../session-handler.js';
6
+ import { createLogger } from '../logger.js';
7
+ const logger = createLogger('QUEUE');
8
+ export async function handleQueueCommand({ command, }) {
9
+ const message = command.options.getString('message', true);
10
+ const channel = command.channel;
11
+ if (!channel) {
12
+ await command.reply({
13
+ content: 'This command can only be used in a channel',
14
+ ephemeral: true,
15
+ flags: SILENT_MESSAGE_FLAGS,
16
+ });
17
+ return;
18
+ }
19
+ const isThread = [
20
+ ChannelType.PublicThread,
21
+ ChannelType.PrivateThread,
22
+ ChannelType.AnnouncementThread,
23
+ ].includes(channel.type);
24
+ if (!isThread) {
25
+ await command.reply({
26
+ content: 'This command can only be used in a thread with an active session',
27
+ ephemeral: true,
28
+ flags: SILENT_MESSAGE_FLAGS,
29
+ });
30
+ return;
31
+ }
32
+ const row = getDatabase()
33
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
34
+ .get(channel.id);
35
+ if (!row?.session_id) {
36
+ await command.reply({
37
+ content: 'No active session in this thread. Send a message directly instead.',
38
+ ephemeral: true,
39
+ flags: SILENT_MESSAGE_FLAGS,
40
+ });
41
+ return;
42
+ }
43
+ // Check if there's an active request running
44
+ const hasActiveRequest = abortControllers.has(row.session_id);
45
+ if (!hasActiveRequest) {
46
+ // No active request, send immediately
47
+ const textChannel = await resolveTextChannel(channel);
48
+ const { projectDirectory } = getKimakiMetadata(textChannel);
49
+ if (!projectDirectory) {
50
+ await command.reply({
51
+ content: 'Could not determine project directory',
52
+ ephemeral: true,
53
+ flags: SILENT_MESSAGE_FLAGS,
54
+ });
55
+ return;
56
+ }
57
+ await command.reply({
58
+ content: `» **${command.user.displayName}:** ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`,
59
+ flags: SILENT_MESSAGE_FLAGS,
60
+ });
61
+ logger.log(`[QUEUE] No active request, sending immediately in thread ${channel.id}`);
62
+ handleOpencodeSession({
63
+ prompt: message,
64
+ thread: channel,
65
+ projectDirectory,
66
+ channelId: textChannel?.id || channel.id,
67
+ }).catch(async (e) => {
68
+ logger.error(`[QUEUE] Failed to send message:`, e);
69
+ const errorMsg = e instanceof Error ? e.message : String(e);
70
+ await sendThreadMessage(channel, `✗ Failed: ${errorMsg.slice(0, 200)}`);
71
+ });
72
+ return;
73
+ }
74
+ // Add to queue
75
+ const queuePosition = addToQueue({
76
+ threadId: channel.id,
77
+ message: {
78
+ prompt: message,
79
+ userId: command.user.id,
80
+ username: command.user.displayName,
81
+ queuedAt: Date.now(),
82
+ },
83
+ });
84
+ await command.reply({
85
+ content: `✅ Message queued (position: ${queuePosition}). Will be sent after current response.`,
86
+ ephemeral: true,
87
+ flags: SILENT_MESSAGE_FLAGS,
88
+ });
89
+ logger.log(`[QUEUE] User ${command.user.displayName} queued message in thread ${channel.id}`);
90
+ }
91
+ export async function handleClearQueueCommand({ command, }) {
92
+ const channel = command.channel;
93
+ if (!channel) {
94
+ await command.reply({
95
+ content: 'This command can only be used in a channel',
96
+ ephemeral: true,
97
+ flags: SILENT_MESSAGE_FLAGS,
98
+ });
99
+ return;
100
+ }
101
+ const isThread = [
102
+ ChannelType.PublicThread,
103
+ ChannelType.PrivateThread,
104
+ ChannelType.AnnouncementThread,
105
+ ].includes(channel.type);
106
+ if (!isThread) {
107
+ await command.reply({
108
+ content: 'This command can only be used in a thread',
109
+ ephemeral: true,
110
+ flags: SILENT_MESSAGE_FLAGS,
111
+ });
112
+ return;
113
+ }
114
+ const queueLength = getQueueLength(channel.id);
115
+ if (queueLength === 0) {
116
+ await command.reply({
117
+ content: 'No messages in queue',
118
+ ephemeral: true,
119
+ flags: SILENT_MESSAGE_FLAGS,
120
+ });
121
+ return;
122
+ }
123
+ clearQueue(channel.id);
124
+ await command.reply({
125
+ content: `🗑 Cleared ${queueLength} queued message${queueLength > 1 ? 's' : ''}`,
126
+ flags: SILENT_MESSAGE_FLAGS,
127
+ });
128
+ logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
129
+ }