kimaki 0.4.23 → 0.4.25

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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/bin.js +6 -1
  3. package/dist/ai-tool-to-genai.js +3 -0
  4. package/dist/channel-management.js +3 -0
  5. package/dist/cli.js +93 -14
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +97 -0
  8. package/dist/commands/create-new-project.js +78 -0
  9. package/dist/commands/fork.js +186 -0
  10. package/dist/commands/model.js +294 -0
  11. package/dist/commands/permissions.js +126 -0
  12. package/dist/commands/queue.js +129 -0
  13. package/dist/commands/resume.js +145 -0
  14. package/dist/commands/session.js +144 -0
  15. package/dist/commands/share.js +80 -0
  16. package/dist/commands/types.js +2 -0
  17. package/dist/commands/undo-redo.js +161 -0
  18. package/dist/database.js +3 -0
  19. package/dist/discord-bot.js +3 -0
  20. package/dist/discord-utils.js +10 -1
  21. package/dist/format-tables.js +3 -0
  22. package/dist/genai-worker-wrapper.js +3 -0
  23. package/dist/genai-worker.js +3 -0
  24. package/dist/genai.js +3 -0
  25. package/dist/interaction-handler.js +71 -697
  26. package/dist/logger.js +3 -0
  27. package/dist/markdown.js +3 -0
  28. package/dist/message-formatting.js +41 -6
  29. package/dist/opencode.js +3 -0
  30. package/dist/session-handler.js +47 -3
  31. package/dist/system-message.js +16 -0
  32. package/dist/tools.js +3 -0
  33. package/dist/utils.js +3 -0
  34. package/dist/voice-handler.js +3 -0
  35. package/dist/voice.js +3 -0
  36. package/dist/worker-types.js +3 -0
  37. package/dist/xml.js +3 -0
  38. package/package.json +11 -12
  39. package/src/ai-tool-to-genai.ts +4 -0
  40. package/src/channel-management.ts +4 -0
  41. package/src/cli.ts +93 -14
  42. package/src/commands/abort.ts +94 -0
  43. package/src/commands/add-project.ts +138 -0
  44. package/src/commands/create-new-project.ts +111 -0
  45. package/src/{fork.ts → commands/fork.ts} +39 -5
  46. package/src/{model-command.ts → commands/model.ts} +7 -5
  47. package/src/commands/permissions.ts +146 -0
  48. package/src/commands/queue.ts +181 -0
  49. package/src/commands/resume.ts +230 -0
  50. package/src/commands/session.ts +186 -0
  51. package/src/commands/share.ts +96 -0
  52. package/src/commands/types.ts +25 -0
  53. package/src/commands/undo-redo.ts +213 -0
  54. package/src/database.ts +4 -0
  55. package/src/discord-bot.ts +4 -0
  56. package/src/discord-utils.ts +12 -0
  57. package/src/format-tables.ts +4 -0
  58. package/src/genai-worker-wrapper.ts +4 -0
  59. package/src/genai-worker.ts +4 -0
  60. package/src/genai.ts +4 -0
  61. package/src/interaction-handler.ts +81 -919
  62. package/src/logger.ts +4 -0
  63. package/src/markdown.ts +4 -0
  64. package/src/message-formatting.ts +52 -7
  65. package/src/opencode.ts +4 -0
  66. package/src/session-handler.ts +70 -3
  67. package/src/system-message.ts +17 -0
  68. package/src/tools.ts +4 -0
  69. package/src/utils.ts +4 -0
  70. package/src/voice-handler.ts +4 -0
  71. package/src/voice.ts +4 -0
  72. package/src/worker-types.ts +4 -0
  73. package/src/xml.ts +4 -0
  74. package/README.md +0 -48
