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/src/discordBot.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { spawn, exec, type ChildProcess } from 'node:child_process'
|
|
|
34
34
|
import fs, { createWriteStream } from 'node:fs'
|
|
35
35
|
import { mkdir } from 'node:fs/promises'
|
|
36
36
|
import net from 'node:net'
|
|
37
|
+
import os from 'node:os'
|
|
37
38
|
import path from 'node:path'
|
|
38
39
|
import { promisify } from 'node:util'
|
|
39
40
|
import { PassThrough, Transform, type TransformCallback } from 'node:stream'
|
|
@@ -69,16 +70,30 @@ const opencodeServers = new Map<
|
|
|
69
70
|
// Map of session ID to current AbortController
|
|
70
71
|
const abortControllers = new Map<string, AbortController>()
|
|
71
72
|
|
|
72
|
-
// Map of guild ID to voice connection
|
|
73
|
+
// Map of guild ID to voice connection
|
|
73
74
|
const voiceConnections = new Map<
|
|
74
75
|
string,
|
|
75
76
|
{
|
|
76
77
|
connection: VoiceConnection
|
|
77
|
-
genAiWorker?: GenAIWorker
|
|
78
78
|
userAudioStream?: fs.WriteStream
|
|
79
79
|
}
|
|
80
80
|
>()
|
|
81
81
|
|
|
82
|
+
// Map of channel ID to GenAI worker and session state
|
|
83
|
+
// This allows sessions to persist across voice channel changes
|
|
84
|
+
const genAiSessions = new Map<
|
|
85
|
+
string,
|
|
86
|
+
{
|
|
87
|
+
genAiWorker: GenAIWorker
|
|
88
|
+
hasActiveSessions: boolean // Track if there are active OpenCode sessions
|
|
89
|
+
pendingCleanup: boolean // Track if cleanup was requested (user left channel)
|
|
90
|
+
cleanupTimer?: NodeJS.Timeout // Timer for delayed cleanup
|
|
91
|
+
guildId: string
|
|
92
|
+
channelId: string
|
|
93
|
+
directory: string
|
|
94
|
+
}
|
|
95
|
+
>()
|
|
96
|
+
|
|
82
97
|
// Map of directory to retry count for server restarts
|
|
83
98
|
const serverRetryCount = new Map<string, number>()
|
|
84
99
|
|
|
@@ -153,11 +168,13 @@ async function setupVoiceHandling({
|
|
|
153
168
|
guildId,
|
|
154
169
|
channelId,
|
|
155
170
|
appId,
|
|
171
|
+
cleanupGenAiSession,
|
|
156
172
|
}: {
|
|
157
173
|
connection: VoiceConnection
|
|
158
174
|
guildId: string
|
|
159
175
|
channelId: string
|
|
160
176
|
appId: string
|
|
177
|
+
cleanupGenAiSession: (channelId: string) => Promise<void>
|
|
161
178
|
}) {
|
|
162
179
|
voiceLogger.log(
|
|
163
180
|
`Setting up voice handling for guild ${guildId}, channel ${channelId}`,
|
|
@@ -190,12 +207,36 @@ async function setupVoiceHandling({
|
|
|
190
207
|
// Create user audio stream for debugging
|
|
191
208
|
voiceData.userAudioStream = await createUserAudioLogStream(guildId, channelId)
|
|
192
209
|
|
|
210
|
+
// Check if we already have a GenAI session for this channel
|
|
211
|
+
const existingSession = genAiSessions.get(channelId)
|
|
212
|
+
if (existingSession) {
|
|
213
|
+
voiceLogger.log(`Reusing existing GenAI session for channel ${channelId}`)
|
|
214
|
+
|
|
215
|
+
// Cancel any pending cleanup since user has returned
|
|
216
|
+
if (existingSession.pendingCleanup) {
|
|
217
|
+
voiceLogger.log(
|
|
218
|
+
`Cancelling pending cleanup for channel ${channelId} - user returned`,
|
|
219
|
+
)
|
|
220
|
+
existingSession.pendingCleanup = false
|
|
221
|
+
|
|
222
|
+
if (existingSession.cleanupTimer) {
|
|
223
|
+
clearTimeout(existingSession.cleanupTimer)
|
|
224
|
+
existingSession.cleanupTimer = undefined
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Session already exists, just update the voice handling
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
193
232
|
// Get API keys from database
|
|
194
233
|
const apiKeys = getDatabase()
|
|
195
234
|
.prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
|
|
196
235
|
.get(appId) as { gemini_api_key: string | null } | undefined
|
|
197
236
|
|
|
198
|
-
//
|
|
237
|
+
// Track if sessions are active
|
|
238
|
+
let hasActiveSessions = false
|
|
239
|
+
|
|
199
240
|
const genAiWorker = await createGenAIWorker({
|
|
200
241
|
directory,
|
|
201
242
|
guildId,
|
|
@@ -263,30 +304,82 @@ async function setupVoiceHandling({
|
|
|
263
304
|
genAiWorker.interrupt()
|
|
264
305
|
connection.setSpeaking(false)
|
|
265
306
|
},
|
|
307
|
+
onAllSessionsCompleted() {
|
|
308
|
+
// All OpenCode sessions have completed
|
|
309
|
+
hasActiveSessions = false
|
|
310
|
+
voiceLogger.log('All OpenCode sessions completed for this GenAI session')
|
|
311
|
+
|
|
312
|
+
// Update the stored session state
|
|
313
|
+
const session = genAiSessions.get(channelId)
|
|
314
|
+
if (session) {
|
|
315
|
+
session.hasActiveSessions = false
|
|
316
|
+
|
|
317
|
+
// If cleanup is pending (user left channel), schedule cleanup with grace period
|
|
318
|
+
if (session.pendingCleanup) {
|
|
319
|
+
voiceLogger.log(
|
|
320
|
+
`Scheduling cleanup for channel ${channelId} in 1 minute`,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
// Clear any existing timer
|
|
324
|
+
if (session.cleanupTimer) {
|
|
325
|
+
clearTimeout(session.cleanupTimer)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Schedule cleanup after 1 minute grace period
|
|
329
|
+
session.cleanupTimer = setTimeout(() => {
|
|
330
|
+
// Double-check that cleanup is still needed
|
|
331
|
+
const currentSession = genAiSessions.get(channelId)
|
|
332
|
+
if (
|
|
333
|
+
currentSession?.pendingCleanup &&
|
|
334
|
+
!currentSession.hasActiveSessions
|
|
335
|
+
) {
|
|
336
|
+
voiceLogger.log(
|
|
337
|
+
`Grace period expired, cleaning up GenAI session for channel ${channelId}`,
|
|
338
|
+
)
|
|
339
|
+
// Use the main cleanup function - defined later in startDiscordBot
|
|
340
|
+
cleanupGenAiSession(channelId)
|
|
341
|
+
}
|
|
342
|
+
}, 60000) // 1 minute
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
},
|
|
266
346
|
onToolCallCompleted(params) {
|
|
347
|
+
// Note: We now track at the tools.ts level, but still handle completion messages
|
|
348
|
+
voiceLogger.log(`OpenCode session ${params.sessionId} completed`)
|
|
349
|
+
|
|
267
350
|
const text = params.error
|
|
268
351
|
? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${params.error?.message || String(params.error)}\n</systemMessage>`
|
|
269
352
|
: `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`
|
|
270
353
|
|
|
271
354
|
genAiWorker.sendTextInput(text)
|
|
355
|
+
|
|
356
|
+
// Mark that we have active sessions (will be updated by onAllSessionsCompleted when done)
|
|
357
|
+
hasActiveSessions = true
|
|
358
|
+
const session = genAiSessions.get(channelId)
|
|
359
|
+
if (session) {
|
|
360
|
+
session.hasActiveSessions = true
|
|
361
|
+
}
|
|
272
362
|
},
|
|
273
363
|
onError(error) {
|
|
274
364
|
voiceLogger.error('GenAI worker error:', error)
|
|
275
365
|
},
|
|
276
366
|
})
|
|
277
367
|
|
|
278
|
-
// Stop any existing GenAI worker before storing new one
|
|
279
|
-
if (voiceData.genAiWorker) {
|
|
280
|
-
voiceLogger.log('Stopping existing GenAI worker before creating new one')
|
|
281
|
-
await voiceData.genAiWorker.stop()
|
|
282
|
-
}
|
|
283
|
-
|
|
284
368
|
// Send initial greeting
|
|
285
369
|
genAiWorker.sendTextInput(
|
|
286
370
|
`<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`,
|
|
287
371
|
)
|
|
288
372
|
|
|
289
|
-
|
|
373
|
+
// Store the GenAI session
|
|
374
|
+
genAiSessions.set(channelId, {
|
|
375
|
+
genAiWorker,
|
|
376
|
+
hasActiveSessions,
|
|
377
|
+
pendingCleanup: false,
|
|
378
|
+
cleanupTimer: undefined,
|
|
379
|
+
guildId,
|
|
380
|
+
channelId,
|
|
381
|
+
directory,
|
|
382
|
+
})
|
|
290
383
|
|
|
291
384
|
// Set up voice receiver for user input
|
|
292
385
|
const receiver = connection.receiver
|
|
@@ -294,6 +387,15 @@ async function setupVoiceHandling({
|
|
|
294
387
|
// Remove all existing listeners to prevent accumulation
|
|
295
388
|
receiver.speaking.removeAllListeners('start')
|
|
296
389
|
|
|
390
|
+
// Get the GenAI session for this channel
|
|
391
|
+
const genAiSession = genAiSessions.get(channelId)
|
|
392
|
+
if (!genAiSession) {
|
|
393
|
+
voiceLogger.error(
|
|
394
|
+
`GenAI session was just created but not found for channel ${channelId}`,
|
|
395
|
+
)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
|
|
297
399
|
// Counter to track overlapping speaking sessions
|
|
298
400
|
let speakingSessionCount = 0
|
|
299
401
|
|
|
@@ -349,9 +451,10 @@ async function setupVoiceHandling({
|
|
|
349
451
|
return
|
|
350
452
|
}
|
|
351
453
|
|
|
352
|
-
|
|
454
|
+
const genAiSession = genAiSessions.get(channelId)
|
|
455
|
+
if (!genAiSession) {
|
|
353
456
|
voiceLogger.warn(
|
|
354
|
-
`[VOICE] Received audio frame but no GenAI
|
|
457
|
+
`[VOICE] Received audio frame but no GenAI session active for channel ${channelId}`,
|
|
355
458
|
)
|
|
356
459
|
return
|
|
357
460
|
}
|
|
@@ -361,7 +464,7 @@ async function setupVoiceHandling({
|
|
|
361
464
|
voiceData.userAudioStream?.write(frame)
|
|
362
465
|
|
|
363
466
|
// stream incrementally — low latency
|
|
364
|
-
|
|
467
|
+
genAiSession.genAiWorker.sendRealtimeInput({
|
|
365
468
|
audio: {
|
|
366
469
|
mimeType: 'audio/pcm;rate=16000',
|
|
367
470
|
data: frame.toString('base64'),
|
|
@@ -374,7 +477,8 @@ async function setupVoiceHandling({
|
|
|
374
477
|
voiceLogger.log(
|
|
375
478
|
`User ${userId} stopped speaking (session ${currentSessionCount})`,
|
|
376
479
|
)
|
|
377
|
-
|
|
480
|
+
const genAiSession = genAiSessions.get(channelId)
|
|
481
|
+
genAiSession?.genAiWorker.sendRealtimeInput({
|
|
378
482
|
audioStreamEnd: true,
|
|
379
483
|
})
|
|
380
484
|
} else {
|
|
@@ -402,7 +506,7 @@ async function setupVoiceHandling({
|
|
|
402
506
|
})
|
|
403
507
|
}
|
|
404
508
|
|
|
405
|
-
|
|
509
|
+
function frameMono16khz(): Transform {
|
|
406
510
|
// Hardcoded: 16 kHz, mono, 16-bit PCM, 20 ms -> 320 samples -> 640 bytes
|
|
407
511
|
const FRAME_BYTES =
|
|
408
512
|
(100 /*ms*/ * 16_000 /*Hz*/ * 1 /*channels*/ * 2) /*bytes per sample*/ /
|
|
@@ -453,7 +557,19 @@ export function frameMono16khz(): Transform {
|
|
|
453
557
|
|
|
454
558
|
export function getDatabase(): Database.Database {
|
|
455
559
|
if (!db) {
|
|
456
|
-
|
|
560
|
+
// Create ~/.kimaki directory if it doesn't exist
|
|
561
|
+
const kimakiDir = path.join(os.homedir(), '.kimaki')
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
fs.mkdirSync(kimakiDir, { recursive: true })
|
|
565
|
+
} catch (error) {
|
|
566
|
+
dbLogger.error('Failed to create ~/.kimaki directory:', error)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const dbPath = path.join(kimakiDir, 'discord-sessions.db')
|
|
570
|
+
|
|
571
|
+
dbLogger.log(`Opening database at: ${dbPath}`)
|
|
572
|
+
db = new Database(dbPath)
|
|
457
573
|
|
|
458
574
|
// Initialize tables
|
|
459
575
|
db.exec(`
|
|
@@ -685,7 +801,7 @@ async function processVoiceAttachment({
|
|
|
685
801
|
const apiKeys = getDatabase()
|
|
686
802
|
.prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
|
|
687
803
|
.get(appId) as { gemini_api_key: string | null } | undefined
|
|
688
|
-
|
|
804
|
+
|
|
689
805
|
if (apiKeys?.gemini_api_key) {
|
|
690
806
|
geminiApiKey = apiKeys.gemini_api_key
|
|
691
807
|
}
|
|
@@ -809,8 +925,10 @@ export async function initializeOpencodeForDirectory(directory: string) {
|
|
|
809
925
|
// `[OPENCODE] Starting new server on port ${port} for directory: ${directory}`,
|
|
810
926
|
// )
|
|
811
927
|
|
|
928
|
+
const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
|
|
929
|
+
|
|
812
930
|
const serverProcess = spawn(
|
|
813
|
-
|
|
931
|
+
opencodeCommand,
|
|
814
932
|
['serve', '--port', port.toString()],
|
|
815
933
|
{
|
|
816
934
|
stdio: 'pipe',
|
|
@@ -863,7 +981,23 @@ export async function initializeOpencodeForDirectory(directory: string) {
|
|
|
863
981
|
})
|
|
864
982
|
|
|
865
983
|
await waitForServer(port)
|
|
866
|
-
|
|
984
|
+
|
|
985
|
+
// Create a custom fetch that disables Bun's default timeout
|
|
986
|
+
const customFetch = (
|
|
987
|
+
input: string | URL | Request,
|
|
988
|
+
init?: RequestInit,
|
|
989
|
+
): Promise<Response> => {
|
|
990
|
+
return fetch(input, {
|
|
991
|
+
...init,
|
|
992
|
+
// @ts-ignore - Bun-specific option to disable timeout
|
|
993
|
+
timeout: false,
|
|
994
|
+
})
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const client = createOpencodeClient({
|
|
998
|
+
baseUrl: `http://localhost:${port}`,
|
|
999
|
+
fetch: customFetch,
|
|
1000
|
+
})
|
|
867
1001
|
|
|
868
1002
|
opencodeServers.set(directory, {
|
|
869
1003
|
process: serverProcess,
|
|
@@ -957,7 +1091,7 @@ function formatPart(part: Part): string {
|
|
|
957
1091
|
part.state.status === 'completed'
|
|
958
1092
|
? '◼︎'
|
|
959
1093
|
: part.state.status === 'error'
|
|
960
|
-
? '
|
|
1094
|
+
? '⨯'
|
|
961
1095
|
: ''
|
|
962
1096
|
const title = `${icon} ${part.tool} ${toolTitle}`
|
|
963
1097
|
|
|
@@ -1854,6 +1988,93 @@ export async function startDiscordBot({
|
|
|
1854
1988
|
)
|
|
1855
1989
|
await interaction.respond([])
|
|
1856
1990
|
}
|
|
1991
|
+
} else if (interaction.commandName === 'session') {
|
|
1992
|
+
const focusedOption = interaction.options.getFocused(true)
|
|
1993
|
+
|
|
1994
|
+
if (focusedOption.name === 'files') {
|
|
1995
|
+
const focusedValue = focusedOption.value
|
|
1996
|
+
|
|
1997
|
+
// Split by comma to handle multiple files
|
|
1998
|
+
const parts = focusedValue.split(',')
|
|
1999
|
+
const previousFiles = parts
|
|
2000
|
+
.slice(0, -1)
|
|
2001
|
+
.map((f) => f.trim())
|
|
2002
|
+
.filter((f) => f)
|
|
2003
|
+
const currentQuery = (parts[parts.length - 1] || '').trim()
|
|
2004
|
+
|
|
2005
|
+
// Get the channel's project directory from its topic
|
|
2006
|
+
let projectDirectory: string | undefined
|
|
2007
|
+
if (
|
|
2008
|
+
interaction.channel &&
|
|
2009
|
+
interaction.channel.type === ChannelType.GuildText
|
|
2010
|
+
) {
|
|
2011
|
+
const textChannel = resolveTextChannel(
|
|
2012
|
+
interaction.channel as TextChannel | ThreadChannel | null,
|
|
2013
|
+
)
|
|
2014
|
+
if (textChannel) {
|
|
2015
|
+
const { projectDirectory: directory, channelAppId } =
|
|
2016
|
+
getKimakiMetadata(textChannel)
|
|
2017
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
2018
|
+
await interaction.respond([])
|
|
2019
|
+
return
|
|
2020
|
+
}
|
|
2021
|
+
projectDirectory = directory
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
if (!projectDirectory) {
|
|
2026
|
+
await interaction.respond([])
|
|
2027
|
+
return
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
try {
|
|
2031
|
+
// Get OpenCode client for this directory
|
|
2032
|
+
const getClient =
|
|
2033
|
+
await initializeOpencodeForDirectory(projectDirectory)
|
|
2034
|
+
|
|
2035
|
+
// Use find.files to search for files based on current query
|
|
2036
|
+
const response = await getClient().find.files({
|
|
2037
|
+
query: {
|
|
2038
|
+
query: currentQuery || '',
|
|
2039
|
+
},
|
|
2040
|
+
})
|
|
2041
|
+
|
|
2042
|
+
// Get file paths from the response
|
|
2043
|
+
const files = response.data || []
|
|
2044
|
+
|
|
2045
|
+
// Build the prefix with previous files
|
|
2046
|
+
const prefix =
|
|
2047
|
+
previousFiles.length > 0
|
|
2048
|
+
? previousFiles.join(', ') + ', '
|
|
2049
|
+
: ''
|
|
2050
|
+
|
|
2051
|
+
// Map to Discord autocomplete format
|
|
2052
|
+
const choices = files
|
|
2053
|
+
.slice(0, 25) // Discord limit
|
|
2054
|
+
.map((file: string) => {
|
|
2055
|
+
const fullValue = prefix + file
|
|
2056
|
+
// Get all basenames for display
|
|
2057
|
+
const allFiles = [...previousFiles, file]
|
|
2058
|
+
const allBasenames = allFiles.map(
|
|
2059
|
+
(f) => f.split('/').pop() || f,
|
|
2060
|
+
)
|
|
2061
|
+
let displayName = allBasenames.join(', ')
|
|
2062
|
+
// Truncate if too long
|
|
2063
|
+
if (displayName.length > 100) {
|
|
2064
|
+
displayName = '…' + displayName.slice(-97)
|
|
2065
|
+
}
|
|
2066
|
+
return {
|
|
2067
|
+
name: displayName,
|
|
2068
|
+
value: fullValue,
|
|
2069
|
+
}
|
|
2070
|
+
})
|
|
2071
|
+
|
|
2072
|
+
await interaction.respond(choices)
|
|
2073
|
+
} catch (error) {
|
|
2074
|
+
voiceLogger.error('[AUTOCOMPLETE] Error fetching files:', error)
|
|
2075
|
+
await interaction.respond([])
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
1857
2078
|
}
|
|
1858
2079
|
}
|
|
1859
2080
|
|
|
@@ -1861,7 +2082,100 @@ export async function startDiscordBot({
|
|
|
1861
2082
|
if (interaction.isChatInputCommand()) {
|
|
1862
2083
|
const command = interaction
|
|
1863
2084
|
|
|
1864
|
-
if (command.commandName === '
|
|
2085
|
+
if (command.commandName === 'session') {
|
|
2086
|
+
await command.deferReply({ ephemeral: false })
|
|
2087
|
+
|
|
2088
|
+
const prompt = command.options.getString('prompt', true)
|
|
2089
|
+
const filesString = command.options.getString('files') || ''
|
|
2090
|
+
const channel = command.channel
|
|
2091
|
+
|
|
2092
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
2093
|
+
await command.editReply(
|
|
2094
|
+
'This command can only be used in text channels',
|
|
2095
|
+
)
|
|
2096
|
+
return
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
const textChannel = channel as TextChannel
|
|
2100
|
+
|
|
2101
|
+
// Get project directory from channel topic
|
|
2102
|
+
let projectDirectory: string | undefined
|
|
2103
|
+
let channelAppId: string | undefined
|
|
2104
|
+
|
|
2105
|
+
if (textChannel.topic) {
|
|
2106
|
+
const extracted = extractTagsArrays({
|
|
2107
|
+
xml: textChannel.topic,
|
|
2108
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
2109
|
+
})
|
|
2110
|
+
|
|
2111
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
2112
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// Check if this channel belongs to current bot instance
|
|
2116
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
2117
|
+
await command.editReply(
|
|
2118
|
+
'This channel is not configured for this bot',
|
|
2119
|
+
)
|
|
2120
|
+
return
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
if (!projectDirectory) {
|
|
2124
|
+
await command.editReply(
|
|
2125
|
+
'This channel is not configured with a project directory',
|
|
2126
|
+
)
|
|
2127
|
+
return
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
2131
|
+
await command.editReply(
|
|
2132
|
+
`Directory does not exist: ${projectDirectory}`,
|
|
2133
|
+
)
|
|
2134
|
+
return
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
try {
|
|
2138
|
+
// Initialize OpenCode client for the directory
|
|
2139
|
+
const getClient =
|
|
2140
|
+
await initializeOpencodeForDirectory(projectDirectory)
|
|
2141
|
+
|
|
2142
|
+
// Process file mentions - split by comma only
|
|
2143
|
+
const files = filesString
|
|
2144
|
+
.split(',')
|
|
2145
|
+
.map((f) => f.trim())
|
|
2146
|
+
.filter((f) => f)
|
|
2147
|
+
|
|
2148
|
+
// Build the full prompt with file mentions
|
|
2149
|
+
let fullPrompt = prompt
|
|
2150
|
+
if (files.length > 0) {
|
|
2151
|
+
fullPrompt = `${prompt}\n\n@${files.join(' @')}`
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// Send a message first, then create thread from it
|
|
2155
|
+
const starterMessage = await textChannel.send({
|
|
2156
|
+
content: `🚀 **Starting OpenCode session**\n📝 ${prompt.slice(0, 200)}${prompt.length > 200 ? '…' : ''}${files.length > 0 ? `\n📎 Files: ${files.join(', ')}` : ''}`,
|
|
2157
|
+
})
|
|
2158
|
+
|
|
2159
|
+
// Create thread from the message
|
|
2160
|
+
const thread = await starterMessage.startThread({
|
|
2161
|
+
name: prompt.slice(0, 100),
|
|
2162
|
+
autoArchiveDuration: 1440, // 24 hours
|
|
2163
|
+
reason: 'OpenCode session',
|
|
2164
|
+
})
|
|
2165
|
+
|
|
2166
|
+
await command.editReply(
|
|
2167
|
+
`Created new session in ${thread.toString()}`,
|
|
2168
|
+
)
|
|
2169
|
+
|
|
2170
|
+
// Start the OpenCode session
|
|
2171
|
+
await handleOpencodeSession(fullPrompt, thread, projectDirectory)
|
|
2172
|
+
} catch (error) {
|
|
2173
|
+
voiceLogger.error('[SESSION] Error:', error)
|
|
2174
|
+
await command.editReply(
|
|
2175
|
+
`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2176
|
+
)
|
|
2177
|
+
}
|
|
2178
|
+
} else if (command.commandName === 'resume') {
|
|
1865
2179
|
await command.deferReply({ ephemeral: false })
|
|
1866
2180
|
|
|
1867
2181
|
const sessionId = command.options.getString('session', true)
|
|
@@ -1974,12 +2288,12 @@ export async function startDiscordBot({
|
|
|
1974
2288
|
if (message.info.role === 'user') {
|
|
1975
2289
|
// Render user messages
|
|
1976
2290
|
const userParts = message.parts.filter(
|
|
1977
|
-
(p) => p.type === 'text',
|
|
2291
|
+
(p) => p.type === 'text' && !p.synthetic,
|
|
1978
2292
|
)
|
|
1979
2293
|
const userTexts = userParts
|
|
1980
2294
|
.map((p) => {
|
|
1981
|
-
if (
|
|
1982
|
-
return
|
|
2295
|
+
if (p.type === 'text') {
|
|
2296
|
+
return p.text
|
|
1983
2297
|
}
|
|
1984
2298
|
return ''
|
|
1985
2299
|
})
|
|
@@ -2031,21 +2345,43 @@ export async function startDiscordBot({
|
|
|
2031
2345
|
},
|
|
2032
2346
|
)
|
|
2033
2347
|
|
|
2348
|
+
// Helper function to clean up GenAI session
|
|
2349
|
+
async function cleanupGenAiSession(channelId: string) {
|
|
2350
|
+
const genAiSession = genAiSessions.get(channelId)
|
|
2351
|
+
if (!genAiSession) return
|
|
2352
|
+
|
|
2353
|
+
try {
|
|
2354
|
+
// Clear any cleanup timer
|
|
2355
|
+
if (genAiSession.cleanupTimer) {
|
|
2356
|
+
clearTimeout(genAiSession.cleanupTimer)
|
|
2357
|
+
genAiSession.cleanupTimer = undefined
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
voiceLogger.log(`Stopping GenAI worker for channel ${channelId}...`)
|
|
2361
|
+
await genAiSession.genAiWorker.stop()
|
|
2362
|
+
voiceLogger.log(`GenAI worker stopped for channel ${channelId}`)
|
|
2363
|
+
|
|
2364
|
+
// Remove from map
|
|
2365
|
+
genAiSessions.delete(channelId)
|
|
2366
|
+
voiceLogger.log(`GenAI session cleanup complete for channel ${channelId}`)
|
|
2367
|
+
} catch (error) {
|
|
2368
|
+
voiceLogger.error(
|
|
2369
|
+
`Error during GenAI session cleanup for channel ${channelId}:`,
|
|
2370
|
+
error,
|
|
2371
|
+
)
|
|
2372
|
+
// Still remove from map even if there was an error
|
|
2373
|
+
genAiSessions.delete(channelId)
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2034
2377
|
// Helper function to clean up voice connection and associated resources
|
|
2035
|
-
async function cleanupVoiceConnection(guildId: string) {
|
|
2378
|
+
async function cleanupVoiceConnection(guildId: string, channelId?: string) {
|
|
2036
2379
|
const voiceData = voiceConnections.get(guildId)
|
|
2037
2380
|
if (!voiceData) return
|
|
2038
2381
|
|
|
2039
2382
|
voiceLogger.log(`Starting cleanup for guild ${guildId}`)
|
|
2040
2383
|
|
|
2041
2384
|
try {
|
|
2042
|
-
// Stop GenAI worker if exists (this is async!)
|
|
2043
|
-
if (voiceData.genAiWorker) {
|
|
2044
|
-
voiceLogger.log(`Stopping GenAI worker...`)
|
|
2045
|
-
await voiceData.genAiWorker.stop()
|
|
2046
|
-
voiceLogger.log(`GenAI worker stopped`)
|
|
2047
|
-
}
|
|
2048
|
-
|
|
2049
2385
|
// Close user audio stream if exists
|
|
2050
2386
|
if (voiceData.userAudioStream) {
|
|
2051
2387
|
voiceLogger.log(`Closing user audio stream...`)
|
|
@@ -2069,7 +2405,45 @@ export async function startDiscordBot({
|
|
|
2069
2405
|
|
|
2070
2406
|
// Remove from map
|
|
2071
2407
|
voiceConnections.delete(guildId)
|
|
2072
|
-
voiceLogger.log(`
|
|
2408
|
+
voiceLogger.log(`Voice connection cleanup complete for guild ${guildId}`)
|
|
2409
|
+
|
|
2410
|
+
// Mark the GenAI session for cleanup when all sessions complete
|
|
2411
|
+
if (channelId) {
|
|
2412
|
+
const genAiSession = genAiSessions.get(channelId)
|
|
2413
|
+
if (genAiSession) {
|
|
2414
|
+
voiceLogger.log(
|
|
2415
|
+
`Marking channel ${channelId} for cleanup when sessions complete`,
|
|
2416
|
+
)
|
|
2417
|
+
genAiSession.pendingCleanup = true
|
|
2418
|
+
|
|
2419
|
+
// If no active sessions, trigger cleanup immediately (with grace period)
|
|
2420
|
+
if (!genAiSession.hasActiveSessions) {
|
|
2421
|
+
voiceLogger.log(
|
|
2422
|
+
`No active sessions, scheduling cleanup for channel ${channelId} in 1 minute`,
|
|
2423
|
+
)
|
|
2424
|
+
|
|
2425
|
+
// Clear any existing timer
|
|
2426
|
+
if (genAiSession.cleanupTimer) {
|
|
2427
|
+
clearTimeout(genAiSession.cleanupTimer)
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// Schedule cleanup after 1 minute grace period
|
|
2431
|
+
genAiSession.cleanupTimer = setTimeout(() => {
|
|
2432
|
+
// Double-check that cleanup is still needed
|
|
2433
|
+
const currentSession = genAiSessions.get(channelId)
|
|
2434
|
+
if (
|
|
2435
|
+
currentSession?.pendingCleanup &&
|
|
2436
|
+
!currentSession.hasActiveSessions
|
|
2437
|
+
) {
|
|
2438
|
+
voiceLogger.log(
|
|
2439
|
+
`Grace period expired, cleaning up GenAI session for channel ${channelId}`,
|
|
2440
|
+
)
|
|
2441
|
+
cleanupGenAiSession(channelId)
|
|
2442
|
+
}
|
|
2443
|
+
}, 60000) // 1 minute
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2073
2447
|
} catch (error) {
|
|
2074
2448
|
voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error)
|
|
2075
2449
|
// Still remove from map even if there was an error
|
|
@@ -2127,7 +2501,7 @@ export async function startDiscordBot({
|
|
|
2127
2501
|
)
|
|
2128
2502
|
|
|
2129
2503
|
// Properly clean up all resources
|
|
2130
|
-
await cleanupVoiceConnection(guildId)
|
|
2504
|
+
await cleanupVoiceConnection(guildId, oldState.channelId)
|
|
2131
2505
|
} else {
|
|
2132
2506
|
voiceLogger.log(
|
|
2133
2507
|
`Other admins still in channel, bot staying in voice channel`,
|
|
@@ -2177,6 +2551,16 @@ export async function startDiscordBot({
|
|
|
2177
2551
|
selfDeaf: false,
|
|
2178
2552
|
selfMute: false,
|
|
2179
2553
|
})
|
|
2554
|
+
|
|
2555
|
+
// Set up voice handling for the new channel
|
|
2556
|
+
// This will reuse existing GenAI session if one exists
|
|
2557
|
+
await setupVoiceHandling({
|
|
2558
|
+
connection: voiceData.connection,
|
|
2559
|
+
guildId,
|
|
2560
|
+
channelId: voiceChannel.id,
|
|
2561
|
+
appId: currentAppId!,
|
|
2562
|
+
cleanupGenAiSession,
|
|
2563
|
+
})
|
|
2180
2564
|
}
|
|
2181
2565
|
} else {
|
|
2182
2566
|
voiceLogger.log(
|
|
@@ -2259,6 +2643,7 @@ export async function startDiscordBot({
|
|
|
2259
2643
|
guildId: newState.guild.id,
|
|
2260
2644
|
channelId: voiceChannel.id,
|
|
2261
2645
|
appId: currentAppId!,
|
|
2646
|
+
cleanupGenAiSession,
|
|
2262
2647
|
})
|
|
2263
2648
|
|
|
2264
2649
|
// Handle connection state changes
|
|
@@ -2286,7 +2671,7 @@ export async function startDiscordBot({
|
|
|
2286
2671
|
`Connection destroyed for guild: ${newState.guild.name}`,
|
|
2287
2672
|
)
|
|
2288
2673
|
// Use the cleanup function to ensure everything is properly closed
|
|
2289
|
-
await cleanupVoiceConnection(newState.guild.id)
|
|
2674
|
+
await cleanupVoiceConnection(newState.guild.id, voiceChannel.id)
|
|
2290
2675
|
})
|
|
2291
2676
|
|
|
2292
2677
|
// Handle errors
|
|
@@ -2298,7 +2683,7 @@ export async function startDiscordBot({
|
|
|
2298
2683
|
})
|
|
2299
2684
|
} catch (error) {
|
|
2300
2685
|
voiceLogger.error(`Failed to join voice channel:`, error)
|
|
2301
|
-
await cleanupVoiceConnection(newState.guild.id)
|
|
2686
|
+
await cleanupVoiceConnection(newState.guild.id, voiceChannel.id)
|
|
2302
2687
|
}
|
|
2303
2688
|
} catch (error) {
|
|
2304
2689
|
voiceLogger.error('Error in voice state update handler:', error)
|
|
@@ -2318,24 +2703,44 @@ export async function startDiscordBot({
|
|
|
2318
2703
|
;(global as any).shuttingDown = true
|
|
2319
2704
|
|
|
2320
2705
|
try {
|
|
2321
|
-
// Clean up all voice connections
|
|
2322
|
-
const
|
|
2323
|
-
for (const [guildId] of voiceConnections) {
|
|
2706
|
+
// Clean up all voice connections
|
|
2707
|
+
const voiceCleanupPromises: Promise<void>[] = []
|
|
2708
|
+
for (const [guildId, voiceData] of voiceConnections) {
|
|
2324
2709
|
voiceLogger.log(
|
|
2325
2710
|
`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
|
|
2326
2711
|
)
|
|
2327
|
-
|
|
2712
|
+
// Find the channel ID for this connection
|
|
2713
|
+
const channelId = voiceData.connection.joinConfig.channelId || undefined
|
|
2714
|
+
voiceCleanupPromises.push(cleanupVoiceConnection(guildId, channelId))
|
|
2328
2715
|
}
|
|
2329
2716
|
|
|
2330
|
-
// Wait for all cleanups to complete
|
|
2331
|
-
if (
|
|
2717
|
+
// Wait for all voice cleanups to complete
|
|
2718
|
+
if (voiceCleanupPromises.length > 0) {
|
|
2332
2719
|
voiceLogger.log(
|
|
2333
|
-
`[SHUTDOWN] Waiting for ${
|
|
2720
|
+
`[SHUTDOWN] Waiting for ${voiceCleanupPromises.length} voice connection(s) to clean up...`,
|
|
2334
2721
|
)
|
|
2335
|
-
await Promise.allSettled(
|
|
2722
|
+
await Promise.allSettled(voiceCleanupPromises)
|
|
2336
2723
|
discordLogger.log(`All voice connections cleaned up`)
|
|
2337
2724
|
}
|
|
2338
2725
|
|
|
2726
|
+
// Clean up all GenAI sessions (force cleanup regardless of active sessions)
|
|
2727
|
+
const genAiCleanupPromises: Promise<void>[] = []
|
|
2728
|
+
for (const [channelId, session] of genAiSessions) {
|
|
2729
|
+
voiceLogger.log(
|
|
2730
|
+
`[SHUTDOWN] Cleaning up GenAI session for channel ${channelId} (active sessions: ${session.hasActiveSessions})`,
|
|
2731
|
+
)
|
|
2732
|
+
genAiCleanupPromises.push(cleanupGenAiSession(channelId))
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
// Wait for all GenAI cleanups to complete
|
|
2736
|
+
if (genAiCleanupPromises.length > 0) {
|
|
2737
|
+
voiceLogger.log(
|
|
2738
|
+
`[SHUTDOWN] Waiting for ${genAiCleanupPromises.length} GenAI session(s) to clean up...`,
|
|
2739
|
+
)
|
|
2740
|
+
await Promise.allSettled(genAiCleanupPromises)
|
|
2741
|
+
discordLogger.log(`All GenAI sessions cleaned up`)
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2339
2744
|
// Kill all OpenCode servers
|
|
2340
2745
|
for (const [dir, server] of opencodeServers) {
|
|
2341
2746
|
if (!server.process.killed) {
|
|
@@ -2348,7 +2753,10 @@ export async function startDiscordBot({
|
|
|
2348
2753
|
opencodeServers.clear()
|
|
2349
2754
|
|
|
2350
2755
|
discordLogger.log('Closing database...')
|
|
2351
|
-
|
|
2756
|
+
if (db) {
|
|
2757
|
+
db.close()
|
|
2758
|
+
db = null
|
|
2759
|
+
}
|
|
2352
2760
|
|
|
2353
2761
|
discordLogger.log('Destroying Discord client...')
|
|
2354
2762
|
discordClient.destroy()
|