kimaki 0.4.17 → 0.4.19

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.
@@ -39,11 +39,13 @@ export function getOpencodeSystemMessage({ sessionId }) {
39
39
  return `
40
40
  The user is reading your messages from inside Discord, via kimaki.xyz
41
41
 
42
+ The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
43
+
42
44
  Your current OpenCode session ID is: ${sessionId}
43
45
 
44
46
  ## uploading files to discord
45
47
 
46
- To upload files (images, screenshots, etc.) to the Discord thread, run:
48
+ To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
47
49
 
48
50
  npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
49
51
 
@@ -458,6 +460,7 @@ export function getDatabase() {
458
460
  CREATE TABLE IF NOT EXISTS bot_api_keys (
459
461
  app_id TEXT PRIMARY KEY,
460
462
  gemini_api_key TEXT,
463
+ xai_api_key TEXT,
461
464
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
462
465
  )
463
466
  `);
@@ -642,11 +645,50 @@ async function processVoiceAttachment({ message, thread, projectDirectory, isNew
642
645
  await sendThreadMessage(thread, `📝 **Transcribed message:** ${escapeDiscordFormatting(transcription)}`);
643
646
  return transcription;
644
647
  }
645
- function getImageAttachments(message) {
646
- const imageAttachments = Array.from(message.attachments.values()).filter((attachment) => attachment.contentType?.startsWith('image/'));
647
- return imageAttachments.map((attachment) => ({
648
+ const TEXT_MIME_TYPES = [
649
+ 'text/',
650
+ 'application/json',
651
+ 'application/xml',
652
+ 'application/javascript',
653
+ 'application/typescript',
654
+ 'application/x-yaml',
655
+ 'application/toml',
656
+ ];
657
+ function isTextMimeType(contentType) {
658
+ if (!contentType) {
659
+ return false;
660
+ }
661
+ return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix));
662
+ }
663
+ async function getTextAttachments(message) {
664
+ const textAttachments = Array.from(message.attachments.values()).filter((attachment) => isTextMimeType(attachment.contentType));
665
+ if (textAttachments.length === 0) {
666
+ return '';
667
+ }
668
+ const textContents = await Promise.all(textAttachments.map(async (attachment) => {
669
+ try {
670
+ const response = await fetch(attachment.url);
671
+ if (!response.ok) {
672
+ return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`;
673
+ }
674
+ const text = await response.text();
675
+ return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`;
676
+ }
677
+ catch (error) {
678
+ const errMsg = error instanceof Error ? error.message : String(error);
679
+ return `<attachment filename="${attachment.name}" error="${errMsg}" />`;
680
+ }
681
+ }));
682
+ return textContents.join('\n\n');
683
+ }
684
+ function getFileAttachments(message) {
685
+ const fileAttachments = Array.from(message.attachments.values()).filter((attachment) => {
686
+ const contentType = attachment.contentType || '';
687
+ return (contentType.startsWith('image/') || contentType === 'application/pdf');
688
+ });
689
+ return fileAttachments.map((attachment) => ({
648
690
  type: 'file',
649
- mime: attachment.contentType || 'image/png',
691
+ mime: attachment.contentType || 'application/octet-stream',
650
692
  filename: attachment.name,
651
693
  url: attachment.url,
652
694
  }));
@@ -897,13 +939,6 @@ export async function initializeOpencodeForDirectory(directory) {
897
939
  function getToolSummaryText(part) {
898
940
  if (part.type !== 'tool')
899
941
  return '';
900
- if (part.state.status !== 'completed' && part.state.status !== 'error')
901
- return '';
902
- if (part.tool === 'bash') {
903
- const output = part.state.status === 'completed' ? part.state.output : part.state.error;
904
- const lines = (output || '').split('\n').filter((l) => l.trim());
905
- return `(${lines.length} line${lines.length === 1 ? '' : 's'})`;
906
- }
907
942
  if (part.tool === 'edit') {
908
943
  const newString = part.state.input?.newString || '';
909
944
  const oldString = part.state.input?.oldString || '';
@@ -921,7 +956,8 @@ function getToolSummaryText(part) {
921
956
  const urlWithoutProtocol = url.replace(/^https?:\/\//, '');
922
957
  return urlWithoutProtocol ? `(${urlWithoutProtocol})` : '';
923
958
  }
924
- if (part.tool === 'read' ||
959
+ if (part.tool === 'bash' ||
960
+ part.tool === 'read' ||
925
961
  part.tool === 'list' ||
926
962
  part.tool === 'glob' ||
927
963
  part.tool === 'grep' ||
@@ -937,7 +973,7 @@ function getToolSummaryText(part) {
937
973
  if (value === null || value === undefined)
938
974
  return null;
939
975
  const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
940
- const truncatedValue = stringValue.length > 100 ? stringValue.slice(0, 100) + '…' : stringValue;
976
+ const truncatedValue = stringValue.length > 300 ? stringValue.slice(0, 300) + '…' : stringValue;
941
977
  return `${key}: ${truncatedValue}`;
942
978
  })
943
979
  .filter(Boolean);
@@ -945,16 +981,6 @@ function getToolSummaryText(part) {
945
981
  return '';
946
982
  return `(${inputFields.join(', ')})`;
947
983
  }
948
- function getToolOutputToDisplay(part) {
949
- if (part.type !== 'tool')
950
- return '';
951
- if (part.state.status !== 'completed' && part.state.status !== 'error')
952
- return '';
953
- if (part.state.status === 'error') {
954
- return part.state.error || 'Unknown error';
955
- }
956
- return '';
957
- }
958
984
  function formatTodoList(part) {
959
985
  if (part.type !== 'tool' || part.tool !== 'todowrite')
960
986
  return '';
@@ -999,14 +1025,14 @@ function formatPart(part) {
999
1025
  if (part.tool === 'todowrite') {
1000
1026
  return formatTodoList(part);
1001
1027
  }
1002
- if (part.state.status !== 'completed' && part.state.status !== 'error') {
1028
+ if (part.state.status === 'pending') {
1003
1029
  return '';
1004
1030
  }
1005
1031
  const summaryText = getToolSummaryText(part);
1006
- const outputToDisplay = getToolOutputToDisplay(part);
1032
+ const stateTitle = 'title' in part.state ? part.state.title : undefined;
1007
1033
  let toolTitle = '';
1008
1034
  if (part.state.status === 'error') {
1009
- toolTitle = 'error';
1035
+ toolTitle = part.state.error || 'error';
1010
1036
  }
1011
1037
  else if (part.tool === 'bash') {
1012
1038
  const command = part.state.input?.command || '';
@@ -1016,18 +1042,14 @@ function formatPart(part) {
1016
1042
  toolTitle = `\`${command}\``;
1017
1043
  }
1018
1044
  else {
1019
- toolTitle = part.state.title ? `*${part.state.title}*` : '';
1045
+ toolTitle = stateTitle ? `*${stateTitle}*` : '';
1020
1046
  }
1021
1047
  }
1022
- else if (part.state.title) {
1023
- toolTitle = `*${part.state.title}*`;
1048
+ else if (stateTitle) {
1049
+ toolTitle = `*${stateTitle}*`;
1024
1050
  }
1025
- const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : '';
1026
- const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
1027
- if (outputToDisplay) {
1028
- return title + '\n\n' + outputToDisplay;
1029
- }
1030
- return title;
1051
+ const icon = part.state.status === 'error' ? '⨯' : '◼︎';
1052
+ return `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
1031
1053
  }
1032
1054
  discordLogger.warn('Unknown part type:', part);
1033
1055
  return '';
@@ -1090,9 +1112,10 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1090
1112
  }
1091
1113
  }
1092
1114
  if (!session) {
1093
- voiceLogger.log(`[SESSION] Creating new session with title: "${prompt.slice(0, 80)}"`);
1115
+ const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80);
1116
+ voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`);
1094
1117
  const sessionResponse = await getClient().session.create({
1095
- body: { title: prompt.slice(0, 80) },
1118
+ body: { title: sessionTitle },
1096
1119
  });
1097
1120
  session = sessionResponse.data;
1098
1121
  sessionLogger.log(`Created new session ${session?.id}`);
@@ -1143,7 +1166,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1143
1166
  let stopTyping = null;
1144
1167
  let usedModel;
1145
1168
  let usedProviderID;
1146
- let inputTokens = 0;
1169
+ let tokensUsedInSession = 0;
1147
1170
  const sendPartMessage = async (part) => {
1148
1171
  const content = formatPart(part) + '\n\n';
1149
1172
  if (!content.trim() || content.length === 0) {
@@ -1219,12 +1242,13 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1219
1242
  }
1220
1243
  // Track assistant message ID
1221
1244
  if (msg.role === 'assistant') {
1245
+ const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write;
1246
+ if (newTokensTotal > 0) {
1247
+ tokensUsedInSession = newTokensTotal;
1248
+ }
1222
1249
  assistantMessageId = msg.id;
1223
1250
  usedModel = msg.modelID;
1224
1251
  usedProviderID = msg.providerID;
1225
- if (msg.tokens.input > 0) {
1226
- inputTokens = msg.tokens.input;
1227
- }
1228
1252
  }
1229
1253
  }
1230
1254
  else if (event.type === 'message.part.updated') {
@@ -1247,13 +1271,12 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1247
1271
  if (part.type === 'step-start') {
1248
1272
  stopTyping = startTyping(thread);
1249
1273
  }
1274
+ // Send tool parts immediately when they start running
1275
+ if (part.type === 'tool' && part.state.status === 'running') {
1276
+ await sendPartMessage(part);
1277
+ }
1250
1278
  // Check if this is a step-finish part
1251
1279
  if (part.type === 'step-finish') {
1252
- // Track tokens from step-finish part
1253
- if (part.tokens?.input && part.tokens.input > 0) {
1254
- inputTokens = part.tokens.input;
1255
- voiceLogger.log(`[STEP-FINISH] Captured tokens: ${inputTokens}`);
1256
- }
1257
1280
  // Send all parts accumulated so far to Discord
1258
1281
  for (const p of currentParts) {
1259
1282
  // Skip step-start and step-finish parts as they have no visual content
@@ -1359,22 +1382,20 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1359
1382
  const attachCommand = port ? ` ⋅ ${session.id}` : '';
1360
1383
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
1361
1384
  let contextInfo = '';
1362
- if (inputTokens > 0 && usedProviderID && usedModel) {
1363
- try {
1364
- const providersResponse = await getClient().provider.list({ query: { directory } });
1365
- const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
1366
- const model = provider?.models?.[usedModel];
1367
- if (model?.limit?.context) {
1368
- const percentage = Math.round((inputTokens / model.limit.context) * 100);
1369
- contextInfo = ` ⋅ ${percentage}%`;
1370
- }
1371
- }
1372
- catch (e) {
1373
- sessionLogger.error('Failed to fetch provider info for context percentage:', e);
1385
+ try {
1386
+ const providersResponse = await getClient().provider.list({ query: { directory } });
1387
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
1388
+ const model = provider?.models?.[usedModel || ''];
1389
+ if (model?.limit?.context) {
1390
+ const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100);
1391
+ contextInfo = ` ${percentage}%`;
1374
1392
  }
1375
1393
  }
1394
+ catch (e) {
1395
+ sessionLogger.error('Failed to fetch provider info for context percentage:', e);
1396
+ }
1376
1397
  await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`);
1377
- sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${inputTokens}`);
1398
+ sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
1378
1399
  }
1379
1400
  else {
1380
1401
  sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
@@ -1412,17 +1433,30 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1412
1433
  signal: abortController.signal,
1413
1434
  });
1414
1435
  }
1436
+ if (response.error) {
1437
+ const errorMessage = (() => {
1438
+ const err = response.error;
1439
+ if (err && typeof err === 'object') {
1440
+ if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
1441
+ return String(err.data.message);
1442
+ }
1443
+ if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
1444
+ return JSON.stringify(err.errors);
1445
+ }
1446
+ }
1447
+ return JSON.stringify(err);
1448
+ })();
1449
+ throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`);
1450
+ }
1415
1451
  abortController.abort('finished');
1416
1452
  sessionLogger.log(`Successfully sent prompt, got response`);
1417
- // Update reaction to success
1418
1453
  if (originalMessage) {
1419
1454
  try {
1420
1455
  await originalMessage.reactions.removeAll();
1421
1456
  await originalMessage.react('✅');
1422
- discordLogger.log(`Added success reaction to message`);
1423
1457
  }
1424
1458
  catch (e) {
1425
- discordLogger.log(`Could not update reaction:`, e);
1459
+ discordLogger.log(`Could not update reactions:`, e);
1426
1460
  }
1427
1461
  }
1428
1462
  return { sessionID: session.id, result: response.data, port };
@@ -1596,14 +1630,18 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1596
1630
  if (transcription) {
1597
1631
  messageContent = transcription;
1598
1632
  }
1599
- const images = getImageAttachments(message);
1633
+ const fileAttachments = getFileAttachments(message);
1634
+ const textAttachmentsContent = await getTextAttachments(message);
1635
+ const promptWithAttachments = textAttachmentsContent
1636
+ ? `${messageContent}\n\n${textAttachmentsContent}`
1637
+ : messageContent;
1600
1638
  const parsedCommand = parseSlashCommand(messageContent);
1601
1639
  await handleOpencodeSession({
1602
- prompt: messageContent,
1640
+ prompt: promptWithAttachments,
1603
1641
  thread,
1604
1642
  projectDirectory,
1605
1643
  originalMessage: message,
1606
- images,
1644
+ images: fileAttachments,
1607
1645
  parsedCommand,
1608
1646
  });
1609
1647
  return;
@@ -1664,14 +1702,18 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1664
1702
  if (transcription) {
1665
1703
  messageContent = transcription;
1666
1704
  }
1667
- const images = getImageAttachments(message);
1705
+ const fileAttachments = getFileAttachments(message);
1706
+ const textAttachmentsContent = await getTextAttachments(message);
1707
+ const promptWithAttachments = textAttachmentsContent
1708
+ ? `${messageContent}\n\n${textAttachmentsContent}`
1709
+ : messageContent;
1668
1710
  const parsedCommand = parseSlashCommand(messageContent);
1669
1711
  await handleOpencodeSession({
1670
- prompt: messageContent,
1712
+ prompt: promptWithAttachments,
1671
1713
  thread,
1672
1714
  projectDirectory,
1673
1715
  originalMessage: message,
1674
- images,
1716
+ images: fileAttachments,
1675
1717
  parsedCommand,
1676
1718
  });
1677
1719
  }
@@ -2093,6 +2135,72 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2093
2135
  await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
2094
2136
  }
2095
2137
  }
2138
+ else if (command.commandName === 'add-new-project') {
2139
+ await command.deferReply({ ephemeral: false });
2140
+ const projectName = command.options.getString('name', true);
2141
+ const guild = command.guild;
2142
+ const channel = command.channel;
2143
+ if (!guild) {
2144
+ await command.editReply('This command can only be used in a guild');
2145
+ return;
2146
+ }
2147
+ if (!channel || channel.type !== ChannelType.GuildText) {
2148
+ await command.editReply('This command can only be used in a text channel');
2149
+ return;
2150
+ }
2151
+ const sanitizedName = projectName
2152
+ .toLowerCase()
2153
+ .replace(/[^a-z0-9-]/g, '-')
2154
+ .replace(/-+/g, '-')
2155
+ .replace(/^-|-$/g, '')
2156
+ .slice(0, 100);
2157
+ if (!sanitizedName) {
2158
+ await command.editReply('Invalid project name');
2159
+ return;
2160
+ }
2161
+ const kimakiDir = path.join(os.homedir(), 'kimaki');
2162
+ const projectDirectory = path.join(kimakiDir, sanitizedName);
2163
+ try {
2164
+ if (!fs.existsSync(kimakiDir)) {
2165
+ fs.mkdirSync(kimakiDir, { recursive: true });
2166
+ discordLogger.log(`Created kimaki directory: ${kimakiDir}`);
2167
+ }
2168
+ if (fs.existsSync(projectDirectory)) {
2169
+ await command.editReply(`Project directory already exists: ${projectDirectory}`);
2170
+ return;
2171
+ }
2172
+ fs.mkdirSync(projectDirectory, { recursive: true });
2173
+ discordLogger.log(`Created project directory: ${projectDirectory}`);
2174
+ const { execSync } = await import('node:child_process');
2175
+ execSync('git init', { cwd: projectDirectory, stdio: 'pipe' });
2176
+ discordLogger.log(`Initialized git in: ${projectDirectory}`);
2177
+ const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
2178
+ guild,
2179
+ projectDirectory,
2180
+ appId: currentAppId,
2181
+ });
2182
+ const textChannel = await guild.channels.fetch(textChannelId);
2183
+ await command.editReply(`✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`);
2184
+ const starterMessage = await textChannel.send({
2185
+ content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
2186
+ });
2187
+ const thread = await starterMessage.startThread({
2188
+ name: `Init: ${sanitizedName}`,
2189
+ autoArchiveDuration: 1440,
2190
+ reason: 'New project session',
2191
+ });
2192
+ await handleOpencodeSession({
2193
+ prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
2194
+ thread,
2195
+ projectDirectory,
2196
+ });
2197
+ discordLogger.log(`Created new project ${channelName} at ${projectDirectory}`);
2198
+ }
2199
+ catch (error) {
2200
+ voiceLogger.error('[ADD-NEW-PROJECT] Error:', error);
2201
+ await command.editReply(`Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`);
2202
+ }
2203
+ }
2096
2204
  else if (command.commandName === 'accept' ||
2097
2205
  command.commandName === 'accept-always') {
2098
2206
  const scope = command.commandName === 'accept-always' ? 'always' : 'once';
@@ -2264,6 +2372,70 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2264
2372
  });
2265
2373
  }
2266
2374
  }
2375
+ else if (command.commandName === 'share') {
2376
+ const channel = command.channel;
2377
+ if (!channel) {
2378
+ await command.reply({
2379
+ content: 'This command can only be used in a channel',
2380
+ ephemeral: true,
2381
+ });
2382
+ return;
2383
+ }
2384
+ const isThread = [
2385
+ ChannelType.PublicThread,
2386
+ ChannelType.PrivateThread,
2387
+ ChannelType.AnnouncementThread,
2388
+ ].includes(channel.type);
2389
+ if (!isThread) {
2390
+ await command.reply({
2391
+ content: 'This command can only be used in a thread with an active session',
2392
+ ephemeral: true,
2393
+ });
2394
+ return;
2395
+ }
2396
+ const textChannel = resolveTextChannel(channel);
2397
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel);
2398
+ if (!directory) {
2399
+ await command.reply({
2400
+ content: 'Could not determine project directory for this channel',
2401
+ ephemeral: true,
2402
+ });
2403
+ return;
2404
+ }
2405
+ const row = getDatabase()
2406
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
2407
+ .get(channel.id);
2408
+ if (!row?.session_id) {
2409
+ await command.reply({
2410
+ content: 'No active session in this thread',
2411
+ ephemeral: true,
2412
+ });
2413
+ return;
2414
+ }
2415
+ const sessionId = row.session_id;
2416
+ try {
2417
+ const getClient = await initializeOpencodeForDirectory(directory);
2418
+ const response = await getClient().session.share({
2419
+ path: { id: sessionId },
2420
+ });
2421
+ if (!response.data?.share?.url) {
2422
+ await command.reply({
2423
+ content: 'Failed to generate share URL',
2424
+ ephemeral: true,
2425
+ });
2426
+ return;
2427
+ }
2428
+ await command.reply(`🔗 **Session shared:** ${response.data.share.url}`);
2429
+ sessionLogger.log(`Session ${sessionId} shared: ${response.data.share.url}`);
2430
+ }
2431
+ catch (error) {
2432
+ voiceLogger.error('[SHARE] Error:', error);
2433
+ await command.reply({
2434
+ content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
2435
+ ephemeral: true,
2436
+ });
2437
+ }
2438
+ }
2267
2439
  }
2268
2440
  }
2269
2441
  catch (error) {
@@ -2479,7 +2651,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2479
2651
  }
2480
2652
  });
2481
2653
  await discordClient.login(token);
2482
- const handleShutdown = async (signal) => {
2654
+ const handleShutdown = async (signal, { skipExit = false } = {}) => {
2483
2655
  discordLogger.log(`Received ${signal}, cleaning up...`);
2484
2656
  // Prevent multiple shutdown calls
2485
2657
  if (global.shuttingDown) {
@@ -2516,12 +2688,16 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2516
2688
  }
2517
2689
  discordLogger.log('Destroying Discord client...');
2518
2690
  discordClient.destroy();
2519
- discordLogger.log('Cleanup complete, exiting.');
2520
- process.exit(0);
2691
+ discordLogger.log('Cleanup complete.');
2692
+ if (!skipExit) {
2693
+ process.exit(0);
2694
+ }
2521
2695
  }
2522
2696
  catch (error) {
2523
2697
  voiceLogger.error('[SHUTDOWN] Error during cleanup:', error);
2524
- process.exit(1);
2698
+ if (!skipExit) {
2699
+ process.exit(1);
2700
+ }
2525
2701
  }
2526
2702
  };
2527
2703
  // Override default signal handlers to prevent immediate exit
@@ -2543,6 +2719,23 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2543
2719
  process.exit(1);
2544
2720
  }
2545
2721
  });
2722
+ process.on('SIGUSR2', async () => {
2723
+ discordLogger.log('Received SIGUSR2, restarting after cleanup...');
2724
+ try {
2725
+ await handleShutdown('SIGUSR2', { skipExit: true });
2726
+ }
2727
+ catch (error) {
2728
+ voiceLogger.error('[SIGUSR2] Error during shutdown:', error);
2729
+ }
2730
+ const { spawn } = await import('node:child_process');
2731
+ spawn(process.argv[0], [...process.execArgv, ...process.argv.slice(1)], {
2732
+ stdio: 'inherit',
2733
+ detached: true,
2734
+ cwd: process.cwd(),
2735
+ env: process.env,
2736
+ }).unref();
2737
+ process.exit(0);
2738
+ });
2546
2739
  // Prevent unhandled promise rejections from crashing the process during shutdown
2547
2740
  process.on('unhandledRejection', (reason, promise) => {
2548
2741
  if (global.shuttingDown) {
package/dist/genai.js CHANGED
@@ -169,7 +169,7 @@ export async function startGenAiSession({ onAssistantAudioChunk, onAssistantStar
169
169
  const ai = new GoogleGenAI({
170
170
  apiKey,
171
171
  });
172
- const model = 'models/gemini-2.5-flash-live-preview';
172
+ const model = 'gemini-2.5-flash-native-audio-preview-12-2025';
173
173
  session = await ai.live.connect({
174
174
  model,
175
175
  callbacks: {
@@ -0,0 +1,95 @@
1
+ import WebSocket from 'ws';
2
+ import { createLogger } from './logger.js';
3
+ const xaiLogger = createLogger('XAI');
4
+ export class XaiRealtimeClient {
5
+ ws = null;
6
+ apiKey;
7
+ baseUrl = 'wss://api.x.ai/v1/realtime';
8
+ callbacks;
9
+ isConnected = false;
10
+ constructor(options) {
11
+ this.apiKey = options.apiKey;
12
+ this.callbacks = options.callbacks || {};
13
+ }
14
+ async connect() {
15
+ return new Promise((resolve, reject) => {
16
+ this.ws = new WebSocket(this.baseUrl, {
17
+ headers: {
18
+ Authorization: `Bearer ${this.apiKey}`,
19
+ },
20
+ });
21
+ this.ws.on('open', () => {
22
+ xaiLogger.log('WebSocket connected');
23
+ this.isConnected = true;
24
+ this.callbacks.onOpen?.();
25
+ resolve();
26
+ });
27
+ this.ws.on('message', (data) => {
28
+ try {
29
+ const event = JSON.parse(data.toString());
30
+ this.callbacks.onMessage?.(event);
31
+ }
32
+ catch (error) {
33
+ xaiLogger.error('Failed to parse message:', error);
34
+ }
35
+ });
36
+ this.ws.on('error', (error) => {
37
+ xaiLogger.error('WebSocket error:', error);
38
+ this.callbacks.onError?.(error);
39
+ if (!this.isConnected) {
40
+ reject(error);
41
+ }
42
+ });
43
+ this.ws.on('close', (code, reason) => {
44
+ xaiLogger.log('WebSocket closed:', code, reason.toString());
45
+ this.isConnected = false;
46
+ this.callbacks.onClose?.({ code, reason: reason.toString() });
47
+ });
48
+ });
49
+ }
50
+ send(event) {
51
+ if (!this.ws || !this.isConnected) {
52
+ throw new Error('WebSocket is not connected');
53
+ }
54
+ this.ws.send(JSON.stringify(event));
55
+ }
56
+ updateSession(session) {
57
+ this.send({ type: 'session.update', session });
58
+ }
59
+ appendInputAudio(base64Audio) {
60
+ this.send({ type: 'input_audio_buffer.append', audio: base64Audio });
61
+ }
62
+ commitInputAudio() {
63
+ this.send({ type: 'input_audio_buffer.commit' });
64
+ }
65
+ clearInputAudio() {
66
+ this.send({ type: 'input_audio_buffer.clear' });
67
+ }
68
+ createConversationItem(item, previousItemId) {
69
+ this.send({
70
+ type: 'conversation.item.create',
71
+ previous_item_id: previousItemId,
72
+ item,
73
+ });
74
+ }
75
+ sendFunctionCallOutput(callId, output) {
76
+ this.createConversationItem({
77
+ type: 'function_call_output',
78
+ call_id: callId,
79
+ output,
80
+ });
81
+ }
82
+ createResponse(modalities) {
83
+ this.send({ type: 'response.create', response: modalities ? { modalities } : undefined });
84
+ }
85
+ close() {
86
+ if (this.ws) {
87
+ this.ws.close();
88
+ this.ws = null;
89
+ this.isConnected = false;
90
+ }
91
+ }
92
+ get connected() {
93
+ return this.isConnected;
94
+ }
95
+ }