@@ -1,41 +1,21 @@
1
- import {
2
- ChannelType,
3
- Events,
4
- ThreadAutoArchiveDuration,
5
- type Client,
6
- type Interaction,
7
- type TextChannel,
8
- type ThreadChannel,
9
- } from 'discord.js'
10
- import fs from 'node:fs'
11
- import os from 'node:os'
12
- import path from 'node:path'
13
- import { getDatabase } from './database.js'
14
- import { initializeOpencodeForDirectory } from './opencode.js'
15
- import {
16
- sendThreadMessage,
17
- resolveTextChannel,
18
- getKimakiMetadata,
19
- SILENT_MESSAGE_FLAGS,
20
- } from './discord-utils.js'
21
- import { handleForkCommand, handleForkSelectMenu } from './fork.js'
22
- import {
23
- handleModelCommand,
24
- handleProviderSelectMenu,
25
- handleModelSelectMenu,
26
- } from './model-command.js'
27
- import { formatPart } from './message-formatting.js'
28
- import { createProjectChannels } from './channel-management.js'
29
- import {
30
- handleOpencodeSession,
31
- parseSlashCommand,
32
- abortControllers,
33
- pendingPermissions,
34
- } from './session-handler.js'
35
- import { extractTagsArrays } from './xml.js'
1
+ // Discord slash command and interaction handler.
2
+ // Processes all slash commands (/session, /resume, /fork, /model, /abort, etc.)
3
+ // and manages autocomplete, select menu interactions for the bot.
4
+
5
+ import { Events, type Client, type Interaction } from 'discord.js'
6
+ import { handleSessionCommand, handleSessionAutocomplete } from './commands/session.js'
7
+ import { handleResumeCommand, handleResumeAutocomplete } from './commands/resume.js'
8
+ import { handleAddProjectCommand, handleAddProjectAutocomplete } from './commands/add-project.js'
9
+ import { handleCreateNewProjectCommand } from './commands/create-new-project.js'
10
+ import { handleAcceptCommand, handleRejectCommand } from './commands/permissions.js'
11
+ import { handleAbortCommand } from './commands/abort.js'
12
+ import { handleShareCommand } from './commands/share.js'
13
+ import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js'
14
+ import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu } from './commands/model.js'
15
+ import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js'
16
+ import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js'
36
17
  import { createLogger } from './logger.js'
37
18
 
38
- const discordLogger = createLogger('DISCORD')
39
19
  const interactionLogger = createLogger('INTERACTION')
40
20
 
