kimaki 0.1.5 → 0.2.0

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.
package/src/discordBot.ts CHANGED
@@ -70,16 +70,30 @@ const opencodeServers = new Map<
70
70
  // Map of session ID to current AbortController
71
71
  const abortControllers = new Map<string, AbortController>()
72
72
 
73
- // Map of guild ID to voice connection and GenAI worker
73
+ // Map of guild ID to voice connection
74
74
  const voiceConnections = new Map<
75
75
  string,
76
76
  {
77
77
  connection: VoiceConnection
78
- genAiWorker?: GenAIWorker
79
78
  userAudioStream?: fs.WriteStream
80
79
  }
81
80
  >()
82
81
 
82
+ // Map of channel ID to GenAI worker and session state
83
+ // This allows sessions to persist across voice channel changes
84
+ const genAiSessions = new Map<
85
+ string,
86
+ {
87
+ genAiWorker: GenAIWorker
88
+ hasActiveSessions: boolean // Track if there are active OpenCode sessions
89
+ pendingCleanup: boolean // Track if cleanup was requested (user left channel)
90
+ cleanupTimer?: NodeJS.Timeout // Timer for delayed cleanup
91
+ guildId: string
92
+ channelId: string
93
+ directory: string
94
+ }
95
+ >()
96
+
83
97
  // Map of directory to retry count for server restarts
84
98
  const serverRetryCount = new Map<string, number>()
85
99
 
