kimaki 0.4.34 → 0.4.36

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 (79) hide show
  1. package/dist/ai-tool-to-genai.js +1 -3
  2. package/dist/channel-management.js +1 -1
  3. package/dist/cli.js +142 -39
  4. package/dist/commands/abort.js +1 -1
  5. package/dist/commands/add-project.js +1 -1
  6. package/dist/commands/agent.js +6 -2
  7. package/dist/commands/ask-question.js +2 -1
  8. package/dist/commands/fork.js +7 -7
  9. package/dist/commands/queue.js +2 -2
  10. package/dist/commands/remove-project.js +109 -0
  11. package/dist/commands/resume.js +3 -5
  12. package/dist/commands/session.js +56 -1
  13. package/dist/commands/share.js +1 -1
  14. package/dist/commands/undo-redo.js +2 -2
  15. package/dist/commands/user-command.js +3 -6
  16. package/dist/config.js +1 -1
  17. package/dist/discord-bot.js +4 -10
  18. package/dist/discord-utils.js +33 -9
  19. package/dist/genai.js +4 -6
  20. package/dist/interaction-handler.js +8 -1
  21. package/dist/markdown.js +1 -3
  22. package/dist/message-formatting.js +7 -3
  23. package/dist/openai-realtime.js +3 -5
  24. package/dist/opencode.js +2 -3
  25. package/dist/session-handler.js +42 -25
  26. package/dist/system-message.js +5 -3
  27. package/dist/tools.js +9 -22
  28. package/dist/unnest-code-blocks.js +4 -2
  29. package/dist/unnest-code-blocks.test.js +40 -15
  30. package/dist/voice-handler.js +9 -12
  31. package/dist/voice.js +5 -3
  32. package/dist/xml.js +2 -4
  33. package/package.json +3 -2
  34. package/src/__snapshots__/compact-session-context-no-system.md +24 -24
  35. package/src/__snapshots__/compact-session-context.md +31 -31
  36. package/src/ai-tool-to-genai.ts +3 -11
  37. package/src/channel-management.ts +14 -25
  38. package/src/cli.ts +290 -195
  39. package/src/commands/abort.ts +1 -3
  40. package/src/commands/add-project.ts +8 -14
  41. package/src/commands/agent.ts +16 -9
  42. package/src/commands/ask-question.ts +8 -7
  43. package/src/commands/create-new-project.ts +8 -14
  44. package/src/commands/fork.ts +23 -27
  45. package/src/commands/model.ts +14 -11
  46. package/src/commands/permissions.ts +1 -1
  47. package/src/commands/queue.ts +6 -19
  48. package/src/commands/remove-project.ts +136 -0
  49. package/src/commands/resume.ts +11 -30
  50. package/src/commands/session.ts +68 -9
  51. package/src/commands/share.ts +1 -3
  52. package/src/commands/types.ts +1 -3
  53. package/src/commands/undo-redo.ts +6 -18
  54. package/src/commands/user-command.ts +8 -10
  55. package/src/config.ts +5 -5
  56. package/src/database.ts +10 -8
  57. package/src/discord-bot.ts +22 -46
  58. package/src/discord-utils.ts +35 -18
  59. package/src/escape-backticks.test.ts +0 -2
  60. package/src/format-tables.ts +1 -4
  61. package/src/genai-worker-wrapper.ts +3 -9
  62. package/src/genai-worker.ts +4 -19
  63. package/src/genai.ts +10 -42
  64. package/src/interaction-handler.ts +133 -121
  65. package/src/markdown.test.ts +10 -32
  66. package/src/markdown.ts +6 -14
  67. package/src/message-formatting.ts +13 -14
  68. package/src/openai-realtime.ts +25 -47
  69. package/src/opencode.ts +26 -37
  70. package/src/session-handler.ts +111 -75
  71. package/src/system-message.ts +13 -3
  72. package/src/tools.ts +13 -39
  73. package/src/unnest-code-blocks.test.ts +42 -15
  74. package/src/unnest-code-blocks.ts +4 -2
  75. package/src/utils.ts +1 -4
  76. package/src/voice-handler.ts +34 -78
  77. package/src/voice.ts +11 -19
  78. package/src/xml.test.ts +1 -1
  79. package/src/xml.ts +3 -12
@@ -10,9 +10,7 @@ import { createLogger } from '../logger.js'
10
10
 