41
21
  export function registerInteractionHandler({
@@ -51,939 +31,121 @@ export function registerInteractionHandler({
51
31
  Events.InteractionCreate,
52
32
  async (interaction: Interaction) => {
53
33
  try {
54
- interactionLogger.log(`[INTERACTION] Received: ${interaction.type} - ${interaction.isChatInputCommand() ? interaction.commandName : interaction.isAutocomplete() ? `autocomplete:${interaction.commandName}` : 'other'}`)
34
+ interactionLogger.log(
35
+ `[INTERACTION] Received: ${interaction.type} - ${
36
+ interaction.isChatInputCommand()
37
+ ? interaction.commandName
38
+ : interaction.isAutocomplete()
39
+ ? `autocomplete:${interaction.commandName}`
40
+ : 'other'
41
+ }`,
42
+ )
55
43
 
56
44
  if (interaction.isAutocomplete()) {
57
- if (interaction.commandName === 'resume') {
58
- const focusedValue = interaction.options.getFocused()
59
-
60
- let projectDirectory: string | undefined
61
- if (interaction.channel) {
62
- const textChannel = await resolveTextChannel(
63
- interaction.channel as TextChannel | ThreadChannel | null,
64
- )
65
- if (textChannel) {
66
- const { projectDirectory: directory, channelAppId } =
67
- getKimakiMetadata(textChannel)
68
- if (channelAppId && channelAppId !== appId) {
69
- await interaction.respond([])
70
- return
71
- }
72
- projectDirectory = directory
73
- }
74
- }
75
-
76
- if (!projectDirectory) {
77
- await interaction.respond([])
45
+ switch (interaction.commandName) {
46
+ case 'session':
47
+ await handleSessionAutocomplete({ interaction, appId })
78
48
  return
79
- }
80
-
81
- try {
82
- const getClient =
83
- await initializeOpencodeForDirectory(projectDirectory)
84
-
85
- const sessionsResponse = await getClient().session.list()
86
- if (!sessionsResponse.data) {
87
- await interaction.respond([])
88
- return
89
- }
90
-
91
- const existingSessionIds = new Set(
92
- (
93
- getDatabase()
94
- .prepare('SELECT session_id FROM thread_sessions')
95
- .all() as { session_id: string }[]
96
- ).map((row) => row.session_id),
97
- )
98
-
99
- const sessions = sessionsResponse.data
100
- .filter((session) => !existingSessionIds.has(session.id))
101
- .filter((session) =>
102
- session.title
103
- .toLowerCase()
104
- .includes(focusedValue.toLowerCase()),
105
- )
106
- .slice(0, 25)
107
- .map((session) => {
108
- const dateStr = new Date(
109
- session.time.updated,
110
- ).toLocaleString()
111
- const suffix = ` (${dateStr})`
112
- const maxTitleLength = 100 - suffix.length
113
-
114
- let title = session.title
115
- if (title.length > maxTitleLength) {
116
- title = title.slice(0, Math.max(0, maxTitleLength - 1)) + '…'
117
- }
118
-
119
- return {
120
- name: `${title}${suffix}`,
121
- value: session.id,
122
- }
123
- })
124
-
125
- await interaction.respond(sessions)
126
- } catch (error) {
127
- interactionLogger.error(
128
- '[AUTOCOMPLETE] Error fetching sessions:',
129
- error,
130
- )
131
- await interaction.respond([])
132
- }
133
- } else if (interaction.commandName === 'session') {
134
- const focusedOption = interaction.options.getFocused(true)
135
-
136
- if (focusedOption.name === 'files') {
137
- const focusedValue = focusedOption.value
138
-
139
- const parts = focusedValue.split(',')
140
- const previousFiles = parts
141
- .slice(0, -1)
142
- .map((f) => f.trim())
143
- .filter((f) => f)
144
- const currentQuery = (parts[parts.length - 1] || '').trim()
145
-
146
- let projectDirectory: string | undefined
147
- if (interaction.channel) {
148
- const textChannel = await resolveTextChannel(
149
- interaction.channel as TextChannel | ThreadChannel | null,
150
- )
151
- if (textChannel) {
152
- const { projectDirectory: directory, channelAppId } =
153
- getKimakiMetadata(textChannel)
154
- if (channelAppId && channelAppId !== appId) {
155
- await interaction.respond([])
156
- return
157
- }
158
- projectDirectory = directory
159
- }
160
- }
161
-
162
- if (!projectDirectory) {
163
- await interaction.respond([])
164
- return
165
- }
166
-
167
- try {
168
- const getClient =
169
- await initializeOpencodeForDirectory(projectDirectory)
170
-
171
- const response = await getClient().find.files({
172
- query: {
173
- query: currentQuery || '',
174
- },
175
- })
176
-
177
- const files = response.data || []
178
49
 
179
- const prefix =
180
- previousFiles.length > 0
181
- ? previousFiles.join(', ') + ', '
182
- : ''
183
-
184
- const choices = files
185
- .map((file: string) => {
186
- const fullValue = prefix + file
187
- const allFiles = [...previousFiles, file]
188
- const allBasenames = allFiles.map(
189
- (f) => f.split('/').pop() || f,
190
- )
191
- let displayName = allBasenames.join(', ')
192
- if (displayName.length > 100) {
193
- displayName = '…' + displayName.slice(-97)
194
- }
195
- return {
196
- name: displayName,
197
- value: fullValue,
198
- }
199
- })
200
- .filter((choice) => choice.value.length <= 100)
201
- .slice(0, 25)
202
-
203
- await interaction.respond(choices)
204
- } catch (error) {
205
- interactionLogger.error('[AUTOCOMPLETE] Error fetching files:', error)
206
- await interaction.respond([])
207
- }
208
- }
209
- } else if (interaction.commandName === 'add-project') {
210
- const focusedValue = interaction.options.getFocused()
211
-
212
- try {
213
- const currentDir = process.cwd()
214
- const getClient = await initializeOpencodeForDirectory(currentDir)
215
-
216
- const projectsResponse = await getClient().project.list({})
217
- if (!projectsResponse.data) {
218
- await interaction.respond([])
219
- return
220
- }
221
-
222
- const db = getDatabase()
223
- const existingDirs = db
224
- .prepare(
225
- 'SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?',
226
- )
227
- .all('text') as { directory: string }[]
228
- const existingDirSet = new Set(
229
- existingDirs.map((row) => row.directory),
230
- )
231
-
232
- const availableProjects = projectsResponse.data.filter(
233
- (project) => !existingDirSet.has(project.worktree),
234
- )
50
+ case 'resume':
51
+ await handleResumeAutocomplete({ interaction, appId })
52
+ return
235
53
 
236
- const projects = availableProjects
237
- .filter((project) => {
238
- const baseName = path.basename(project.worktree)
239
- const searchText = `${baseName} ${project.worktree}`.toLowerCase()
240
- return searchText.includes(focusedValue.toLowerCase())
241
- })
242
- .sort((a, b) => {
243
- const aTime = a.time.initialized || a.time.created
244
- const bTime = b.time.initialized || b.time.created
245
- return bTime - aTime
246
- })
247
- .slice(0, 25)
248
- .map((project) => {
249
- const name = `${path.basename(project.worktree)} (${project.worktree})`
250
- return {
251
- name: name.length > 100 ? name.slice(0, 99) + '…' : name,
252
- value: project.id,
253
- }
254
- })
54
+ case 'add-project':
55
+ await handleAddProjectAutocomplete({ interaction, appId })
56
+ return
255
57
 
256
- await interaction.respond(projects)
257
- } catch (error) {
258
- interactionLogger.error(
259
- '[AUTOCOMPLETE] Error fetching projects:',
260
- error,
261
- )
58
+ default:
262
59
  await interaction.respond([])
263
- }
60
+ return
264
61
  }
265
62
  }
266
63
 
267
64
  if (interaction.isChatInputCommand()) {
268
- const command = interaction
269
- interactionLogger.log(`[COMMAND] Processing: ${command.commandName}`)
270
-
271
- if (command.commandName === 'session') {
272
- await command.deferReply({ ephemeral: false })
273
-
274
- const prompt = command.options.getString('prompt', true)
275
- const filesString = command.options.getString('files') || ''
276
- const channel = command.channel
277
-
278
- if (!channel || channel.type !== ChannelType.GuildText) {
279
- await command.editReply(
280
- 'This command can only be used in text channels',
281
- )
282
- return
283
- }
284
-
285
- const textChannel = channel as TextChannel
286
-
287
- let projectDirectory: string | undefined
288
- let channelAppId: string | undefined
289
-
290
- if (textChannel.topic) {
291
- const extracted = extractTagsArrays({
292
- xml: textChannel.topic,
293
- tags: ['kimaki.directory', 'kimaki.app'],
294
- })
295
-
296
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
297
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
298
- }
299
-
300
- if (channelAppId && channelAppId !== appId) {
301
- await command.editReply(
302
- 'This channel is not configured for this bot',
303
- )
304
- return
305
- }
306
-
307
- if (!projectDirectory) {
308
- await command.editReply(
309
- 'This channel is not configured with a project directory',
310
- )
311
- return
312
- }
313
-
314
- if (!fs.existsSync(projectDirectory)) {
315
- await command.editReply(
316
- `Directory does not exist: ${projectDirectory}`,
317
- )
318
- return
319
- }
320
-
321
- try {
322
- const getClient =
323
- await initializeOpencodeForDirectory(projectDirectory)
324
-
325
- const files = filesString
326
- .split(',')
327
- .map((f) => f.trim())
328
- .filter((f) => f)
329
-
330
- let fullPrompt = prompt
331
- if (files.length > 0) {
332
- fullPrompt = `${prompt}\n\n@${files.join(' @')}`
333
- }
334
-
335
- const starterMessage = await textChannel.send({
336
- content: `🚀 **Starting OpenCode session**\n📝 ${prompt.slice(0, 200)}${prompt.length > 200 ? '…' : ''}${files.length > 0 ? `\n📎 Files: ${files.join(', ')}` : ''}`,
337
- flags: SILENT_MESSAGE_FLAGS,
338
- })
339
-
340
- const thread = await starterMessage.startThread({
341
- name: prompt.slice(0, 100),
342
- autoArchiveDuration: 1440,
343
- reason: 'OpenCode session',
344
- })
345
-
346
- await command.editReply(
347
- `Created new session in ${thread.toString()}`,
348
- )
349
-
350
- const parsedCommand = parseSlashCommand(fullPrompt)
351
- await handleOpencodeSession({
352
- prompt: fullPrompt,
353
- thread,
354
- projectDirectory,
355
- parsedCommand,
356
- channelId: textChannel.id,
357
- })
358
- } catch (error) {
359
- interactionLogger.error('[SESSION] Error:', error)
360
- await command.editReply(
361
- `Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`,
362
- )
363
- }
364
- } else if (command.commandName === 'resume') {
365
- await command.deferReply({ ephemeral: false })
366
-
367
- const sessionId = command.options.getString('session', true)
368
- const channel = command.channel
369
-
370
- if (!channel || channel.type !== ChannelType.GuildText) {
371
- await command.editReply(
372
- 'This command can only be used in text channels',
373
- )
374
- return
375
- }
376
-
377
- const textChannel = channel as TextChannel
378
-
379
- let projectDirectory: string | undefined
380
- let channelAppId: string | undefined
381
-
382
- if (textChannel.topic) {
383
- const extracted = extractTagsArrays({
384
- xml: textChannel.topic,
385
- tags: ['kimaki.directory', 'kimaki.app'],
386
- })
387
-
388
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
389
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
390
- }
391
-
392
- if (channelAppId && channelAppId !== appId) {
393
- await command.editReply(
394
- 'This channel is not configured for this bot',
395
- )
396
- return
397
- }
398
-
399
- if (!projectDirectory) {
400
- await command.editReply(
401
- 'This channel is not configured with a project directory',
402
- )
403
- return
404
- }
405
-
406
- if (!fs.existsSync(projectDirectory)) {
407
- await command.editReply(
408
- `Directory does not exist: ${projectDirectory}`,
409
- )
410
- return
411
- }
412
-
413
- try {
414
- const getClient =
415
- await initializeOpencodeForDirectory(projectDirectory)
416
-
417
- const sessionResponse = await getClient().session.get({
418
- path: { id: sessionId },
419
- })
420
-
421
- if (!sessionResponse.data) {
422
- await command.editReply('Session not found')
423
- return
424
- }
425
-
426
- const sessionTitle = sessionResponse.data.title
427
-
428
- const thread = await textChannel.threads.create({
429
- name: `Resume: ${sessionTitle}`.slice(0, 100),
430
- autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
431
- reason: `Resuming session ${sessionId}`,
432
- })
433
-
434
- getDatabase()
435
- .prepare(
436
- 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
437
- )
438
- .run(thread.id, sessionId)
439
-
440
- interactionLogger.log(
441
- `[RESUME] Created thread ${thread.id} for session ${sessionId}`,
442
- )
443
-
444
- const messagesResponse = await getClient().session.messages({
445
- path: { id: sessionId },
446
- })
447
-
448
- if (!messagesResponse.data) {
449
- throw new Error('Failed to fetch session messages')
450
- }
451
-
452
- const messages = messagesResponse.data
65
+ interactionLogger.log(`[COMMAND] Processing: ${interaction.commandName}`)
453
66
 
454
- await command.editReply(
455
- `Resumed session "${sessionTitle}" in ${thread.toString()}`,
456
- )
457
-
458
- await sendThreadMessage(
459
- thread,
460
- `📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`,
461
- )
462
-
463
- const allAssistantParts: { id: string; content: string }[] = []
464
- for (const message of messages) {
465
- if (message.info.role === 'assistant') {
466
- for (const part of message.parts) {
467
- const content = formatPart(part)
468
- if (content.trim()) {
469
- allAssistantParts.push({ id: part.id, content })
470
- }
471
- }
472
- }
473
- }
474
-
475
- const partsToRender = allAssistantParts.slice(-30)
476
- const skippedCount = allAssistantParts.length - partsToRender.length
477
-
478
- if (skippedCount > 0) {
479
- await sendThreadMessage(
480
- thread,
481
- `*Skipped ${skippedCount} older assistant parts...*`,
482
- )
483
- }
484
-
485
- if (partsToRender.length > 0) {
486
- const combinedContent = partsToRender
487
- .map((p) => p.content)
488
- .join('\n')
489
-
490
- const discordMessage = await sendThreadMessage(
491
- thread,
492
- combinedContent,
493
- )
494
-
495
- const stmt = getDatabase().prepare(
496
- 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
497
- )
498
-
499
- const transaction = getDatabase().transaction(
500
- (parts: { id: string }[]) => {
501
- for (const part of parts) {
502
- stmt.run(part.id, discordMessage.id, thread.id)
503
- }
504
- },
505
- )
506
-
507
- transaction(partsToRender)
508
- }
509
-
510
- const messageCount = messages.length
511
-
512
- await sendThreadMessage(
513
- thread,
514
- `✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`,
515
- )
516
- } catch (error) {
517
- interactionLogger.error('[RESUME] Error:', error)
518
- await command.editReply(
519
- `Failed to resume session: ${error instanceof Error ? error.message : 'Unknown error'}`,
520
- )
521
- }
522
- } else if (command.commandName === 'add-project') {
523
- await command.deferReply({ ephemeral: false })
524
-
525
- const projectId = command.options.getString('project', true)
526
- const guild = command.guild
527
-
528
- if (!guild) {
529
- await command.editReply('This command can only be used in a guild')
67
+ switch (interaction.commandName) {
68
+ case 'session':
69
+ await handleSessionCommand({ command: interaction, appId })
530
70
  return
531
- }
532
-
533
- try {
534
- const currentDir = process.cwd()
535
- const getClient = await initializeOpencodeForDirectory(currentDir)
536
-
537
- const projectsResponse = await getClient().project.list({})
538
- if (!projectsResponse.data) {
539
- await command.editReply('Failed to fetch projects')
540
- return
541
- }
542
-
543
- const project = projectsResponse.data.find(
544
- (p) => p.id === projectId,
545
- )
546
-
547
- if (!project) {
548
- await command.editReply('Project not found')
549
- return
550
- }
551
-
552
- const directory = project.worktree
553
71
 
554
- if (!fs.existsSync(directory)) {
555
- await command.editReply(`Directory does not exist: ${directory}`)
556
- return
557
- }
558
-
559
- const db = getDatabase()
560
- const existingChannel = db
561
- .prepare(
562
- 'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
563
- )
564
- .get(directory, 'text') as { channel_id: string } | undefined
565
-
566
- if (existingChannel) {
567
- await command.editReply(
568
- `A channel already exists for this directory: <#${existingChannel.channel_id}>`,
569
- )
570
- return
571
- }
572
-
573
- const { textChannelId, voiceChannelId, channelName } =
574
- await createProjectChannels({
575
- guild,
576
- projectDirectory: directory,
577
- appId,
578
- })
579
-
580
- await command.editReply(
581
- `✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``,
582
- )
583
-
584
- discordLogger.log(
585
- `Created channels for project ${channelName} at ${directory}`,
586
- )
587
- } catch (error) {
588
- interactionLogger.error('[ADD-PROJECT] Error:', error)
589
- await command.editReply(
590
- `Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
591
- )
592
- }
593
- } else if (command.commandName === 'create-new-project') {
594
- await command.deferReply({ ephemeral: false })
595
-
596
- const projectName = command.options.getString('name', true)
597
- const guild = command.guild
598
- const channel = command.channel
599
-
600
- if (!guild) {
601
- await command.editReply('This command can only be used in a guild')
72
+ case 'resume':
73
+ await handleResumeCommand({ command: interaction, appId })
602
74
  return
603
- }
604
75
 
605
- if (!channel || channel.type !== ChannelType.GuildText) {
606
- await command.editReply('This command can only be used in a text channel')
76
+ case 'add-project':
77
+ await handleAddProjectCommand({ command: interaction, appId })
607
78
  return
608
- }
609
79
 
610
- const sanitizedName = projectName
611
- .toLowerCase()
612
- .replace(/[^a-z0-9-]/g, '-')
613
- .replace(/-+/g, '-')
614
- .replace(/^-|-$/g, '')
615
- .slice(0, 100)
616
-
617
- if (!sanitizedName) {
618
- await command.editReply('Invalid project name')
80
+ case 'create-new-project':
81
+ await handleCreateNewProjectCommand({ command: interaction, appId })
619
82
  return
620
- }
621
-
622
- const kimakiDir = path.join(os.homedir(), 'kimaki')
623
- const projectDirectory = path.join(kimakiDir, sanitizedName)
624
-
625
- try {
626
- if (!fs.existsSync(kimakiDir)) {
627
- fs.mkdirSync(kimakiDir, { recursive: true })
628
- discordLogger.log(`Created kimaki directory: ${kimakiDir}`)
629
- }
630
-
631
- if (fs.existsSync(projectDirectory)) {
632
- await command.editReply(`Project directory already exists: ${projectDirectory}`)
633
- return
634
- }
635
-
636
- fs.mkdirSync(projectDirectory, { recursive: true })
637
- discordLogger.log(`Created project directory: ${projectDirectory}`)
638
-
639
- const { execSync } = await import('node:child_process')
640
- execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
641
- discordLogger.log(`Initialized git in: ${projectDirectory}`)
642
-
643
- const { textChannelId, voiceChannelId, channelName } =
644
- await createProjectChannels({
645
- guild,
646
- projectDirectory,
647
- appId,
648
- })
649
-
650
- const textChannel = await guild.channels.fetch(textChannelId) as TextChannel
651
-
652
- await command.editReply(
653
- `✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`,
654
- )
655
-
656
- const starterMessage = await textChannel.send({
657
- content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
658
- flags: SILENT_MESSAGE_FLAGS,
659
- })
660
-
661
- const thread = await starterMessage.startThread({
662
- name: `Init: ${sanitizedName}`,
663
- autoArchiveDuration: 1440,
664
- reason: 'New project session',
665
- })
666
83
 
667
- await handleOpencodeSession({
668
- prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
669
- thread,
670
- projectDirectory,
671
- channelId: textChannel.id,
672
- })
673
-
674
- discordLogger.log(
675
- `Created new project ${channelName} at ${projectDirectory}`,
676
- )
677
- } catch (error) {
678
- interactionLogger.error('[ADD-NEW-PROJECT] Error:', error)
679
- await command.editReply(
680
- `Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`,
681
- )
682
- }
683
- } else if (
684
- command.commandName === 'accept' ||
685
- command.commandName === 'accept-always'
686
- ) {
687
- const scope = command.commandName === 'accept-always' ? 'always' : 'once'
688
- const channel = command.channel
689
-
690
- if (!channel) {
691
- await command.reply({
692
- content: 'This command can only be used in a channel',
693
- ephemeral: true,
694
- flags: SILENT_MESSAGE_FLAGS,
695
- })
84
+ case 'accept':
85
+ case 'accept-always':
86
+ await handleAcceptCommand({ command: interaction, appId })
696
87
  return
697
- }
698
-
699
- const isThread = [
700
- ChannelType.PublicThread,
701
- ChannelType.PrivateThread,
702
- ChannelType.AnnouncementThread,
703
- ].includes(channel.type)
704
88
 
705
- if (!isThread) {
706
- await command.reply({
707
- content: 'This command can only be used in a thread with an active session',
708
- ephemeral: true,
709
- flags: SILENT_MESSAGE_FLAGS,
710
- })
89
+ case 'reject':
90
+ await handleRejectCommand({ command: interaction, appId })
711
91
  return
712
- }
713
92
 
714
- const pending = pendingPermissions.get(channel.id)
715
- if (!pending) {
716
- await command.reply({
717
- content: 'No pending permission request in this thread',
718
- ephemeral: true,
719
- flags: SILENT_MESSAGE_FLAGS,
720
- })
93
+ case 'abort':
94
+ await handleAbortCommand({ command: interaction, appId })
721
95
  return
722
- }
723
-
724
- try {
725
- const getClient = await initializeOpencodeForDirectory(pending.directory)
726
- await getClient().postSessionIdPermissionsPermissionId({
727
- path: {
728
- id: pending.permission.sessionID,
729
- permissionID: pending.permission.id,
730
- },
731
- body: {
732
- response: scope,
733
- },
734
- })
735
-
736
- pendingPermissions.delete(channel.id)
737
- const msg =
738
- scope === 'always'
739
- ? `✅ Permission **accepted** (auto-approve similar requests)`
740
- : `✅ Permission **accepted**`
741
- await command.reply({ content: msg, flags: SILENT_MESSAGE_FLAGS })
742
- discordLogger.log(
743
- `Permission ${pending.permission.id} accepted with scope: ${scope}`,
744
- )
745
- } catch (error) {
746
- interactionLogger.error('[ACCEPT] Error:', error)
747
- await command.reply({
748
- content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
749
- ephemeral: true,
750
- flags: SILENT_MESSAGE_FLAGS,
751
- })
752
- }
753
- } else if (command.commandName === 'reject') {
754
- const channel = command.channel
755
96
 
756
- if (!channel) {
757
- await command.reply({
758
- content: 'This command can only be used in a channel',
759
- ephemeral: true,
760
- flags: SILENT_MESSAGE_FLAGS,
761
- })
97
+ case 'share':
98
+ await handleShareCommand({ command: interaction, appId })
762
99
  return
763
- }
764
100
 
765
- const isThread = [
766
- ChannelType.PublicThread,
767
- ChannelType.PrivateThread,
768
- ChannelType.AnnouncementThread,
769
- ].includes(channel.type)
770
-
771
- if (!isThread) {
772
- await command.reply({
773
- content: 'This command can only be used in a thread with an active session',
774
- ephemeral: true,
775
- flags: SILENT_MESSAGE_FLAGS,
776
- })
777
- return
778
- }
779
-
780
- const pending = pendingPermissions.get(channel.id)
781
- if (!pending) {
782
- await command.reply({
783
- content: 'No pending permission request in this thread',
784
- ephemeral: true,
785
- flags: SILENT_MESSAGE_FLAGS,
786
- })
101
+ case 'fork':
102
+ await handleForkCommand(interaction)
787
103
  return
788
- }
789
-
790
- try {
791
- const getClient = await initializeOpencodeForDirectory(pending.directory)
792
- await getClient().postSessionIdPermissionsPermissionId({
793
- path: {
794
- id: pending.permission.sessionID,
795
- permissionID: pending.permission.id,
796
- },
797
- body: {
798
- response: 'reject',
799
- },
800
- })
801
104
 
802
- pendingPermissions.delete(channel.id)
803
- await command.reply({ content: `❌ Permission **rejected**`, flags: SILENT_MESSAGE_FLAGS })
804
- discordLogger.log(`Permission ${pending.permission.id} rejected`)
805
- } catch (error) {
806
- interactionLogger.error('[REJECT] Error:', error)
807
- await command.reply({
808
- content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
809
- ephemeral: true,
810
- flags: SILENT_MESSAGE_FLAGS,
811
- })
812
- }
813
- } else if (command.commandName === 'abort') {
814
- const channel = command.channel
815
-
816
- if (!channel) {
817
- await command.reply({
818
- content: 'This command can only be used in a channel',
819
- ephemeral: true,
820
- flags: SILENT_MESSAGE_FLAGS,
821
- })
105
+ case 'model':
106
+ await handleModelCommand({ interaction, appId })
822
107
  return
823
- }
824
-
825
- const isThread = [
826
- ChannelType.PublicThread,
827
- ChannelType.PrivateThread,
828
- ChannelType.AnnouncementThread,
829
- ].includes(channel.type)
830
108
 
831
- if (!isThread) {
832
- await command.reply({
833
- content: 'This command can only be used in a thread with an active session',
834
- ephemeral: true,
835
- flags: SILENT_MESSAGE_FLAGS,
836
- })
109
+ case 'queue':
110
+ await handleQueueCommand({ command: interaction, appId })
837
111
  return
838
- }
839
-
840
- const textChannel = await resolveTextChannel(channel as ThreadChannel)
841
- const { projectDirectory: directory } = getKimakiMetadata(textChannel)
842
112
 
843
- if (!directory) {
844
- await command.reply({
845
- content: 'Could not determine project directory for this channel',
846
- ephemeral: true,
847
- flags: SILENT_MESSAGE_FLAGS,
848
- })
113
+ case 'clear-queue':
114
+ await handleClearQueueCommand({ command: interaction, appId })
849
115
  return
850
- }
851
116
 
852
- const row = getDatabase()
853
- .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
854
- .get(channel.id) as { session_id: string } | undefined
855
-
856
- if (!row?.session_id) {
857
- await command.reply({
858
- content: 'No active session in this thread',
859
- ephemeral: true,
860
- flags: SILENT_MESSAGE_FLAGS,
861
- })
117
+ case 'undo':
118
+ await handleUndoCommand({ command: interaction, appId })
862
119
  return
863
- }
864
-
865
- const sessionId = row.session_id
866
120
 
867
- try {
868
- const existingController = abortControllers.get(sessionId)
869
- if (existingController) {
870
- existingController.abort(new Error('User requested abort'))
871
- abortControllers.delete(sessionId)
872
- }
873
-
874
- const getClient = await initializeOpencodeForDirectory(directory)
875
- await getClient().session.abort({
876
- path: { id: sessionId },
877
- })
878
-
879
- await command.reply({ content: `🛑 Request **aborted**`, flags: SILENT_MESSAGE_FLAGS })
880
- discordLogger.log(`Session ${sessionId} aborted by user`)
881
- } catch (error) {
882
- interactionLogger.error('[ABORT] Error:', error)
883
- await command.reply({
884
- content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
885
- ephemeral: true,
886
- flags: SILENT_MESSAGE_FLAGS,
887
- })
888
- }
889
- } else if (command.commandName === 'share') {
890
- const channel = command.channel
891
-
892
- if (!channel) {
893
- await command.reply({
894
- content: 'This command can only be used in a channel',
895
- ephemeral: true,
896
- flags: SILENT_MESSAGE_FLAGS,
897
- })
121
+ case 'redo':
122
+ await handleRedoCommand({ command: interaction, appId })
898
123
  return
899
- }
900
-
901
- const isThread = [
902
- ChannelType.PublicThread,
903
- ChannelType.PrivateThread,
904
- ChannelType.AnnouncementThread,
905
- ].includes(channel.type)
906
-
907
- if (!isThread) {
908
- await command.reply({
909
- content: 'This command can only be used in a thread with an active session',
910
- ephemeral: true,
911
- flags: SILENT_MESSAGE_FLAGS,
912
- })
913
- return
914
- }
915
-
916
- const textChannel = await resolveTextChannel(channel as ThreadChannel)
917
- const { projectDirectory: directory } = getKimakiMetadata(textChannel)
918
-
919
- if (!directory) {
920
- await command.reply({
921
- content: 'Could not determine project directory for this channel',
922
- ephemeral: true,
923
- flags: SILENT_MESSAGE_FLAGS,
924
- })
925
- return
926
- }
927
-
928
- const row = getDatabase()
929
- .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
930
- .get(channel.id) as { session_id: string } | undefined
931
-
932
- if (!row?.session_id) {
933
- await command.reply({
934
- content: 'No active session in this thread',
935
- ephemeral: true,
936
- flags: SILENT_MESSAGE_FLAGS,
937
- })
938
- return
939
- }
940
-
941
- const sessionId = row.session_id
942
-
943
- try {
944
- const getClient = await initializeOpencodeForDirectory(directory)
945
- const response = await getClient().session.share({
946
- path: { id: sessionId },
947
- })
948
-
949
- if (!response.data?.share?.url) {
950
- await command.reply({
951
- content: 'Failed to generate share URL',
952
- ephemeral: true,
953
- flags: SILENT_MESSAGE_FLAGS,
954
- })
955
- return
956
- }
957
-
958
- await command.reply({ content: `🔗 **Session shared:** ${response.data.share.url}`, flags: SILENT_MESSAGE_FLAGS })
959
- discordLogger.log(`Session ${sessionId} shared: ${response.data.share.url}`)
960
- } catch (error) {
961
- interactionLogger.error('[SHARE] Error:', error)
962
- await command.reply({
963
- content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
964
- ephemeral: true,
965
- flags: SILENT_MESSAGE_FLAGS,
966
- })
967
- }
968
- } else if (command.commandName === 'fork') {
969
- await handleForkCommand(command)
970
- } else if (command.commandName === 'model') {
971
- await handleModelCommand({ interaction: command, appId })
972
124
  }
125
+ return
973
126
  }
974
127
 
975
128
  if (interaction.isStringSelectMenu()) {
976
- if (interaction.customId.startsWith('fork_select:')) {
129
+ const customId = interaction.customId
130
+
131
+ if (customId.startsWith('fork_select:')) {
977
132
  await handleForkSelectMenu(interaction)
978
- } else if (interaction.customId.startsWith('model_provider:')) {
133
+ return
134
+ }
135
+
136
+ if (customId.startsWith('model_provider:')) {
979
137
  await handleProviderSelectMenu(interaction)
980
- } else if (interaction.customId.startsWith('model_select:')) {
138
+ return
139
+ }
140
+
141
+ if (customId.startsWith('model_select:')) {
981
142
  await handleModelSelectMenu(interaction)
143
+ return
982
144
  }
145
+ return
983
146
  }
984
147
  } catch (error) {
985
148
  interactionLogger.error('[INTERACTION] Error handling interaction:', error)
986
- // Try to respond to the interaction if possible
987
149
  try {
988
150
  if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) {
989
151
  await interaction.reply({