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/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { Events, ChannelType, REST, Routes, SlashCommandBuilder, } from 'discord
7
7
  import path from 'node:path';
8
8
  import fs from 'node:fs';
9
9
  import { createLogger } from './logger.js';
10
+ import { spawnSync, execSync } from 'node:child_process';
10
11
  const cliLogger = createLogger('CLI');
11
12
  const cli = cac('kimaki');
12
13
  process.title = 'kimaki';
@@ -75,6 +76,56 @@ async function ensureKimakiCategory(guild) {
75
76
  async function run({ restart, addChannels }) {
76
77
  const forceSetup = Boolean(restart);
77
78
  intro('🤖 Discord Bot Setup');
79
+ // Step 0: Check if OpenCode CLI is available
80
+ const opencodeCheck = spawnSync('which', ['opencode'], { shell: true });
81
+ if (opencodeCheck.status !== 0) {
82
+ note('OpenCode CLI is required but not found in your PATH.', '⚠️ OpenCode Not Found');
83
+ const shouldInstall = await confirm({
84
+ message: 'Would you like to install OpenCode right now?',
85
+ });
86
+ if (isCancel(shouldInstall) || !shouldInstall) {
87
+ cancel('OpenCode CLI is required to run this bot');
88
+ process.exit(0);
89
+ }
90
+ const s = spinner();
91
+ s.start('Installing OpenCode CLI...');
92
+ try {
93
+ execSync('curl -fsSL https://opencode.ai/install | bash', {
94
+ stdio: 'inherit',
95
+ shell: '/bin/bash',
96
+ });
97
+ s.stop('OpenCode CLI installed successfully!');
98
+ // The install script adds opencode to PATH via shell configuration
99
+ // For the current process, we need to check common installation paths
100
+ const possiblePaths = [
101
+ `${process.env.HOME}/.local/bin/opencode`,
102
+ `${process.env.HOME}/.opencode/bin/opencode`,
103
+ '/usr/local/bin/opencode',
104
+ '/opt/opencode/bin/opencode',
105
+ ];
106
+ const installedPath = possiblePaths.find((p) => {
107
+ try {
108
+ fs.accessSync(p, fs.constants.F_OK);
109
+ return true;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ });
115
+ if (!installedPath) {
116
+ note('OpenCode was installed but may not be available in this session.\n' +
117
+ 'Please restart your terminal and run this command again.', '⚠️ Restart Required');
118
+ process.exit(0);
119
+ }
120
+ // For subsequent spawn calls in this session, we can use the full path
121
+ process.env.OPENCODE_PATH = installedPath;
122
+ }
123
+ catch (error) {
124
+ s.stop('Failed to install OpenCode CLI');
125
+ cliLogger.error('Installation error:', error instanceof Error ? error.message : String(error));
126
+ process.exit(EXIT_NO_RESTART);
127
+ }
128
+ }
78
129
  const db = getDatabase();
79
130
  let appId;
80
131
  let token;
@@ -231,7 +282,7 @@ async function run({ restart, addChannels }) {
231
282
  s.start('Fetching OpenCode projects...');
232
283
  let projects = [];
233
284
  try {
234
- const projectsResponse = await getClient().project.list();
285
+ const projectsResponse = await getClient().project.list({});
235
286
  if (!projectsResponse.data) {
236
287
  throw new Error('Failed to fetch projects');
237
288
  }
@@ -270,23 +321,23 @@ async function run({ restart, addChannels }) {
270
321
  }
271
322
  if (guilds.length === 1) {
272
323
  targetGuild = guilds[0];
324
+ note(`Using server: ${targetGuild.name}`, 'Server Selected');
273
325
  }
274
326
  else {
275
- const guildId = await text({
276
- message: 'Enter the Discord server ID to create channels in:',
277
- placeholder: guilds[0]?.id,
278
- validate(value) {
279
- if (!value)
280
- return 'Server ID is required';
281
- if (!guilds.find((g) => g.id === value))
282
- return 'Invalid server ID';
283
- },
327
+ const guildSelection = await multiselect({
328
+ message: 'Select a Discord server to create channels in:',
329
+ options: guilds.map((guild) => ({
330
+ value: guild.id,
331
+ label: `${guild.name} (${guild.memberCount} members)`,
332
+ })),
333
+ required: true,
334
+ maxItems: 1,
284
335
  });
285
- if (isCancel(guildId)) {
336
+ if (isCancel(guildSelection)) {
286
337
  cancel('Setup cancelled');
287
338
  process.exit(0);
288
339
  }
289
- targetGuild = guilds.find((g) => g.id === guildId);
340
+ targetGuild = guilds.find((g) => g.id === guildSelection[0]);
290
341
  }
291
342
  s.start('Creating Discord channels...');
292
343
  for (const projectId of selectedProjects) {
@@ -27,8 +27,11 @@ const dbLogger = createLogger('DB');
27
27
  const opencodeServers = new Map();
28
28
  // Map of session ID to current AbortController
29
29
  const abortControllers = new Map();
30
- // Map of guild ID to voice connection and GenAI worker
30
+ // Map of guild ID to voice connection
31
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();
32
35
  // Map of directory to retry count for server restarts
33
36
  const serverRetryCount = new Map();
34
37
  let db = null;
@@ -79,7 +82,7 @@ async function createUserAudioLogStream(guildId, channelId) {
79
82
  }
80
83
  }
81
84
  // Set up voice handling for a connection (called once per connection)
82
- async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
85
+ async function setupVoiceHandling({ connection, guildId, channelId, appId, cleanupGenAiSession, }) {
83
86
  voiceLogger.log(`Setting up voice handling for guild ${guildId}, channel ${channelId}`);
84
87
  // Check if this voice channel has an associated directory
85
88
  const channelDirRow = getDatabase()
@@ -99,11 +102,28 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
99
102
  }
100
103
  // Create user audio stream for debugging
101
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
+ }
102
121
  // Get API keys from database
103
122
  const apiKeys = getDatabase()
104
123
  .prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
105
124
  .get(appId);
106
- // Create GenAI worker
125
+ // Track if sessions are active
126
+ let hasActiveSessions = false;
107
127
  const genAiWorker = await createGenAIWorker({
108
128
  directory,
109
129
  guildId,
@@ -171,28 +191,75 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
171
191
  genAiWorker.interrupt();
172
192
  connection.setSpeaking(false);
173
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
+ },
174
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`);
175
226
  const text = params.error
176
227
  ? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${params.error?.message || String(params.error)}\n</systemMessage>`
177
228
  : `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`;
178
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
+ }
179
236
  },
180
237
  onError(error) {
181
238
  voiceLogger.error('GenAI worker error:', error);
182
239
  },
183
240
  });
184
- // Stop any existing GenAI worker before storing new one
185
- if (voiceData.genAiWorker) {
186
- voiceLogger.log('Stopping existing GenAI worker before creating new one');
187
- await voiceData.genAiWorker.stop();
188
- }
189
241
  // Send initial greeting
190
242
  genAiWorker.sendTextInput(`<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`);
191
- 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
+ });
192
253
  // Set up voice receiver for user input
193
254
  const receiver = connection.receiver;
194
255
  // Remove all existing listeners to prevent accumulation
195
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
+ }
196
263
  // Counter to track overlapping speaking sessions
197
264
  let speakingSessionCount = 0;
198
265
  receiver.speaking.on('start', (userId) => {
@@ -239,15 +306,16 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
239
306
  // )
240
307
  return;
241
308
  }
242
- if (!voiceData.genAiWorker) {
243
- 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}`);
244
312
  return;
245
313
  }
246
314
  // voiceLogger.debug('User audio chunk length', frame.length)
247
315
  // Write to PCM file if stream exists
248
316
  voiceData.userAudioStream?.write(frame);
249
317
  // stream incrementally — low latency
250
- voiceData.genAiWorker.sendRealtimeInput({
318
+ genAiSession.genAiWorker.sendRealtimeInput({
251
319
  audio: {
252
320
  mimeType: 'audio/pcm;rate=16000',
253
321
  data: frame.toString('base64'),
@@ -258,7 +326,8 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
258
326
  // Only send audioStreamEnd if this is still the current session
259
327
  if (currentSessionCount === speakingSessionCount) {
260
328
  voiceLogger.log(`User ${userId} stopped speaking (session ${currentSessionCount})`);
261
- voiceData.genAiWorker?.sendRealtimeInput({
329
+ const genAiSession = genAiSessions.get(channelId);
330
+ genAiSession?.genAiWorker.sendRealtimeInput({
262
331
  audioStreamEnd: true,
263
332
  });
264
333
  }
@@ -281,7 +350,7 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
281
350
  });
282
351
  });
283
352
  }
284
- export function frameMono16khz() {
353
+ function frameMono16khz() {
285
354
  // Hardcoded: 16 kHz, mono, 16-bit PCM, 20 ms -> 320 samples -> 640 bytes
286
355
  const FRAME_BYTES = (100 /*ms*/ * 16_000 /*Hz*/ * 1 /*channels*/ * 2) /*bytes per sample*/ /
287
356
  1000;
@@ -608,7 +677,8 @@ export async function initializeOpencodeForDirectory(directory) {
608
677
  // console.log(
609
678
  // `[OPENCODE] Starting new server on port ${port} for directory: ${directory}`,
610
679
  // )
611
- 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()], {
612
682
  stdio: 'pipe',
613
683
  detached: false,
614
684
  cwd: directory,
@@ -648,7 +718,18 @@ export async function initializeOpencodeForDirectory(directory) {
648
718
  }
649
719
  });
650
720
  await waitForServer(port);
651
- 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
+ });
652
733
  opencodeServers.set(directory, {
653
734
  process: serverProcess,
654
735
  client,
@@ -730,7 +811,7 @@ function formatPart(part) {
730
811
  const icon = part.state.status === 'completed'
731
812
  ? '◼︎'
732
813
  : part.state.status === 'error'
733
- ? '✖️'
814
+ ? ''
734
815
  : '';
735
816
  const title = `${icon} ${part.tool} ${toolTitle}`;
736
817
  let text = title;
@@ -1400,11 +1481,147 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1400
1481
  await interaction.respond([]);
1401
1482
  }
1402
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
+ }
1403
1554
  }
1404
1555
  // Handle slash commands
1405
1556
  if (interaction.isChatInputCommand()) {
1406
1557
  const command = interaction;
1407
- 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') {
1408
1625
  await command.deferReply({ ephemeral: false });
1409
1626
  const sessionId = command.options.getString('session', true);
1410
1627
  const channel = command.channel;
@@ -1476,11 +1693,11 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1476
1693
  for (const message of messages) {
1477
1694
  if (message.info.role === 'user') {
1478
1695
  // Render user messages
1479
- const userParts = message.parts.filter((p) => p.type === 'text');
1696
+ const userParts = message.parts.filter((p) => p.type === 'text' && !p.synthetic);
1480
1697
  const userTexts = userParts
1481
1698
  .map((p) => {
1482
- if (typeof p.text === 'string') {
1483
- return extractNonXmlContent(p.text);
1699
+ if (p.type === 'text') {
1700
+ return p.text;
1484
1701
  }
1485
1702
  return '';
1486
1703
  })
@@ -1520,19 +1737,37 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1520
1737
  voiceLogger.error('[INTERACTION] Error handling interaction:', error);
1521
1738
  }
1522
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
+ }
1523
1764
  // Helper function to clean up voice connection and associated resources
1524
- async function cleanupVoiceConnection(guildId) {
1765
+ async function cleanupVoiceConnection(guildId, channelId) {
1525
1766
  const voiceData = voiceConnections.get(guildId);
1526
1767
  if (!voiceData)
1527
1768
  return;
1528
1769
  voiceLogger.log(`Starting cleanup for guild ${guildId}`);
1529
1770
  try {
1530
- // Stop GenAI worker if exists (this is async!)
1531
- if (voiceData.genAiWorker) {
1532
- voiceLogger.log(`Stopping GenAI worker...`);
1533
- await voiceData.genAiWorker.stop();
1534
- voiceLogger.log(`GenAI worker stopped`);
1535
- }
1536
1771
  // Close user audio stream if exists
1537
1772
  if (voiceData.userAudioStream) {
1538
1773
  voiceLogger.log(`Closing user audio stream...`);
@@ -1552,7 +1787,33 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1552
1787
  }
1553
1788
  // Remove from map
1554
1789
  voiceConnections.delete(guildId);
1555
- 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
+ }
1556
1817
  }
1557
1818
  catch (error) {
1558
1819
  voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error);
@@ -1595,7 +1856,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1595
1856
  if (!hasOtherAdmins) {
1596
1857
  voiceLogger.log(`No other admins in channel, bot leaving voice channel in guild: ${guild.name}`);
1597
1858
  // Properly clean up all resources
1598
- await cleanupVoiceConnection(guildId);
1859
+ await cleanupVoiceConnection(guildId, oldState.channelId);
1599
1860
  }
1600
1861
  else {
1601
1862
  voiceLogger.log(`Other admins still in channel, bot staying in voice channel`);
@@ -1631,6 +1892,15 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1631
1892
  selfDeaf: false,
1632
1893
  selfMute: false,
1633
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
+ });
1634
1904
  }
1635
1905
  }
1636
1906
  else {
@@ -1689,6 +1959,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1689
1959
  guildId: newState.guild.id,
1690
1960
  channelId: voiceChannel.id,
1691
1961
  appId: currentAppId,
1962
+ cleanupGenAiSession,
1692
1963
  });
1693
1964
  // Handle connection state changes
1694
1965
  connection.on(VoiceConnectionStatus.Disconnected, async () => {
@@ -1711,7 +1982,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1711
1982
  connection.on(VoiceConnectionStatus.Destroyed, async () => {
1712
1983
  voiceLogger.log(`Connection destroyed for guild: ${newState.guild.name}`);
1713
1984
  // Use the cleanup function to ensure everything is properly closed
1714
- await cleanupVoiceConnection(newState.guild.id);
1985
+ await cleanupVoiceConnection(newState.guild.id, voiceChannel.id);
1715
1986
  });
1716
1987
  // Handle errors
1717
1988
  connection.on('error', (error) => {
@@ -1720,7 +1991,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1720
1991
  }
1721
1992
  catch (error) {
1722
1993
  voiceLogger.error(`Failed to join voice channel:`, error);
1723
- await cleanupVoiceConnection(newState.guild.id);
1994
+ await cleanupVoiceConnection(newState.guild.id, voiceChannel.id);
1724
1995
  }
1725
1996
  }
1726
1997
  catch (error) {
@@ -1738,18 +2009,32 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1738
2009
  ;
1739
2010
  global.shuttingDown = true;
1740
2011
  try {
1741
- // Clean up all voice connections (this includes GenAI workers and audio streams)
1742
- const cleanupPromises = [];
1743
- for (const [guildId] of voiceConnections) {
2012
+ // Clean up all voice connections
2013
+ const voiceCleanupPromises = [];
2014
+ for (const [guildId, voiceData] of voiceConnections) {
1744
2015
  voiceLogger.log(`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`);
1745
- cleanupPromises.push(cleanupVoiceConnection(guildId));
1746
- }
1747
- // Wait for all cleanups to complete
1748
- if (cleanupPromises.length > 0) {
1749
- voiceLogger.log(`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`);
1750
- await Promise.allSettled(cleanupPromises);
2016
+ // Find the channel ID for this connection
2017
+ const channelId = voiceData.connection.joinConfig.channelId || undefined;
2018
+ voiceCleanupPromises.push(cleanupVoiceConnection(guildId, channelId));
2019
+ }
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);
1751
2024
  discordLogger.log(`All voice connections cleaned up`);
1752
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
+ }
1753
2038
  // Kill all OpenCode servers
1754
2039
  for (const [dir, server] of opencodeServers) {
1755
2040
  if (!server.process.killed) {
@@ -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;