@@ -154,11 +168,13 @@ async function setupVoiceHandling({
154
168
  guildId,
155
169
  channelId,
156
170
  appId,
171
+ cleanupGenAiSession,
157
172
  }: {
158
173
  connection: VoiceConnection
159
174
  guildId: string
160
175
  channelId: string
161
176
  appId: string
177
+ cleanupGenAiSession: (channelId: string) => Promise<void>
162
178
  }) {
163
179
  voiceLogger.log(
164
180
  `Setting up voice handling for guild ${guildId}, channel ${channelId}`,
@@ -191,12 +207,36 @@ async function setupVoiceHandling({
191
207
  // Create user audio stream for debugging
192
208
  voiceData.userAudioStream = await createUserAudioLogStream(guildId, channelId)
193
209
 
210
+ // Check if we already have a GenAI session for this channel
211
+ const existingSession = genAiSessions.get(channelId)
212
+ if (existingSession) {
213
+ voiceLogger.log(`Reusing existing GenAI session for channel ${channelId}`)
214
+
215
+ // Cancel any pending cleanup since user has returned
216
+ if (existingSession.pendingCleanup) {
217
+ voiceLogger.log(
218
+ `Cancelling pending cleanup for channel ${channelId} - user returned`,
219
+ )
220
+ existingSession.pendingCleanup = false
221
+
222
+ if (existingSession.cleanupTimer) {
223
+ clearTimeout(existingSession.cleanupTimer)
224
+ existingSession.cleanupTimer = undefined
225
+ }
226
+ }
227
+
228
+ // Session already exists, just update the voice handling
229
+ return
230
+ }
231
+
194
232
  // Get API keys from database
195
233
  const apiKeys = getDatabase()
196
234
  .prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
197
235
  .get(appId) as { gemini_api_key: string | null } | undefined
198
236
 
199
- // Create GenAI worker
237
+ // Track if sessions are active
238
+ let hasActiveSessions = false
239
+
200
240
  const genAiWorker = await createGenAIWorker({
201
241
  directory,
202
242
  guildId,
@@ -264,30 +304,82 @@ async function setupVoiceHandling({
264
304
  genAiWorker.interrupt()
265
305
  connection.setSpeaking(false)
266
306
  },
307
+ onAllSessionsCompleted() {
308
+ // All OpenCode sessions have completed
309
+ hasActiveSessions = false
310
+ voiceLogger.log('All OpenCode sessions completed for this GenAI session')
311
+
312
+ // Update the stored session state
313
+ const session = genAiSessions.get(channelId)
314
+ if (session) {
315
+ session.hasActiveSessions = false
316
+
317
+ // If cleanup is pending (user left channel), schedule cleanup with grace period
318
+ if (session.pendingCleanup) {
319
+ voiceLogger.log(
320
+ `Scheduling cleanup for channel ${channelId} in 1 minute`,
321
+ )
322
+
323
+ // Clear any existing timer
324
+ if (session.cleanupTimer) {
325
+ clearTimeout(session.cleanupTimer)
326
+ }
327
+
328
+ // Schedule cleanup after 1 minute grace period
329
+ session.cleanupTimer = setTimeout(() => {
330
+ // Double-check that cleanup is still needed
331
+ const currentSession = genAiSessions.get(channelId)
332
+ if (
333
+ currentSession?.pendingCleanup &&
334
+ !currentSession.hasActiveSessions
335
+ ) {
336
+ voiceLogger.log(
337
+ `Grace period expired, cleaning up GenAI session for channel ${channelId}`,
338
+ )
339
+ // Use the main cleanup function - defined later in startDiscordBot
340
+ cleanupGenAiSession(channelId)
341
+ }
342
+ }, 60000) // 1 minute
343
+ }
344
+ }
345
+ },
267
346
  onToolCallCompleted(params) {
347
+ // Note: We now track at the tools.ts level, but still handle completion messages
348
+ voiceLogger.log(`OpenCode session ${params.sessionId} completed`)
349
+
268
350
  const text = params.error
269
351
  ? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${params.error?.message || String(params.error)}\n</systemMessage>`
270
352
  : `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`
271
353
 
272
354
  genAiWorker.sendTextInput(text)
355
+
356
+ // Mark that we have active sessions (will be updated by onAllSessionsCompleted when done)
357
+ hasActiveSessions = true
358
+ const session = genAiSessions.get(channelId)
359
+ if (session) {
360
+ session.hasActiveSessions = true
361
+ }
273
362
  },
274
363
  onError(error) {
275
364
  voiceLogger.error('GenAI worker error:', error)
276
365
  },
277
366
  })
278
367
 
279
- // Stop any existing GenAI worker before storing new one
280
- if (voiceData.genAiWorker) {
281
- voiceLogger.log('Stopping existing GenAI worker before creating new one')
282
- await voiceData.genAiWorker.stop()
283
- }
284
-
285
368
  // Send initial greeting
286
369
  genAiWorker.sendTextInput(
287
370
  `<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`,
288
371
  )
289
372
 
290
- voiceData.genAiWorker = genAiWorker
373
+ // Store the GenAI session
374
+ genAiSessions.set(channelId, {
375
+ genAiWorker,
376
+ hasActiveSessions,
377
+ pendingCleanup: false,
378
+ cleanupTimer: undefined,
379
+ guildId,
380
+ channelId,
381
+ directory,
382
+ })
291
383
 
292
384
  // Set up voice receiver for user input
293
385
  const receiver = connection.receiver
@@ -295,6 +387,15 @@ async function setupVoiceHandling({
295
387
  // Remove all existing listeners to prevent accumulation
296
388
  receiver.speaking.removeAllListeners('start')
297
389
 
390
+ // Get the GenAI session for this channel
391
+ const genAiSession = genAiSessions.get(channelId)
392
+ if (!genAiSession) {
393
+ voiceLogger.error(
394
+ `GenAI session was just created but not found for channel ${channelId}`,
395
+ )
396
+ return
397
+ }
398
+
298
399
  // Counter to track overlapping speaking sessions
299
400
  let speakingSessionCount = 0
300
401
 
@@ -350,9 +451,10 @@ async function setupVoiceHandling({
350
451
  return
351
452
  }
352
453
 
353
- if (!voiceData.genAiWorker) {
454
+ const genAiSession = genAiSessions.get(channelId)
455
+ if (!genAiSession) {
354
456
  voiceLogger.warn(
355
- `[VOICE] Received audio frame but no GenAI worker active for guild ${guildId}`,
457
+ `[VOICE] Received audio frame but no GenAI session active for channel ${channelId}`,
356
458
  )
357
459
  return
358
460
  }
@@ -362,7 +464,7 @@ async function setupVoiceHandling({
362
464
  voiceData.userAudioStream?.write(frame)
363
465
 
364
466
  // stream incrementally — low latency
365
- voiceData.genAiWorker.sendRealtimeInput({
467
+ genAiSession.genAiWorker.sendRealtimeInput({
366
468
  audio: {
367
469
  mimeType: 'audio/pcm;rate=16000',
368
470
  data: frame.toString('base64'),
@@ -375,7 +477,8 @@ async function setupVoiceHandling({
375
477
  voiceLogger.log(
376
478
  `User ${userId} stopped speaking (session ${currentSessionCount})`,
377
479
  )
378
- voiceData.genAiWorker?.sendRealtimeInput({
480
+ const genAiSession = genAiSessions.get(channelId)
481
+ genAiSession?.genAiWorker.sendRealtimeInput({
379
482
  audioStreamEnd: true,
380
483
  })
381
484
  } else {
@@ -403,7 +506,7 @@ async function setupVoiceHandling({
403
506
  })
404
507
  }
405
508
 
406
- export function frameMono16khz(): Transform {
509
+ function frameMono16khz(): Transform {
407
510
  // Hardcoded: 16 kHz, mono, 16-bit PCM, 20 ms -> 320 samples -> 640 bytes
408
511
  const FRAME_BYTES =
409
512
  (100 /*ms*/ * 16_000 /*Hz*/ * 1 /*channels*/ * 2) /*bytes per sample*/ /
@@ -822,8 +925,10 @@ export async function initializeOpencodeForDirectory(directory: string) {
822
925
  // `[OPENCODE] Starting new server on port ${port} for directory: ${directory}`,
823
926
  // )
824
927
 
928
+ const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
929
+
825
930
  const serverProcess = spawn(
826
- 'opencode',
931
+ opencodeCommand,
827
932
  ['serve', '--port', port.toString()],
828
933
  {
829
934
  stdio: 'pipe',
@@ -876,7 +981,23 @@ export async function initializeOpencodeForDirectory(directory: string) {
876
981
  })
877
982
 
878
983
  await waitForServer(port)
879
- const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` })
984
+
985
+ // Create a custom fetch that disables Bun's default timeout
986
+ const customFetch = (
987
+ input: string | URL | Request,
988
+ init?: RequestInit,
989
+ ): Promise<Response> => {
990
+ return fetch(input, {
991
+ ...init,
992
+ // @ts-ignore - Bun-specific option to disable timeout
993
+ timeout: false,
994
+ })
995
+ }
996
+
997
+ const client = createOpencodeClient({
998
+ baseUrl: `http://localhost:${port}`,
999
+ fetch: customFetch,
1000
+ })
880
1001
 
881
1002
  opencodeServers.set(directory, {
882
1003
  process: serverProcess,
@@ -970,7 +1091,7 @@ function formatPart(part: Part): string {
970
1091
  part.state.status === 'completed'
971
1092
  ? '◼︎'
972
1093
  : part.state.status === 'error'
973
- ? '✖️'
1094
+ ? ''
974
1095
  : ''
975
1096
  const title = `${icon} ${part.tool} ${toolTitle}`
976
1097
 
@@ -1547,7 +1668,6 @@ export async function startDiscordBot({
1547
1668
  discordLogger.log(`Bot Application ID (provided): ${currentAppId}`)
1548
1669
  }
1549
1670
 
1550
-
1551
1671
  // List all guilds and channels that belong to this bot
1552
1672
  for (const guild of c.guilds.cache.values()) {
1553
1673
  discordLogger.log(`${guild.name} (${guild.id})`)
@@ -1868,6 +1988,93 @@ export async function startDiscordBot({
1868
1988
  )
1869
1989
  await interaction.respond([])
1870
1990
  }
1991
+ } else if (interaction.commandName === 'session') {
1992
+ const focusedOption = interaction.options.getFocused(true)
1993
+
1994
+ if (focusedOption.name === 'files') {
1995
+ const focusedValue = focusedOption.value
1996
+
1997
+ // Split by comma to handle multiple files
1998
+ const parts = focusedValue.split(',')
1999
+ const previousFiles = parts
2000
+ .slice(0, -1)
2001
+ .map((f) => f.trim())
2002
+ .filter((f) => f)
2003
+ const currentQuery = (parts[parts.length - 1] || '').trim()
2004
+
2005
+ // Get the channel's project directory from its topic
2006
+ let projectDirectory: string | undefined
2007
+ if (
2008
+ interaction.channel &&
2009
+ interaction.channel.type === ChannelType.GuildText
2010
+ ) {
2011
+ const textChannel = resolveTextChannel(
2012
+ interaction.channel as TextChannel | ThreadChannel | null,
2013
+ )
2014
+ if (textChannel) {
2015
+ const { projectDirectory: directory, channelAppId } =
2016
+ getKimakiMetadata(textChannel)
2017
+ if (channelAppId && channelAppId !== currentAppId) {
2018
+ await interaction.respond([])
2019
+ return
2020
+ }
2021
+ projectDirectory = directory
2022
+ }
2023
+ }
2024
+
2025
+ if (!projectDirectory) {
2026
+ await interaction.respond([])
2027
+ return
2028
+ }
2029
+
2030
+ try {
2031
+ // Get OpenCode client for this directory
2032
+ const getClient =
2033
+ await initializeOpencodeForDirectory(projectDirectory)
2034
+
2035
+ // Use find.files to search for files based on current query
2036
+ const response = await getClient().find.files({
2037
+ query: {
2038
+ query: currentQuery || '',
2039
+ },
2040
+ })
2041
+
2042
+ // Get file paths from the response
2043
+ const files = response.data || []
2044
+
2045
+ // Build the prefix with previous files
2046
+ const prefix =
2047
+ previousFiles.length > 0
2048
+ ? previousFiles.join(', ') + ', '
2049
+ : ''
2050
+
2051
+ // Map to Discord autocomplete format
2052
+ const choices = files
2053
+ .slice(0, 25) // Discord limit
2054
+ .map((file: string) => {
2055
+ const fullValue = prefix + file
2056
+ // Get all basenames for display
2057
+ const allFiles = [...previousFiles, file]
2058
+ const allBasenames = allFiles.map(
2059
+ (f) => f.split('/').pop() || f,
2060
+ )
2061
+ let displayName = allBasenames.join(', ')
2062
+ // Truncate if too long
2063
+ if (displayName.length > 100) {
2064
+ displayName = '…' + displayName.slice(-97)
2065
+ }
2066
+ return {
2067
+ name: displayName,
2068
+ value: fullValue,
2069
+ }
2070
+ })
2071
+
2072
+ await interaction.respond(choices)
2073
+ } catch (error) {
2074
+ voiceLogger.error('[AUTOCOMPLETE] Error fetching files:', error)
2075
+ await interaction.respond([])
2076
+ }
2077
+ }
1871
2078
  }
1872
2079
  }
1873
2080
 
@@ -1875,7 +2082,100 @@ export async function startDiscordBot({
1875
2082
  if (interaction.isChatInputCommand()) {
1876
2083
  const command = interaction
1877
2084
 
1878
- if (command.commandName === 'resume') {
2085
+ if (command.commandName === 'session') {
2086
+ await command.deferReply({ ephemeral: false })
2087
+
2088
+ const prompt = command.options.getString('prompt', true)
2089
+ const filesString = command.options.getString('files') || ''
2090
+ const channel = command.channel
2091
+
2092
+ if (!channel || channel.type !== ChannelType.GuildText) {
2093
+ await command.editReply(
2094
+ 'This command can only be used in text channels',
2095
+ )
2096
+ return
2097
+ }
2098
+
2099
+ const textChannel = channel as TextChannel
2100
+
2101
+ // Get project directory from channel topic
2102
+ let projectDirectory: string | undefined
2103
+ let channelAppId: string | undefined
2104
+
2105
+ if (textChannel.topic) {
2106
+ const extracted = extractTagsArrays({
2107
+ xml: textChannel.topic,
2108
+ tags: ['kimaki.directory', 'kimaki.app'],
2109
+ })
2110
+
2111
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
2112
+ channelAppId = extracted['kimaki.app']?.[0]?.trim()
2113
+ }
2114
+
2115
+ // Check if this channel belongs to current bot instance
2116
+ if (channelAppId && channelAppId !== currentAppId) {
2117
+ await command.editReply(
2118
+ 'This channel is not configured for this bot',
2119
+ )
2120
+ return
2121
+ }
2122
+
2123
+ if (!projectDirectory) {
2124
+ await command.editReply(
2125
+ 'This channel is not configured with a project directory',
2126
+ )
2127
+ return
2128
+ }
2129
+
2130
+ if (!fs.existsSync(projectDirectory)) {
2131
+ await command.editReply(
2132
+ `Directory does not exist: ${projectDirectory}`,
2133
+ )
2134
+ return
2135
+ }
2136
+
2137
+ try {
2138
+ // Initialize OpenCode client for the directory
2139
+ const getClient =
2140
+ await initializeOpencodeForDirectory(projectDirectory)
2141
+
2142
+ // Process file mentions - split by comma only
2143
+ const files = filesString
2144
+ .split(',')
2145
+ .map((f) => f.trim())
2146
+ .filter((f) => f)
2147
+
2148
+ // Build the full prompt with file mentions
2149
+ let fullPrompt = prompt
2150
+ if (files.length > 0) {
2151
+ fullPrompt = `${prompt}\n\n@${files.join(' @')}`
2152
+ }
2153
+
2154
+ // Send a message first, then create thread from it
2155
+ const starterMessage = await textChannel.send({
2156
+ content: `🚀 **Starting OpenCode session**\n📝 ${prompt.slice(0, 200)}${prompt.length > 200 ? '…' : ''}${files.length > 0 ? `\n📎 Files: ${files.join(', ')}` : ''}`,
2157
+ })
2158
+
2159
+ // Create thread from the message
2160
+ const thread = await starterMessage.startThread({
2161
+ name: prompt.slice(0, 100),
2162
+ autoArchiveDuration: 1440, // 24 hours
2163
+ reason: 'OpenCode session',
2164
+ })
2165
+
2166
+ await command.editReply(
2167
+ `Created new session in ${thread.toString()}`,
2168
+ )
2169
+
2170
+ // Start the OpenCode session
2171
+ await handleOpencodeSession(fullPrompt, thread, projectDirectory)
2172
+ } catch (error) {
2173
+ voiceLogger.error('[SESSION] Error:', error)
2174
+ await command.editReply(
2175
+ `Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`,
2176
+ )
2177
+ }
2178
+ } else if (command.commandName === 'resume') {
1879
2179
  await command.deferReply({ ephemeral: false })
1880
2180
 
1881
2181
  const sessionId = command.options.getString('session', true)
@@ -1988,12 +2288,12 @@ export async function startDiscordBot({
1988
2288
  if (message.info.role === 'user') {
1989
2289
  // Render user messages
1990
2290
  const userParts = message.parts.filter(
1991
- (p) => p.type === 'text',
2291
+ (p) => p.type === 'text' && !p.synthetic,
1992
2292
  )
1993
2293
  const userTexts = userParts
1994
2294
  .map((p) => {
1995
- if (typeof p.text === 'string') {
1996
- return extractNonXmlContent(p.text)
2295
+ if (p.type === 'text') {
2296
+ return p.text
1997
2297
  }
1998
2298
  return ''
1999
2299
  })
@@ -2045,21 +2345,43 @@ export async function startDiscordBot({
2045
2345
  },
2046
2346
  )
2047
2347
 
2348
+ // Helper function to clean up GenAI session
2349
+ async function cleanupGenAiSession(channelId: string) {
2350
+ const genAiSession = genAiSessions.get(channelId)
2351
+ if (!genAiSession) return
2352
+
2353
+ try {
2354
+ // Clear any cleanup timer
2355
+ if (genAiSession.cleanupTimer) {
2356
+ clearTimeout(genAiSession.cleanupTimer)
2357
+ genAiSession.cleanupTimer = undefined
2358
+ }
2359
+
2360
+ voiceLogger.log(`Stopping GenAI worker for channel ${channelId}...`)
2361
+ await genAiSession.genAiWorker.stop()
2362
+ voiceLogger.log(`GenAI worker stopped for channel ${channelId}`)
2363
+
2364
+ // Remove from map
2365
+ genAiSessions.delete(channelId)
2366
+ voiceLogger.log(`GenAI session cleanup complete for channel ${channelId}`)
2367
+ } catch (error) {
2368
+ voiceLogger.error(
2369
+ `Error during GenAI session cleanup for channel ${channelId}:`,
2370
+ error,
2371
+ )
2372
+ // Still remove from map even if there was an error
2373
+ genAiSessions.delete(channelId)
2374
+ }
2375
+ }
2376
+
2048
2377
  // Helper function to clean up voice connection and associated resources
2049
- async function cleanupVoiceConnection(guildId: string) {
2378
+ async function cleanupVoiceConnection(guildId: string, channelId?: string) {
2050
2379
  const voiceData = voiceConnections.get(guildId)
2051
2380
  if (!voiceData) return
2052
2381
 
2053
2382
  voiceLogger.log(`Starting cleanup for guild ${guildId}`)
2054
2383
 
2055
2384
  try {
2056
- // Stop GenAI worker if exists (this is async!)
2057
- if (voiceData.genAiWorker) {
2058
- voiceLogger.log(`Stopping GenAI worker...`)
2059
- await voiceData.genAiWorker.stop()
2060
- voiceLogger.log(`GenAI worker stopped`)
2061
- }
2062
-
2063
2385
  // Close user audio stream if exists
2064
2386
  if (voiceData.userAudioStream) {
2065
2387
  voiceLogger.log(`Closing user audio stream...`)
@@ -2083,7 +2405,45 @@ export async function startDiscordBot({
2083
2405
 
2084
2406
  // Remove from map
2085
2407
  voiceConnections.delete(guildId)
2086
- voiceLogger.log(`Cleanup complete for guild ${guildId}`)
2408
+ voiceLogger.log(`Voice connection cleanup complete for guild ${guildId}`)
2409
+
2410
+ // Mark the GenAI session for cleanup when all sessions complete
2411
+ if (channelId) {
2412
+ const genAiSession = genAiSessions.get(channelId)
2413
+ if (genAiSession) {
2414
+ voiceLogger.log(
2415
+ `Marking channel ${channelId} for cleanup when sessions complete`,
2416
+ )
2417
+ genAiSession.pendingCleanup = true
2418
+
2419
+ // If no active sessions, trigger cleanup immediately (with grace period)
2420
+ if (!genAiSession.hasActiveSessions) {
2421
+ voiceLogger.log(
2422
+ `No active sessions, scheduling cleanup for channel ${channelId} in 1 minute`,
2423
+ )
2424
+
2425
+ // Clear any existing timer
2426
+ if (genAiSession.cleanupTimer) {
2427
+ clearTimeout(genAiSession.cleanupTimer)
2428
+ }
2429
+
2430
+ // Schedule cleanup after 1 minute grace period
2431
+ genAiSession.cleanupTimer = setTimeout(() => {
2432
+ // Double-check that cleanup is still needed
2433
+ const currentSession = genAiSessions.get(channelId)
2434
+ if (
2435
+ currentSession?.pendingCleanup &&
2436
+ !currentSession.hasActiveSessions
2437
+ ) {
2438
+ voiceLogger.log(
2439
+ `Grace period expired, cleaning up GenAI session for channel ${channelId}`,
2440
+ )
2441
+ cleanupGenAiSession(channelId)
2442
+ }
2443
+ }, 60000) // 1 minute
2444
+ }
2445
+ }
2446
+ }
2087
2447
  } catch (error) {
2088
2448
  voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error)
2089
2449
  // Still remove from map even if there was an error
@@ -2141,7 +2501,7 @@ export async function startDiscordBot({
2141
2501
  )
2142
2502
 
2143
2503
  // Properly clean up all resources
2144
- await cleanupVoiceConnection(guildId)
2504
+ await cleanupVoiceConnection(guildId, oldState.channelId)
2145
2505
  } else {
2146
2506
  voiceLogger.log(
2147
2507
  `Other admins still in channel, bot staying in voice channel`,
@@ -2191,6 +2551,16 @@ export async function startDiscordBot({
2191
2551
  selfDeaf: false,
2192
2552
  selfMute: false,
2193
2553
  })
2554
+
2555
+ // Set up voice handling for the new channel
2556
+ // This will reuse existing GenAI session if one exists
2557
+ await setupVoiceHandling({
2558
+ connection: voiceData.connection,
2559
+ guildId,
2560
+ channelId: voiceChannel.id,
2561
+ appId: currentAppId!,
2562
+ cleanupGenAiSession,
2563
+ })
2194
2564
  }
2195
2565
  } else {
2196
2566
  voiceLogger.log(
@@ -2273,6 +2643,7 @@ export async function startDiscordBot({
2273
2643
  guildId: newState.guild.id,
2274
2644
  channelId: voiceChannel.id,
2275
2645
  appId: currentAppId!,
2646
+ cleanupGenAiSession,
2276
2647
  })
2277
2648
 
2278
2649
  // Handle connection state changes
@@ -2300,7 +2671,7 @@ export async function startDiscordBot({
2300
2671
  `Connection destroyed for guild: ${newState.guild.name}`,
2301
2672
  )
2302
2673
  // Use the cleanup function to ensure everything is properly closed
2303
- await cleanupVoiceConnection(newState.guild.id)
2674
+ await cleanupVoiceConnection(newState.guild.id, voiceChannel.id)
2304
2675
  })
2305
2676
 
2306
2677
  // Handle errors
@@ -2312,7 +2683,7 @@ export async function startDiscordBot({
2312
2683
  })
2313
2684
  } catch (error) {
2314
2685
  voiceLogger.error(`Failed to join voice channel:`, error)
2315
- await cleanupVoiceConnection(newState.guild.id)
2686
+ await cleanupVoiceConnection(newState.guild.id, voiceChannel.id)
2316
2687
  }
2317
2688
  } catch (error) {
2318
2689
  voiceLogger.error('Error in voice state update handler:', error)
@@ -2332,24 +2703,44 @@ export async function startDiscordBot({
2332
2703
  ;(global as any).shuttingDown = true
2333
2704
 
2334
2705
  try {
2335
- // Clean up all voice connections (this includes GenAI workers and audio streams)
2336
- const cleanupPromises: Promise<void>[] = []
2337
- for (const [guildId] of voiceConnections) {
2706
+ // Clean up all voice connections
2707
+ const voiceCleanupPromises: Promise<void>[] = []
2708
+ for (const [guildId, voiceData] of voiceConnections) {
2338
2709
  voiceLogger.log(
2339
2710
  `[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
2340
2711
  )
2341
- cleanupPromises.push(cleanupVoiceConnection(guildId))
2712
+ // Find the channel ID for this connection
2713
+ const channelId = voiceData.connection.joinConfig.channelId || undefined
2714
+ voiceCleanupPromises.push(cleanupVoiceConnection(guildId, channelId))
2342
2715
  }
2343
2716
 
2344
- // Wait for all cleanups to complete
2345
- if (cleanupPromises.length > 0) {
2717
+ // Wait for all voice cleanups to complete
2718
+ if (voiceCleanupPromises.length > 0) {
2346
2719
  voiceLogger.log(
2347
- `[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`,
2720
+ `[SHUTDOWN] Waiting for ${voiceCleanupPromises.length} voice connection(s) to clean up...`,
2348
2721
  )
2349
- await Promise.allSettled(cleanupPromises)
2722
+ await Promise.allSettled(voiceCleanupPromises)
2350
2723
  discordLogger.log(`All voice connections cleaned up`)
2351
2724
  }
2352
2725
 
2726
+ // Clean up all GenAI sessions (force cleanup regardless of active sessions)
2727
+ const genAiCleanupPromises: Promise<void>[] = []
2728
+ for (const [channelId, session] of genAiSessions) {
2729
+ voiceLogger.log(
2730
+ `[SHUTDOWN] Cleaning up GenAI session for channel ${channelId} (active sessions: ${session.hasActiveSessions})`,
2731
+ )
2732
+ genAiCleanupPromises.push(cleanupGenAiSession(channelId))
2733
+ }
2734
+
2735
+ // Wait for all GenAI cleanups to complete
2736
+ if (genAiCleanupPromises.length > 0) {
2737
+ voiceLogger.log(
2738
+ `[SHUTDOWN] Waiting for ${genAiCleanupPromises.length} GenAI session(s) to clean up...`,
2739
+ )
2740
+ await Promise.allSettled(genAiCleanupPromises)
2741
+ discordLogger.log(`All GenAI sessions cleaned up`)
2742
+ }
2743
+
2353
2744
  // Kill all OpenCode servers
2354
2745
  for (const [dir, server] of opencodeServers) {
2355
2746
  if (!server.process.killed) {