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.
@@ -8,6 +8,7 @@ import { spawn, exec } from 'node:child_process';
8
8
  import fs, { createWriteStream } from 'node:fs';
9
9
  import { mkdir } from 'node:fs/promises';
10
10
  import net from 'node:net';
11
+ import os from 'node:os';
11
12
  import path from 'node:path';
12
13
  import { promisify } from 'node:util';
13
14
  import { PassThrough, Transform } from 'node:stream';
@@ -26,8 +27,11 @@ const dbLogger = createLogger('DB');
26
27
  const opencodeServers = new Map();
27
28
  // Map of session ID to current AbortController
28
29
  const abortControllers = new Map();
29
- // Map of guild ID to voice connection and GenAI worker
30
+ // Map of guild ID to voice connection
30
31
  const voiceConnections = new Map();
32
+ // Map of channel ID to GenAI worker and session state
33
+ // This allows sessions to persist across voice channel changes
34
+ const genAiSessions = new Map();
31
35
  // Map of directory to retry count for server restarts
32
36
  const serverRetryCount = new Map();
33
37
  let db = null;
@@ -78,7 +82,7 @@ async function createUserAudioLogStream(guildId, channelId) {
78
82
  }
79
83
  }
80
84
  // Set up voice handling for a connection (called once per connection)
81
- async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
85
+ async function setupVoiceHandling({ connection, guildId, channelId, appId, cleanupGenAiSession, }) {
82
86
  voiceLogger.log(`Setting up voice handling for guild ${guildId}, channel ${channelId}`);
83
87
  // Check if this voice channel has an associated directory
84
88
  const channelDirRow = getDatabase()
@@ -98,11 +102,28 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
98
102
  }
99
103
  // Create user audio stream for debugging
100
104
  voiceData.userAudioStream = await createUserAudioLogStream(guildId, channelId);
105
+ // Check if we already have a GenAI session for this channel
106
+ const existingSession = genAiSessions.get(channelId);
107
+ if (existingSession) {
108
+ voiceLogger.log(`Reusing existing GenAI session for channel ${channelId}`);
109
+ // Cancel any pending cleanup since user has returned
110
+ if (existingSession.pendingCleanup) {
111
+ voiceLogger.log(`Cancelling pending cleanup for channel ${channelId} - user returned`);
112
+ existingSession.pendingCleanup = false;
113
+ if (existingSession.cleanupTimer) {
114
+ clearTimeout(existingSession.cleanupTimer);
115
+ existingSession.cleanupTimer = undefined;
116
+ }
117
+ }
118
+ // Session already exists, just update the voice handling
119
+ return;
120
+ }
101
121
  // Get API keys from database
102
122
  const apiKeys = getDatabase()
103
123
  .prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
104
124
  .get(appId);
