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 +63 -12
- package/dist/discordBot.js +326 -41
- 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 +434 -43
- 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/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
|
|
276
|
-
message: '
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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(
|
|
336
|
+
if (isCancel(guildSelection)) {
|
|
286
337
|
cancel('Setup cancelled');
|
|
287
338
|
process.exit(0);
|
|
288
339
|
}
|
|
289
|
-
targetGuild = guilds.find((g) => g.id ===
|
|
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) {
|
package/dist/discordBot.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 === '
|
|
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 (
|
|
1483
|
-
return
|
|
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(`
|
|
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
|
|
1742
|
-
const
|
|
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
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
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;
|