shuvmaki 0.4.26

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.
Files changed (94) hide show
  1. package/bin.js +70 -0
  2. package/dist/ai-tool-to-genai.js +210 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/channel-management.js +97 -0
  5. package/dist/cli.js +709 -0
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +184 -0
  22. package/dist/discord-bot.js +384 -0
  23. package/dist/discord-utils.js +217 -0
  24. package/dist/escape-backticks.test.js +410 -0
  25. package/dist/format-tables.js +96 -0
  26. package/dist/format-tables.test.js +418 -0
  27. package/dist/genai-worker-wrapper.js +109 -0
  28. package/dist/genai-worker.js +297 -0
  29. package/dist/genai.js +232 -0
  30. package/dist/interaction-handler.js +144 -0
  31. package/dist/logger.js +51 -0
  32. package/dist/markdown.js +310 -0
  33. package/dist/markdown.test.js +262 -0
  34. package/dist/message-formatting.js +273 -0
  35. package/dist/message-formatting.test.js +73 -0
  36. package/dist/openai-realtime.js +228 -0
  37. package/dist/opencode.js +216 -0
  38. package/dist/session-handler.js +580 -0
  39. package/dist/system-message.js +61 -0
  40. package/dist/tools.js +356 -0
  41. package/dist/utils.js +85 -0
  42. package/dist/voice-handler.js +541 -0
  43. package/dist/voice.js +314 -0
  44. package/dist/worker-types.js +4 -0
  45. package/dist/xml.js +92 -0
  46. package/dist/xml.test.js +32 -0
  47. package/package.json +60 -0
  48. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  49. package/src/__snapshots__/compact-session-context.md +47 -0
  50. package/src/ai-tool-to-genai.test.ts +296 -0
  51. package/src/ai-tool-to-genai.ts +255 -0
  52. package/src/channel-management.ts +161 -0
  53. package/src/cli.ts +1010 -0
  54. package/src/commands/abort.ts +94 -0
  55. package/src/commands/add-project.ts +139 -0
  56. package/src/commands/agent.ts +201 -0
  57. package/src/commands/ask-question.ts +276 -0
  58. package/src/commands/create-new-project.ts +111 -0
  59. package/src/commands/fork.ts +257 -0
  60. package/src/commands/model.ts +402 -0
  61. package/src/commands/permissions.ts +146 -0
  62. package/src/commands/queue.ts +181 -0
  63. package/src/commands/resume.ts +230 -0
  64. package/src/commands/session.ts +184 -0
  65. package/src/commands/share.ts +96 -0
  66. package/src/commands/types.ts +25 -0
  67. package/src/commands/undo-redo.ts +213 -0
  68. package/src/commands/user-command.ts +178 -0
  69. package/src/database.ts +220 -0
  70. package/src/discord-bot.ts +513 -0
  71. package/src/discord-utils.ts +282 -0
  72. package/src/escape-backticks.test.ts +447 -0
  73. package/src/format-tables.test.ts +440 -0
  74. package/src/format-tables.ts +110 -0
  75. package/src/genai-worker-wrapper.ts +160 -0
  76. package/src/genai-worker.ts +366 -0
  77. package/src/genai.ts +321 -0
  78. package/src/interaction-handler.ts +187 -0
  79. package/src/logger.ts +57 -0
  80. package/src/markdown.test.ts +358 -0
  81. package/src/markdown.ts +365 -0
  82. package/src/message-formatting.test.ts +81 -0
  83. package/src/message-formatting.ts +340 -0
  84. package/src/openai-realtime.ts +363 -0
  85. package/src/opencode.ts +277 -0
  86. package/src/session-handler.ts +758 -0
  87. package/src/system-message.ts +62 -0
  88. package/src/tools.ts +428 -0
  89. package/src/utils.ts +118 -0
  90. package/src/voice-handler.ts +760 -0
  91. package/src/voice.ts +432 -0
  92. package/src/worker-types.ts +66 -0
  93. package/src/xml.test.ts +37 -0
  94. package/src/xml.ts +121 -0