105
- // Create GenAI worker
125
+ // Track if sessions are active
126
+ let hasActiveSessions = false;
106
127
  const genAiWorker = await createGenAIWorker({
107
128
  directory,
108
129
  guildId,
@@ -170,28 +191,75 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
170
191
  genAiWorker.interrupt();
171
192
  connection.setSpeaking(false);
172
193
  },
194
+ onAllSessionsCompleted() {
195
+ // All OpenCode sessions have completed
196
+ hasActiveSessions = false;
197
+ voiceLogger.log('All OpenCode sessions completed for this GenAI session');
198
+ // Update the stored session state
199
+ const session = genAiSessions.get(channelId);
200
+ if (session) {
201
+ session.hasActiveSessions = false;
202
+ // If cleanup is pending (user left channel), schedule cleanup with grace period
203
+ if (session.pendingCleanup) {
204
+ voiceLogger.log(`Scheduling cleanup for channel ${channelId} in 1 minute`);
205
+ // Clear any existing timer
206
+ if (session.cleanupTimer) {
207
+ clearTimeout(session.cleanupTimer);
208
+ }
209
+ // Schedule cleanup after 1 minute grace period
210
+ session.cleanupTimer = setTimeout(() => {
211
+ // Double-check that cleanup is still needed
212
+ const currentSession = genAiSessions.get(channelId);
213
+ if (currentSession?.pendingCleanup &&
214
+ !currentSession.hasActiveSessions) {
215
+ voiceLogger.log(`Grace period expired, cleaning up GenAI session for channel ${channelId}`);
216
+ // Use the main cleanup function - defined later in startDiscordBot
217
+ cleanupGenAiSession(channelId);
218
+ }
219
+ }, 60000); // 1 minute
220
+ }
221
+ }
222
+ },
173
223
  onToolCallCompleted(params) {
224
+ // Note: We now track at the tools.ts level, but still handle completion messages
225
+ voiceLogger.log(`OpenCode session ${params.sessionId} completed`);
174
226
  const text = params.error
175
227
  ? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${params.error?.message || String(params.error)}\n</systemMessage>`
176
228
  : `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`;
177
229
  genAiWorker.sendTextInput(text);
230
+ // Mark that we have active sessions (will be updated by onAllSessionsCompleted when done)
231
+ hasActiveSessions = true;
232
+ const session = genAiSessions.get(channelId);
233
+ if (session) {
234
+ session.hasActiveSessions = true;
235
+ }
178
236
  },
179
237
  onError(error) {
180
238
  voiceLogger.error('GenAI worker error:', error);
181
239
  },
182
240
  });
183
- // Stop any existing GenAI worker before storing new one
184
- if (voiceData.genAiWorker) {
185
- voiceLogger.log('Stopping existing GenAI worker before creating new one');
186
- await voiceData.genAiWorker.stop();
187
- }
188
241
  // Send initial greeting
189
242
  genAiWorker.sendTextInput(`<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`);
190
- voiceData.genAiWorker = genAiWorker;
243
+ // Store the GenAI session
244
+ genAiSessions.set(channelId, {
245
+ genAiWorker,
246
+ hasActiveSessions,
247
+ pendingCleanup: false,
248
+ cleanupTimer: undefined,
249
+ guildId,
250
+ channelId,
251
+ directory,
252
+ });
191
253
  // Set up voice receiver for user input
192
254
  const receiver = connection.receiver;
193
255
  // Remove all existing listeners to prevent accumulation
194
256
  receiver.speaking.removeAllListeners('start');
257
+ // Get the GenAI session for this channel
258
+ const genAiSession = genAiSessions.get(channelId);
259
+ if (!genAiSession) {
260
+ voiceLogger.error(`GenAI session was just created but not found for channel ${channelId}`);
261
+ return;
262
+ }
195
263
  // Counter to track overlapping speaking sessions
196
264
  let speakingSessionCount = 0;
197
265
  receiver.speaking.on('start', (userId) => {
@@ -238,15 +306,16 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
238
306
  // )
239
307
  return;
240
308
  }
241
- if (!voiceData.genAiWorker) {
242
- voiceLogger.warn(`[VOICE] Received audio frame but no GenAI worker active for guild ${guildId}`);
309
+ const genAiSession = genAiSessions.get(channelId);
310
+ if (!genAiSession) {
311
+ voiceLogger.warn(`[VOICE] Received audio frame but no GenAI session active for channel ${channelId}`);
243
312
  return;
244
313
  }
245
314
  // voiceLogger.debug('User audio chunk length', frame.length)
246
315
  // Write to PCM file if stream exists
247
316
  voiceData.userAudioStream?.write(frame);
248
317
  // stream incrementally — low latency
249
- voiceData.genAiWorker.sendRealtimeInput({
318
+ genAiSession.genAiWorker.sendRealtimeInput({
250
319
  audio: {
251
320
  mimeType: 'audio/pcm;rate=16000',
252
321
  data: frame.toString('base64'),
@@ -257,7 +326,8 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
257
326
  // Only send audioStreamEnd if this is still the current session
258
327
  if (currentSessionCount === speakingSessionCount) {
259
328
  voiceLogger.log(`User ${userId} stopped speaking (session ${currentSessionCount})`);
260
- voiceData.genAiWorker?.sendRealtimeInput({
329
+ const genAiSession = genAiSessions.get(channelId);
330
+ genAiSession?.genAiWorker.sendRealtimeInput({
261
331
  audioStreamEnd: true,
262
332
  });
263
333
  }
@@ -280,7 +350,7 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
280
350
  });
281
351
  });
282
352
  }
283
- export function frameMono16khz() {
353
+ function frameMono16khz() {
284
354
  // Hardcoded: 16 kHz, mono, 16-bit PCM, 20 ms -> 320 samples -> 640 bytes
285
355
  const FRAME_BYTES = (100 /*ms*/ * 16_000 /*Hz*/ * 1 /*channels*/ * 2) /*bytes per sample*/ /
286
356
  1000;
@@ -322,7 +392,17 @@ export function frameMono16khz() {
322
392
  }
323
393
  export function getDatabase() {
324
394
  if (!db) {
325
- db = new Database('discord-sessions.db');
395
+ // Create ~/.kimaki directory if it doesn't exist
396
+ const kimakiDir = path.join(os.homedir(), '.kimaki');
397
+ try {
398
+ fs.mkdirSync(kimakiDir, { recursive: true });
399
+ }
400
+ catch (error) {
401
+ dbLogger.error('Failed to create ~/.kimaki directory:', error);
402
+ }
403
+ const dbPath = path.join(kimakiDir, 'discord-sessions.db');
404
+ dbLogger.log(`Opening database at: ${dbPath}`);
405
+ db = new Database(dbPath);
326
406
  // Initialize tables
327
407
  db.exec(`
328
408
  CREATE TABLE IF NOT EXISTS thread_sessions (
@@ -597,7 +677,8 @@ export async function initializeOpencodeForDirectory(directory) {
597
677
  // console.log(
598
678
  // `[OPENCODE] Starting new server on port ${port} for directory: ${directory}`,
599
679
  // )
600
- const serverProcess = spawn('opencode', ['serve', '--port', port.toString()], {
680
+ const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
681
+ const serverProcess = spawn(opencodeCommand, ['serve', '--port', port.toString()], {
601
682
  stdio: 'pipe',
602
683
  detached: false,
603
684
  cwd: directory,
@@ -637,7 +718,18 @@ export async function initializeOpencodeForDirectory(directory) {
637
718
  }
638
719
  });
639
720
  await waitForServer(port);
640
- const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` });
721
+ // Create a custom fetch that disables Bun's default timeout
722
+ const customFetch = (input, init) => {
723
+ return fetch(input, {
724
+ ...init,
725
+ // @ts-ignore - Bun-specific option to disable timeout
726
+ timeout: false,
727
+ });
728
+ };
729
+ const client = createOpencodeClient({
730
+ baseUrl: `http://localhost:${port}`,
731
+ fetch: customFetch,
732
+ });
641
733
  opencodeServers.set(directory, {
642
734
  process: serverProcess,
643
735
  client,
@@ -719,7 +811,7 @@ function formatPart(part) {
719
811
  const icon = part.state.status === 'completed'
720
812
  ? '◼︎'
721
813
  : part.state.status === 'error'
722
- ? '✖️'
814
+ ? ''
723
815
  : '';
724
816
  const title = `${icon} ${part.tool} ${toolTitle}`;
725
817
  let text = title;
@@ -1389,11 +1481,147 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1389
1481
  await interaction.respond([]);
1390
1482
  }
1391
1483
  }
1484
+ else if (interaction.commandName === 'session') {
1485
+ const focusedOption = interaction.options.getFocused(true);
1486
+ if (focusedOption.name === 'files') {
1487
+ const focusedValue = focusedOption.value;
1488
+ // Split by comma to handle multiple files
1489
+ const parts = focusedValue.split(',');
1490
+ const previousFiles = parts
1491
+ .slice(0, -1)
1492
+ .map((f) => f.trim())
1493
+ .filter((f) => f);
1494
+ const currentQuery = (parts[parts.length - 1] || '').trim();
1495
+ // Get the channel's project directory from its topic
1496
+ let projectDirectory;
1497
+ if (interaction.channel &&
1498
+ interaction.channel.type === ChannelType.GuildText) {
1499
+ const textChannel = resolveTextChannel(interaction.channel);
1500
+ if (textChannel) {
1501
+ const { projectDirectory: directory, channelAppId } = getKimakiMetadata(textChannel);
1502
+ if (channelAppId && channelAppId !== currentAppId) {
1503
+ await interaction.respond([]);
1504
+ return;
1505
+ }
1506
+ projectDirectory = directory;
1507
+ }
1508
+ }
1509
+ if (!projectDirectory) {
1510
+ await interaction.respond([]);
1511
+ return;
1512
+ }
1513
+ try {
1514
+ // Get OpenCode client for this directory
1515
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
1516
+ // Use find.files to search for files based on current query
1517
+ const response = await getClient().find.files({
1518
+ query: {
1519
+ query: currentQuery || '',
1520
+ },
1521
+ });
1522
+ // Get file paths from the response
1523
+ const files = response.data || [];
1524
+ // Build the prefix with previous files
1525
+ const prefix = previousFiles.length > 0
1526
+ ? previousFiles.join(', ') + ', '
1527
+ : '';
1528
+ // Map to Discord autocomplete format
1529
+ const choices = files
1530
+ .slice(0, 25) // Discord limit
1531
+ .map((file) => {
1532
+ const fullValue = prefix + file;
1533
+ // Get all basenames for display
1534
+ const allFiles = [...previousFiles, file];
1535
+ const allBasenames = allFiles.map((f) => f.split('/').pop() || f);
1536
+ let displayName = allBasenames.join(', ');
1537
+ // Truncate if too long
1538
+ if (displayName.length > 100) {
1539
+ displayName = '…' + displayName.slice(-97);
1540
+ }
1541
+ return {
1542
+ name: displayName,
1543
+ value: fullValue,
1544
+ };
1545
+ });
1546
+ await interaction.respond(choices);
1547
+ }
1548
+ catch (error) {
1549
+ voiceLogger.error('[AUTOCOMPLETE] Error fetching files:', error);
1550
+ await interaction.respond([]);
1551
+ }
1552
+ }
1553
+ }
1392
1554
  }
1393
1555
  // Handle slash commands
1394
1556
  if (interaction.isChatInputCommand()) {
1395
1557
  const command = interaction;
1396
- if (command.commandName === 'resume') {
1558
+ if (command.commandName === 'session') {
1559
+ await command.deferReply({ ephemeral: false });
1560
+ const prompt = command.options.getString('prompt', true);
1561
+ const filesString = command.options.getString('files') || '';
1562
+ const channel = command.channel;
1563
+ if (!channel || channel.type !== ChannelType.GuildText) {
1564
+ await command.editReply('This command can only be used in text channels');
1565
+ return;
1566
+ }
1567
+ const textChannel = channel;
1568
+ // Get project directory from channel topic
1569
+ let projectDirectory;
1570
+ let channelAppId;
1571
+ if (textChannel.topic) {
1572
+ const extracted = extractTagsArrays({
1573
+ xml: textChannel.topic,
1574
+ tags: ['kimaki.directory', 'kimaki.app'],
1575
+ });
1576
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
1577
+ channelAppId = extracted['kimaki.app']?.[0]?.trim();
1578
+ }
1579
+ // Check if this channel belongs to current bot instance
1580
+ if (channelAppId && channelAppId !== currentAppId) {
1581
+ await command.editReply('This channel is not configured for this bot');
1582
+ return;
1583
+ }
1584
+ if (!projectDirectory) {
1585
+ await command.editReply('This channel is not configured with a project directory');
1586
+ return;
1587
+ }
1588
+ if (!fs.existsSync(projectDirectory)) {
1589
+ await command.editReply(`Directory does not exist: ${projectDirectory}`);
1590
+ return;
1591
+ }
1592
+ try {
1593
+ // Initialize OpenCode client for the directory
1594
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
1595
+ // Process file mentions - split by comma only
1596
+ const files = filesString
1597
+ .split(',')
1598
+ .map((f) => f.trim())
1599
+ .filter((f) => f);
1600
+ // Build the full prompt with file mentions
1601
+ let fullPrompt = prompt;
1602
+ if (files.length > 0) {
1603
+ fullPrompt = `${prompt}\n\n@${files.join(' @')}`;
1604
+ }
1605
+ // Send a message first, then create thread from it
1606
+ const starterMessage = await textChannel.send({
1607
+ content: `🚀 **Starting OpenCode session**\n📝 ${prompt.slice(0, 200)}${prompt.length > 200 ? '…' : ''}${files.length > 0 ? `\n📎 Files: ${files.join(', ')}` : ''}`,
1608
+ });
1609
+ // Create thread from the message
1610
+ const thread = await starterMessage.startThread({
1611
+ name: prompt.slice(0, 100),
1612
+ autoArchiveDuration: 1440, // 24 hours
1613
+ reason: 'OpenCode session',
1614
+ });
1615
+ await command.editReply(`Created new session in ${thread.toString()}`);
1616
+ // Start the OpenCode session
1617
+ await handleOpencodeSession(fullPrompt, thread, projectDirectory);
1618
+ }
1619
+ catch (error) {
1620
+ voiceLogger.error('[SESSION] Error:', error);
1621
+ await command.editReply(`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`);
1622
+ }
1623
+ }
1624
+ else if (command.commandName === 'resume') {
1397
1625
  await command.deferReply({ ephemeral: false });
1398
1626
  const sessionId = command.options.getString('session', true);
1399
1627
  const channel = command.channel;
@@ -1465,11 +1693,11 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1465
1693
  for (const message of messages) {
1466
1694
  if (message.info.role === 'user') {
1467
1695
  // Render user messages
1468
- const userParts = message.parts.filter((p) => p.type === 'text');
1696
+ const userParts = message.parts.filter((p) => p.type === 'text' && !p.synthetic);
1469
1697
  const userTexts = userParts
1470
1698
  .map((p) => {
1471
- if (typeof p.text === 'string') {
1472
- return extractNonXmlContent(p.text);
1699
+ if (p.type === 'text') {
1700
+ return p.text;
1473
1701
  }
1474
1702
  return '';
1475
1703
  })
@@ -1509,19 +1737,37 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1509
1737
  voiceLogger.error('[INTERACTION] Error handling interaction:', error);
1510
1738
  }
1511
1739
  });
1740
+ // Helper function to clean up GenAI session
1741
+ async function cleanupGenAiSession(channelId) {
1742
+ const genAiSession = genAiSessions.get(channelId);
1743
+ if (!genAiSession)
1744
+ return;
1745
+ try {
1746
+ // Clear any cleanup timer
1747
+ if (genAiSession.cleanupTimer) {
1748
+ clearTimeout(genAiSession.cleanupTimer);
1749
+ genAiSession.cleanupTimer = undefined;
1750
+ }
1751
+ voiceLogger.log(`Stopping GenAI worker for channel ${channelId}...`);
1752
+ await genAiSession.genAiWorker.stop();
1753
+ voiceLogger.log(`GenAI worker stopped for channel ${channelId}`);
1754
+ // Remove from map
1755
+ genAiSessions.delete(channelId);
1756
+ voiceLogger.log(`GenAI session cleanup complete for channel ${channelId}`);
1757
+ }
1758
+ catch (error) {
1759
+ voiceLogger.error(`Error during GenAI session cleanup for channel ${channelId}:`, error);
1760
+ // Still remove from map even if there was an error
1761
+ genAiSessions.delete(channelId);
1762
+ }
1763
+ }
1512
1764
  // Helper function to clean up voice connection and associated resources
1513
- async function cleanupVoiceConnection(guildId) {
1765
+ async function cleanupVoiceConnection(guildId, channelId) {
1514
1766
  const voiceData = voiceConnections.get(guildId);
1515
1767
  if (!voiceData)
1516
1768
  return;
1517
1769
  voiceLogger.log(`Starting cleanup for guild ${guildId}`);
1518
1770
  try {
1519
- // Stop GenAI worker if exists (this is async!)
1520
- if (voiceData.genAiWorker) {
1521
- voiceLogger.log(`Stopping GenAI worker...`);
1522
- await voiceData.genAiWorker.stop();
1523
- voiceLogger.log(`GenAI worker stopped`);
1524
- }
1525
1771
  // Close user audio stream if exists
1526
1772
  if (voiceData.userAudioStream) {
1527
1773
  voiceLogger.log(`Closing user audio stream...`);
@@ -1541,7 +1787,33 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1541
1787
  }
1542
1788
  // Remove from map
1543
1789
  voiceConnections.delete(guildId);
1544
- voiceLogger.log(`Cleanup complete for guild ${guildId}`);
1790
+ voiceLogger.log(`Voice connection cleanup complete for guild ${guildId}`);
1791
+ // Mark the GenAI session for cleanup when all sessions complete
1792
+ if (channelId) {
1793
+ const genAiSession = genAiSessions.get(channelId);
1794
+ if (genAiSession) {
1795
+ voiceLogger.log(`Marking channel ${channelId} for cleanup when sessions complete`);
1796
+ genAiSession.pendingCleanup = true;
1797
+ // If no active sessions, trigger cleanup immediately (with grace period)
1798
+ if (!genAiSession.hasActiveSessions) {
1799
+ voiceLogger.log(`No active sessions, scheduling cleanup for channel ${channelId} in 1 minute`);
1800
+ // Clear any existing timer
1801
+ if (genAiSession.cleanupTimer) {
1802
+ clearTimeout(genAiSession.cleanupTimer);
1803
+ }
1804
+ // Schedule cleanup after 1 minute grace period
1805
+ genAiSession.cleanupTimer = setTimeout(() => {
1806
+ // Double-check that cleanup is still needed
1807
+ const currentSession = genAiSessions.get(channelId);
1808
+ if (currentSession?.pendingCleanup &&
1809
+ !currentSession.hasActiveSessions) {
1810
+ voiceLogger.log(`Grace period expired, cleaning up GenAI session for channel ${channelId}`);
1811
+ cleanupGenAiSession(channelId);
1812
+ }
1813
+ }, 60000); // 1 minute
1814
+ }
1815
+ }
1816
+ }
1545
1817
  }
1546
1818
  catch (error) {
1547
1819
  voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error);
@@ -1584,7 +1856,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1584
1856
  if (!hasOtherAdmins) {
1585
1857
  voiceLogger.log(`No other admins in channel, bot leaving voice channel in guild: ${guild.name}`);
1586
1858
  // Properly clean up all resources
1587
- await cleanupVoiceConnection(guildId);
1859
+ await cleanupVoiceConnection(guildId, oldState.channelId);
1588
1860
  }
1589
1861
  else {
1590
1862
  voiceLogger.log(`Other admins still in channel, bot staying in voice channel`);
@@ -1620,6 +1892,15 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1620
1892
  selfDeaf: false,
1621
1893
  selfMute: false,
1622
1894
  });
1895
+ // Set up voice handling for the new channel
1896
+ // This will reuse existing GenAI session if one exists
1897
+ await setupVoiceHandling({
1898
+ connection: voiceData.connection,
1899
+ guildId,
1900
+ channelId: voiceChannel.id,
1901
+ appId: currentAppId,
1902
+ cleanupGenAiSession,
1903
+ });
1623
1904
  }
1624
1905
  }
1625
1906
  else {
@@ -1678,6 +1959,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1678
1959
  guildId: newState.guild.id,
1679
1960
  channelId: voiceChannel.id,
1680
1961
  appId: currentAppId,
1962
+ cleanupGenAiSession,
1681
1963
  });
1682
1964
  // Handle connection state changes
1683
1965
  connection.on(VoiceConnectionStatus.Disconnected, async () => {
@@ -1700,7 +1982,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1700
1982
  connection.on(VoiceConnectionStatus.Destroyed, async () => {
1701
1983
  voiceLogger.log(`Connection destroyed for guild: ${newState.guild.name}`);
1702
1984
  // Use the cleanup function to ensure everything is properly closed
1703
- await cleanupVoiceConnection(newState.guild.id);
1985
+ await cleanupVoiceConnection(newState.guild.id, voiceChannel.id);
1704
1986
  });
1705
1987
  // Handle errors
1706
1988
  connection.on('error', (error) => {
@@ -1709,7 +1991,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1709
1991
  }
1710
1992
  catch (error) {
1711
1993
  voiceLogger.error(`Failed to join voice channel:`, error);
1712
- await cleanupVoiceConnection(newState.guild.id);
1994
+ await cleanupVoiceConnection(newState.guild.id, voiceChannel.id);
1713
1995
  }
1714
1996
  }
1715
1997
  catch (error) {
@@ -1727,18 +2009,32 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1727
2009
  ;
1728
2010
  global.shuttingDown = true;
1729
2011
  try {
1730
- // Clean up all voice connections (this includes GenAI workers and audio streams)
1731
- const cleanupPromises = [];
1732
- for (const [guildId] of voiceConnections) {
2012
+ // Clean up all voice connections
2013
+ const voiceCleanupPromises = [];
2014
+ for (const [guildId, voiceData] of voiceConnections) {
1733
2015
  voiceLogger.log(`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`);
1734
- cleanupPromises.push(cleanupVoiceConnection(guildId));
2016
+ // Find the channel ID for this connection
2017
+ const channelId = voiceData.connection.joinConfig.channelId || undefined;
2018
+ voiceCleanupPromises.push(cleanupVoiceConnection(guildId, channelId));
1735
2019
  }
1736
- // Wait for all cleanups to complete
1737
- if (cleanupPromises.length > 0) {
1738
- voiceLogger.log(`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`);
1739
- await Promise.allSettled(cleanupPromises);
2020
+ // Wait for all voice cleanups to complete
2021
+ if (voiceCleanupPromises.length > 0) {
2022
+ voiceLogger.log(`[SHUTDOWN] Waiting for ${voiceCleanupPromises.length} voice connection(s) to clean up...`);
2023
+ await Promise.allSettled(voiceCleanupPromises);
1740
2024
  discordLogger.log(`All voice connections cleaned up`);
1741
2025
  }
2026
+ // Clean up all GenAI sessions (force cleanup regardless of active sessions)
2027
+ const genAiCleanupPromises = [];
2028
+ for (const [channelId, session] of genAiSessions) {
2029
+ voiceLogger.log(`[SHUTDOWN] Cleaning up GenAI session for channel ${channelId} (active sessions: ${session.hasActiveSessions})`);
2030
+ genAiCleanupPromises.push(cleanupGenAiSession(channelId));
2031
+ }
2032
+ // Wait for all GenAI cleanups to complete
2033
+ if (genAiCleanupPromises.length > 0) {
2034
+ voiceLogger.log(`[SHUTDOWN] Waiting for ${genAiCleanupPromises.length} GenAI session(s) to clean up...`);
2035
+ await Promise.allSettled(genAiCleanupPromises);
2036
+ discordLogger.log(`All GenAI sessions cleaned up`);
2037
+ }
1742
2038
  // Kill all OpenCode servers
1743
2039
  for (const [dir, server] of opencodeServers) {
1744
2040
  if (!server.process.killed) {
@@ -1748,7 +2044,10 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1748
2044
  }
1749
2045
  opencodeServers.clear();
1750
2046
  discordLogger.log('Closing database...');
1751
- getDatabase().close();
2047
+ if (db) {
2048
+ db.close();
2049
+ db = null;
2050
+ }
1752
2051
  discordLogger.log('Destroying Discord client...');
1753
2052
  discordClient.destroy();
1754
2053
  discordLogger.log('Cleanup complete, exiting.');
@@ -20,6 +20,9 @@ export function createGenAIWorker(options) {
20
20
  case 'assistantInterruptSpeaking':
21
21
  options.onAssistantInterruptSpeaking?.();
22
22
  break;
23
+ case 'allSessionsCompleted':
24
+ options.onAllSessionsCompleted?.();
25
+ break;
23
26
  case 'toolCallCompleted':
24
27
  options.onToolCallCompleted?.(message);
25
28
  break;
@@ -205,6 +205,11 @@ parentPort.on('message', async (message) => {
205
205
  ...params,
206
206
  });
207
207
  },
208
+ onAllSessionsCompleted: () => {
209
+ parentPort.postMessage({
210
+ type: 'allSessionsCompleted',
211
+ });
212
+ },
208
213
  });
209
214
  // Start GenAI session
210
215
  session = await startGenAiSession({