kimaki 0.4.18 → 0.4.20

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,6 +39,8 @@ 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
@@ -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,35 +1025,36 @@ 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 || '';
1013
1039
  const isSingleLine = !command.includes('\n');
1014
1040
  const hasBackticks = command.includes('`');
1015
1041
  if (isSingleLine && command.length <= 120 && !hasBackticks) {
1016
- toolTitle = `\`${command}\``;
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 (part.tool === 'edit' || part.tool === 'write') {
1049
+ const filePath = part.state.input?.filePath || '';
1050
+ const fileName = filePath.split('/').pop() || filePath;
1051
+ toolTitle = fileName ? `_${fileName}_` : '';
1024
1052
  }
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;
1053
+ else if (stateTitle) {
1054
+ toolTitle = `_${stateTitle}_`;
1029
1055
  }
1030
- return title;
1056
+ const icon = part.state.status === 'error' ? '⨯' : '◼︎';
1057
+ return `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
1031
1058
  }
1032
1059
  discordLogger.warn('Unknown part type:', part);
1033
1060
  return '';
@@ -1052,16 +1079,6 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1052
1079
  voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
1053
1080
  // Track session start time
1054
1081
  const sessionStartTime = Date.now();
1055
- // Add processing reaction to original message
1056
- if (originalMessage) {
1057
- try {
1058
- await originalMessage.react('⏳');
1059
- discordLogger.log(`Added processing reaction to message`);
1060
- }
1061
- catch (e) {
1062
- discordLogger.log(`Could not add processing reaction:`, e);
1063
- }
1064
- }
1065
1082
  // Use default directory if not specified
1066
1083
  const directory = projectDirectory || process.cwd();
1067
1084
  sessionLogger.log(`Using directory: ${directory}`);
@@ -1090,9 +1107,10 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1090
1107
  }
1091
1108
  }
1092
1109
  if (!session) {
1093
- voiceLogger.log(`[SESSION] Creating new session with title: "${prompt.slice(0, 80)}"`);
1110
+ const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80);
1111
+ voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`);
1094
1112
  const sessionResponse = await getClient().session.create({
1095
- body: { title: prompt.slice(0, 80) },
1113
+ body: { title: sessionTitle },
1096
1114
  });
1097
1115
  session = sessionResponse.data;
1098
1116
  sessionLogger.log(`Created new session ${session?.id}`);
@@ -1111,39 +1129,37 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1111
1129
  voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
1112
1130
  existingController.abort(new Error('New request started'));
1113
1131
  }
1114
- if (abortControllers.has(session.id)) {
1115
- abortControllers.get(session.id)?.abort(new Error('new reply'));
1116
- }
1117
1132
  const abortController = new AbortController();
1118
- // Store this controller for this session
1119
1133
  abortControllers.set(session.id, abortController);
1134
+ if (existingController) {
1135
+ await new Promise((resolve) => { setTimeout(resolve, 200); });
1136
+ if (abortController.signal.aborted) {
1137
+ sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`);
1138
+ return;
1139
+ }
1140
+ }
1141
+ if (abortController.signal.aborted) {
1142
+ sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`);
1143
+ return;
1144
+ }
1120
1145
  const eventsResult = await getClient().event.subscribe({
1121
1146
  signal: abortController.signal,
1122
1147
  });
1148
+ if (abortController.signal.aborted) {
1149
+ sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
1150
+ return;
1151
+ }
1123
1152
  const events = eventsResult.stream;
1124
1153
  sessionLogger.log(`Subscribed to OpenCode events`);
1125
- // Load existing part-message mappings from database
1126
- const partIdToMessage = new Map();
1127
- const existingParts = getDatabase()
1128
- .prepare('SELECT part_id, message_id FROM part_messages WHERE thread_id = ?')
1129
- .all(thread.id);
1130
- // Pre-populate map with existing messages
1131
- for (const row of existingParts) {
1132
- try {
1133
- const message = await thread.messages.fetch(row.message_id);
1134
- if (message) {
1135
- partIdToMessage.set(row.part_id, message);
1136
- }
1137
- }
1138
- catch (error) {
1139
- voiceLogger.log(`Could not fetch message ${row.message_id} for part ${row.part_id}`);
1140
- }
1141
- }
1154
+ const sentPartIds = new Set(getDatabase()
1155
+ .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
1156
+ .all(thread.id)
1157
+ .map((row) => row.part_id));
1142
1158
  let currentParts = [];
