kimaki 0.1.4 → 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
@@ -34,6 +34,7 @@ import { spawn, exec, type ChildProcess } from 'node:child_process'
34
34
  import fs, { createWriteStream } from 'node:fs'
35
35
  import { mkdir } from 'node:fs/promises'
36
36
  import net from 'node:net'
37
+ import os from 'node:os'
37
38
  import path from 'node:path'
38
39
  import { promisify } from 'node:util'
39
40
  import { PassThrough, Transform, type TransformCallback } from 'node:stream'
@@ -69,16 +70,30 @@ const opencodeServers = new Map<
69
70
  // Map of session ID to current AbortController
70
71
  const abortControllers = new Map<string, AbortController>()
71
72
 
72
- // Map of guild ID to voice connection and GenAI worker
73
+ // Map of guild ID to voice connection
73
74
  const voiceConnections = new Map<
74
75
  string,
75
76
  {
76
77
  connection: VoiceConnection
77
- genAiWorker?: GenAIWorker
78
78
  userAudioStream?: fs.WriteStream
79
79
  }
80
80
  >()
81
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
+
82
97
  // Map of directory to retry count for server restarts
83
98
  const serverRetryCount = new Map<string, number>()
84
99
 
@@ -153,11 +168,13 @@ async function setupVoiceHandling({
153
168
  guildId,
154
169
  channelId,
155
170
  appId,
171
+ cleanupGenAiSession,
156
172
  }: {
157
173
  connection: VoiceConnection
158
174
  guildId: string
159
175
  channelId: string
160
176
  appId: string
177
+ cleanupGenAiSession: (channelId: string) => Promise<void>
161
178
  }) {
162
179
  voiceLogger.log(
163
180
  `Setting up voice handling for guild ${guildId}, channel ${channelId}`,
@@ -190,12 +207,36 @@ async function setupVoiceHandling({
190
207
  // Create user audio stream for debugging
191
208
  voiceData.userAudioStream = await createUserAudioLogStream(guildId, channelId)
192
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
+
193
232
  // Get API keys from database
194
233
  const apiKeys = getDatabase()
195
234
  .prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
196
235
  .get(appId) as { gemini_api_key: string | null } | undefined
197
236
 
198
- // Create GenAI worker
237
+ // Track if sessions are active
238
+ let hasActiveSessions = false
239
+
199
240
  const genAiWorker = await createGenAIWorker({
200
241
  directory,
201
242
  guildId,
@@ -263,30 +304,82 @@ async function setupVoiceHandling({
263
304
  genAiWorker.interrupt()
264
305
  connection.setSpeaking(false)
265
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
+ },
266
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
+
267
350
  const text = params.error
268
351
  ? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${params.error?.message || String(params.error)}\n</systemMessage>`
269
352
  : `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`
270
353
 
271
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
+ }
272
362
  },
273
363
  onError(error) {
274
364
  voiceLogger.error('GenAI worker error:', error)
275
365
  },
276
366
  })
277
367
 
278
- // Stop any existing GenAI worker before storing new one
279
- if (voiceData.genAiWorker) {
280
- voiceLogger.log('Stopping existing GenAI worker before creating new one')
281
- await voiceData.genAiWorker.stop()
282
- }
283
-
284
368
  // Send initial greeting
285
369
  genAiWorker.sendTextInput(
286
370
  `<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`,
287
371
  )
288
372
 
289
- 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
+ })
290
383
 
291
384
  // Set up voice receiver for user input
292
385
  const receiver = connection.receiver
@@ -294,6 +387,15 @@ async function setupVoiceHandling({
294
387
  // Remove all existing listeners to prevent accumulation
295
388
  receiver.speaking.removeAllListeners('start')
296
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
+
297
399
  // Counter to track overlapping speaking sessions
298
400
  let speakingSessionCount = 0
299
401
 
@@ -349,9 +451,10 @@ async function setupVoiceHandling({
349
451
  return
350
452
  }
351
453
 
352
- if (!voiceData.genAiWorker) {
454
+ const genAiSession = genAiSessions.get(channelId)
455
+ if (!genAiSession) {
353
456
  voiceLogger.warn(
354
- `[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}`,
355
458
  )
356
459
  return
357
460
  }
@@ -361,7 +464,7 @@ async function setupVoiceHandling({
361
464
  voiceData.userAudioStream?.write(frame)
362
465
 
363
466
  // stream incrementally — low latency
364
- voiceData.genAiWorker.sendRealtimeInput({
467
+ genAiSession.genAiWorker.sendRealtimeInput({
365
468
  audio: {
366
469
  mimeType: 'audio/pcm;rate=16000',
367
470
  data: frame.toString('base64'),
@@ -374,7 +477,8 @@ async function setupVoiceHandling({
374
477
  voiceLogger.log(
375
478
  `User ${userId} stopped speaking (session ${currentSessionCount})`,
376
479
  )
377
- voiceData.genAiWorker?.sendRealtimeInput({
480
+ const genAiSession = genAiSessions.get(channelId)
481
+ genAiSession?.genAiWorker.sendRealtimeInput({
378
482
  audioStreamEnd: true,
379
483
  })
380
484
  } else {
@@ -402,7 +506,7 @@ async function setupVoiceHandling({
402
506
  })
403
507
  }
404
508
 
405
- export function frameMono16khz(): Transform {
509
+ function frameMono16khz(): Transform {
406
510
  // Hardcoded: 16 kHz, mono, 16-bit PCM, 20 ms -> 320 samples -> 640 bytes
407
511
  const FRAME_BYTES =
408
512
  (100 /*ms*/ * 16_000 /*Hz*/ * 1 /*channels*/ * 2) /*bytes per sample*/ /
@@ -453,7 +557,19 @@ export function frameMono16khz(): Transform {
453
557
 
454
558
  export function getDatabase(): Database.Database {
455
559
  if (!db) {
456
- db = new Database('discord-sessions.db')
560
+ // Create ~/.kimaki directory if it doesn't exist
561
+ const kimakiDir = path.join(os.homedir(), '.kimaki')
562
+
563
+ try {
564
+ fs.mkdirSync(kimakiDir, { recursive: true })
565
+ } catch (error) {
566
+ dbLogger.error('Failed to create ~/.kimaki directory:', error)
567
+ }
568
+
569
+ const dbPath = path.join(kimakiDir, 'discord-sessions.db')
570
+
571
+ dbLogger.log(`Opening database at: ${dbPath}`)
572
+ db = new Database(dbPath)
457
573
 
458
574
  // Initialize tables
459
575
  db.exec(`
@@ -685,7 +801,7 @@ async function processVoiceAttachment({
685
801
  const apiKeys = getDatabase()
686
802
  .prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
687
803
  .get(appId) as { gemini_api_key: string | null } | undefined
688
-
804
+
689
805
  if (apiKeys?.gemini_api_key) {
690
806
  geminiApiKey = apiKeys.gemini_api_key
691
807
  }
@@ -809,8 +925,10 @@ export async function initializeOpencodeForDirectory(directory: string) {
809
925
  // `[OPENCODE] Starting new server on port ${port} for directory: ${directory}`,
810
926
  // )
811
927
 
928
+ const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
929
+
812
930
  const serverProcess = spawn(
813
- 'opencode',
931
+ opencodeCommand,
814
932
  ['serve', '--port', port.toString()],
815
933
  {
816
934
  stdio: 'pipe',
@@ -863,7 +981,23 @@ export async function initializeOpencodeForDirectory(directory: string) {
863
981
  })
864
982
 
865
983
  await waitForServer(port)
866
- 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
+ })
867
1001
 
868
1002
  opencodeServers.set(directory, {
869
1003
  process: serverProcess,
@@ -957,7 +1091,7 @@ function formatPart(part: Part): string {
957
1091
  part.state.status === 'completed'
958
1092
  ? '◼︎'
959
1093
  : part.state.status === 'error'
960
- ? '✖️'
1094
+ ? ''
961
1095
  : ''
962
1096
  const title = `${icon} ${part.tool} ${toolTitle}`
963
1097
 
@@ -1854,6 +1988,93 @@ export async function startDiscordBot({
1854
1988
  )
1855
1989
  await interaction.respond([])
1856
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
+ }
1857
2078
  }
1858
2079
  }
1859
2080
 
@@ -1861,7 +2082,100 @@ export async function startDiscordBot({
1861
2082
  if (interaction.isChatInputCommand()) {
1862
2083
  const command = interaction
1863
2084
 
1864
- 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') {
1865
2179
  await command.deferReply({ ephemeral: false })
1866
2180
 
1867
2181
  const sessionId = command.options.getString('session', true)
@@ -1974,12 +2288,12 @@ export async function startDiscordBot({
1974
2288
  if (message.info.role === 'user') {
1975
2289
  // Render user messages
1976
2290
  const userParts = message.parts.filter(
1977
- (p) => p.type === 'text',
2291
+ (p) => p.type === 'text' && !p.synthetic,
1978
2292
  )
1979
2293
  const userTexts = userParts
1980
2294
  .map((p) => {
1981
- if (typeof p.text === 'string') {
1982
- return extractNonXmlContent(p.text)
2295
+ if (p.type === 'text') {
2296
+ return p.text
1983
2297
  }
1984
2298
  return ''
1985
2299
  })
@@ -2031,21 +2345,43 @@ export async function startDiscordBot({
2031
2345
  },
2032
2346
  )
2033
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
+
2034
2377
  // Helper function to clean up voice connection and associated resources
2035
- async function cleanupVoiceConnection(guildId: string) {
2378
+ async function cleanupVoiceConnection(guildId: string, channelId?: string) {
2036
2379
  const voiceData = voiceConnections.get(guildId)
2037
2380
  if (!voiceData) return
2038
2381
 
2039
2382
  voiceLogger.log(`Starting cleanup for guild ${guildId}`)
2040
2383
 
2041
2384
  try {
2042
- // Stop GenAI worker if exists (this is async!)
2043
- if (voiceData.genAiWorker) {
2044
- voiceLogger.log(`Stopping GenAI worker...`)
2045
- await voiceData.genAiWorker.stop()
2046
- voiceLogger.log(`GenAI worker stopped`)
2047
- }
2048
-
2049
2385
  // Close user audio stream if exists
2050
2386
  if (voiceData.userAudioStream) {
2051
2387
  voiceLogger.log(`Closing user audio stream...`)
@@ -2069,7 +2405,45 @@ export async function startDiscordBot({
2069
2405
 
2070
2406
  // Remove from map
2071
2407
  voiceConnections.delete(guildId)
2072
- 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
+ }
2073
2447
  } catch (error) {
2074
2448
  voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error)
2075
2449
  // Still remove from map even if there was an error
@@ -2127,7 +2501,7 @@ export async function startDiscordBot({
2127
2501
  )
2128
2502
 
2129
2503
  // Properly clean up all resources
2130
- await cleanupVoiceConnection(guildId)
2504
+ await cleanupVoiceConnection(guildId, oldState.channelId)
2131
2505
  } else {
2132
2506
  voiceLogger.log(
2133
2507
  `Other admins still in channel, bot staying in voice channel`,
@@ -2177,6 +2551,16 @@ export async function startDiscordBot({
2177
2551
  selfDeaf: false,
2178
2552
  selfMute: false,
2179
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
+ })
2180
2564
  }
2181
2565
  } else {
2182
2566
  voiceLogger.log(
@@ -2259,6 +2643,7 @@ export async function startDiscordBot({
2259
2643
  guildId: newState.guild.id,
2260
2644
  channelId: voiceChannel.id,
2261
2645
  appId: currentAppId!,
2646
+ cleanupGenAiSession,
2262
2647
  })
2263
2648
 
2264
2649
  // Handle connection state changes
@@ -2286,7 +2671,7 @@ export async function startDiscordBot({
2286
2671
  `Connection destroyed for guild: ${newState.guild.name}`,
2287
2672
  )
2288
2673
  // Use the cleanup function to ensure everything is properly closed
2289
- await cleanupVoiceConnection(newState.guild.id)
2674
+ await cleanupVoiceConnection(newState.guild.id, voiceChannel.id)
2290
2675
  })
2291
2676
 
2292
2677
  // Handle errors
@@ -2298,7 +2683,7 @@ export async function startDiscordBot({
2298
2683
  })
2299
2684
  } catch (error) {
2300
2685
  voiceLogger.error(`Failed to join voice channel:`, error)
2301
- await cleanupVoiceConnection(newState.guild.id)
2686
+ await cleanupVoiceConnection(newState.guild.id, voiceChannel.id)
2302
2687
  }
2303
2688
  } catch (error) {
2304
2689
  voiceLogger.error('Error in voice state update handler:', error)
@@ -2318,24 +2703,44 @@ export async function startDiscordBot({
2318
2703
  ;(global as any).shuttingDown = true
2319
2704
 
2320
2705
  try {
2321
- // Clean up all voice connections (this includes GenAI workers and audio streams)
2322
- const cleanupPromises: Promise<void>[] = []
2323
- for (const [guildId] of voiceConnections) {
2706
+ // Clean up all voice connections
2707
+ const voiceCleanupPromises: Promise<void>[] = []
2708
+ for (const [guildId, voiceData] of voiceConnections) {
2324
2709
  voiceLogger.log(
2325
2710
  `[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
2326
2711
  )
2327
- 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))
2328
2715
  }
2329
2716
 
2330
- // Wait for all cleanups to complete
2331
- if (cleanupPromises.length > 0) {
2717
+ // Wait for all voice cleanups to complete
2718
+ if (voiceCleanupPromises.length > 0) {
2332
2719
  voiceLogger.log(
2333
- `[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`,
2720
+ `[SHUTDOWN] Waiting for ${voiceCleanupPromises.length} voice connection(s) to clean up...`,
2334
2721
  )
2335
- await Promise.allSettled(cleanupPromises)
2722
+ await Promise.allSettled(voiceCleanupPromises)
2336
2723
  discordLogger.log(`All voice connections cleaned up`)
2337
2724
  }
2338
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
+
2339
2744
  // Kill all OpenCode servers
2340
2745
  for (const [dir, server] of opencodeServers) {
2341
2746
  if (!server.process.killed) {
@@ -2348,7 +2753,10 @@ export async function startDiscordBot({
2348
2753
  opencodeServers.clear()
2349
2754
 
2350
2755
  discordLogger.log('Closing database...')
2351
- getDatabase().close()
2756
+ if (db) {
2757
+ db.close()
2758
+ db = null
2759
+ }
2352
2760
 
2353
2761
  discordLogger.log('Destroying Discord client...')
2354
2762
  discordClient.destroy()