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/dist/cli.js +63 -12
- package/dist/discordBot.js +341 -42
- package/dist/genai-worker-wrapper.js +3 -0
- package/dist/genai-worker.js +5 -0
- package/dist/tools.js +46 -5
- package/package.json +1 -1
- package/src/cli.ts +80 -10
- package/src/discordBot.ts +453 -45
- package/src/genai-worker-wrapper.ts +4 -0
- package/src/genai-worker.ts +5 -0
- package/src/tools.ts +59 -4
- package/src/worker-types.ts +3 -0
package/dist/discordBot.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 === '
|
|
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 (
|
|
1472
|
-
return
|
|
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(`
|
|
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
|
|
1731
|
-
const
|
|
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
|
-
|
|
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 (
|
|
1738
|
-
voiceLogger.log(`[SHUTDOWN] Waiting for ${
|
|
1739
|
-
await Promise.allSettled(
|
|
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
|
-
|
|
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;
|
package/dist/genai-worker.js
CHANGED
|
@@ -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({
|