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/src/discordBot.ts
CHANGED
|
@@ -70,16 +70,30 @@ const opencodeServers = new Map<
|
|
|
70
70
|
// Map of session ID to current AbortController
|
|
71
71
|
const abortControllers = new Map<string, AbortController>()
|
|
72
72
|
|
|
73
|
-
// Map of guild ID to voice connection
|
|
73
|
+
// Map of guild ID to voice connection
|
|
74
74
|
const voiceConnections = new Map<
|
|
75
75
|
string,
|
|
76
76
|
{
|
|
77
77
|
connection: VoiceConnection
|
|
78
|
-
genAiWorker?: GenAIWorker
|
|
79
78
|
userAudioStream?: fs.WriteStream
|
|
80
79
|
}
|
|
81
80
|
>()
|
|
82
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
|
+
|
|
83
97
|
// Map of directory to retry count for server restarts
|
|
84
98
|
const serverRetryCount = new Map<string, number>()
|
|
85
99
|
|
|
@@ -154,11 +168,13 @@ async function setupVoiceHandling({
|
|
|
154
168
|
guildId,
|
|
155
169
|
channelId,
|
|
156
170
|
appId,
|
|
171
|
+
cleanupGenAiSession,
|
|
157
172
|
}: {
|
|
158
173
|
connection: VoiceConnection
|
|
159
174
|
guildId: string
|
|
160
175
|
channelId: string
|
|
161
176
|
appId: string
|
|
177
|
+
cleanupGenAiSession: (channelId: string) => Promise<void>
|
|
162
178
|
}) {
|
|
163
179
|
voiceLogger.log(
|
|
164
180
|
`Setting up voice handling for guild ${guildId}, channel ${channelId}`,
|
|
@@ -191,12 +207,36 @@ async function setupVoiceHandling({
|
|
|
191
207
|
// Create user audio stream for debugging
|
|
192
208
|
voiceData.userAudioStream = await createUserAudioLogStream(guildId, channelId)
|
|
193
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
|
+
|
|
194
232
|
// Get API keys from database
|
|
195
233
|
const apiKeys = getDatabase()
|
|
196
234
|
.prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
|
|
197
235
|
.get(appId) as { gemini_api_key: string | null } | undefined
|
|
198
236
|
|
|
199
|
-
//
|
|
237
|
+
// Track if sessions are active
|
|
238
|
+
let hasActiveSessions = false
|
|
239
|
+
|
|
200
240
|
const genAiWorker = await createGenAIWorker({
|
|
201
241
|
directory,
|
|
202
242
|
guildId,
|
|
@@ -264,30 +304,82 @@ async function setupVoiceHandling({
|
|
|
264
304
|
genAiWorker.interrupt()
|
|
265
305
|
connection.setSpeaking(false)
|
|
266
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
|
+
},
|
|
267
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
|
+
|
|
268
350
|
const text = params.error
|
|
269
351
|
? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${params.error?.message || String(params.error)}\n</systemMessage>`
|
|
270
352
|
: `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`
|
|
271
353
|
|
|
272
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
|
+
}
|
|
273
362
|
},
|
|
274
363
|
onError(error) {
|
|
275
364
|
voiceLogger.error('GenAI worker error:', error)
|
|
276
365
|
},
|
|
277
366
|
})
|
|
278
367
|
|
|
279
|
-
// Stop any existing GenAI worker before storing new one
|
|
280
|
-
if (voiceData.genAiWorker) {
|
|
281
|
-
voiceLogger.log('Stopping existing GenAI worker before creating new one')
|
|
282
|
-
await voiceData.genAiWorker.stop()
|
|
283
|
-
}
|
|
284
|
-
|
|
285
368
|
// Send initial greeting
|
|
286
369
|
genAiWorker.sendTextInput(
|
|
287
370
|
`<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`,
|
|
288
371
|
)
|
|
289
372
|
|
|
290
|
-
|
|
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
|
+
})
|
|
291
383
|
|
|
292
384
|
// Set up voice receiver for user input
|
|
293
385
|
const receiver = connection.receiver
|
|
@@ -295,6 +387,15 @@ async function setupVoiceHandling({
|
|
|
295
387
|
// Remove all existing listeners to prevent accumulation
|
|
296
388
|
receiver.speaking.removeAllListeners('start')
|
|
297
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
|
+
|
|
298
399
|
// Counter to track overlapping speaking sessions
|
|
299
400
|
let speakingSessionCount = 0
|
|
300
401
|
|
|
@@ -350,9 +451,10 @@ async function setupVoiceHandling({
|
|
|
350
451
|
return
|
|
351
452
|
}
|
|
352
453
|
|
|
353
|
-
|
|
454
|
+
const genAiSession = genAiSessions.get(channelId)
|
|
455
|
+
if (!genAiSession) {
|
|
354
456
|
voiceLogger.warn(
|
|
355
|
-
`[VOICE] Received audio frame but no GenAI
|
|
457
|
+
`[VOICE] Received audio frame but no GenAI session active for channel ${channelId}`,
|
|
356
458
|
)
|
|
357
459
|
return
|
|
358
460
|
}
|
|
@@ -362,7 +464,7 @@ async function setupVoiceHandling({
|
|
|
362
464
|
voiceData.userAudioStream?.write(frame)
|
|
363
465
|
|
|
364
466
|
// stream incrementally — low latency
|
|
365
|
-
|
|
467
|
+
genAiSession.genAiWorker.sendRealtimeInput({
|
|
366
468
|
audio: {
|
|
367
469
|
mimeType: 'audio/pcm;rate=16000',
|
|
368
470
|
data: frame.toString('base64'),
|
|
@@ -375,7 +477,8 @@ async function setupVoiceHandling({
|
|
|
375
477
|
voiceLogger.log(
|
|
376
478
|
`User ${userId} stopped speaking (session ${currentSessionCount})`,
|
|
377
479
|
)
|
|
378
|
-
|
|
480
|
+
const genAiSession = genAiSessions.get(channelId)
|
|
481
|
+
genAiSession?.genAiWorker.sendRealtimeInput({
|
|
379
482
|
audioStreamEnd: true,
|
|
380
483
|
})
|
|
381
484
|
} else {
|
|
@@ -403,7 +506,7 @@ async function setupVoiceHandling({
|
|
|
403
506
|
})
|
|
404
507
|
}
|
|
405
508
|
|
|
406
|
-
|
|
509
|
+
function frameMono16khz(): Transform {
|
|
407
510
|
// Hardcoded: 16 kHz, mono, 16-bit PCM, 20 ms -> 320 samples -> 640 bytes
|
|
408
511
|
const FRAME_BYTES =
|
|
409
512
|
(100 /*ms*/ * 16_000 /*Hz*/ * 1 /*channels*/ * 2) /*bytes per sample*/ /
|
|
@@ -822,8 +925,10 @@ export async function initializeOpencodeForDirectory(directory: string) {
|
|
|
822
925
|
// `[OPENCODE] Starting new server on port ${port} for directory: ${directory}`,
|
|
823
926
|
// )
|
|
824
927
|
|
|
928
|
+
const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
|
|
929
|
+
|
|
825
930
|
const serverProcess = spawn(
|
|
826
|
-
|
|
931
|
+
opencodeCommand,
|
|
827
932
|
['serve', '--port', port.toString()],
|
|
828
933
|
{
|
|
829
934
|
stdio: 'pipe',
|
|
@@ -876,7 +981,23 @@ export async function initializeOpencodeForDirectory(directory: string) {
|
|
|
876
981
|
})
|
|
877
982
|
|
|
878
983
|
await waitForServer(port)
|
|
879
|
-
|
|
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
|
+
})
|
|
880
1001
|
|
|
881
1002
|
opencodeServers.set(directory, {
|
|
882
1003
|
process: serverProcess,
|
|
@@ -970,7 +1091,7 @@ function formatPart(part: Part): string {
|
|
|
970
1091
|
part.state.status === 'completed'
|
|
971
1092
|
? '◼︎'
|
|
972
1093
|
: part.state.status === 'error'
|
|
973
|
-
? '
|
|
1094
|
+
? '⨯'
|
|
974
1095
|
: ''
|
|
975
1096
|
const title = `${icon} ${part.tool} ${toolTitle}`
|
|
976
1097
|
|
|
@@ -1547,7 +1668,6 @@ export async function startDiscordBot({
|
|
|
1547
1668
|
discordLogger.log(`Bot Application ID (provided): ${currentAppId}`)
|
|
1548
1669
|
}
|
|
1549
1670
|
|
|
1550
|
-
|
|
1551
1671
|
// List all guilds and channels that belong to this bot
|
|
1552
1672
|
for (const guild of c.guilds.cache.values()) {
|
|
1553
1673
|
discordLogger.log(`${guild.name} (${guild.id})`)
|
|
@@ -1868,6 +1988,93 @@ export async function startDiscordBot({
|
|
|
1868
1988
|
)
|
|
1869
1989
|
await interaction.respond([])
|
|
1870
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
|
+
}
|
|
1871
2078
|
}
|
|
1872
2079
|
}
|
|
1873
2080
|
|
|
@@ -1875,7 +2082,100 @@ export async function startDiscordBot({
|
|
|
1875
2082
|
if (interaction.isChatInputCommand()) {
|
|
1876
2083
|
const command = interaction
|
|
1877
2084
|
|
|
1878
|
-
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') {
|
|
1879
2179
|
await command.deferReply({ ephemeral: false })
|
|
1880
2180
|
|
|
1881
2181
|
const sessionId = command.options.getString('session', true)
|
|
@@ -1988,12 +2288,12 @@ export async function startDiscordBot({
|
|
|
1988
2288
|
if (message.info.role === 'user') {
|
|
1989
2289
|
// Render user messages
|
|
1990
2290
|
const userParts = message.parts.filter(
|
|
1991
|
-
(p) => p.type === 'text',
|
|
2291
|
+
(p) => p.type === 'text' && !p.synthetic,
|
|
1992
2292
|
)
|
|
1993
2293
|
const userTexts = userParts
|
|
1994
2294
|
.map((p) => {
|
|
1995
|
-
if (
|
|
1996
|
-
return
|
|
2295
|
+
if (p.type === 'text') {
|
|
2296
|
+
return p.text
|
|
1997
2297
|
}
|
|
1998
2298
|
return ''
|
|
1999
2299
|
})
|
|
@@ -2045,21 +2345,43 @@ export async function startDiscordBot({
|
|
|
2045
2345
|
},
|
|
2046
2346
|
)
|
|
2047
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
|
+
|
|
2048
2377
|
// Helper function to clean up voice connection and associated resources
|
|
2049
|
-
async function cleanupVoiceConnection(guildId: string) {
|
|
2378
|
+
async function cleanupVoiceConnection(guildId: string, channelId?: string) {
|
|
2050
2379
|
const voiceData = voiceConnections.get(guildId)
|
|
2051
2380
|
if (!voiceData) return
|
|
2052
2381
|
|
|
2053
2382
|
voiceLogger.log(`Starting cleanup for guild ${guildId}`)
|
|
2054
2383
|
|
|
2055
2384
|
try {
|
|
2056
|
-
// Stop GenAI worker if exists (this is async!)
|
|
2057
|
-
if (voiceData.genAiWorker) {
|
|
2058
|
-
voiceLogger.log(`Stopping GenAI worker...`)
|
|
2059
|
-
await voiceData.genAiWorker.stop()
|
|
2060
|
-
voiceLogger.log(`GenAI worker stopped`)
|
|
2061
|
-
}
|
|
2062
|
-
|
|
2063
2385
|
// Close user audio stream if exists
|
|
2064
2386
|
if (voiceData.userAudioStream) {
|
|
2065
2387
|
voiceLogger.log(`Closing user audio stream...`)
|
|
@@ -2083,7 +2405,45 @@ export async function startDiscordBot({
|
|
|
2083
2405
|
|
|
2084
2406
|
// Remove from map
|
|
2085
2407
|
voiceConnections.delete(guildId)
|
|
2086
|
-
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
|
+
}
|
|
2087
2447
|
} catch (error) {
|
|
2088
2448
|
voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error)
|
|
2089
2449
|
// Still remove from map even if there was an error
|
|
@@ -2141,7 +2501,7 @@ export async function startDiscordBot({
|
|
|
2141
2501
|
)
|
|
2142
2502
|
|
|
2143
2503
|
// Properly clean up all resources
|
|
2144
|
-
await cleanupVoiceConnection(guildId)
|
|
2504
|
+
await cleanupVoiceConnection(guildId, oldState.channelId)
|
|
2145
2505
|
} else {
|
|
2146
2506
|
voiceLogger.log(
|
|
2147
2507
|
`Other admins still in channel, bot staying in voice channel`,
|
|
@@ -2191,6 +2551,16 @@ export async function startDiscordBot({
|
|
|
2191
2551
|
selfDeaf: false,
|
|
2192
2552
|
selfMute: false,
|
|
2193
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
|
+
})
|
|
2194
2564
|
}
|
|
2195
2565
|
} else {
|
|
2196
2566
|
voiceLogger.log(
|
|
@@ -2273,6 +2643,7 @@ export async function startDiscordBot({
|
|
|
2273
2643
|
guildId: newState.guild.id,
|
|
2274
2644
|
channelId: voiceChannel.id,
|
|
2275
2645
|
appId: currentAppId!,
|
|
2646
|
+
cleanupGenAiSession,
|
|
2276
2647
|
})
|
|
2277
2648
|
|
|
2278
2649
|
// Handle connection state changes
|
|
@@ -2300,7 +2671,7 @@ export async function startDiscordBot({
|
|
|
2300
2671
|
`Connection destroyed for guild: ${newState.guild.name}`,
|
|
2301
2672
|
)
|
|
2302
2673
|
// Use the cleanup function to ensure everything is properly closed
|
|
2303
|
-
await cleanupVoiceConnection(newState.guild.id)
|
|
2674
|
+
await cleanupVoiceConnection(newState.guild.id, voiceChannel.id)
|
|
2304
2675
|
})
|
|
2305
2676
|
|
|
2306
2677
|
// Handle errors
|
|
@@ -2312,7 +2683,7 @@ export async function startDiscordBot({
|
|
|
2312
2683
|
})
|
|
2313
2684
|
} catch (error) {
|
|
2314
2685
|
voiceLogger.error(`Failed to join voice channel:`, error)
|
|
2315
|
-
await cleanupVoiceConnection(newState.guild.id)
|
|
2686
|
+
await cleanupVoiceConnection(newState.guild.id, voiceChannel.id)
|
|
2316
2687
|
}
|
|
2317
2688
|
} catch (error) {
|
|
2318
2689
|
voiceLogger.error('Error in voice state update handler:', error)
|
|
@@ -2332,24 +2703,44 @@ export async function startDiscordBot({
|
|
|
2332
2703
|
;(global as any).shuttingDown = true
|
|
2333
2704
|
|
|
2334
2705
|
try {
|
|
2335
|
-
// Clean up all voice connections
|
|
2336
|
-
const
|
|
2337
|
-
for (const [guildId] of voiceConnections) {
|
|
2706
|
+
// Clean up all voice connections
|
|
2707
|
+
const voiceCleanupPromises: Promise<void>[] = []
|
|
2708
|
+
for (const [guildId, voiceData] of voiceConnections) {
|
|
2338
2709
|
voiceLogger.log(
|
|
2339
2710
|
`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
|
|
2340
2711
|
)
|
|
2341
|
-
|
|
2712
|
+
// Find the channel ID for this connection
|
|
2713
|
+
const channelId = voiceData.connection.joinConfig.channelId || undefined
|
|
2714
|
+
voiceCleanupPromises.push(cleanupVoiceConnection(guildId, channelId))
|
|
2342
2715
|
}
|
|
2343
2716
|
|
|
2344
|
-
// Wait for all cleanups to complete
|
|
2345
|
-
if (
|
|
2717
|
+
// Wait for all voice cleanups to complete
|
|
2718
|
+
if (voiceCleanupPromises.length > 0) {
|
|
2346
2719
|
voiceLogger.log(
|
|
2347
|
-
`[SHUTDOWN] Waiting for ${
|
|
2720
|
+
`[SHUTDOWN] Waiting for ${voiceCleanupPromises.length} voice connection(s) to clean up...`,
|
|
2348
2721
|
)
|
|
2349
|
-
await Promise.allSettled(
|
|
2722
|
+
await Promise.allSettled(voiceCleanupPromises)
|
|
2350
2723
|
discordLogger.log(`All voice connections cleaned up`)
|
|
2351
2724
|
}
|
|
2352
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
|
+
|
|
2353
2744
|
// Kill all OpenCode servers
|
|
2354
2745
|
for (const [dir, server] of opencodeServers) {
|
|
2355
2746
|
if (!server.process.killed) {
|