1143
1159
  let stopTyping = null;
1144
1160
  let usedModel;
1145
1161
  let usedProviderID;
1146
- let inputTokens = 0;
1162
+ let tokensUsedInSession = 0;
1147
1163
  const sendPartMessage = async (part) => {
1148
1164
  const content = formatPart(part) + '\n\n';
1149
1165
  if (!content.trim() || content.length === 0) {
@@ -1151,12 +1167,12 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1151
1167
  return;
1152
1168
  }
1153
1169
  // Skip if already sent
1154
- if (partIdToMessage.has(part.id)) {
1170
+ if (sentPartIds.has(part.id)) {
1155
1171
  return;
1156
1172
  }
1157
1173
  try {
1158
1174
  const firstMessage = await sendThreadMessage(thread, content);
1159
- partIdToMessage.set(part.id, firstMessage);
1175
+ sentPartIds.add(part.id);
1160
1176
  // Store part-message mapping in database
1161
1177
  getDatabase()
1162
1178
  .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
@@ -1219,12 +1235,13 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1219
1235
  }
1220
1236
  // Track assistant message ID
1221
1237
  if (msg.role === 'assistant') {
1238
+ const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write;
1239
+ if (newTokensTotal > 0) {
1240
+ tokensUsedInSession = newTokensTotal;
1241
+ }
1222
1242
  assistantMessageId = msg.id;
1223
1243
  usedModel = msg.modelID;
1224
1244
  usedProviderID = msg.providerID;
1225
- if (msg.tokens.input > 0) {
1226
- inputTokens = msg.tokens.input;
1227
- }
1228
1245
  }
1229
1246
  }
1230
1247
  else if (event.type === 'message.part.updated') {
@@ -1247,13 +1264,16 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1247
1264
  if (part.type === 'step-start') {
1248
1265
  stopTyping = startTyping(thread);
1249
1266
  }
1267
+ // Send tool parts immediately when they start running
1268
+ if (part.type === 'tool' && part.state.status === 'running') {
1269
+ await sendPartMessage(part);
1270
+ }
1271
+ // Send reasoning parts immediately (shows "◼︎ thinking" indicator early)
1272
+ if (part.type === 'reasoning') {
1273
+ await sendPartMessage(part);
1274
+ }
1250
1275
  // Check if this is a step-finish part
1251
1276
  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
1277
  // Send all parts accumulated so far to Discord
1258
1278
  for (const p of currentParts) {
1259
1279
  // Skip step-start and step-finish parts as they have no visual content
@@ -1338,7 +1358,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1338
1358
  finally {
1339
1359
  // Send any remaining parts that weren't sent
1340
1360
  for (const part of currentParts) {
1341
- if (!partIdToMessage.has(part.id)) {
1361
+ if (!sentPartIds.has(part.id)) {
1342
1362
  try {
1343
1363
  await sendPartMessage(part);
1344
1364
  }
@@ -1359,22 +1379,20 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1359
1379
  const attachCommand = port ? ` ⋅ ${session.id}` : '';
1360
1380
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
1361
1381
  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);
1382
+ try {
1383
+ const providersResponse = await getClient().provider.list({ query: { directory } });
1384
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
1385
+ const model = provider?.models?.[usedModel || ''];
1386
+ if (model?.limit?.context) {
1387
+ const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100);
1388
+ contextInfo = ` ${percentage}%`;
1374
1389
  }
1375
1390
  }
1391
+ catch (e) {
1392
+ sessionLogger.error('Failed to fetch provider info for context percentage:', e);
1393
+ }
1376
1394
  await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`);
1377
- sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${inputTokens}`);
1395
+ sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
1378
1396
  }
1379
1397
  else {
1380
1398
  sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
@@ -1382,8 +1400,19 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1382
1400
  }
1383
1401
  };
1384
1402
  try {
1385
- // Start the event handler
1386
1403
  const eventHandlerPromise = eventHandler();
1404
+ if (abortController.signal.aborted) {
1405
+ sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
1406
+ return;
1407
+ }
1408
+ if (originalMessage) {
1409
+ try {
1410
+ await originalMessage.react('⏳');
1411
+ }
1412
+ catch (e) {
1413
+ discordLogger.log(`Could not add processing reaction:`, e);
1414
+ }
1415
+ }
1387
1416
  let response;
1388
1417
  if (parsedCommand?.isCommand) {
1389
1418
  sessionLogger.log(`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`);
@@ -1412,17 +1441,30 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1412
1441
  signal: abortController.signal,
1413
1442
  });
1414
1443
  }
1444
+ if (response.error) {
1445
+ const errorMessage = (() => {
1446
+ const err = response.error;
1447
+ if (err && typeof err === 'object') {
1448
+ if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
1449
+ return String(err.data.message);
1450
+ }
1451
+ if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
1452
+ return JSON.stringify(err.errors);
1453
+ }
1454
+ }
1455
+ return JSON.stringify(err);
1456
+ })();
1457
+ throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`);
1458
+ }
1415
1459
  abortController.abort('finished');
1416
1460
  sessionLogger.log(`Successfully sent prompt, got response`);
1417
- // Update reaction to success
1418
1461
  if (originalMessage) {
1419
1462
  try {
1420
1463
  await originalMessage.reactions.removeAll();
1421
1464
  await originalMessage.react('✅');
1422
- discordLogger.log(`Added success reaction to message`);
1423
1465
  }
1424
1466
  catch (e) {
1425
- discordLogger.log(`Could not update reaction:`, e);
1467
+ discordLogger.log(`Could not update reactions:`, e);
1426
1468
  }
1427
1469
  }
1428
1470
  return { sessionID: session.id, result: response.data, port };
@@ -1596,14 +1638,18 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1596
1638
  if (transcription) {
1597
1639
  messageContent = transcription;
1598
1640
  }
1599
- const images = getImageAttachments(message);
1641
+ const fileAttachments = getFileAttachments(message);
1642
+ const textAttachmentsContent = await getTextAttachments(message);
1643
+ const promptWithAttachments = textAttachmentsContent
1644
+ ? `${messageContent}\n\n${textAttachmentsContent}`
1645
+ : messageContent;
1600
1646
  const parsedCommand = parseSlashCommand(messageContent);
1601
1647
  await handleOpencodeSession({
1602
- prompt: messageContent,
1648
+ prompt: promptWithAttachments,
1603
1649
  thread,
1604
1650
  projectDirectory,
1605
1651
  originalMessage: message,
1606
- images,
1652
+ images: fileAttachments,
1607
1653
  parsedCommand,
1608
1654
  });
1609
1655
  return;
@@ -1664,14 +1710,18 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1664
1710
  if (transcription) {
1665
1711
  messageContent = transcription;
1666
1712
  }
1667
- const images = getImageAttachments(message);
1713
+ const fileAttachments = getFileAttachments(message);
1714
+ const textAttachmentsContent = await getTextAttachments(message);
1715
+ const promptWithAttachments = textAttachmentsContent
1716
+ ? `${messageContent}\n\n${textAttachmentsContent}`
1717
+ : messageContent;
1668
1718
  const parsedCommand = parseSlashCommand(messageContent);
1669
1719
  await handleOpencodeSession({
1670
- prompt: messageContent,
1720
+ prompt: promptWithAttachments,
1671
1721
  thread,
1672
1722
  projectDirectory,
1673
1723
  originalMessage: message,
1674
- images,
1724
+ images: fileAttachments,
1675
1725
  parsedCommand,
1676
1726
  });
1677
1727
  }
@@ -2093,6 +2143,72 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2093
2143
  await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
2094
2144
  }
2095
2145
  }
2146
+ else if (command.commandName === 'add-new-project') {
2147
+ await command.deferReply({ ephemeral: false });
2148
+ const projectName = command.options.getString('name', true);
2149
+ const guild = command.guild;
2150
+ const channel = command.channel;
2151
+ if (!guild) {
2152
+ await command.editReply('This command can only be used in a guild');
2153
+ return;
2154
+ }
2155
+ if (!channel || channel.type !== ChannelType.GuildText) {
2156
+ await command.editReply('This command can only be used in a text channel');
2157
+ return;
2158
+ }
2159
+ const sanitizedName = projectName
2160
+ .toLowerCase()
2161
+ .replace(/[^a-z0-9-]/g, '-')
2162
+ .replace(/-+/g, '-')
2163
+ .replace(/^-|-$/g, '')
2164
+ .slice(0, 100);
2165
+ if (!sanitizedName) {
2166
+ await command.editReply('Invalid project name');
2167
+ return;
2168
+ }
2169
+ const kimakiDir = path.join(os.homedir(), 'kimaki');
2170
+ const projectDirectory = path.join(kimakiDir, sanitizedName);
2171
+ try {
2172
+ if (!fs.existsSync(kimakiDir)) {
2173
+ fs.mkdirSync(kimakiDir, { recursive: true });
2174
+ discordLogger.log(`Created kimaki directory: ${kimakiDir}`);
2175
+ }
2176
+ if (fs.existsSync(projectDirectory)) {
2177
+ await command.editReply(`Project directory already exists: ${projectDirectory}`);
2178
+ return;
2179
+ }
2180
+ fs.mkdirSync(projectDirectory, { recursive: true });
2181
+ discordLogger.log(`Created project directory: ${projectDirectory}`);
2182
+ const { execSync } = await import('node:child_process');
2183
+ execSync('git init', { cwd: projectDirectory, stdio: 'pipe' });
2184
+ discordLogger.log(`Initialized git in: ${projectDirectory}`);
2185
+ const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
2186
+ guild,
2187
+ projectDirectory,
2188
+ appId: currentAppId,
2189
+ });
2190
+ const textChannel = await guild.channels.fetch(textChannelId);
2191
+ await command.editReply(`✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`);
2192
+ const starterMessage = await textChannel.send({
2193
+ content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
2194
+ });
2195
+ const thread = await starterMessage.startThread({
2196
+ name: `Init: ${sanitizedName}`,
2197
+ autoArchiveDuration: 1440,
2198
+ reason: 'New project session',
2199
+ });
2200
+ await handleOpencodeSession({
2201
+ prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
2202
+ thread,
2203
+ projectDirectory,
2204
+ });
2205
+ discordLogger.log(`Created new project ${channelName} at ${projectDirectory}`);
2206
+ }
2207
+ catch (error) {
2208
+ voiceLogger.error('[ADD-NEW-PROJECT] Error:', error);
2209
+ await command.editReply(`Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`);
2210
+ }
2211
+ }
2096
2212
  else if (command.commandName === 'accept' ||
2097
2213
  command.commandName === 'accept-always') {
2098
2214
  const scope = command.commandName === 'accept-always' ? 'always' : 'once';
@@ -2264,6 +2380,70 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2264
2380
  });
2265
2381
  }
2266
2382
  }
2383
+ else if (command.commandName === 'share') {
2384
+ const channel = command.channel;
2385
+ if (!channel) {
2386
+ await command.reply({
2387
+ content: 'This command can only be used in a channel',
2388
+ ephemeral: true,
2389
+ });
2390
+ return;
2391
+ }
2392
+ const isThread = [
2393
+ ChannelType.PublicThread,
2394
+ ChannelType.PrivateThread,
2395
+ ChannelType.AnnouncementThread,
2396
+ ].includes(channel.type);
2397
+ if (!isThread) {
2398
+ await command.reply({
2399
+ content: 'This command can only be used in a thread with an active session',
2400
+ ephemeral: true,
2401
+ });
2402
+ return;
2403
+ }
2404
+ const textChannel = resolveTextChannel(channel);
2405
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel);
2406
+ if (!directory) {
2407
+ await command.reply({
2408
+ content: 'Could not determine project directory for this channel',
2409
+ ephemeral: true,
2410
+ });
2411
+ return;
2412
+ }
2413
+ const row = getDatabase()
2414
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
2415
+ .get(channel.id);
2416
+ if (!row?.session_id) {
2417
+ await command.reply({
2418
+ content: 'No active session in this thread',
2419
+ ephemeral: true,
2420
+ });
2421
+ return;
2422
+ }
2423
+ const sessionId = row.session_id;
2424
+ try {
2425
+ const getClient = await initializeOpencodeForDirectory(directory);
2426
+ const response = await getClient().session.share({
2427
+ path: { id: sessionId },
2428
+ });
2429
+ if (!response.data?.share?.url) {
2430
+ await command.reply({
2431
+ content: 'Failed to generate share URL',
2432
+ ephemeral: true,
2433
+ });
2434
+ return;
2435
+ }
2436
+ await command.reply(`🔗 **Session shared:** ${response.data.share.url}`);
2437
+ sessionLogger.log(`Session ${sessionId} shared: ${response.data.share.url}`);
2438
+ }
2439
+ catch (error) {
2440
+ voiceLogger.error('[SHARE] Error:', error);
2441
+ await command.reply({
2442
+ content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
2443
+ ephemeral: true,
2444
+ });
2445
+ }
2446
+ }
2267
2447
  }
2268
2448
  }
2269
2449
  catch (error) {
@@ -2479,7 +2659,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2479
2659
  }
2480
2660
  });
2481
2661
  await discordClient.login(token);
2482
- const handleShutdown = async (signal) => {
2662
+ const handleShutdown = async (signal, { skipExit = false } = {}) => {
2483
2663
  discordLogger.log(`Received ${signal}, cleaning up...`);
2484
2664
  // Prevent multiple shutdown calls
2485
2665
  if (global.shuttingDown) {
@@ -2516,12 +2696,16 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2516
2696
  }
2517
2697
  discordLogger.log('Destroying Discord client...');
2518
2698
  discordClient.destroy();
2519
- discordLogger.log('Cleanup complete, exiting.');
2520
- process.exit(0);
2699
+ discordLogger.log('Cleanup complete.');
2700
+ if (!skipExit) {
2701
+ process.exit(0);
2702
+ }
2521
2703
  }
2522
2704
  catch (error) {
2523
2705
  voiceLogger.error('[SHUTDOWN] Error during cleanup:', error);
2524
- process.exit(1);
2706
+ if (!skipExit) {
2707
+ process.exit(1);
2708
+ }
2525
2709
  }
2526
2710
  };
2527
2711
  // Override default signal handlers to prevent immediate exit
@@ -2543,6 +2727,23 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2543
2727
  process.exit(1);
2544
2728
  }
2545
2729
  });
2730
+ process.on('SIGUSR2', async () => {
2731
+ discordLogger.log('Received SIGUSR2, restarting after cleanup...');
2732
+ try {
2733
+ await handleShutdown('SIGUSR2', { skipExit: true });
2734
+ }
2735
+ catch (error) {
2736
+ voiceLogger.error('[SIGUSR2] Error during shutdown:', error);
2737
+ }
2738
+ const { spawn } = await import('node:child_process');
2739
+ spawn(process.argv[0], [...process.execArgv, ...process.argv.slice(1)], {
2740
+ stdio: 'inherit',
2741
+ detached: true,
2742
+ cwd: process.cwd(),
2743
+ env: process.env,
2744
+ }).unref();
2745
+ process.exit(0);
2746
+ });
2546
2747
  // Prevent unhandled promise rejections from crashing the process during shutdown
2547
2748
  process.on('unhandledRejection', (reason, promise) => {
2548
2749
  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: {