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,111 @@
1
+ // /create-new-project command - Create a new project folder, initialize git, and start a session.
2
+
3
+ import { ChannelType, type TextChannel } from 'discord.js'
4
+ import fs from 'node:fs'
5
+ import os from 'node:os'
6
+ import path from 'node:path'
7
+ import type { CommandContext } from './types.js'
8
+ import { createProjectChannels } from '../channel-management.js'
9
+ import { handleOpencodeSession } from '../session-handler.js'
10
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
11
+ import { createLogger } from '../logger.js'
12
+
13
+ const logger = createLogger('CREATE-NEW-PROJECT')
14
+
15
+ export async function handleCreateNewProjectCommand({
16
+ command,
17
+ appId,
18
+ }: CommandContext): Promise<void> {
19
+ await command.deferReply({ ephemeral: false })
20
+
21
+ const projectName = command.options.getString('name', true)
22
+ const guild = command.guild
23
+ const channel = command.channel
24
+
25
+ if (!guild) {
26
+ await command.editReply('This command can only be used in a guild')
27
+ return
28
+ }
29
+
30
+ if (!channel || channel.type !== ChannelType.GuildText) {
31
+ await command.editReply('This command can only be used in a text channel')
32
+ return
33
+ }
34
+
35
+ const sanitizedName = projectName
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9-]/g, '-')
38
+ .replace(/-+/g, '-')
39
+ .replace(/^-|-$/g, '')
40
+ .slice(0, 100)
41
+
42
+ if (!sanitizedName) {
43
+ await command.editReply('Invalid project name')
44
+ return
45
+ }
46
+
47
+ const kimakiDir = path.join(os.homedir(), 'kimaki')
48
+ const projectDirectory = path.join(kimakiDir, sanitizedName)
49
+
50
+ try {
51
+ if (!fs.existsSync(kimakiDir)) {
52
+ fs.mkdirSync(kimakiDir, { recursive: true })
53
+ logger.log(`Created kimaki directory: ${kimakiDir}`)
54
+ }
55
+
56
+ if (fs.existsSync(projectDirectory)) {
57
+ await command.editReply(
58
+ `Project directory already exists: ${projectDirectory}`,
59
+ )
60
+ return
61
+ }
62
+
63
+ fs.mkdirSync(projectDirectory, { recursive: true })
64
+ logger.log(`Created project directory: ${projectDirectory}`)
65
+
66
+ const { execSync } = await import('node:child_process')
67
+ execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
68
+ logger.log(`Initialized git in: ${projectDirectory}`)
69
+
70
+ const { textChannelId, voiceChannelId, channelName } =
71
+ await createProjectChannels({
72
+ guild,
73
+ projectDirectory,
74
+ appId,
75
+ })
76
+
77
+ const textChannel = (await guild.channels.fetch(
78
+ textChannelId,
79
+ )) as TextChannel
80
+
81
+ await command.editReply(
82
+ `✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`,
83
+ )
84
+
85
+ const starterMessage = await textChannel.send({
86
+ content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
87
+ flags: SILENT_MESSAGE_FLAGS,
88
+ })
89
+
90
+ const thread = await starterMessage.startThread({
91
+ name: `Init: ${sanitizedName}`,
92
+ autoArchiveDuration: 1440,
93
+ reason: 'New project session',
94
+ })
95
+
96
+ await handleOpencodeSession({
97
+ prompt:
98
+ 'The project was just initialized. Say hi and ask what the user wants to build.',
99
+ thread,
100
+ projectDirectory,
101
+ channelId: textChannel.id,
102
+ })
103
+
104
+ logger.log(`Created new project ${channelName} at ${projectDirectory}`)
105
+ } catch (error) {
106
+ logger.error('[CREATE-NEW-PROJECT] Error:', error)
107
+ await command.editReply(
108
+ `Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`,
109
+ )
110
+ }
111
+ }
@@ -1,3 +1,5 @@
1
+ // /fork command - Fork the session from a past user message.
2
+
1
3
  import {
2
4
  ChatInputCommandInteraction,
3
5
  StringSelectMenuInteraction,
@@ -7,11 +9,11 @@ import {
7
9
  ThreadAutoArchiveDuration,
8
10
  type ThreadChannel,
9
11
  } from 'discord.js'
10
- import type { TextPart } from '@opencode-ai/sdk'
11
- import { getDatabase } from './database.js'
12
- import { initializeOpencodeForDirectory } from './opencode.js'
13
- import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from './discord-utils.js'
14
- import { createLogger } from './logger.js'
12
+ import { getDatabase } from '../database.js'
13
+ import { initializeOpencodeForDirectory } from '../opencode.js'
14
+ import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js'
15
+ import { collectLastAssistantParts } from '../message-formatting.js'
16
+ import { createLogger } from '../logger.js'
15
17
 
16
18
  const sessionLogger = createLogger('SESSION')
17
19
  const forkLogger = createLogger('FORK')
@@ -97,7 +99,7 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
97
99
  const recentMessages = userMessages.slice(-25)
98
100
 
99
101
  const options = recentMessages.map((m, index) => {
100
- const textPart = m.parts.find((p) => p.type === 'text') as TextPart | undefined
102
+ const textPart = m.parts.find((p) => p.type === 'text') as { type: 'text'; text: string } | undefined
101
103
  const preview = textPart?.text?.slice(0, 80) || '(no text)'
102
104
  const label = `${index + 1}. ${preview}${preview.length >= 80 ? '...' : ''}`
103
105
 
@@ -209,7 +211,38 @@ export async function handleForkSelectMenu(interaction: StringSelectMenuInteract
209
211
 
210
212
  await sendThreadMessage(
211
213
  thread,
212
- `**Forked session created!**\nFrom: \`${sessionId}\`\nNew session: \`${forkedSession.id}\`\n\nYou can now continue the conversation from this point.`
214
+ `**Forked session created!**\nFrom: \`${sessionId}\`\nNew session: \`${forkedSession.id}\``,
215
+ )
216
+
217
+ // Fetch and display the last assistant messages from the forked session
218
+ const messagesResponse = await getClient().session.messages({
219
+ path: { id: forkedSession.id },
220
+ })
221
+
222
+ if (messagesResponse.data) {
223
+ const { partIds, content } = collectLastAssistantParts({
224
+ messages: messagesResponse.data,
225
+ })
226
+
227
+ if (content.trim()) {
228
+ const discordMessage = await sendThreadMessage(thread, content)
229
+
230
+ // Store part-message mappings for future reference
231
+ const stmt = getDatabase().prepare(
232
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
233
+ )
234
+ const transaction = getDatabase().transaction((ids: string[]) => {
235
+ for (const partId of ids) {
236
+ stmt.run(partId, discordMessage.id, thread.id)
237
+ }
238
+ })
239
+ transaction(partIds)
240
+ }
241
+ }
242
+
243
+ await sendThreadMessage(
244
+ thread,
245
+ `You can now continue the conversation from this point.`,
213
246
  )
214
247
 
215
248
  await interaction.editReply(
@@ -1,3 +1,5 @@
1
+ // /model command - Set the preferred model for this channel or session.
2
+
1
3
  import {
2
4
  ChatInputCommandInteraction,
3
5
  StringSelectMenuInteraction,
@@ -8,10 +10,11 @@ import {
8
10
  type TextChannel,
9
11
  } from 'discord.js'
10
12
  import crypto from 'node:crypto'
11
- import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from './database.js'
12
- import { initializeOpencodeForDirectory } from './opencode.js'
13
- import { resolveTextChannel, getKimakiMetadata } from './discord-utils.js'
14
- import { createLogger } from './logger.js'
13
+ import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from '../database.js'
14
+ import { initializeOpencodeForDirectory } from '../opencode.js'
15
+ import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
16
+ import { abortAndRetrySession } from '../session-handler.js'
17
+ import { createLogger } from '../logger.js'
15
18
 
16
19
  const modelLogger = createLogger('MODEL')
17
20
 
@@ -23,9 +26,10 @@ const pendingModelContexts = new Map<string, {
23
26
  isThread: boolean
24
27
  providerId?: string
25
28
  providerName?: string
29
+ thread?: ThreadChannel
26
30
  }>()
27
31
 
28
- type ProviderInfo = {
32
+ export type ProviderInfo = {
29
33
  id: string
30
34
  name: string
31
35
  models: Record<
@@ -154,6 +158,7 @@ export async function handleModelCommand({
154
158
  channelId: targetChannelId,
155
159
  sessionId: sessionId,
156
160
  isThread: isThread,
161
+ thread: isThread ? (channel as ThreadChannel) : undefined,
157
162
  }
158
163
  const contextHash = crypto.randomBytes(8).toString('hex')
159
164
  pendingModelContexts.set(contextHash, context)
@@ -353,10 +358,27 @@ export async function handleModelSelectMenu(
353
358
  setSessionModel(context.sessionId, fullModelId)
354
359
  modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`)
355
360
 
356
- await interaction.editReply({
357
- content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
358
- components: [],
359
- })
361
+ // Check if there's a running request and abort+retry with new model
362
+ let retried = false
363
+ if (context.thread) {
364
+ retried = await abortAndRetrySession({
365
+ sessionId: context.sessionId,
366
+ thread: context.thread,
367
+ projectDirectory: context.dir,
368
+ })
369
+ }
370
+
371
+ if (retried) {
372
+ await interaction.editReply({
373
+ content: `Model changed for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\`\n\n_Retrying current request with new model..._`,
374
+ components: [],
375
+ })
376
+ } else {
377
+ await interaction.editReply({
378
+ content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
379
+ components: [],
380
+ })
381
+ }
360
382
  } else {
361
383
  // Store for channel
362
384
  setChannelModel(context.channelId, fullModelId)
@@ -0,0 +1,146 @@
1
+ // Permission commands - /accept, /accept-always, /reject
2
+
3
+ import { ChannelType } from 'discord.js'
4
+ import type { CommandContext } from './types.js'
5
+ import { initializeOpencodeForDirectory } from '../opencode.js'
6
+ import { pendingPermissions } from '../session-handler.js'
7
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
8
+ import { createLogger } from '../logger.js'
9
+
10
+ const logger = createLogger('PERMISSIONS')
11
+
12
+ export async function handleAcceptCommand({
13
+ command,
14
+ }: CommandContext): Promise<void> {
15
+ const scope = command.commandName === 'accept-always' ? 'always' : 'once'
16
+ const channel = command.channel
17
+
18
+ if (!channel) {
19
+ await command.reply({
20
+ content: 'This command can only be used in a channel',
21
+ ephemeral: true,
22
+ flags: SILENT_MESSAGE_FLAGS,
23
+ })
24
+ return
25
+ }
26
+
27
+ const isThread = [
28
+ ChannelType.PublicThread,
29
+ ChannelType.PrivateThread,
30
+ ChannelType.AnnouncementThread,
31
+ ].includes(channel.type)
32
+
33
+ if (!isThread) {
34
+ await command.reply({
35
+ content: 'This command can only be used in a thread with an active session',
36
+ ephemeral: true,
37
+ flags: SILENT_MESSAGE_FLAGS,
38
+ })
39
+ return
40
+ }
41
+
42
+ const pending = pendingPermissions.get(channel.id)
43
+ if (!pending) {
44
+ await command.reply({
45
+ content: 'No pending permission request in this thread',
46
+ ephemeral: true,
47
+ flags: SILENT_MESSAGE_FLAGS,
48
+ })
49
+ return
50
+ }
51
+
52
+ try {
53
+ const getClient = await initializeOpencodeForDirectory(pending.directory)
54
+ await getClient().postSessionIdPermissionsPermissionId({
55
+ path: {
56
+ id: pending.permission.sessionID,
57
+ permissionID: pending.permission.id,
58
+ },
59
+ body: {
60
+ response: scope,
61
+ },
62
+ })
63
+
64
+ pendingPermissions.delete(channel.id)
65
+ const msg =
66
+ scope === 'always'
67
+ ? `✅ Permission **accepted** (auto-approve similar requests)`
68
+ : `✅ Permission **accepted**`
69
+ await command.reply({ content: msg, flags: SILENT_MESSAGE_FLAGS })
70
+ logger.log(`Permission ${pending.permission.id} accepted with scope: ${scope}`)
71
+ } catch (error) {
72
+ logger.error('[ACCEPT] Error:', error)
73
+ await command.reply({
74
+ content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
75
+ ephemeral: true,
76
+ flags: SILENT_MESSAGE_FLAGS,
77
+ })
78
+ }
79
+ }
80
+
81
+ export async function handleRejectCommand({
82
+ command,
83
+ }: CommandContext): Promise<void> {
84
+ const channel = command.channel
85
+
86
+ if (!channel) {
87
+ await command.reply({
88
+ content: 'This command can only be used in a channel',
89
+ ephemeral: true,
90
+ flags: SILENT_MESSAGE_FLAGS,
91
+ })
92
+ return
93
+ }
94
+
95
+ const isThread = [
96
+ ChannelType.PublicThread,
97
+ ChannelType.PrivateThread,
98
+ ChannelType.AnnouncementThread,
99
+ ].includes(channel.type)
100
+
101
+ if (!isThread) {
102
+ await command.reply({
103
+ content: 'This command can only be used in a thread with an active session',
104
+ ephemeral: true,
105
+ flags: SILENT_MESSAGE_FLAGS,
106
+ })
107
+ return
108
+ }
109
+
110
+ const pending = pendingPermissions.get(channel.id)
111
+ if (!pending) {
112
+ await command.reply({
113
+ content: 'No pending permission request in this thread',
114
+ ephemeral: true,
115
+ flags: SILENT_MESSAGE_FLAGS,
116
+ })
117
+ return
118
+ }
119
+
120
+ try {
121
+ const getClient = await initializeOpencodeForDirectory(pending.directory)
122
+ await getClient().postSessionIdPermissionsPermissionId({
123
+ path: {
124
+ id: pending.permission.sessionID,
125
+ permissionID: pending.permission.id,
126
+ },
127
+ body: {
128
+ response: 'reject',
129
+ },
130
+ })
131
+
132
+ pendingPermissions.delete(channel.id)
133
+ await command.reply({
134
+ content: `❌ Permission **rejected**`,
135
+ flags: SILENT_MESSAGE_FLAGS,
136
+ })
137
+ logger.log(`Permission ${pending.permission.id} rejected`)
138
+ } catch (error) {
139
+ logger.error('[REJECT] Error:', error)
140
+ await command.reply({
141
+ content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
142
+ ephemeral: true,
143
+ flags: SILENT_MESSAGE_FLAGS,
144
+ })
145
+ }
146
+ }
@@ -0,0 +1,181 @@
1
+ // Queue commands - /queue, /clear-queue
2
+
3
+ import { ChannelType, type ThreadChannel } from 'discord.js'
4
+ import type { CommandContext } from './types.js'
5
+ import { getDatabase } from '../database.js'
6
+ import {
7
+ resolveTextChannel,
8
+ getKimakiMetadata,
9
+ sendThreadMessage,
10
+ SILENT_MESSAGE_FLAGS,
11
+ } from '../discord-utils.js'
12
+ import {
13
+ handleOpencodeSession,
14
+ abortControllers,
15
+ addToQueue,
16
+ getQueueLength,
17
+ clearQueue,
18
+ } from '../session-handler.js'
19
+ import { createLogger } from '../logger.js'
20
+
21
+ const logger = createLogger('QUEUE')
22
+
23
+ export async function handleQueueCommand({
24
+ command,
25
+ }: CommandContext): Promise<void> {
26
+ const message = command.options.getString('message', true)
27
+ const channel = command.channel
28
+
29
+ if (!channel) {
30
+ await command.reply({
31
+ content: 'This command can only be used in a channel',
32
+ ephemeral: true,
33
+ flags: SILENT_MESSAGE_FLAGS,
34
+ })
35
+ return
36
+ }
37
+
38
+ const isThread = [
39
+ ChannelType.PublicThread,
40
+ ChannelType.PrivateThread,
41
+ ChannelType.AnnouncementThread,
42
+ ].includes(channel.type)
43
+
44
+ if (!isThread) {
45
+ await command.reply({
46
+ content: 'This command can only be used in a thread with an active session',
47
+ ephemeral: true,
48
+ flags: SILENT_MESSAGE_FLAGS,
49
+ })
50
+ return
51
+ }
52
+
53
+ const row = getDatabase()
54
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
55
+ .get(channel.id) as { session_id: string } | undefined
56
+
57
+ if (!row?.session_id) {
58
+ await command.reply({
59
+ content: 'No active session in this thread. Send a message directly instead.',
60
+ ephemeral: true,
61
+ flags: SILENT_MESSAGE_FLAGS,
62
+ })
63
+ return
64
+ }
65
+
66
+ // Check if there's an active request running
67
+ const hasActiveRequest = abortControllers.has(row.session_id)
68
+
69
+ if (!hasActiveRequest) {
70
+ // No active request, send immediately
71
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
72
+ const { projectDirectory } = getKimakiMetadata(textChannel)
73
+
74
+ if (!projectDirectory) {
75
+ await command.reply({
76
+ content: 'Could not determine project directory',
77
+ ephemeral: true,
78
+ flags: SILENT_MESSAGE_FLAGS,
79
+ })
80
+ return
81
+ }
82
+
83
+ await command.reply({
84
+ content: `» **${command.user.displayName}:** ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`,
85
+ flags: SILENT_MESSAGE_FLAGS,
86
+ })
87
+
88
+ logger.log(
89
+ `[QUEUE] No active request, sending immediately in thread ${channel.id}`,
90
+ )
91
+
92
+ handleOpencodeSession({
93
+ prompt: message,
94
+ thread: channel as ThreadChannel,
95
+ projectDirectory,
96
+ channelId: textChannel?.id || channel.id,
97
+ }).catch(async (e) => {
98
+ logger.error(`[QUEUE] Failed to send message:`, e)
99
+ const errorMsg = e instanceof Error ? e.message : String(e)
100
+ await sendThreadMessage(
101
+ channel as ThreadChannel,
102
+ `✗ Failed: ${errorMsg.slice(0, 200)}`,
103
+ )
104
+ })
105
+
106
+ return
107
+ }
108
+
109
+ // Add to queue
110
+ const queuePosition = addToQueue({
111
+ threadId: channel.id,
112
+ message: {
113
+ prompt: message,
114
+ userId: command.user.id,
115
+ username: command.user.displayName,
116
+ queuedAt: Date.now(),
117
+ },
118
+ })
119
+
120
+ await command.reply({
121
+ content: `✅ Message queued (position: ${queuePosition}). Will be sent after current response.`,
122
+ ephemeral: true,
123
+ flags: SILENT_MESSAGE_FLAGS,
124
+ })
125
+
126
+ logger.log(
127
+ `[QUEUE] User ${command.user.displayName} queued message in thread ${channel.id}`,
128
+ )
129
+ }
130
+
131
+ export async function handleClearQueueCommand({
132
+ command,
133
+ }: CommandContext): Promise<void> {
134
+ const channel = command.channel
135
+
136
+ if (!channel) {
137
+ await command.reply({
138
+ content: 'This command can only be used in a channel',
139
+ ephemeral: true,
140
+ flags: SILENT_MESSAGE_FLAGS,
141
+ })
142
+ return
143
+ }
144
+
145
+ const isThread = [
146
+ ChannelType.PublicThread,
147
+ ChannelType.PrivateThread,
148
+ ChannelType.AnnouncementThread,
149
+ ].includes(channel.type)
150
+
151
+ if (!isThread) {
152
+ await command.reply({
153
+ content: 'This command can only be used in a thread',
154
+ ephemeral: true,
155
+ flags: SILENT_MESSAGE_FLAGS,
156
+ })
157
+ return
158
+ }
159
+
160
+ const queueLength = getQueueLength(channel.id)
161
+
162
+ if (queueLength === 0) {
163
+ await command.reply({
164
+ content: 'No messages in queue',
165
+ ephemeral: true,
166
+ flags: SILENT_MESSAGE_FLAGS,
167
+ })
168
+ return
169
+ }
170
+
171
+ clearQueue(channel.id)
172
+
173
+ await command.reply({
174
+ content: `🗑 Cleared ${queueLength} queued message${queueLength > 1 ? 's' : ''}`,
175
+ flags: SILENT_MESSAGE_FLAGS,
176
+ })
177
+
178
+ logger.log(
179
+ `[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`,
180
+ )
181
+ }