11
11
  const logger = createLogger('ABORT')
12
12
 
13
- export async function handleAbortCommand({
14
- command,
15
- }: CommandContext): Promise<void> {
13
+ export async function handleAbortCommand({ command }: CommandContext): Promise<void> {
16
14
  const channel = command.channel
17
15
 
18
16
  if (!channel) {
@@ -11,10 +11,7 @@ import { abbreviatePath } from '../utils.js'
11
11
 
12
12
  const logger = createLogger('ADD-PROJECT')
13
13
 
14
- export async function handleAddProjectCommand({
15
- command,
16
- appId,
17
- }: CommandContext): Promise<void> {
14
+ export async function handleAddProjectCommand({ command, appId }: CommandContext): Promise<void> {
18
15
  await command.deferReply({ ephemeral: false })
19
16
 
20
17
  const projectId = command.options.getString('project', true)
@@ -63,13 +60,12 @@ export async function handleAddProjectCommand({
63
60
  return
64
61
  }
65
62
 
66
- const { textChannelId, voiceChannelId, channelName } =
67
- await createProjectChannels({
68
- guild,
69
- projectDirectory: directory,
70
- appId,
71
- botName: command.client.user?.username,
72
- })
63
+ const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
64
+ guild,
65
+ projectDirectory: directory,
66
+ appId,
67
+ botName: command.client.user?.username,
68
+ })
73
69
 
74
70
  await command.editReply(
75
71
  `✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``,
@@ -102,9 +98,7 @@ export async function handleAddProjectAutocomplete({
102
98
 
103
99
  const db = getDatabase()
104
100
  const existingDirs = db
105
- .prepare(
106
- 'SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?',
107
- )
101
+ .prepare('SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?')
108
102
  .all('text') as { directory: string }[]
109
103
  const existingDirSet = new Set(existingDirs.map((row) => row.directory))
110
104
 
@@ -17,12 +17,15 @@ import { createLogger } from '../logger.js'
17
17
 
18
18
  const agentLogger = createLogger('AGENT')
19
19
 
20
- const pendingAgentContexts = new Map<string, {
21
- dir: string
22
- channelId: string
23
- sessionId?: string
24
- isThread: boolean
25
- }>()
20
+ const pendingAgentContexts = new Map<
21
+ string,
22
+ {
23
+ dir: string
24
+ channelId: string
25
+ sessionId?: string
26
+ isThread: boolean
27
+ }
28
+ >()
26
29
 
27
30
  export async function handleAgentCommand({
28
31
  interaction,
@@ -72,7 +75,9 @@ export async function handleAgentCommand({
72
75
  channelAppId = metadata.channelAppId
73
76
  targetChannelId = channel.id
74
77
  } else {
75
- await interaction.editReply({ content: 'This command can only be used in text channels or threads' })
78
+ await interaction.editReply({
79
+ content: 'This command can only be used in text channels or threads',
80
+ })
76
81
  return
77
82
  }
78
83
 
@@ -82,7 +87,9 @@ export async function handleAgentCommand({
82
87
  }
83
88
 
84
89
  if (!projectDirectory) {
85
- await interaction.editReply({ content: 'This channel is not configured with a project directory' })
90
+ await interaction.editReply({
91
+ content: 'This channel is not configured with a project directory',
92
+ })
86
93
  return
87
94
  }
88
95
 
@@ -141,7 +148,7 @@ export async function handleAgentCommand({
141
148
  }
142
149
 
143
150
  export async function handleAgentSelectMenu(
144
- interaction: StringSelectMenuInteraction
151
+ interaction: StringSelectMenuInteraction,
145
152
  ): Promise<void> {
146
153
  const customId = interaction.customId
147
154
 
@@ -95,9 +95,10 @@ export async function showAskUserQuestionDropdowns({
95
95
  },
96
96
  ]
97
97
 
98
+ const placeholder = options.find((x) => x.label)?.label || 'Select an option'
98
99
  const selectMenu = new StringSelectMenuBuilder()
99
100
  .setCustomId(`ask_question:${contextHash}:${i}`)
100
- .setPlaceholder(`Select an option`)
101
+ .setPlaceholder(placeholder)
101
102
  .addOptions(options)
102
103
 
103
104
  // Enable multi-select if the question supports it
@@ -122,7 +123,7 @@ export async function showAskUserQuestionDropdowns({
122
123
  * Handle dropdown selection for AskUserQuestion.
123
124
  */
124
125
  export async function handleAskQuestionSelectMenu(
125
- interaction: StringSelectMenuInteraction
126
+ interaction: StringSelectMenuInteraction,
126
127
  ): Promise<void> {
127
128
  const customId = interaction.customId
128
129
 
@@ -196,9 +197,7 @@ export async function handleAskQuestionSelectMenu(
196
197
  * Submit all collected answers back to the OpenCode session.
197
198
  * Uses the question.reply API to provide answers to the waiting tool.
198
199
  */
199
- async function submitQuestionAnswers(
200
- context: PendingQuestionContext
201
- ): Promise<void> {
200
+ async function submitQuestionAnswers(context: PendingQuestionContext): Promise<void> {
202
201
  try {
203
202
  const clientV2 = getOpencodeClientV2(context.directory)
204
203
  if (!clientV2) {
@@ -215,12 +214,14 @@ async function submitQuestionAnswers(
215
214
  answers,
216
215
  })
217
216
 
218
- logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`)
217
+ logger.log(
218
+ `Submitted answers for question ${context.requestId} in session ${context.sessionId}`,
219
+ )
219
220
  } catch (error) {
220
221
  logger.error('Failed to submit answers:', error)
221
222
  await sendThreadMessage(
222
223
  context.thread,
223
- `✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`
224
+ `✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`,
224
225
  )
225
226
  }
226
227
  }
@@ -54,9 +54,7 @@ export async function handleCreateNewProjectCommand({
54
54
  }
55
55
 
56
56
  if (fs.existsSync(projectDirectory)) {
57
- await command.editReply(
58
- `Project directory already exists: ${projectDirectory}`,
59
- )
57
+ await command.editReply(`Project directory already exists: ${projectDirectory}`)
60
58
  return
61
59
  }
62
60
 
@@ -67,16 +65,13 @@ export async function handleCreateNewProjectCommand({
67
65
  execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
68
66
  logger.log(`Initialized git in: ${projectDirectory}`)
69
67
 
70
- const { textChannelId, voiceChannelId, channelName } =
71
- await createProjectChannels({
72
- guild,
73
- projectDirectory,
74
- appId,
75
- })
68
+ const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
69
+ guild,
70
+ projectDirectory,
71
+ appId,
72
+ })
76
73
 
77
- const textChannel = (await guild.channels.fetch(
78
- textChannelId,
79
- )) as TextChannel
74
+ const textChannel = (await guild.channels.fetch(textChannelId)) as TextChannel
80
75
 
81
76
  await command.editReply(
82
77
  `✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`,
@@ -94,8 +89,7 @@ export async function handleCreateNewProjectCommand({
94
89
  })
95
90
 
96
91
  await handleOpencodeSession({
97
- prompt:
98
- 'The project was just initialized. Say hi and ask what the user wants to build.',
92
+ prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
99
93
  thread,
100
94
  projectDirectory,
101
95
  channelId: textChannel.id,
@@ -85,9 +85,7 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
85
85
  return
86
86
  }
87
87
 
88
- const userMessages = messagesResponse.data.filter(
89
- (m) => m.info.role === 'user'
90
- )
88
+ const userMessages = messagesResponse.data.filter((m) => m.info.role === 'user')
91
89
 
92
90
  if (userMessages.length === 0) {
93
91
  await interaction.editReply({
@@ -99,7 +97,9 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
99
97
  const recentMessages = userMessages.slice(-25)
100
98
 
101
99
  const options = recentMessages.map((m, index) => {
102
- const textPart = m.parts.find((p) => p.type === 'text') as { type: 'text'; text: string } | undefined
100
+ const textPart = m.parts.find((p) => p.type === 'text') as
101
+ | { type: 'text'; text: string }
102
+ | undefined
103
103
  const preview = textPart?.text?.slice(0, 80) || '(no text)'
104
104
  const label = `${index + 1}. ${preview}${preview.length >= 80 ? '...' : ''}`
105
105
 
@@ -117,11 +117,11 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
117
117
  .setPlaceholder('Select a message to fork from')
118
118
  .addOptions(options)
119
119
 
120
- const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>()
121
- .addComponents(selectMenu)
120
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
122
121
 
123
122
  await interaction.editReply({
124
- content: '**Fork Session**\nSelect the user message to fork from. The forked session will continue as if you had not sent that message:',
123
+ content:
124
+ '**Fork Session**\nSelect the user message to fork from. The forked session will continue as if you had not sent that message:',
125
125
  components: [actionRow],
126
126
  })
127
127
  } catch (error) {
@@ -132,7 +132,9 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
132
132
  }
133
133
  }
134
134
 
135
- export async function handleForkSelectMenu(interaction: StringSelectMenuInteraction): Promise<void> {
135
+ export async function handleForkSelectMenu(
136
+ interaction: StringSelectMenuInteraction,
137
+ ): Promise<void> {
136
138
  const customId = interaction.customId
137
139
 
138
140
  if (!customId.startsWith('fork_select:')) {
@@ -177,11 +179,14 @@ export async function handleForkSelectMenu(interaction: StringSelectMenuInteract
177
179
  const forkedSession = forkResponse.data
178
180
  const parentChannel = interaction.channel
179
181
 
180
- if (!parentChannel || ![
181
- ChannelType.PublicThread,
182
- ChannelType.PrivateThread,
183
- ChannelType.AnnouncementThread,
184
- ].includes(parentChannel.type)) {
182
+ if (
183
+ !parentChannel ||
184
+ ![
185
+ ChannelType.PublicThread,
186
+ ChannelType.PrivateThread,
187
+ ChannelType.AnnouncementThread,
188
+ ].includes(parentChannel.type)
189
+ ) {
185
190
  await interaction.editReply('Could not access parent channel')
186
191
  return
187
192
  }
@@ -200,14 +205,10 @@ export async function handleForkSelectMenu(interaction: StringSelectMenuInteract
200
205
  })
201
206
 
202
207
  getDatabase()
203
- .prepare(
204
- 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)'
205
- )
208
+ .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
206
209
  .run(thread.id, forkedSession.id)
207
210
 
208
- sessionLogger.log(
209
- `Created forked session ${forkedSession.id} in thread ${thread.id}`
210
- )
211
+ sessionLogger.log(`Created forked session ${forkedSession.id} in thread ${thread.id}`)
211
212
 
212
213
  await sendThreadMessage(
213
214
  thread,
@@ -240,18 +241,13 @@ export async function handleForkSelectMenu(interaction: StringSelectMenuInteract
240
241
  }
241
242
  }
242
243
 
243
- await sendThreadMessage(
244
- thread,
245
- `You can now continue the conversation from this point.`,
246
- )
244
+ await sendThreadMessage(thread, `You can now continue the conversation from this point.`)
247
245
 
248
- await interaction.editReply(
249
- `Session forked! Continue in ${thread.toString()}`
250
- )
246
+ await interaction.editReply(`Session forked! Continue in ${thread.toString()}`)
251
247
  } catch (error) {
252
248
  forkLogger.error('Error forking session:', error)
253
249
  await interaction.editReply(
254
- `Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`
250
+ `Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`,
255
251
  )
256
252
  }
257
253
  }
@@ -19,15 +19,18 @@ import { createLogger } from '../logger.js'
19
19
  const modelLogger = createLogger('MODEL')
20
20
 
21
21
  // Store context by hash to avoid customId length limits (Discord max: 100 chars)
22
- const pendingModelContexts = new Map<string, {
23
- dir: string
24
- channelId: string
25
- sessionId?: string
26
- isThread: boolean
27
- providerId?: string
28
- providerName?: string
29
- thread?: ThreadChannel
30
- }>()
22
+ const pendingModelContexts = new Map<
23
+ string,
24
+ {
25
+ dir: string
26
+ channelId: string
27
+ sessionId?: string
28
+ isThread: boolean
29
+ providerId?: string
30
+ providerName?: string
31
+ thread?: ThreadChannel
32
+ }
33
+ >()
31
34
 
32
35
  export type ProviderInfo = {
33
36
  id: string
@@ -196,7 +199,7 @@ export async function handleModelCommand({
196
199
  * Shows a second select menu with models for the chosen provider.
197
200
  */
198
201
  export async function handleProviderSelectMenu(
199
- interaction: StringSelectMenuInteraction
202
+ interaction: StringSelectMenuInteraction,
200
203
  ): Promise<void> {
201
204
  const customId = interaction.customId
202
205
 
@@ -317,7 +320,7 @@ export async function handleProviderSelectMenu(
317
320
  * Stores the model preference in the database.
318
321
  */
319
322
  export async function handleModelSelectMenu(
320
- interaction: StringSelectMenuInteraction
323
+ interaction: StringSelectMenuInteraction,
321
324
  ): Promise<void> {
322
325
  const customId = interaction.customId
323
326
 
@@ -96,7 +96,7 @@ export async function showPermissionDropdown({
96
96
  * Handle dropdown selection for permission.
97
97
  */
98
98
  export async function handlePermissionSelectMenu(
99
- interaction: StringSelectMenuInteraction
99
+ interaction: StringSelectMenuInteraction,
100
100
  ): Promise<void> {
101
101
  const customId = interaction.customId
102
102
 
@@ -20,9 +20,7 @@ import { createLogger } from '../logger.js'
20
20
 
21
21
  const logger = createLogger('QUEUE')
22
22
 
23
- export async function handleQueueCommand({
24
- command,
25
- }: CommandContext): Promise<void> {
23
+ export async function handleQueueCommand({ command }: CommandContext): Promise<void> {
26
24
  const message = command.options.getString('message', true)
27
25
  const channel = command.channel
28
26
 
@@ -85,9 +83,7 @@ export async function handleQueueCommand({
85
83
  flags: SILENT_MESSAGE_FLAGS,
86
84
  })
87
85
 
88
- logger.log(
89
- `[QUEUE] No active request, sending immediately in thread ${channel.id}`,
90
- )
86
+ logger.log(`[QUEUE] No active request, sending immediately in thread ${channel.id}`)
91
87
 
92
88
  handleOpencodeSession({
93
89
  prompt: message,
@@ -97,10 +93,7 @@ export async function handleQueueCommand({
97
93
  }).catch(async (e) => {
98
94
  logger.error(`[QUEUE] Failed to send message:`, e)
99
95
  const errorMsg = e instanceof Error ? e.message : String(e)
100
- await sendThreadMessage(
101
- channel as ThreadChannel,
102
- `✗ Failed: ${errorMsg.slice(0, 200)}`,
103
- )
96
+ await sendThreadMessage(channel as ThreadChannel, `✗ Failed: ${errorMsg.slice(0, 200)}`)
104
97
  })
105
98
 
106
99
  return
@@ -123,14 +116,10 @@ export async function handleQueueCommand({
123
116
  flags: SILENT_MESSAGE_FLAGS,
124
117
  })
125
118
 
126
- logger.log(
127
- `[QUEUE] User ${command.user.displayName} queued message in thread ${channel.id}`,
128
- )
119
+ logger.log(`[QUEUE] User ${command.user.displayName} queued message in thread ${channel.id}`)
129
120
  }
130
121
 
131
- export async function handleClearQueueCommand({
132
- command,
133
- }: CommandContext): Promise<void> {
122
+ export async function handleClearQueueCommand({ command }: CommandContext): Promise<void> {
134
123
  const channel = command.channel
135
124
 
136
125
  if (!channel) {
@@ -175,7 +164,5 @@ export async function handleClearQueueCommand({
175
164
  flags: SILENT_MESSAGE_FLAGS,
176
165
  })
177
166
 
178
- logger.log(
179
- `[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`,
180
- )
167
+ logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`)
181
168
  }
@@ -0,0 +1,136 @@
1
+ // /remove-project command - Remove Discord channels for a project.
2
+
3
+ import path from 'node:path'
4
+ import type { CommandContext, AutocompleteContext } from './types.js'
5
+ import { getDatabase } from '../database.js'
6
+ import { createLogger } from '../logger.js'
7
+ import { abbreviatePath } from '../utils.js'
8
+
9
+ const logger = createLogger('REMOVE-PROJECT')
10
+
11
+ export async function handleRemoveProjectCommand({ command, appId }: CommandContext): Promise<void> {
12
+ await command.deferReply({ ephemeral: false })
13
+
14
+ const directory = command.options.getString('project', true)
15
+ const guild = command.guild
16
+
17
+ if (!guild) {
18
+ await command.editReply('This command can only be used in a guild')
19
+ return
20
+ }
21
+
22
+ try {
23
+ const db = getDatabase()
24
+
25
+ // Get channel IDs for this directory
26
+ const channels = db
27
+ .prepare('SELECT channel_id, channel_type FROM channel_directories WHERE directory = ?')
28
+ .all(directory) as { channel_id: string; channel_type: string }[]
29
+
30
+ if (channels.length === 0) {
31
+ await command.editReply(`No channels found for directory: \`${directory}\``)
32
+ return
33
+ }
34
+
35
+ const deletedChannels: string[] = []
36
+ const failedChannels: string[] = []
37
+
38
+ for (const { channel_id, channel_type } of channels) {
39
+ try {
40
+ const channel = await guild.channels.fetch(channel_id).catch(() => null)
41
+
42
+ if (channel) {
43
+ await channel.delete(`Removed by /remove-project command`)
44
+ deletedChannels.push(`${channel_type}: ${channel_id}`)
45
+ } else {
46
+ // Channel doesn't exist in this guild or was already deleted
47
+ deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`)
48
+ }
49
+ } catch (error) {
50
+ logger.error(`Failed to delete channel ${channel_id}:`, error)
51
+ failedChannels.push(`${channel_type}: ${channel_id}`)
52
+ }
53
+ }
54
+
55
+ // Remove from database
56
+ db.prepare('DELETE FROM channel_directories WHERE directory = ?').run(directory)
57
+
58
+ const projectName = path.basename(directory)
59
+ let message = `Removed project **${projectName}**\n`
60
+ message += `Directory: \`${directory}\`\n\n`
61
+
62
+ if (deletedChannels.length > 0) {
63
+ message += `Deleted channels:\n${deletedChannels.map((c) => `- ${c}`).join('\n')}`
64
+ }
65
+
66
+ if (failedChannels.length > 0) {
67
+ message += `\n\nFailed to delete (may be in another server):\n${failedChannels.map((c) => `- ${c}`).join('\n')}`
68
+ }
69
+
70
+ await command.editReply(message)
71
+ logger.log(`Removed project ${projectName} at ${directory}`)
72
+ } catch (error) {
73
+ logger.error('[REMOVE-PROJECT] Error:', error)
74
+ await command.editReply(
75
+ `Failed to remove project: ${error instanceof Error ? error.message : 'Unknown error'}`,
76
+ )
77
+ }
78
+ }
79
+
80
+ export async function handleRemoveProjectAutocomplete({
81
+ interaction,
82
+ appId,
83
+ }: AutocompleteContext): Promise<void> {
84
+ const focusedValue = interaction.options.getFocused()
85
+ const guild = interaction.guild
86
+
87
+ if (!guild) {
88
+ await interaction.respond([])
89
+ return
90
+ }
91
+
92
+ try {
93
+ const db = getDatabase()
94
+
95
+ // Get all directories with channels
96
+ const allChannels = db
97
+ .prepare(
98
+ 'SELECT DISTINCT directory, channel_id FROM channel_directories WHERE channel_type = ?',
99
+ )
100
+ .all('text') as { directory: string; channel_id: string }[]
101
+
102
+ // Filter to only channels that exist in this guild
103
+ const projectsInGuild: { directory: string; channelId: string }[] = []
104
+
105
+ for (const { directory, channel_id } of allChannels) {
106
+ try {
107
+ const channel = await guild.channels.fetch(channel_id).catch(() => null)
108
+ if (channel) {
109
+ projectsInGuild.push({ directory, channelId: channel_id })
110
+ }
111
+ } catch {
112
+ // Channel not in this guild, skip
113
+ }
114
+ }
115
+
116
+ const projects = projectsInGuild
117
+ .filter(({ directory }) => {
118
+ const baseName = path.basename(directory)
119
+ const searchText = `${baseName} ${directory}`.toLowerCase()
120
+ return searchText.includes(focusedValue.toLowerCase())
121
+ })
122
+ .slice(0, 25)
123
+ .map(({ directory }) => {
124
+ const name = `${path.basename(directory)} (${abbreviatePath(directory)})`
125
+ return {
126
+ name: name.length > 100 ? name.slice(0, 99) + '...' : name,
127
+ value: directory,
128
+ }
129
+ })
130
+
131
+ await interaction.respond(projects)
132
+ } catch (error) {
133
+ logger.error('[AUTOCOMPLETE] Error fetching projects:', error)
134
+ await interaction.respond([])
135
+ }
136
+ }
@@ -10,21 +10,14 @@ import fs from 'node:fs'
10
10
  import type { CommandContext, AutocompleteContext } from './types.js'
11
11
  import { getDatabase } from '../database.js'
12
12
  import { initializeOpencodeForDirectory } from '../opencode.js'
13
- import {
14
- sendThreadMessage,
15
- resolveTextChannel,
16
- getKimakiMetadata,
17
- } from '../discord-utils.js'
13
+ import { sendThreadMessage, resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
18
14
  import { extractTagsArrays } from '../xml.js'
19
15
  import { collectLastAssistantParts } from '../message-formatting.js'
20
16
  import { createLogger } from '../logger.js'
21
17
 
22
18
  const logger = createLogger('RESUME')
23
19
 
24
- export async function handleResumeCommand({
25
- command,
26
- appId,
27
- }: CommandContext): Promise<void> {
20
+ export async function handleResumeCommand({ command, appId }: CommandContext): Promise<void> {
28
21
  await command.deferReply({ ephemeral: false })
29
22
 
30
23
  const sessionId = command.options.getString('session', true)
@@ -56,9 +49,7 @@ export async function handleResumeCommand({
56
49
  }
57
50
 
58
51
  if (!projectDirectory) {
59
- await command.editReply(
60
- 'This channel is not configured with a project directory',
61
- )
52
+ await command.editReply('This channel is not configured with a project directory')
62
53
  return
63
54
  }
64
55
 
@@ -88,9 +79,7 @@ export async function handleResumeCommand({
88
79
  })
89
80
 
90
81
  getDatabase()
91
- .prepare(
92
- 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
93
- )
82
+ .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
94
83
  .run(thread.id, sessionId)
95
84
 
96
85
  logger.log(`[RESUME] Created thread ${thread.id} for session ${sessionId}`)
@@ -105,9 +94,7 @@ export async function handleResumeCommand({
105
94
 
106
95
  const messages = messagesResponse.data
107
96
 
108
- await command.editReply(
109
- `Resumed session "${sessionTitle}" in ${thread.toString()}`,
110
- )
97
+ await command.editReply(`Resumed session "${sessionTitle}" in ${thread.toString()}`)
111
98
 
112
99
  await sendThreadMessage(
113
100
  thread,
@@ -119,10 +106,7 @@ export async function handleResumeCommand({
119
106
  })
120
107
 
121
108
  if (skippedCount > 0) {
122
- await sendThreadMessage(
123
- thread,
124
- `*Skipped ${skippedCount} older assistant parts...*`,
125
- )
109
+ await sendThreadMessage(thread, `*Skipped ${skippedCount} older assistant parts...*`)
126
110
  }
127
111
 
128
112
  if (content.trim()) {
@@ -168,8 +152,7 @@ export async function handleResumeAutocomplete({
168
152
  interaction.channel as TextChannel | ThreadChannel | null,
169
153
  )
170
154
  if (textChannel) {
171
- const { projectDirectory: directory, channelAppId } =
172
- getKimakiMetadata(textChannel)
155
+ const { projectDirectory: directory, channelAppId } = getKimakiMetadata(textChannel)
173
156
  if (channelAppId && channelAppId !== appId) {
174
157
  await interaction.respond([])
175
158
  return
@@ -194,17 +177,15 @@ export async function handleResumeAutocomplete({
194
177
 
195
178
  const existingSessionIds = new Set(
196
179
  (
197
- getDatabase()
198
- .prepare('SELECT session_id FROM thread_sessions')
199
- .all() as { session_id: string }[]
180
+ getDatabase().prepare('SELECT session_id FROM thread_sessions').all() as {
181
+ session_id: string
182
+ }[]
200
183
  ).map((row) => row.session_id),
201
184
  )
202
185
 
203
186
  const sessions = sessionsResponse.data
204
187
  .filter((session) => !existingSessionIds.has(session.id))
205
- .filter((session) =>
206
- session.title.toLowerCase().includes(focusedValue.toLowerCase()),
207
- )
188
+ .filter((session) => session.title.toLowerCase().includes(focusedValue.toLowerCase()))
208
189
  .slice(0, 25)
209
190
  .map((session) => {
210
191
  const dateStr = new Date(session.time.updated).toLocaleString()