@@ -0,0 +1,297 @@
1
+ // Worker thread for GenAI voice processing.
2
+ // Runs in a separate thread to handle audio encoding/decoding without blocking.
3
+ // Resamples 24kHz GenAI output to 48kHz stereo Opus packets for Discord.
4
+ import { parentPort, threadId } from 'node:worker_threads';
5
+ import { createWriteStream } from 'node:fs';
6
+ import { mkdir } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { Resampler } from '@purinton/resampler';
9
+ import * as prism from 'prism-media';
10
+ import { startGenAiSession } from './genai.js';
11
+ import { getTools } from './tools.js';
12
+ import { createLogger } from './logger.js';
13
+ if (!parentPort) {
14
+ throw new Error('This module must be run as a worker thread');
15
+ }
16
+ const workerLogger = createLogger(`WORKER ${threadId}`);
17
+ workerLogger.log('GenAI worker started');
18
+ // Define sendError early so it can be used by global handlers
19
+ function sendError(error) {
20
+ if (parentPort) {
21
+ parentPort.postMessage({
22
+ type: 'error',
23
+ error,
24
+ });
25
+ }
26
+ }
27
+ // Add global error handlers for the worker thread
28
+ process.on('uncaughtException', (error) => {
29
+ workerLogger.error('Uncaught exception in worker:', error);
30
+ sendError(`Worker crashed: ${error.message}`);
31
+ // Exit immediately on uncaught exception
32
+ process.exit(1);
33
+ });
34
+ process.on('unhandledRejection', (reason, promise) => {
35
+ workerLogger.error('Unhandled rejection in worker:', reason, 'at promise:', promise);
36
+ sendError(`Worker unhandled rejection: ${reason}`);
37
+ });
38
+ // Audio configuration
39
+ const AUDIO_CONFIG = {
40
+ inputSampleRate: 24000, // GenAI output
41
+ inputChannels: 1,
42
+ outputSampleRate: 48000, // Discord expects
43
+ outputChannels: 2,
44
+ opusFrameSize: 960, // 20ms at 48kHz
45
+ };
46
+ // Initialize audio processing components
47
+ const resampler = new Resampler({
48
+ inRate: AUDIO_CONFIG.inputSampleRate,
49
+ outRate: AUDIO_CONFIG.outputSampleRate,
50
+ inChannels: AUDIO_CONFIG.inputChannels,
51
+ outChannels: AUDIO_CONFIG.outputChannels,
52
+ volume: 1,
53
+ filterWindow: 8,
54
+ });
55
+ const opusEncoder = new prism.opus.Encoder({
56
+ rate: AUDIO_CONFIG.outputSampleRate,
57
+ channels: AUDIO_CONFIG.outputChannels,
58
+ frameSize: AUDIO_CONFIG.opusFrameSize,
59
+ });
60
+ // Pipe resampler to encoder with error handling
61
+ resampler.pipe(opusEncoder).on('error', (error) => {
62
+ workerLogger.error('Pipe error between resampler and encoder:', error);
63
+ sendError(`Audio pipeline error: ${error.message}`);
64
+ });
65
+ // Opus packet queue and interval for 20ms packet sending
66
+ const opusPacketQueue = [];
67
+ let packetInterval = null;
68
+ // Send packets every 20ms
69
+ function startPacketSending() {
70
+ if (packetInterval)
71
+ return;
72
+ packetInterval = setInterval(() => {
73
+ const packet = opusPacketQueue.shift();
74
+ if (!packet)
75
+ return;
76
+ // Transfer packet as ArrayBuffer
77
+ const arrayBuffer = packet.buffer.slice(packet.byteOffset, packet.byteOffset + packet.byteLength);
78
+ parentPort.postMessage({
79
+ type: 'assistantOpusPacket',
80
+ packet: arrayBuffer,
81
+ }, [arrayBuffer]);
82
+ }, 20);
83
+ }
84
+ function stopPacketSending() {
85
+ if (packetInterval) {
86
+ clearInterval(packetInterval);
87
+ packetInterval = null;
88
+ }
89
+ opusPacketQueue.length = 0;
90
+ }
91
+ // Session state
92
+ let session = null;
93
+ // Audio log stream for assistant audio
94
+ let audioLogStream = null;
95
+ // Create assistant audio log stream for debugging
96
+ async function createAssistantAudioLogStream(guildId, channelId) {
97
+ if (!process.env.DEBUG)
98
+ return null;
99
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
100
+ const audioDir = path.join(process.cwd(), 'discord-audio-logs', guildId, channelId);
101
+ try {
102
+ await mkdir(audioDir, { recursive: true });
103
+ // Create stream for assistant audio (24kHz mono s16le PCM)
104
+ const outputFileName = `assistant_${timestamp}.24.pcm`;
105
+ const outputFilePath = path.join(audioDir, outputFileName);
106
+ const outputAudioStream = createWriteStream(outputFilePath);
107
+ // Add error handler to prevent crashes
108
+ outputAudioStream.on('error', (error) => {
109
+ workerLogger.error(`Assistant audio log stream error:`, error);
110
+ });
111
+ workerLogger.log(`Created assistant audio log: ${outputFilePath}`);
112
+ return outputAudioStream;
113
+ }
114
+ catch (error) {
115
+ workerLogger.error(`Failed to create audio log directory:`, error);
116
+ return null;
117
+ }
118
+ }
119
+ // Handle encoded Opus packets
120
+ opusEncoder.on('data', (packet) => {
121
+ opusPacketQueue.push(packet);
122
+ });
123
+ // Handle stream end events
124
+ opusEncoder.on('end', () => {
125
+ workerLogger.log('Opus encoder stream ended');
126
+ });
127
+ resampler.on('end', () => {
128
+ workerLogger.log('Resampler stream ended');
129
+ });
130
+ // Handle errors
131
+ resampler.on('error', (error) => {
132
+ workerLogger.error(`Resampler error:`, error);
133
+ sendError(`Resampler error: ${error.message}`);
134
+ });
135
+ opusEncoder.on('error', (error) => {
136
+ workerLogger.error(`Encoder error:`, error);
137
+ // Check for specific corrupted data errors
138
+ if (error.message?.includes('The compressed data passed is corrupted')) {
139
+ workerLogger.warn('Received corrupted audio data in opus encoder');
140
+ }
141
+ else {
142
+ sendError(`Encoder error: ${error.message}`);
143
+ }
144
+ });
145
+ async function cleanupAsync() {
146
+ workerLogger.log(`Starting async cleanup`);
147
+ stopPacketSending();
148
+ if (session) {
149
+ workerLogger.log(`Stopping GenAI session`);
150
+ session.stop();
151
+ session = null;
152
+ }
153
+ // Wait for audio log stream to finish writing
154
+ if (audioLogStream) {
155
+ workerLogger.log(`Closing assistant audio log stream`);
156
+ await new Promise((resolve, reject) => {
157
+ audioLogStream.end(() => {
158
+ workerLogger.log(`Assistant audio log stream closed`);
159
+ resolve();
160
+ });
161
+ audioLogStream.on('error', reject);
162
+ // Add timeout to prevent hanging
163
+ setTimeout(() => {
164
+ workerLogger.log(`Audio stream close timeout, continuing`);
165
+ resolve();
166
+ }, 3000);
167
+ });
168
+ audioLogStream = null;
169
+ }
170
+ // Unpipe and end the encoder first
171
+ resampler.unpipe(opusEncoder);
172
+ // End the encoder stream
173
+ await new Promise((resolve) => {
174
+ opusEncoder.end(() => {
175
+ workerLogger.log(`Opus encoder ended`);
176
+ resolve();
177
+ });
178
+ // Add timeout
179
+ setTimeout(resolve, 1000);
180
+ });
181
+ // End the resampler stream
182
+ await new Promise((resolve) => {
183
+ resampler.end(() => {
184
+ workerLogger.log(`Resampler ended`);
185
+ resolve();
186
+ });
187
+ // Add timeout
188
+ setTimeout(resolve, 1000);
189
+ });
190
+ workerLogger.log(`Async cleanup complete`);
191
+ }
192
+ // Handle messages from main thread
193
+ parentPort.on('message', async (message) => {
194
+ try {
195
+ switch (message.type) {
196
+ case 'init': {
197
+ workerLogger.log(`Initializing with directory:`, message.directory);
198
+ // Create audio log stream for assistant audio
199
+ audioLogStream = await createAssistantAudioLogStream(message.guildId, message.channelId);
200
+ // Start packet sending interval
201
+ startPacketSending();
202
+ // Get tools for the directory
203
+ const { tools } = await getTools({
204
+ directory: message.directory,
205
+ onMessageCompleted: (params) => {
206
+ parentPort.postMessage({
207
+ type: 'toolCallCompleted',
208
+ ...params,
209
+ });
210
+ },
211
+ });
212
+ // Start GenAI session
213
+ session = await startGenAiSession({
214
+ tools,
215
+ systemMessage: message.systemMessage,
216
+ geminiApiKey: message.geminiApiKey,
217
+ onAssistantAudioChunk({ data }) {
218
+ // Write to audio log if enabled
219
+ if (audioLogStream && !audioLogStream.destroyed) {
220
+ audioLogStream.write(data, (err) => {
221
+ if (err) {
222
+ workerLogger.error('Error writing to audio log:', err);
223
+ }
224
+ });
225
+ }
226
+ // Write PCM data to resampler which will output Opus packets
227
+ if (!resampler.destroyed) {
228
+ resampler.write(data, (err) => {
229
+ if (err) {
230
+ workerLogger.error('Error writing to resampler:', err);
231
+ sendError(`Failed to process audio: ${err.message}`);
232
+ }
233
+ });
234
+ }
235
+ },
236
+ onAssistantStartSpeaking() {
237
+ parentPort.postMessage({
238
+ type: 'assistantStartSpeaking',
239
+ });
240
+ },
241
+ onAssistantStopSpeaking() {
242
+ parentPort.postMessage({
243
+ type: 'assistantStopSpeaking',
244
+ });
245
+ },
246
+ onAssistantInterruptSpeaking() {
247
+ parentPort.postMessage({
248
+ type: 'assistantInterruptSpeaking',
249
+ });
250
+ },
251
+ });
252
+ // Notify main thread we're ready
253
+ parentPort.postMessage({
254
+ type: 'ready',
255
+ });
256
+ break;
257
+ }
258
+ case 'sendRealtimeInput': {
259
+ if (!session) {
260
+ sendError('Session not initialized');
261
+ return;
262
+ }
263
+ session.session.sendRealtimeInput({
264
+ audio: message.audio,
265
+ audioStreamEnd: message.audioStreamEnd,
266
+ });
267
+ break;
268
+ }
269
+ case 'sendTextInput': {
270
+ if (!session) {
271
+ sendError('Session not initialized');
272
+ return;
273
+ }
274
+ session.session.sendRealtimeInput({
275
+ text: message.text,
276
+ });
277
+ break;
278
+ }
279
+ case 'interrupt': {
280
+ workerLogger.log(`Interrupting playback`);
281
+ // Clear the opus packet queue
282
+ opusPacketQueue.length = 0;
283
+ break;
284
+ }
285
+ case 'stop': {
286
+ workerLogger.log(`Stopping worker`);
287
+ await cleanupAsync();
288
+ // process.exit(0)
289
+ break;
290
+ }
291
+ }
292
+ }
293
+ catch (error) {
294
+ workerLogger.error(`Error handling message:`, error);
295
+ sendError(error instanceof Error ? error.message : 'Unknown error in worker');
296
+ }
297
+ });
package/dist/genai.js ADDED
@@ -0,0 +1,232 @@
1
+ // Google GenAI Live session manager for real-time voice interactions.
2
+ // Establishes bidirectional audio streaming with Gemini, handles tool calls,
3
+ // and manages the assistant's audio output for Discord voice channels.
4
+ import { GoogleGenAI, LiveServerMessage, MediaResolution, Modality, Session, } from '@google/genai';
5
+ import { writeFile } from 'fs';
6
+ import { createLogger } from './logger.js';
7
+ import { aiToolToCallableTool } from './ai-tool-to-genai.js';
8
+ const genaiLogger = createLogger('GENAI');
9
+ const audioParts = [];
10
+ function saveBinaryFile(fileName, content) {
11
+ writeFile(fileName, content, 'utf8', (err) => {
12
+ if (err) {
13
+ genaiLogger.error(`Error writing file ${fileName}:`, err);
14
+ return;
15
+ }
16
+ genaiLogger.log(`Appending stream content to file ${fileName}.`);
17
+ });
18
+ }
19
+ function convertToWav(rawData, mimeType) {
20
+ const options = parseMimeType(mimeType);
21
+ const dataLength = rawData.reduce((a, b) => a + b.length, 0);
22
+ const wavHeader = createWavHeader(dataLength, options);
23
+ const buffer = Buffer.concat(rawData);
24
+ return Buffer.concat([wavHeader, buffer]);
25
+ }
26
+ function parseMimeType(mimeType) {
27
+ const [fileType, ...params] = mimeType.split(';').map((s) => s.trim());
28
+ const [_, format] = fileType?.split('/') || [];
29
+ const options = {
30
+ numChannels: 1,
31
+ bitsPerSample: 16,
32
+ };
33
+ if (format && format.startsWith('L')) {
34
+ const bits = parseInt(format.slice(1), 10);
35
+ if (!isNaN(bits)) {
36
+ options.bitsPerSample = bits;
37
+ }
38
+ }
39
+ for (const param of params) {
40
+ const [key, value] = param.split('=').map((s) => s.trim());
41
+ if (key === 'rate') {
42
+ options.sampleRate = parseInt(value || '', 10);
43
+ }
44
+ }
45
+ return options;
46
+ }
47
+ function createWavHeader(dataLength, options) {
48
+ const { numChannels, sampleRate, bitsPerSample } = options;
49
+ // http://soundfile.sapp.org/doc/WaveFormat
50
+ const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
51
+ const blockAlign = (numChannels * bitsPerSample) / 8;
52
+ const buffer = Buffer.alloc(44);
53
+ buffer.write('RIFF', 0); // ChunkID
54
+ buffer.writeUInt32LE(36 + dataLength, 4); // ChunkSize
55
+ buffer.write('WAVE', 8); // Format
56
+ buffer.write('fmt ', 12); // Subchunk1ID
57
+ buffer.writeUInt32LE(16, 16); // Subchunk1Size (PCM)
58
+ buffer.writeUInt16LE(1, 20); // AudioFormat (1 = PCM)
59
+ buffer.writeUInt16LE(numChannels, 22); // NumChannels
60
+ buffer.writeUInt32LE(sampleRate, 24); // SampleRate
61
+ buffer.writeUInt32LE(byteRate, 28); // ByteRate
62
+ buffer.writeUInt16LE(blockAlign, 32); // BlockAlign
63
+ buffer.writeUInt16LE(bitsPerSample, 34); // BitsPerSample
64
+ buffer.write('data', 36); // Subchunk2ID
65
+ buffer.writeUInt32LE(dataLength, 40); // Subchunk2Size
66
+ return buffer;
67
+ }
68
+ function defaultAudioChunkHandler({ data, mimeType, }) {
69
+ audioParts.push(data);
70
+ const fileName = 'audio.wav';
71
+ const buffer = convertToWav(audioParts, mimeType);
72
+ saveBinaryFile(fileName, buffer);
73
+ }
74
+ export async function startGenAiSession({ onAssistantAudioChunk, onAssistantStartSpeaking, onAssistantStopSpeaking, onAssistantInterruptSpeaking, systemMessage, tools, geminiApiKey, } = {}) {
75
+ let session = undefined;
76
+ const callableTools = [];
77
+ let isAssistantSpeaking = false;
78
+ const audioChunkHandler = onAssistantAudioChunk || defaultAudioChunkHandler;
79
+ // Convert AI SDK tools to GenAI CallableTools
80
+ if (tools) {
81
+ for (const [name, tool] of Object.entries(tools)) {
82
+ callableTools.push(aiToolToCallableTool(tool, name));
83
+ }
84
+ }
85
+ function handleModelTurn(message) {
86
+ if (message.toolCall) {
87
+ genaiLogger.log('Tool call:', message.toolCall);
88
+ // Handle tool calls
89
+ if (message.toolCall.functionCalls && callableTools.length > 0) {
90
+ for (const tool of callableTools) {
91
+ if (!message.toolCall.functionCalls.some((x) => x.name === tool.name)) {
92
+ continue;
93
+ }
94
+ tool
95
+ .callTool(message.toolCall.functionCalls)
96
+ .then((parts) => {
97
+ const functionResponses = parts
98
+ .filter((part) => part.functionResponse)
99
+ .map((part) => ({
100
+ response: part.functionResponse.response,
101
+ id: part.functionResponse.id,
102
+ name: part.functionResponse.name,
103
+ }));
104
+ if (functionResponses.length > 0 && session) {
105
+ session.sendToolResponse({ functionResponses });
106
+ genaiLogger.log('client-toolResponse: ' +
107
+ JSON.stringify({ functionResponses }));
108
+ }
109
+ })
110
+ .catch((error) => {
111
+ genaiLogger.error('Error handling tool calls:', error);
112
+ });
113
+ }
114
+ }
115
+ }
116
+ if (message.serverContent?.modelTurn?.parts) {
117
+ for (const part of message.serverContent.modelTurn.parts) {
118
+ if (part?.fileData) {
119
+ genaiLogger.log(`File: ${part?.fileData.fileUri}`);
120
+ }
121
+ if (part?.inlineData) {
122
+ const inlineData = part.inlineData;
123
+ if (!inlineData.mimeType ||
124
+ !inlineData.mimeType.startsWith('audio/')) {
125
+ genaiLogger.log('Skipping non-audio inlineData:', inlineData.mimeType);
126
+ continue;
127
+ }
128
+ // Trigger start speaking callback the first time audio is received
129
+ if (!isAssistantSpeaking && onAssistantStartSpeaking) {
130
+ isAssistantSpeaking = true;
131
+ onAssistantStartSpeaking();
132
+ }
133
+ const buffer = Buffer.from(inlineData?.data ?? '', 'base64');
134
+ audioChunkHandler({
135
+ data: buffer,
136
+ mimeType: inlineData.mimeType ?? '',
137
+ });
138
+ }
139
+ if (part?.text) {
140
+ genaiLogger.log('Text:', part.text);
141
+ }
142
+ }
143
+ }
144
+ // Handle input transcription (user's audio transcription)
145
+ if (message.serverContent?.inputTranscription?.text) {
146
+ genaiLogger.log('[user transcription]', message.serverContent.inputTranscription.text);
147
+ }
148
+ // Handle output transcription (model's audio transcription)
149
+ if (message.serverContent?.outputTranscription?.text) {
150
+ genaiLogger.log('[assistant transcription]', message.serverContent.outputTranscription.text);
151
+ }
152
+ if (message.serverContent?.interrupted) {
153
+ genaiLogger.log('Assistant was interrupted');
154
+ if (isAssistantSpeaking && onAssistantInterruptSpeaking) {
155
+ isAssistantSpeaking = false;
156
+ onAssistantInterruptSpeaking();
157
+ }
158
+ }
159
+ if (message.serverContent?.turnComplete) {
160
+ genaiLogger.log('Assistant turn complete');
161
+ if (isAssistantSpeaking && onAssistantStopSpeaking) {
162
+ isAssistantSpeaking = false;
163
+ onAssistantStopSpeaking();
164
+ }
165
+ }
166
+ }
167
+ const apiKey = geminiApiKey || process.env.GEMINI_API_KEY;
168
+ if (!apiKey) {
169
+ genaiLogger.error('No Gemini API key provided');
170
+ throw new Error('Gemini API key is required for voice interactions');
171
+ }
172
+ const ai = new GoogleGenAI({
173
+ apiKey,
174
+ });
175
+ const model = 'gemini-2.5-flash-native-audio-preview-12-2025';
176
+ session = await ai.live.connect({
177
+ model,
178
+ callbacks: {
179
+ onopen: function () {
180
+ genaiLogger.debug('Opened');
181
+ },
182
+ onmessage: function (message) {
183
+ // genaiLogger.log(message)
184
+ try {
185
+ handleModelTurn(message);
186
+ }
187
+ catch (error) {
188
+ genaiLogger.error('Error handling turn:', error);
189
+ }
190
+ },
191
+ onerror: function (e) {
192
+ genaiLogger.debug('Error:', e.message);
193
+ },
194
+ onclose: function (e) {
195
+ genaiLogger.debug('Close:', e.reason);
196
+ },
197
+ },
198
+ config: {
199
+ tools: callableTools,
200
+ responseModalities: [Modality.AUDIO],
201
+ mediaResolution: MediaResolution.MEDIA_RESOLUTION_MEDIUM,
202
+ inputAudioTranscription: {}, // transcribes your input speech
203
+ outputAudioTranscription: {}, // transcribes the model's spoken audio
204
+ systemInstruction: {
205
+ parts: [
206
+ {
207
+ text: systemMessage || '',
208
+ },
209
+ ],
210
+ },
211
+ speechConfig: {
212
+ voiceConfig: {
213
+ prebuiltVoiceConfig: {
214
+ voiceName: 'Charon', // Orus also not bad
215
+ },
216
+ },
217
+ },
218
+ contextWindowCompression: {
219
+ triggerTokens: '25600',
220
+ slidingWindow: { targetTokens: '12800' },
221
+ },
222
+ },
223
+ });
224
+ return {
225
+ session,
226
+ stop: () => {
227
+ const currentSession = session;
228
+ session = undefined;
229
+ currentSession?.close();
230
+ },
231
+ };
232
+ }
@@ -0,0 +1,144 @@
1
+ // Discord slash command and interaction handler.
2
+ // Processes all slash commands (/session, /resume, /fork, /model, /abort, etc.)
3
+ // and manages autocomplete, select menu interactions for the bot.
4
+ import { Events } from 'discord.js';
5
+ import { handleSessionCommand, handleSessionAutocomplete } from './commands/session.js';
6
+ import { handleResumeCommand, handleResumeAutocomplete } from './commands/resume.js';
7
+ import { handleAddProjectCommand, handleAddProjectAutocomplete } from './commands/add-project.js';
8
+ import { handleCreateNewProjectCommand } from './commands/create-new-project.js';
9
+ import { handleAcceptCommand, handleRejectCommand } from './commands/permissions.js';
10
+ import { handleAbortCommand } from './commands/abort.js';
11
+ import { handleShareCommand } from './commands/share.js';
12
+ import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js';
13
+ import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu } from './commands/model.js';
14
+ import { handleAgentCommand, handleAgentSelectMenu } from './commands/agent.js';
15
+ import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
16
+ import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js';
17
+ import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js';
18
+ import { handleUserCommand } from './commands/user-command.js';
19
+ import { createLogger } from './logger.js';
20
+ const interactionLogger = createLogger('INTERACTION');
21
+ export function registerInteractionHandler({ discordClient, appId, }) {
22
+ interactionLogger.log('[REGISTER] Interaction handler registered');
23
+ discordClient.on(Events.InteractionCreate, async (interaction) => {
24
+ try {
25
+ interactionLogger.log(`[INTERACTION] Received: ${interaction.type} - ${interaction.isChatInputCommand()
26
+ ? interaction.commandName
27
+ : interaction.isAutocomplete()
28
+ ? `autocomplete:${interaction.commandName}`
29
+ : 'other'}`);
30
+ if (interaction.isAutocomplete()) {
31
+ switch (interaction.commandName) {
32
+ case 'session':
33
+ await handleSessionAutocomplete({ interaction, appId });
34
+ return;
35
+ case 'resume':
36
+ await handleResumeAutocomplete({ interaction, appId });
37
+ return;
38
+ case 'add-project':
39
+ await handleAddProjectAutocomplete({ interaction, appId });
40
+ return;
41
+ default:
42
+ await interaction.respond([]);
43
+ return;
44
+ }
45
+ }
46
+ if (interaction.isChatInputCommand()) {
47
+ interactionLogger.log(`[COMMAND] Processing: ${interaction.commandName}`);
48
+ switch (interaction.commandName) {
49
+ case 'session':
50
+ await handleSessionCommand({ command: interaction, appId });
51
+ return;
52
+ case 'resume':
53
+ await handleResumeCommand({ command: interaction, appId });
54
+ return;
55
+ case 'add-project':
56
+ await handleAddProjectCommand({ command: interaction, appId });
57
+ return;
58
+ case 'create-new-project':
59
+ await handleCreateNewProjectCommand({ command: interaction, appId });
60
+ return;
61
+ case 'accept':
62
+ case 'accept-always':
63
+ await handleAcceptCommand({ command: interaction, appId });
64
+ return;
65
+ case 'reject':
66
+ await handleRejectCommand({ command: interaction, appId });
67
+ return;
68
+ case 'abort':
69
+ case 'stop':
70
+ await handleAbortCommand({ command: interaction, appId });
71
+ return;
72
+ case 'share':
73
+ await handleShareCommand({ command: interaction, appId });
74
+ return;
75
+ case 'fork':
76
+ await handleForkCommand(interaction);
77
+ return;
78
+ case 'model':
79
+ await handleModelCommand({ interaction, appId });
80
+ return;
81
+ case 'agent':
82
+ await handleAgentCommand({ interaction, appId });
83
+ return;
84
+ case 'queue':
85
+ await handleQueueCommand({ command: interaction, appId });
86
+ return;
87
+ case 'clear-queue':
88
+ await handleClearQueueCommand({ command: interaction, appId });
89
+ return;
90
+ case 'undo':
91
+ await handleUndoCommand({ command: interaction, appId });
92
+ return;
93
+ case 'redo':
94
+ await handleRedoCommand({ command: interaction, appId });
95
+ return;
96
+ }
97
+ // Handle user-defined commands (ending with -cmd suffix)
98
+ if (interaction.commandName.endsWith('-cmd')) {
99
+ await handleUserCommand({ command: interaction, appId });
100
+ return;
101
+ }
102
+ return;
103
+ }
104
+ if (interaction.isStringSelectMenu()) {
105
+ const customId = interaction.customId;
106
+ if (customId.startsWith('fork_select:')) {
107
+ await handleForkSelectMenu(interaction);
108
+ return;
109
+ }
110
+ if (customId.startsWith('model_provider:')) {
111
+ await handleProviderSelectMenu(interaction);
112
+ return;
113
+ }
114
+ if (customId.startsWith('model_select:')) {
115
+ await handleModelSelectMenu(interaction);
116
+ return;
117
+ }
118
+ if (customId.startsWith('agent_select:')) {
119
+ await handleAgentSelectMenu(interaction);
120
+ return;
121
+ }
122
+ if (customId.startsWith('ask_question:')) {
123
+ await handleAskQuestionSelectMenu(interaction);
124
+ return;
125
+ }
126
+ return;
127
+ }
128
+ }
129
+ catch (error) {
130
+ interactionLogger.error('[INTERACTION] Error handling interaction:', error);
131
+ try {
132
+ if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) {
133
+ await interaction.reply({
134
+ content: 'An error occurred processing this command.',
135
+ ephemeral: true,
136
+ });
137
+ }
138
+ }
139
+ catch (replyError) {
140
+ interactionLogger.error('[INTERACTION] Failed to send error reply:', replyError);
141
+ }
142
+ }
143
+ });
144
+ }