kimaki 0.2.0 → 0.3.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/discordBot.js +78 -182
- package/dist/genai-worker-wrapper.js +0 -3
- package/dist/genai-worker.js +0 -5
- package/dist/tools.js +5 -46
- package/dist/utils.js +10 -1
- package/package.json +2 -1
- package/src/discordBot.ts +81 -244
- package/src/genai-worker-wrapper.ts +0 -4
- package/src/genai-worker.ts +0 -5
- package/src/tools.ts +4 -59
- package/src/utils.ts +16 -2
- package/src/worker-types.ts +0 -3
package/dist/discordBot.js
CHANGED
|
@@ -18,6 +18,10 @@ import { transcribeAudio } from './voice.js';
|
|
|
18
18
|
import { extractTagsArrays, extractNonXmlContent } from './xml.js';
|
|
19
19
|
import prettyMilliseconds from 'pretty-ms';
|
|
20
20
|
import { createLogger } from './logger.js';
|
|
21
|
+
import { isAbortError } from './utils.js';
|
|
22
|
+
import { setGlobalDispatcher, Agent } from 'undici';
|
|
23
|
+
// disables the automatic 5 minutes abort after no body
|
|
24
|
+
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }));
|
|
21
25
|
const discordLogger = createLogger('DISCORD');
|
|
22
26
|
const voiceLogger = createLogger('VOICE');
|
|
23
27
|
const opencodeLogger = createLogger('OPENCODE');
|
|
@@ -27,11 +31,8 @@ const dbLogger = createLogger('DB');
|
|
|
27
31
|
const opencodeServers = new Map();
|
|
28
32
|
// Map of session ID to current AbortController
|
|
29
33
|
const abortControllers = new Map();
|
|
30
|
-
// Map of guild ID to voice connection
|
|
34
|
+
// Map of guild ID to voice connection and GenAI worker
|
|
31
35
|
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();
|
|
35
36
|
// Map of directory to retry count for server restarts
|
|
36
37
|
const serverRetryCount = new Map();
|
|
37
38
|
let db = null;
|
|
@@ -82,7 +83,7 @@ async function createUserAudioLogStream(guildId, channelId) {
|
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
// Set up voice handling for a connection (called once per connection)
|
|
85
|
-
async function setupVoiceHandling({ connection, guildId, channelId, appId,
|
|
86
|
+
async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
|
|
86
87
|
voiceLogger.log(`Setting up voice handling for guild ${guildId}, channel ${channelId}`);
|
|
87
88
|
// Check if this voice channel has an associated directory
|
|
88
89
|
const channelDirRow = getDatabase()
|
|
@@ -102,28 +103,11 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, clean
|
|
|
102
103
|
}
|
|
103
104
|
// Create user audio stream for debugging
|
|
104
105
|
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
|
-
}
|
|
121
106
|
// Get API keys from database
|
|
122
107
|
const apiKeys = getDatabase()
|
|
123
108
|
.prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
|
|
124
109
|
.get(appId);
|
|
125
|
-
//
|
|
126
|
-
let hasActiveSessions = false;
|
|
110
|
+
// Create GenAI worker
|
|
127
111
|
const genAiWorker = await createGenAIWorker({
|
|
128
112
|
directory,
|
|
129
113
|
guildId,
|
|
@@ -191,75 +175,28 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, clean
|
|
|
191
175
|
genAiWorker.interrupt();
|
|
192
176
|
connection.setSpeaking(false);
|
|
193
177
|
},
|
|
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
|
-
},
|
|
223
178
|
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`);
|
|
226
179
|
const text = params.error
|
|
227
180
|
? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${params.error?.message || String(params.error)}\n</systemMessage>`
|
|
228
181
|
: `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`;
|
|
229
182
|
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
|
-
}
|
|
236
183
|
},
|
|
237
184
|
onError(error) {
|
|
238
185
|
voiceLogger.error('GenAI worker error:', error);
|
|
239
186
|
},
|
|
240
187
|
});
|
|
188
|
+
// Stop any existing GenAI worker before storing new one
|
|
189
|
+
if (voiceData.genAiWorker) {
|
|
190
|
+
voiceLogger.log('Stopping existing GenAI worker before creating new one');
|
|
191
|
+
await voiceData.genAiWorker.stop();
|
|
192
|
+
}
|
|
241
193
|
// Send initial greeting
|
|
242
194
|
genAiWorker.sendTextInput(`<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`);
|
|
243
|
-
|
|
244
|
-
genAiSessions.set(channelId, {
|
|
245
|
-
genAiWorker,
|
|
246
|
-
hasActiveSessions,
|
|
247
|
-
pendingCleanup: false,
|
|
248
|
-
cleanupTimer: undefined,
|
|
249
|
-
guildId,
|
|
250
|
-
channelId,
|
|
251
|
-
directory,
|
|
252
|
-
});
|
|
195
|
+
voiceData.genAiWorker = genAiWorker;
|
|
253
196
|
// Set up voice receiver for user input
|
|
254
197
|
const receiver = connection.receiver;
|
|
255
198
|
// Remove all existing listeners to prevent accumulation
|
|
256
199
|
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
|
-
}
|
|
263
200
|
// Counter to track overlapping speaking sessions
|
|
264
201
|
let speakingSessionCount = 0;
|
|
265
202
|
receiver.speaking.on('start', (userId) => {
|
|
@@ -306,16 +243,15 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, clean
|
|
|
306
243
|
// )
|
|
307
244
|
return;
|
|
308
245
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
voiceLogger.warn(`[VOICE] Received audio frame but no GenAI session active for channel ${channelId}`);
|
|
246
|
+
if (!voiceData.genAiWorker) {
|
|
247
|
+
voiceLogger.warn(`[VOICE] Received audio frame but no GenAI worker active for guild ${guildId}`);
|
|
312
248
|
return;
|
|
313
249
|
}
|
|
314
250
|
// voiceLogger.debug('User audio chunk length', frame.length)
|
|
315
251
|
// Write to PCM file if stream exists
|
|
316
252
|
voiceData.userAudioStream?.write(frame);
|
|
317
253
|
// stream incrementally — low latency
|
|
318
|
-
|
|
254
|
+
voiceData.genAiWorker.sendRealtimeInput({
|
|
319
255
|
audio: {
|
|
320
256
|
mimeType: 'audio/pcm;rate=16000',
|
|
321
257
|
data: frame.toString('base64'),
|
|
@@ -326,8 +262,7 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, clean
|
|
|
326
262
|
// Only send audioStreamEnd if this is still the current session
|
|
327
263
|
if (currentSessionCount === speakingSessionCount) {
|
|
328
264
|
voiceLogger.log(`User ${userId} stopped speaking (session ${currentSessionCount})`);
|
|
329
|
-
|
|
330
|
-
genAiSession?.genAiWorker.sendRealtimeInput({
|
|
265
|
+
voiceData.genAiWorker?.sendRealtimeInput({
|
|
331
266
|
audioStreamEnd: true,
|
|
332
267
|
});
|
|
333
268
|
}
|
|
@@ -684,6 +619,41 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
684
619
|
cwd: directory,
|
|
685
620
|
env: {
|
|
686
621
|
...process.env,
|
|
622
|
+
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
623
|
+
$schema: 'https://opencode.ai/config.json',
|
|
624
|
+
lsp: {
|
|
625
|
+
typescript: { disabled: true },
|
|
626
|
+
eslint: { disabled: true },
|
|
627
|
+
gopls: { disabled: true },
|
|
628
|
+
'ruby-lsp': { disabled: true },
|
|
629
|
+
pyright: { disabled: true },
|
|
630
|
+
'elixir-ls': { disabled: true },
|
|
631
|
+
zls: { disabled: true },
|
|
632
|
+
csharp: { disabled: true },
|
|
633
|
+
vue: { disabled: true },
|
|
634
|
+
rust: { disabled: true },
|
|
635
|
+
clangd: { disabled: true },
|
|
636
|
+
svelte: { disabled: true },
|
|
637
|
+
},
|
|
638
|
+
formatter: {
|
|
639
|
+
prettier: { disabled: true },
|
|
640
|
+
biome: { disabled: true },
|
|
641
|
+
gofmt: { disabled: true },
|
|
642
|
+
mix: { disabled: true },
|
|
643
|
+
zig: { disabled: true },
|
|
644
|
+
'clang-format': { disabled: true },
|
|
645
|
+
ktlint: { disabled: true },
|
|
646
|
+
ruff: { disabled: true },
|
|
647
|
+
rubocop: { disabled: true },
|
|
648
|
+
standardrb: { disabled: true },
|
|
649
|
+
htmlbeautifier: { disabled: true },
|
|
650
|
+
},
|
|
651
|
+
permission: {
|
|
652
|
+
edit: 'allow',
|
|
653
|
+
bash: 'allow',
|
|
654
|
+
webfetch: 'allow',
|
|
655
|
+
},
|
|
656
|
+
}),
|
|
687
657
|
OPENCODE_PORT: port.toString(),
|
|
688
658
|
},
|
|
689
659
|
});
|
|
@@ -718,17 +688,12 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
718
688
|
}
|
|
719
689
|
});
|
|
720
690
|
await waitForServer(port);
|
|
721
|
-
// Create a custom fetch that disables Bun's default timeout
|
|
722
|
-
const customFetch = (input, init) => {
|
|
723
|
-
return fetch(input, {
|
|
724
|
-
...init,
|
|
725
|
-
// @ts-ignore - Bun-specific option to disable timeout
|
|
726
|
-
timeout: false,
|
|
727
|
-
});
|
|
728
|
-
};
|
|
729
691
|
const client = createOpencodeClient({
|
|
730
692
|
baseUrl: `http://localhost:${port}`,
|
|
731
|
-
fetch:
|
|
693
|
+
fetch: (request) => fetch(request, {
|
|
694
|
+
// @ts-ignore
|
|
695
|
+
timeout: false,
|
|
696
|
+
}),
|
|
732
697
|
});
|
|
733
698
|
opencodeServers.set(directory, {
|
|
734
699
|
process: serverProcess,
|
|
@@ -1115,8 +1080,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1115
1080
|
}
|
|
1116
1081
|
}
|
|
1117
1082
|
catch (e) {
|
|
1118
|
-
if (e
|
|
1119
|
-
// Ignore abort controller errors as requested
|
|
1083
|
+
if (isAbortError(e, abortController.signal)) {
|
|
1120
1084
|
sessionLogger.log('AbortController aborted event handling (normal exit)');
|
|
1121
1085
|
return;
|
|
1122
1086
|
}
|
|
@@ -1192,7 +1156,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1192
1156
|
}
|
|
1193
1157
|
catch (error) {
|
|
1194
1158
|
sessionLogger.error(`ERROR: Failed to send prompt:`, error);
|
|
1195
|
-
if (!(error
|
|
1159
|
+
if (!isAbortError(error, abortController.signal)) {
|
|
1196
1160
|
abortController.abort('error');
|
|
1197
1161
|
if (originalMessage) {
|
|
1198
1162
|
try {
|
|
@@ -1737,37 +1701,19 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1737
1701
|
voiceLogger.error('[INTERACTION] Error handling interaction:', error);
|
|
1738
1702
|
}
|
|
1739
1703
|
});
|
|
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
|
-
}
|
|
1764
1704
|
// Helper function to clean up voice connection and associated resources
|
|
1765
|
-
async function cleanupVoiceConnection(guildId
|
|
1705
|
+
async function cleanupVoiceConnection(guildId) {
|
|
1766
1706
|
const voiceData = voiceConnections.get(guildId);
|
|
1767
1707
|
if (!voiceData)
|
|
1768
1708
|
return;
|
|
1769
1709
|
voiceLogger.log(`Starting cleanup for guild ${guildId}`);
|
|
1770
1710
|
try {
|
|
1711
|
+
// Stop GenAI worker if exists (this is async!)
|
|
1712
|
+
if (voiceData.genAiWorker) {
|
|
1713
|
+
voiceLogger.log(`Stopping GenAI worker...`);
|
|
1714
|
+
await voiceData.genAiWorker.stop();
|
|
1715
|
+
voiceLogger.log(`GenAI worker stopped`);
|
|
1716
|
+
}
|
|
1771
1717
|
// Close user audio stream if exists
|
|
1772
1718
|
if (voiceData.userAudioStream) {
|
|
1773
1719
|
voiceLogger.log(`Closing user audio stream...`);
|
|
@@ -1787,33 +1733,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1787
1733
|
}
|
|
1788
1734
|
// Remove from map
|
|
1789
1735
|
voiceConnections.delete(guildId);
|
|
1790
|
-
voiceLogger.log(`
|
|
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
|
-
}
|
|
1736
|
+
voiceLogger.log(`Cleanup complete for guild ${guildId}`);
|
|
1817
1737
|
}
|
|
1818
1738
|
catch (error) {
|
|
1819
1739
|
voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error);
|
|
@@ -1856,7 +1776,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1856
1776
|
if (!hasOtherAdmins) {
|
|
1857
1777
|
voiceLogger.log(`No other admins in channel, bot leaving voice channel in guild: ${guild.name}`);
|
|
1858
1778
|
// Properly clean up all resources
|
|
1859
|
-
await cleanupVoiceConnection(guildId
|
|
1779
|
+
await cleanupVoiceConnection(guildId);
|
|
1860
1780
|
}
|
|
1861
1781
|
else {
|
|
1862
1782
|
voiceLogger.log(`Other admins still in channel, bot staying in voice channel`);
|
|
@@ -1892,15 +1812,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1892
1812
|
selfDeaf: false,
|
|
1893
1813
|
selfMute: false,
|
|
1894
1814
|
});
|
|
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
|
-
});
|
|
1904
1815
|
}
|
|
1905
1816
|
}
|
|
1906
1817
|
else {
|
|
@@ -1959,7 +1870,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1959
1870
|
guildId: newState.guild.id,
|
|
1960
1871
|
channelId: voiceChannel.id,
|
|
1961
1872
|
appId: currentAppId,
|
|
1962
|
-
cleanupGenAiSession,
|
|
1963
1873
|
});
|
|
1964
1874
|
// Handle connection state changes
|
|
1965
1875
|
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
|
@@ -1982,7 +1892,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1982
1892
|
connection.on(VoiceConnectionStatus.Destroyed, async () => {
|
|
1983
1893
|
voiceLogger.log(`Connection destroyed for guild: ${newState.guild.name}`);
|
|
1984
1894
|
// Use the cleanup function to ensure everything is properly closed
|
|
1985
|
-
await cleanupVoiceConnection(newState.guild.id
|
|
1895
|
+
await cleanupVoiceConnection(newState.guild.id);
|
|
1986
1896
|
});
|
|
1987
1897
|
// Handle errors
|
|
1988
1898
|
connection.on('error', (error) => {
|
|
@@ -1991,7 +1901,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1991
1901
|
}
|
|
1992
1902
|
catch (error) {
|
|
1993
1903
|
voiceLogger.error(`Failed to join voice channel:`, error);
|
|
1994
|
-
await cleanupVoiceConnection(newState.guild.id
|
|
1904
|
+
await cleanupVoiceConnection(newState.guild.id);
|
|
1995
1905
|
}
|
|
1996
1906
|
}
|
|
1997
1907
|
catch (error) {
|
|
@@ -2009,31 +1919,17 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2009
1919
|
;
|
|
2010
1920
|
global.shuttingDown = true;
|
|
2011
1921
|
try {
|
|
2012
|
-
// Clean up all voice connections
|
|
2013
|
-
const
|
|
2014
|
-
for (const [guildId
|
|
1922
|
+
// Clean up all voice connections (this includes GenAI workers and audio streams)
|
|
1923
|
+
const cleanupPromises = [];
|
|
1924
|
+
for (const [guildId] of voiceConnections) {
|
|
2015
1925
|
voiceLogger.log(`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`);
|
|
2016
|
-
|
|
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);
|
|
2024
|
-
discordLogger.log(`All voice connections cleaned up`);
|
|
1926
|
+
cleanupPromises.push(cleanupVoiceConnection(guildId));
|
|
2025
1927
|
}
|
|
2026
|
-
//
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
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`);
|
|
1928
|
+
// Wait for all cleanups to complete
|
|
1929
|
+
if (cleanupPromises.length > 0) {
|
|
1930
|
+
voiceLogger.log(`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`);
|
|
1931
|
+
await Promise.allSettled(cleanupPromises);
|
|
1932
|
+
discordLogger.log(`All voice connections cleaned up`);
|
|
2037
1933
|
}
|
|
2038
1934
|
// Kill all OpenCode servers
|
|
2039
1935
|
for (const [dir, server] of opencodeServers) {
|
|
@@ -20,9 +20,6 @@ export function createGenAIWorker(options) {
|
|
|
20
20
|
case 'assistantInterruptSpeaking':
|
|
21
21
|
options.onAssistantInterruptSpeaking?.();
|
|
22
22
|
break;
|
|
23
|
-
case 'allSessionsCompleted':
|
|
24
|
-
options.onAllSessionsCompleted?.();
|
|
25
|
-
break;
|
|
26
23
|
case 'toolCallCompleted':
|
|
27
24
|
options.onToolCallCompleted?.(message);
|
|
28
25
|
break;
|
package/dist/genai-worker.js
CHANGED
|
@@ -205,11 +205,6 @@ parentPort.on('message', async (message) => {
|
|
|
205
205
|
...params,
|
|
206
206
|
});
|
|
207
207
|
},
|
|
208
|
-
onAllSessionsCompleted: () => {
|
|
209
|
-
parentPort.postMessage({
|
|
210
|
-
type: 'allSessionsCompleted',
|
|
211
|
-
});
|
|
212
|
-
},
|
|
213
208
|
});
|
|
214
209
|
// Start GenAI session
|
|
215
210
|
session = await startGenAiSession({
|
package/dist/tools.js
CHANGED
|
@@ -9,14 +9,12 @@ import { formatDistanceToNow } from 'date-fns';
|
|
|
9
9
|
import { ShareMarkdown } from './markdown.js';
|
|
10
10
|
import pc from 'picocolors';
|
|
11
11
|
import { initializeOpencodeForDirectory } from './discordBot.js';
|
|
12
|
-
export async function getTools({ onMessageCompleted,
|
|
12
|
+
export async function getTools({ onMessageCompleted, directory, }) {
|
|
13
13
|
const getClient = await initializeOpencodeForDirectory(directory);
|
|
14
14
|
const client = getClient();
|
|
15
15
|
const markdownRenderer = new ShareMarkdown(client);
|
|
16
16
|
const providersResponse = await client.config.providers({});
|
|
17
17
|
const providers = providersResponse.data?.providers || [];
|
|
18
|
-
// Track all active OpenCode sessions
|
|
19
|
-
const activeSessions = new Set();
|
|
20
18
|
// Helper: get last assistant model for a session (non-summary)
|
|
21
19
|
const getSessionModel = async (sessionId) => {
|
|
22
20
|
const res = await getClient().session.messages({ path: { id: sessionId } });
|
|
@@ -43,9 +41,6 @@ export async function getTools({ onMessageCompleted, onAllSessionsCompleted, dir
|
|
|
43
41
|
}),
|
|
44
42
|
execute: async ({ sessionId, message }) => {
|
|
45
43
|
const sessionModel = await getSessionModel(sessionId);
|
|
46
|
-
// Track this session as active
|
|
47
|
-
activeSessions.add(sessionId);
|
|
48
|
-
toolsLogger.log(`Session ${sessionId} started, ${activeSessions.size} active sessions`);
|
|
49
44
|
// do not await
|
|
50
45
|
getClient()
|
|
51
46
|
.session.prompt({
|
|
@@ -66,14 +61,6 @@ export async function getTools({ onMessageCompleted, onAllSessionsCompleted, dir
|
|
|
66
61
|
data: response.data,
|
|
67
62
|
markdown,
|
|
68
63
|
});
|
|
69
|
-
// Remove from active sessions
|
|
70
|
-
activeSessions.delete(sessionId);
|
|
71
|
-
toolsLogger.log(`Session ${sessionId} completed, ${activeSessions.size} active sessions remaining`);
|
|
72
|
-
// Check if all sessions are complete
|
|
73
|
-
if (activeSessions.size === 0) {
|
|
74
|
-
toolsLogger.log('All sessions completed');
|
|
75
|
-
onAllSessionsCompleted?.();
|
|
76
|
-
}
|
|
77
64
|
})
|
|
78
65
|
.catch((error) => {
|
|
79
66
|
onMessageCompleted?.({
|
|
@@ -81,14 +68,6 @@ export async function getTools({ onMessageCompleted, onAllSessionsCompleted, dir
|
|
|
81
68
|
messageId: '',
|
|
82
69
|
error,
|
|
83
70
|
});
|
|
84
|
-
// Remove from active sessions even on error
|
|
85
|
-
activeSessions.delete(sessionId);
|
|
86
|
-
toolsLogger.log(`Session ${sessionId} failed, ${activeSessions.size} active sessions remaining`);
|
|
87
|
-
// Check if all sessions are complete
|
|
88
|
-
if (activeSessions.size === 0) {
|
|
89
|
-
toolsLogger.log('All sessions completed');
|
|
90
|
-
onAllSessionsCompleted?.();
|
|
91
|
-
}
|
|
92
71
|
});
|
|
93
72
|
return {
|
|
94
73
|
success: true,
|
|
@@ -129,52 +108,32 @@ export async function getTools({ onMessageCompleted, onAllSessionsCompleted, dir
|
|
|
129
108
|
if (!session.data) {
|
|
130
109
|
throw new Error('Failed to create session');
|
|
131
110
|
}
|
|
132
|
-
const newSessionId = session.data.id;
|
|
133
|
-
// Track this session as active
|
|
134
|
-
activeSessions.add(newSessionId);
|
|
135
|
-
toolsLogger.log(`New session ${newSessionId} created, ${activeSessions.size} active sessions`);
|
|
136
111
|
// do not await
|
|
137
112
|
getClient()
|
|
138
113
|
.session.prompt({
|
|
139
|
-
path: { id:
|
|
114
|
+
path: { id: session.data.id },
|
|
140
115
|
body: {
|
|
141
116
|
parts: [{ type: 'text', text: message }],
|
|
142
117
|
},
|
|
143
118
|
})
|
|
144
119
|
.then(async (response) => {
|
|
145
120
|
const markdown = await markdownRenderer.generate({
|
|
146
|
-
sessionID:
|
|
121
|
+
sessionID: session.data.id,
|
|
147
122
|
lastAssistantOnly: true,
|
|
148
123
|
});
|
|
149
124
|
onMessageCompleted?.({
|
|
150
|
-
sessionId:
|
|
125
|
+
sessionId: session.data.id,
|
|
151
126
|
messageId: '',
|
|
152
127
|
data: response.data,
|
|
153
128
|
markdown,
|
|
154
129
|
});
|
|
155
|
-
// Remove from active sessions
|
|
156
|
-
activeSessions.delete(newSessionId);
|
|
157
|
-
toolsLogger.log(`Session ${newSessionId} completed, ${activeSessions.size} active sessions remaining`);
|
|
158
|
-
// Check if all sessions are complete
|
|
159
|
-
if (activeSessions.size === 0) {
|
|
160
|
-
toolsLogger.log('All sessions completed');
|
|
161
|
-
onAllSessionsCompleted?.();
|
|
162
|
-
}
|
|
163
130
|
})
|
|
164
131
|
.catch((error) => {
|
|
165
132
|
onMessageCompleted?.({
|
|
166
|
-
sessionId:
|
|
133
|
+
sessionId: session.data.id,
|
|
167
134
|
messageId: '',
|
|
168
135
|
error,
|
|
169
136
|
});
|
|
170
|
-
// Remove from active sessions even on error
|
|
171
|
-
activeSessions.delete(newSessionId);
|
|
172
|
-
toolsLogger.log(`Session ${newSessionId} failed, ${activeSessions.size} active sessions remaining`);
|
|
173
|
-
// Check if all sessions are complete
|
|
174
|
-
if (activeSessions.size === 0) {
|
|
175
|
-
toolsLogger.log('All sessions completed');
|
|
176
|
-
onAllSessionsCompleted?.();
|
|
177
|
-
}
|
|
178
137
|
});
|
|
179
138
|
return {
|
|
180
139
|
success: true,
|
package/dist/utils.js
CHANGED
|
@@ -30,7 +30,7 @@ export function generateBotInstallUrl({ clientId, permissions = [
|
|
|
30
30
|
}
|
|
31
31
|
export function deduplicateByKey(arr, keyFn) {
|
|
32
32
|
const seen = new Set();
|
|
33
|
-
return arr.filter(item => {
|
|
33
|
+
return arr.filter((item) => {
|
|
34
34
|
const key = keyFn(item);
|
|
35
35
|
if (seen.has(key)) {
|
|
36
36
|
return false;
|
|
@@ -39,3 +39,12 @@ export function deduplicateByKey(arr, keyFn) {
|
|
|
39
39
|
return true;
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
|
+
export function isAbortError(error, signal) {
|
|
43
|
+
return (error instanceof Error &&
|
|
44
|
+
(error.name === 'AbortError' ||
|
|
45
|
+
error.name === 'Aborterror' ||
|
|
46
|
+
error.name === 'aborterror' ||
|
|
47
|
+
error.name.toLowerCase() === 'aborterror' ||
|
|
48
|
+
error.message?.includes('aborted') ||
|
|
49
|
+
(signal?.aborted ?? false)));
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.3.0",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"files": [
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"pretty-ms": "^9.3.0",
|
|
42
42
|
"prism-media": "^1.3.5",
|
|
43
43
|
"string-dedent": "^3.0.2",
|
|
44
|
+
"undici": "^7.16.0",
|
|
44
45
|
"zod": "^4.0.17"
|
|
45
46
|
},
|
|
46
47
|
"scripts": {
|
package/src/discordBot.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
createOpencodeClient,
|
|
3
3
|
type OpencodeClient,
|
|
4
4
|
type Part,
|
|
5
|
+
type Config,
|
|
5
6
|
} from '@opencode-ai/sdk'
|
|
6
7
|
|
|
7
8
|
import { createGenAIWorker, type GenAIWorker } from './genai-worker-wrapper.js'
|
|
@@ -45,6 +46,10 @@ import { extractTagsArrays, extractNonXmlContent } from './xml.js'
|
|
|
45
46
|
import prettyMilliseconds from 'pretty-ms'
|
|
46
47
|
import type { Session } from '@google/genai'
|
|
47
48
|
import { createLogger } from './logger.js'
|
|
49
|
+
import { isAbortError } from './utils.js'
|
|
50
|
+
import { setGlobalDispatcher, Agent } from 'undici'
|
|
51
|
+
// disables the automatic 5 minutes abort after no body
|
|
52
|
+
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }))
|
|
48
53
|
|
|
49
54
|
const discordLogger = createLogger('DISCORD')
|
|
50
55
|
const voiceLogger = createLogger('VOICE')
|
|
@@ -70,30 +75,16 @@ const opencodeServers = new Map<
|
|
|
70
75
|
// Map of session ID to current AbortController
|
|
71
76
|
const abortControllers = new Map<string, AbortController>()
|
|
72
77
|
|
|
73
|
-
// Map of guild ID to voice connection
|
|
78
|
+
// Map of guild ID to voice connection and GenAI worker
|
|
74
79
|
const voiceConnections = new Map<
|
|
75
80
|
string,
|
|
76
81
|
{
|
|
77
82
|
connection: VoiceConnection
|
|
83
|
+
genAiWorker?: GenAIWorker
|
|
78
84
|
userAudioStream?: fs.WriteStream
|
|
79
85
|
}
|
|
80
86
|
>()
|
|
81
87
|
|
|
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
|
-
|
|
97
88
|
// Map of directory to retry count for server restarts
|
|
98
89
|
const serverRetryCount = new Map<string, number>()
|
|
99
90
|
|
|
@@ -168,13 +159,11 @@ async function setupVoiceHandling({
|
|
|
168
159
|
guildId,
|
|
169
160
|
channelId,
|
|
170
161
|
appId,
|
|
171
|
-
cleanupGenAiSession,
|
|
172
162
|
}: {
|
|
173
163
|
connection: VoiceConnection
|
|
174
164
|
guildId: string
|
|
175
165
|
channelId: string
|
|
176
166
|
appId: string
|
|
177
|
-
cleanupGenAiSession: (channelId: string) => Promise<void>
|
|
178
167
|
}) {
|
|
179
168
|
voiceLogger.log(
|
|
180
169
|
`Setting up voice handling for guild ${guildId}, channel ${channelId}`,
|
|
@@ -207,36 +196,12 @@ async function setupVoiceHandling({
|
|
|
207
196
|
// Create user audio stream for debugging
|
|
208
197
|
voiceData.userAudioStream = await createUserAudioLogStream(guildId, channelId)
|
|
209
198
|
|
|
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
|
-
|
|
232
199
|
// Get API keys from database
|
|
233
200
|
const apiKeys = getDatabase()
|
|
234
201
|
.prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
|
|
235
202
|
.get(appId) as { gemini_api_key: string | null } | undefined
|
|
236
203
|
|
|
237
|
-
//
|
|
238
|
-
let hasActiveSessions = false
|
|
239
|
-
|
|
204
|
+
// Create GenAI worker
|
|
240
205
|
const genAiWorker = await createGenAIWorker({
|
|
241
206
|
directory,
|
|
242
207
|
guildId,
|
|
@@ -304,82 +269,30 @@ async function setupVoiceHandling({
|
|
|
304
269
|
genAiWorker.interrupt()
|
|
305
270
|
connection.setSpeaking(false)
|
|
306
271
|
},
|
|
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
|
-
},
|
|
346
272
|
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
|
-
|
|
350
273
|
const text = params.error
|
|
351
274
|
? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${params.error?.message || String(params.error)}\n</systemMessage>`
|
|
352
275
|
: `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`
|
|
353
276
|
|
|
354
277
|
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
|
-
}
|
|
362
278
|
},
|
|
363
279
|
onError(error) {
|
|
364
280
|
voiceLogger.error('GenAI worker error:', error)
|
|
365
281
|
},
|
|
366
282
|
})
|
|
367
283
|
|
|
284
|
+
// Stop any existing GenAI worker before storing new one
|
|
285
|
+
if (voiceData.genAiWorker) {
|
|
286
|
+
voiceLogger.log('Stopping existing GenAI worker before creating new one')
|
|
287
|
+
await voiceData.genAiWorker.stop()
|
|
288
|
+
}
|
|
289
|
+
|
|
368
290
|
// Send initial greeting
|
|
369
291
|
genAiWorker.sendTextInput(
|
|
370
292
|
`<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`,
|
|
371
293
|
)
|
|
372
294
|
|
|
373
|
-
|
|
374
|
-
genAiSessions.set(channelId, {
|
|
375
|
-
genAiWorker,
|
|
376
|
-
hasActiveSessions,
|
|
377
|
-
pendingCleanup: false,
|
|
378
|
-
cleanupTimer: undefined,
|
|
379
|
-
guildId,
|
|
380
|
-
channelId,
|
|
381
|
-
directory,
|
|
382
|
-
})
|
|
295
|
+
voiceData.genAiWorker = genAiWorker
|
|
383
296
|
|
|
384
297
|
// Set up voice receiver for user input
|
|
385
298
|
const receiver = connection.receiver
|
|
@@ -387,15 +300,6 @@ async function setupVoiceHandling({
|
|
|
387
300
|
// Remove all existing listeners to prevent accumulation
|
|
388
301
|
receiver.speaking.removeAllListeners('start')
|
|
389
302
|
|
|
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
|
-
|
|
399
303
|
// Counter to track overlapping speaking sessions
|
|
400
304
|
let speakingSessionCount = 0
|
|
401
305
|
|
|
@@ -451,10 +355,9 @@ async function setupVoiceHandling({
|
|
|
451
355
|
return
|
|
452
356
|
}
|
|
453
357
|
|
|
454
|
-
|
|
455
|
-
if (!genAiSession) {
|
|
358
|
+
if (!voiceData.genAiWorker) {
|
|
456
359
|
voiceLogger.warn(
|
|
457
|
-
`[VOICE] Received audio frame but no GenAI
|
|
360
|
+
`[VOICE] Received audio frame but no GenAI worker active for guild ${guildId}`,
|
|
458
361
|
)
|
|
459
362
|
return
|
|
460
363
|
}
|
|
@@ -464,7 +367,7 @@ async function setupVoiceHandling({
|
|
|
464
367
|
voiceData.userAudioStream?.write(frame)
|
|
465
368
|
|
|
466
369
|
// stream incrementally — low latency
|
|
467
|
-
|
|
370
|
+
voiceData.genAiWorker.sendRealtimeInput({
|
|
468
371
|
audio: {
|
|
469
372
|
mimeType: 'audio/pcm;rate=16000',
|
|
470
373
|
data: frame.toString('base64'),
|
|
@@ -477,8 +380,7 @@ async function setupVoiceHandling({
|
|
|
477
380
|
voiceLogger.log(
|
|
478
381
|
`User ${userId} stopped speaking (session ${currentSessionCount})`,
|
|
479
382
|
)
|
|
480
|
-
|
|
481
|
-
genAiSession?.genAiWorker.sendRealtimeInput({
|
|
383
|
+
voiceData.genAiWorker?.sendRealtimeInput({
|
|
482
384
|
audioStreamEnd: true,
|
|
483
385
|
})
|
|
484
386
|
} else {
|
|
@@ -936,6 +838,41 @@ export async function initializeOpencodeForDirectory(directory: string) {
|
|
|
936
838
|
cwd: directory,
|
|
937
839
|
env: {
|
|
938
840
|
...process.env,
|
|
841
|
+
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
842
|
+
$schema: 'https://opencode.ai/config.json',
|
|
843
|
+
lsp: {
|
|
844
|
+
typescript: { disabled: true },
|
|
845
|
+
eslint: { disabled: true },
|
|
846
|
+
gopls: { disabled: true },
|
|
847
|
+
'ruby-lsp': { disabled: true },
|
|
848
|
+
pyright: { disabled: true },
|
|
849
|
+
'elixir-ls': { disabled: true },
|
|
850
|
+
zls: { disabled: true },
|
|
851
|
+
csharp: { disabled: true },
|
|
852
|
+
vue: { disabled: true },
|
|
853
|
+
rust: { disabled: true },
|
|
854
|
+
clangd: { disabled: true },
|
|
855
|
+
svelte: { disabled: true },
|
|
856
|
+
},
|
|
857
|
+
formatter: {
|
|
858
|
+
prettier: { disabled: true },
|
|
859
|
+
biome: { disabled: true },
|
|
860
|
+
gofmt: { disabled: true },
|
|
861
|
+
mix: { disabled: true },
|
|
862
|
+
zig: { disabled: true },
|
|
863
|
+
'clang-format': { disabled: true },
|
|
864
|
+
ktlint: { disabled: true },
|
|
865
|
+
ruff: { disabled: true },
|
|
866
|
+
rubocop: { disabled: true },
|
|
867
|
+
standardrb: { disabled: true },
|
|
868
|
+
htmlbeautifier: { disabled: true },
|
|
869
|
+
},
|
|
870
|
+
permission: {
|
|
871
|
+
edit: 'allow',
|
|
872
|
+
bash: 'allow',
|
|
873
|
+
webfetch: 'allow',
|
|
874
|
+
},
|
|
875
|
+
} satisfies Config),
|
|
939
876
|
OPENCODE_PORT: port.toString(),
|
|
940
877
|
},
|
|
941
878
|
},
|
|
@@ -982,21 +919,13 @@ export async function initializeOpencodeForDirectory(directory: string) {
|
|
|
982
919
|
|
|
983
920
|
await waitForServer(port)
|
|
984
921
|
|
|
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
922
|
const client = createOpencodeClient({
|
|
998
923
|
baseUrl: `http://localhost:${port}`,
|
|
999
|
-
fetch:
|
|
924
|
+
fetch: (request: Request) =>
|
|
925
|
+
fetch(request, {
|
|
926
|
+
// @ts-ignore
|
|
927
|
+
timeout: false,
|
|
928
|
+
}),
|
|
1000
929
|
})
|
|
1001
930
|
|
|
1002
931
|
opencodeServers.set(directory, {
|
|
@@ -1475,8 +1404,7 @@ async function handleOpencodeSession(
|
|
|
1475
1404
|
}
|
|
1476
1405
|
}
|
|
1477
1406
|
} catch (e) {
|
|
1478
|
-
if (e
|
|
1479
|
-
// Ignore abort controller errors as requested
|
|
1407
|
+
if (isAbortError(e, abortController.signal)) {
|
|
1480
1408
|
sessionLogger.log(
|
|
1481
1409
|
'AbortController aborted event handling (normal exit)',
|
|
1482
1410
|
)
|
|
@@ -1573,7 +1501,7 @@ async function handleOpencodeSession(
|
|
|
1573
1501
|
} catch (error) {
|
|
1574
1502
|
sessionLogger.error(`ERROR: Failed to send prompt:`, error)
|
|
1575
1503
|
|
|
1576
|
-
if (!(error
|
|
1504
|
+
if (!isAbortError(error, abortController.signal)) {
|
|
1577
1505
|
abortController.abort('error')
|
|
1578
1506
|
|
|
1579
1507
|
if (originalMessage) {
|
|
@@ -2345,43 +2273,21 @@ export async function startDiscordBot({
|
|
|
2345
2273
|
},
|
|
2346
2274
|
)
|
|
2347
2275
|
|
|
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
|
-
|
|
2377
2276
|
// Helper function to clean up voice connection and associated resources
|
|
2378
|
-
async function cleanupVoiceConnection(guildId: string
|
|
2277
|
+
async function cleanupVoiceConnection(guildId: string) {
|
|
2379
2278
|
const voiceData = voiceConnections.get(guildId)
|
|
2380
2279
|
if (!voiceData) return
|
|
2381
2280
|
|
|
2382
2281
|
voiceLogger.log(`Starting cleanup for guild ${guildId}`)
|
|
2383
2282
|
|
|
2384
2283
|
try {
|
|
2284
|
+
// Stop GenAI worker if exists (this is async!)
|
|
2285
|
+
if (voiceData.genAiWorker) {
|
|
2286
|
+
voiceLogger.log(`Stopping GenAI worker...`)
|
|
2287
|
+
await voiceData.genAiWorker.stop()
|
|
2288
|
+
voiceLogger.log(`GenAI worker stopped`)
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2385
2291
|
// Close user audio stream if exists
|
|
2386
2292
|
if (voiceData.userAudioStream) {
|
|
2387
2293
|
voiceLogger.log(`Closing user audio stream...`)
|
|
@@ -2405,45 +2311,7 @@ export async function startDiscordBot({
|
|
|
2405
2311
|
|
|
2406
2312
|
// Remove from map
|
|
2407
2313
|
voiceConnections.delete(guildId)
|
|
2408
|
-
voiceLogger.log(`
|
|
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
|
-
}
|
|
2314
|
+
voiceLogger.log(`Cleanup complete for guild ${guildId}`)
|
|
2447
2315
|
} catch (error) {
|
|
2448
2316
|
voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error)
|
|
2449
2317
|
// Still remove from map even if there was an error
|
|
@@ -2501,7 +2369,7 @@ export async function startDiscordBot({
|
|
|
2501
2369
|
)
|
|
2502
2370
|
|
|
2503
2371
|
// Properly clean up all resources
|
|
2504
|
-
await cleanupVoiceConnection(guildId
|
|
2372
|
+
await cleanupVoiceConnection(guildId)
|
|
2505
2373
|
} else {
|
|
2506
2374
|
voiceLogger.log(
|
|
2507
2375
|
`Other admins still in channel, bot staying in voice channel`,
|
|
@@ -2551,16 +2419,6 @@ export async function startDiscordBot({
|
|
|
2551
2419
|
selfDeaf: false,
|
|
2552
2420
|
selfMute: false,
|
|
2553
2421
|
})
|
|
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
|
-
})
|
|
2564
2422
|
}
|
|
2565
2423
|
} else {
|
|
2566
2424
|
voiceLogger.log(
|
|
@@ -2643,7 +2501,6 @@ export async function startDiscordBot({
|
|
|
2643
2501
|
guildId: newState.guild.id,
|
|
2644
2502
|
channelId: voiceChannel.id,
|
|
2645
2503
|
appId: currentAppId!,
|
|
2646
|
-
cleanupGenAiSession,
|
|
2647
2504
|
})
|
|
2648
2505
|
|
|
2649
2506
|
// Handle connection state changes
|
|
@@ -2671,7 +2528,7 @@ export async function startDiscordBot({
|
|
|
2671
2528
|
`Connection destroyed for guild: ${newState.guild.name}`,
|
|
2672
2529
|
)
|
|
2673
2530
|
// Use the cleanup function to ensure everything is properly closed
|
|
2674
|
-
await cleanupVoiceConnection(newState.guild.id
|
|
2531
|
+
await cleanupVoiceConnection(newState.guild.id)
|
|
2675
2532
|
})
|
|
2676
2533
|
|
|
2677
2534
|
// Handle errors
|
|
@@ -2683,7 +2540,7 @@ export async function startDiscordBot({
|
|
|
2683
2540
|
})
|
|
2684
2541
|
} catch (error) {
|
|
2685
2542
|
voiceLogger.error(`Failed to join voice channel:`, error)
|
|
2686
|
-
await cleanupVoiceConnection(newState.guild.id
|
|
2543
|
+
await cleanupVoiceConnection(newState.guild.id)
|
|
2687
2544
|
}
|
|
2688
2545
|
} catch (error) {
|
|
2689
2546
|
voiceLogger.error('Error in voice state update handler:', error)
|
|
@@ -2703,44 +2560,24 @@ export async function startDiscordBot({
|
|
|
2703
2560
|
;(global as any).shuttingDown = true
|
|
2704
2561
|
|
|
2705
2562
|
try {
|
|
2706
|
-
// Clean up all voice connections
|
|
2707
|
-
const
|
|
2708
|
-
for (const [guildId
|
|
2563
|
+
// Clean up all voice connections (this includes GenAI workers and audio streams)
|
|
2564
|
+
const cleanupPromises: Promise<void>[] = []
|
|
2565
|
+
for (const [guildId] of voiceConnections) {
|
|
2709
2566
|
voiceLogger.log(
|
|
2710
2567
|
`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
|
|
2711
2568
|
)
|
|
2712
|
-
|
|
2713
|
-
const channelId = voiceData.connection.joinConfig.channelId || undefined
|
|
2714
|
-
voiceCleanupPromises.push(cleanupVoiceConnection(guildId, channelId))
|
|
2569
|
+
cleanupPromises.push(cleanupVoiceConnection(guildId))
|
|
2715
2570
|
}
|
|
2716
2571
|
|
|
2717
|
-
// Wait for all
|
|
2718
|
-
if (
|
|
2572
|
+
// Wait for all cleanups to complete
|
|
2573
|
+
if (cleanupPromises.length > 0) {
|
|
2719
2574
|
voiceLogger.log(
|
|
2720
|
-
`[SHUTDOWN] Waiting for ${
|
|
2575
|
+
`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`,
|
|
2721
2576
|
)
|
|
2722
|
-
await Promise.allSettled(
|
|
2577
|
+
await Promise.allSettled(cleanupPromises)
|
|
2723
2578
|
discordLogger.log(`All voice connections cleaned up`)
|
|
2724
2579
|
}
|
|
2725
2580
|
|
|
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
|
-
|
|
2744
2581
|
// Kill all OpenCode servers
|
|
2745
2582
|
for (const [dir, server] of opencodeServers) {
|
|
2746
2583
|
if (!server.process.killed) {
|
|
@@ -17,7 +17,6 @@ export interface GenAIWorkerOptions {
|
|
|
17
17
|
onAssistantStartSpeaking?: () => void
|
|
18
18
|
onAssistantStopSpeaking?: () => void
|
|
19
19
|
onAssistantInterruptSpeaking?: () => void
|
|
20
|
-
onAllSessionsCompleted?: () => void
|
|
21
20
|
onToolCallCompleted?: (params: {
|
|
22
21
|
sessionId: string
|
|
23
22
|
messageId: string
|
|
@@ -61,9 +60,6 @@ export function createGenAIWorker(
|
|
|
61
60
|
case 'assistantInterruptSpeaking':
|
|
62
61
|
options.onAssistantInterruptSpeaking?.()
|
|
63
62
|
break
|
|
64
|
-
case 'allSessionsCompleted':
|
|
65
|
-
options.onAllSessionsCompleted?.()
|
|
66
|
-
break
|
|
67
63
|
case 'toolCallCompleted':
|
|
68
64
|
options.onToolCallCompleted?.(message)
|
|
69
65
|
break
|
package/src/genai-worker.ts
CHANGED
|
@@ -265,11 +265,6 @@ parentPort.on('message', async (message: WorkerInMessage) => {
|
|
|
265
265
|
...params,
|
|
266
266
|
} satisfies WorkerOutMessage)
|
|
267
267
|
},
|
|
268
|
-
onAllSessionsCompleted: () => {
|
|
269
|
-
parentPort!.postMessage({
|
|
270
|
-
type: 'allSessionsCompleted',
|
|
271
|
-
} satisfies WorkerOutMessage)
|
|
272
|
-
},
|
|
273
268
|
})
|
|
274
269
|
|
|
275
270
|
// Start GenAI session
|
package/src/tools.ts
CHANGED
|
@@ -19,7 +19,6 @@ import { initializeOpencodeForDirectory } from './discordBot.js'
|
|
|
19
19
|
|
|
20
20
|
export async function getTools({
|
|
21
21
|
onMessageCompleted,
|
|
22
|
-
onAllSessionsCompleted,
|
|
23
22
|
directory,
|
|
24
23
|
}: {
|
|
25
24
|
directory: string
|
|
@@ -30,7 +29,6 @@ export async function getTools({
|
|
|
30
29
|
error?: unknown
|
|
31
30
|
markdown?: string
|
|
32
31
|
}) => void
|
|
33
|
-
onAllSessionsCompleted?: () => void
|
|
34
32
|
}) {
|
|
35
33
|
const getClient = await initializeOpencodeForDirectory(directory)
|
|
36
34
|
const client = getClient()
|
|
@@ -39,9 +37,6 @@ export async function getTools({
|
|
|
39
37
|
|
|
40
38
|
const providersResponse = await client.config.providers({})
|
|
41
39
|
const providers: Provider[] = providersResponse.data?.providers || []
|
|
42
|
-
|
|
43
|
-
// Track all active OpenCode sessions
|
|
44
|
-
const activeSessions = new Set<string>()
|
|
45
40
|
|
|
46
41
|
// Helper: get last assistant model for a session (non-summary)
|
|
47
42
|
const getSessionModel = async (
|
|
@@ -73,10 +68,6 @@ export async function getTools({
|
|
|
73
68
|
execute: async ({ sessionId, message }) => {
|
|
74
69
|
const sessionModel = await getSessionModel(sessionId)
|
|
75
70
|
|
|
76
|
-
// Track this session as active
|
|
77
|
-
activeSessions.add(sessionId)
|
|
78
|
-
toolsLogger.log(`Session ${sessionId} started, ${activeSessions.size} active sessions`)
|
|
79
|
-
|
|
80
71
|
// do not await
|
|
81
72
|
getClient()
|
|
82
73
|
.session.prompt({
|
|
@@ -98,16 +89,6 @@ export async function getTools({
|
|
|
98
89
|
data: response.data,
|
|
99
90
|
markdown,
|
|
100
91
|
})
|
|
101
|
-
|
|
102
|
-
// Remove from active sessions
|
|
103
|
-
activeSessions.delete(sessionId)
|
|
104
|
-
toolsLogger.log(`Session ${sessionId} completed, ${activeSessions.size} active sessions remaining`)
|
|
105
|
-
|
|
106
|
-
// Check if all sessions are complete
|
|
107
|
-
if (activeSessions.size === 0) {
|
|
108
|
-
toolsLogger.log('All sessions completed')
|
|
109
|
-
onAllSessionsCompleted?.()
|
|
110
|
-
}
|
|
111
92
|
})
|
|
112
93
|
.catch((error) => {
|
|
113
94
|
onMessageCompleted?.({
|
|
@@ -115,16 +96,6 @@ export async function getTools({
|
|
|
115
96
|
messageId: '',
|
|
116
97
|
error,
|
|
117
98
|
})
|
|
118
|
-
|
|
119
|
-
// Remove from active sessions even on error
|
|
120
|
-
activeSessions.delete(sessionId)
|
|
121
|
-
toolsLogger.log(`Session ${sessionId} failed, ${activeSessions.size} active sessions remaining`)
|
|
122
|
-
|
|
123
|
-
// Check if all sessions are complete
|
|
124
|
-
if (activeSessions.size === 0) {
|
|
125
|
-
toolsLogger.log('All sessions completed')
|
|
126
|
-
onAllSessionsCompleted?.()
|
|
127
|
-
}
|
|
128
99
|
})
|
|
129
100
|
return {
|
|
130
101
|
success: true,
|
|
@@ -172,58 +143,32 @@ export async function getTools({
|
|
|
172
143
|
throw new Error('Failed to create session')
|
|
173
144
|
}
|
|
174
145
|
|
|
175
|
-
const newSessionId = session.data.id
|
|
176
|
-
|
|
177
|
-
// Track this session as active
|
|
178
|
-
activeSessions.add(newSessionId)
|
|
179
|
-
toolsLogger.log(`New session ${newSessionId} created, ${activeSessions.size} active sessions`)
|
|
180
|
-
|
|
181
146
|
// do not await
|
|
182
147
|
getClient()
|
|
183
148
|
.session.prompt({
|
|
184
|
-
path: { id:
|
|
149
|
+
path: { id: session.data.id },
|
|
185
150
|
body: {
|
|
186
151
|
parts: [{ type: 'text', text: message }],
|
|
187
152
|
},
|
|
188
153
|
})
|
|
189
154
|
.then(async (response) => {
|
|
190
155
|
const markdown = await markdownRenderer.generate({
|
|
191
|
-
sessionID:
|
|
156
|
+
sessionID: session.data.id,
|
|
192
157
|
lastAssistantOnly: true,
|
|
193
158
|
})
|
|
194
159
|
onMessageCompleted?.({
|
|
195
|
-
sessionId:
|
|
160
|
+
sessionId: session.data.id,
|
|
196
161
|
messageId: '',
|
|
197
162
|
data: response.data,
|
|
198
163
|
markdown,
|
|
199
164
|
})
|
|
200
|
-
|
|
201
|
-
// Remove from active sessions
|
|
202
|
-
activeSessions.delete(newSessionId)
|
|
203
|
-
toolsLogger.log(`Session ${newSessionId} completed, ${activeSessions.size} active sessions remaining`)
|
|
204
|
-
|
|
205
|
-
// Check if all sessions are complete
|
|
206
|
-
if (activeSessions.size === 0) {
|
|
207
|
-
toolsLogger.log('All sessions completed')
|
|
208
|
-
onAllSessionsCompleted?.()
|
|
209
|
-
}
|
|
210
165
|
})
|
|
211
166
|
.catch((error) => {
|
|
212
167
|
onMessageCompleted?.({
|
|
213
|
-
sessionId:
|
|
168
|
+
sessionId: session.data.id,
|
|
214
169
|
messageId: '',
|
|
215
170
|
error,
|
|
216
171
|
})
|
|
217
|
-
|
|
218
|
-
// Remove from active sessions even on error
|
|
219
|
-
activeSessions.delete(newSessionId)
|
|
220
|
-
toolsLogger.log(`Session ${newSessionId} failed, ${activeSessions.size} active sessions remaining`)
|
|
221
|
-
|
|
222
|
-
// Check if all sessions are complete
|
|
223
|
-
if (activeSessions.size === 0) {
|
|
224
|
-
toolsLogger.log('All sessions completed')
|
|
225
|
-
onAllSessionsCompleted?.()
|
|
226
|
-
}
|
|
227
172
|
})
|
|
228
173
|
|
|
229
174
|
return {
|
package/src/utils.ts
CHANGED
|
@@ -48,10 +48,9 @@ export function generateBotInstallUrl({
|
|
|
48
48
|
return url.toString()
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
52
51
|
export function deduplicateByKey<T, K>(arr: T[], keyFn: (item: T) => K): T[] {
|
|
53
52
|
const seen = new Set<K>()
|
|
54
|
-
return arr.filter(item => {
|
|
53
|
+
return arr.filter((item) => {
|
|
55
54
|
const key = keyFn(item)
|
|
56
55
|
if (seen.has(key)) {
|
|
57
56
|
return false
|
|
@@ -60,3 +59,18 @@ export function deduplicateByKey<T, K>(arr: T[], keyFn: (item: T) => K): T[] {
|
|
|
60
59
|
return true
|
|
61
60
|
})
|
|
62
61
|
}
|
|
62
|
+
|
|
63
|
+
export function isAbortError(
|
|
64
|
+
error: unknown,
|
|
65
|
+
signal?: AbortSignal,
|
|
66
|
+
): error is Error {
|
|
67
|
+
return (
|
|
68
|
+
error instanceof Error &&
|
|
69
|
+
(error.name === 'AbortError' ||
|
|
70
|
+
error.name === 'Aborterror' ||
|
|
71
|
+
error.name === 'aborterror' ||
|
|
72
|
+
error.name.toLowerCase() === 'aborterror' ||
|
|
73
|
+
error.message?.includes('aborted') ||
|
|
74
|
+
(signal?.aborted ?? false))
|
|
75
|
+
)
|
|
76
|
+
}
|