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.
package/src/discordBot.ts CHANGED
@@ -80,11 +80,13 @@ export function getOpencodeSystemMessage({ sessionId }: { sessionId: string }) {
80
80
  return `
81
81
  The user is reading your messages from inside Discord, via kimaki.xyz
82
82
 
83
+ The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
84
+
83
85
  Your current OpenCode session ID is: ${sessionId}
84
86
 
85
87
  ## uploading files to discord
86
88
 
87
- To upload files (images, screenshots, etc.) to the Discord thread, run:
89
+ To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
88
90
 
89
91
  npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
90
92
 
@@ -620,6 +622,7 @@ export function getDatabase(): Database.Database {
620
622
  CREATE TABLE IF NOT EXISTS bot_api_keys (
621
623
  app_id TEXT PRIMARY KEY,
622
624
  gemini_api_key TEXT,
625
+ xai_api_key TEXT,
623
626
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
624
627
  )
625
628
  `)
@@ -877,14 +880,64 @@ async function processVoiceAttachment({
877
880
  return transcription
878
881
  }
879
882
 
880
- function getImageAttachments(message: Message): FilePartInput[] {
881
- const imageAttachments = Array.from(message.attachments.values()).filter(
882
- (attachment) => attachment.contentType?.startsWith('image/'),
883
+ const TEXT_MIME_TYPES = [
884
+ 'text/',
885
+ 'application/json',
886
+ 'application/xml',
887
+ 'application/javascript',
888
+ 'application/typescript',
889
+ 'application/x-yaml',
890
+ 'application/toml',
891
+ ]
892
+
893
+ function isTextMimeType(contentType: string | null): boolean {
894
+ if (!contentType) {
895
+ return false
896
+ }
897
+ return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix))
898
+ }
899
+
900
+ async function getTextAttachments(message: Message): Promise<string> {
901
+ const textAttachments = Array.from(message.attachments.values()).filter(
902
+ (attachment) => isTextMimeType(attachment.contentType),
883
903
  )
884
904
 
885
- return imageAttachments.map((attachment) => ({
905
+ if (textAttachments.length === 0) {
906
+ return ''
907
+ }
908
+
909
+ const textContents = await Promise.all(
910
+ textAttachments.map(async (attachment) => {
911
+ try {
912
+ const response = await fetch(attachment.url)
913
+ if (!response.ok) {
914
+ return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`
915
+ }
916
+ const text = await response.text()
917
+ return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`
918
+ } catch (error) {
919
+ const errMsg = error instanceof Error ? error.message : String(error)
920
+ return `<attachment filename="${attachment.name}" error="${errMsg}" />`
921
+ }
922
+ }),
923
+ )
924
+
925
+ return textContents.join('\n\n')
926
+ }
927
+
928
+ function getFileAttachments(message: Message): FilePartInput[] {
929
+ const fileAttachments = Array.from(message.attachments.values()).filter(
930
+ (attachment) => {
931
+ const contentType = attachment.contentType || ''
932
+ return (
933
+ contentType.startsWith('image/') || contentType === 'application/pdf'
934
+ )
935
+ },
936
+ )
937
+
938
+ return fileAttachments.map((attachment) => ({
886
939
  type: 'file' as const,
887
- mime: attachment.contentType || 'image/png',
940
+ mime: attachment.contentType || 'application/octet-stream',
888
941
  filename: attachment.name,
889
942
  url: attachment.url,
890
943
  }))
@@ -1204,13 +1257,6 @@ export async function initializeOpencodeForDirectory(directory: string) {
1204
1257
 
1205
1258
  function getToolSummaryText(part: Part): string {
1206
1259
  if (part.type !== 'tool') return ''
1207
- if (part.state.status !== 'completed' && part.state.status !== 'error') return ''
1208
-
1209
- if (part.tool === 'bash') {
1210
- const output = part.state.status === 'completed' ? part.state.output : part.state.error
1211
- const lines = (output || '').split('\n').filter((l: string) => l.trim())
1212
- return `(${lines.length} line${lines.length === 1 ? '' : 's'})`
1213
- }
1214
1260
 
1215
1261
  if (part.tool === 'edit') {
1216
1262
  const newString = (part.state.input?.newString as string) || ''
@@ -1233,6 +1279,7 @@ function getToolSummaryText(part: Part): string {
1233
1279
  }
1234
1280
 
1235
1281
  if (
1282
+ part.tool === 'bash' ||
1236
1283
  part.tool === 'read' ||
1237
1284
  part.tool === 'list' ||
1238
1285
  part.tool === 'glob' ||
@@ -1250,7 +1297,7 @@ function getToolSummaryText(part: Part): string {
1250
1297
  .map(([key, value]) => {
1251
1298
  if (value === null || value === undefined) return null
1252
1299
  const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
1253
- const truncatedValue = stringValue.length > 100 ? stringValue.slice(0, 100) + '…' : stringValue
1300
+ const truncatedValue = stringValue.length > 300 ? stringValue.slice(0, 300) + '…' : stringValue
1254
1301
  return `${key}: ${truncatedValue}`
1255
1302
  })
1256
1303
  .filter(Boolean)
@@ -1260,17 +1307,6 @@ function getToolSummaryText(part: Part): string {
1260
1307
  return `(${inputFields.join(', ')})`
1261
1308
  }
1262
1309
 
1263
- function getToolOutputToDisplay(part: Part): string {
1264
- if (part.type !== 'tool') return ''
1265
- if (part.state.status !== 'completed' && part.state.status !== 'error') return ''
1266
-
1267
- if (part.state.status === 'error') {
1268
- return part.state.error || 'Unknown error'
1269
- }
1270
-
1271
- return ''
1272
- }
1273
-
1274
1310
  function formatTodoList(part: Part): string {
1275
1311
  if (part.type !== 'tool' || part.tool !== 'todowrite') return ''
1276
1312
  const todos =
@@ -1324,16 +1360,16 @@ function formatPart(part: Part): string {
1324
1360
  return formatTodoList(part)
1325
1361
  }
1326
1362
 
1327
- if (part.state.status !== 'completed' && part.state.status !== 'error') {
1363
+ if (part.state.status === 'pending') {
1328
1364
  return ''
1329
1365
  }
1330
1366
 
1331
1367
  const summaryText = getToolSummaryText(part)
1332
- const outputToDisplay = getToolOutputToDisplay(part)
1368
+ const stateTitle = 'title' in part.state ? part.state.title : undefined
1333
1369
 
1334
1370
  let toolTitle = ''
1335
1371
  if (part.state.status === 'error') {
1336
- toolTitle = 'error'
1372
+ toolTitle = part.state.error || 'error'
1337
1373
  } else if (part.tool === 'bash') {
1338
1374
  const command = (part.state.input?.command as string) || ''
1339
1375
  const isSingleLine = !command.includes('\n')
@@ -1341,19 +1377,14 @@ function formatPart(part: Part): string {
1341
1377
  if (isSingleLine && command.length <= 120 && !hasBackticks) {
1342
1378
  toolTitle = `\`${command}\``
1343
1379
  } else {
1344
- toolTitle = part.state.title ? `*${part.state.title}*` : ''
1380
+ toolTitle = stateTitle ? `*${stateTitle}*` : ''
1345
1381
  }
1346
- } else if (part.state.title) {
1347
- toolTitle = `*${part.state.title}*`
1382
+ } else if (stateTitle) {
1383
+ toolTitle = `*${stateTitle}*`
1348
1384
  }
1349
1385
 
1350
- const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : ''
1351
- const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`
1352
-
1353
- if (outputToDisplay) {
1354
- return title + '\n\n' + outputToDisplay
1355
- }
1356
- return title
1386
+ const icon = part.state.status === 'error' ? '⨯' : '◼︎'
1387
+ return `${icon} ${part.tool} ${toolTitle} ${summaryText}`
1357
1388
  }
1358
1389
 
1359
1390
  discordLogger.warn('Unknown part type:', part)
@@ -1444,11 +1475,12 @@ async function handleOpencodeSession({
1444
1475
  }
1445
1476
 
1446
1477
  if (!session) {
1478
+ const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80)
1447
1479
  voiceLogger.log(
1448
- `[SESSION] Creating new session with title: "${prompt.slice(0, 80)}"`,
1480
+ `[SESSION] Creating new session with title: "${sessionTitle}"`,
1449
1481
  )
1450
1482
  const sessionResponse = await getClient().session.create({
1451
- body: { title: prompt.slice(0, 80) },
1483
+ body: { title: sessionTitle },
1452
1484
  })
1453
1485
  session = sessionResponse.data
1454
1486
  sessionLogger.log(`Created new session ${session?.id}`)
@@ -1514,7 +1546,7 @@ async function handleOpencodeSession({
1514
1546
  let stopTyping: (() => void) | null = null
1515
1547
  let usedModel: string | undefined
1516
1548
  let usedProviderID: string | undefined
1517
- let inputTokens = 0
1549
+ let tokensUsedInSession = 0
1518
1550
 
1519
1551
  const sendPartMessage = async (part: Part) => {
1520
1552
  const content = formatPart(part) + '\n\n'
@@ -1603,22 +1635,27 @@ async function handleOpencodeSession({
1603
1635
  if (event.type === 'message.updated') {
1604
1636
  const msg = event.properties.info
1605
1637
 
1638
+
1639
+
1606
1640
  if (msg.sessionID !== session.id) {
1607
1641
  continue
1608
1642
  }
1609
1643
 
1610
1644
  // Track assistant message ID
1611
1645
  if (msg.role === 'assistant') {
1646
+ const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
1647
+ if (newTokensTotal > 0) {
1648
+ tokensUsedInSession = newTokensTotal
1649
+ }
1650
+
1612
1651
  assistantMessageId = msg.id
1613
1652
  usedModel = msg.modelID
1614
1653
  usedProviderID = msg.providerID
1615
- if (msg.tokens.input > 0) {
1616
- inputTokens = msg.tokens.input
1617
- }
1618
1654
  }
1619
1655
  } else if (event.type === 'message.part.updated') {
1620
1656
  const part = event.properties.part
1621
1657
 
1658
+
1622
1659
  if (part.sessionID !== session.id) {
1623
1660
  continue
1624
1661
  }
@@ -1644,13 +1681,14 @@ async function handleOpencodeSession({
1644
1681
  stopTyping = startTyping(thread)
1645
1682
  }
1646
1683
 
1684
+ // Send tool parts immediately when they start running
1685
+ if (part.type === 'tool' && part.state.status === 'running') {
1686
+ await sendPartMessage(part)
1687
+ }
1688
+
1647
1689
  // Check if this is a step-finish part
1648
1690
  if (part.type === 'step-finish') {
1649
- // Track tokens from step-finish part
1650
- if (part.tokens?.input && part.tokens.input > 0) {
1651
- inputTokens = part.tokens.input
1652
- voiceLogger.log(`[STEP-FINISH] Captured tokens: ${inputTokens}`)
1653
- }
1691
+
1654
1692
  // Send all parts accumulated so far to Discord
1655
1693
  for (const p of currentParts) {
1656
1694
  // Skip step-start and step-finish parts as they have no visual content
@@ -1778,21 +1816,22 @@ async function handleOpencodeSession({
1778
1816
  const attachCommand = port ? ` ⋅ ${session.id}` : ''
1779
1817
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
1780
1818
  let contextInfo = ''
1781
- if (inputTokens > 0 && usedProviderID && usedModel) {
1782
- try {
1783
- const providersResponse = await getClient().provider.list({ query: { directory } })
1784
- const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
1785
- const model = provider?.models?.[usedModel]
1786
- if (model?.limit?.context) {
1787
- const percentage = Math.round((inputTokens / model.limit.context) * 100)
1788
- contextInfo = ` ${percentage}%`
1789
- }
1790
- } catch (e) {
1791
- sessionLogger.error('Failed to fetch provider info for context percentage:', e)
1819
+
1820
+
1821
+ try {
1822
+ const providersResponse = await getClient().provider.list({ query: { directory } })
1823
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
1824
+ const model = provider?.models?.[usedModel || '']
1825
+ if (model?.limit?.context) {
1826
+ const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100)
1827
+ contextInfo = ` ⋅ ${percentage}%`
1792
1828
  }
1829
+ } catch (e) {
1830
+ sessionLogger.error('Failed to fetch provider info for context percentage:', e)
1793
1831
  }
1832
+
1794
1833
  await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`)
1795
- sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${inputTokens}`)
1834
+ sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`)
1796
1835
  } else {
1797
1836
  sessionLogger.log(
1798
1837
  `Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
@@ -1805,7 +1844,7 @@ async function handleOpencodeSession({
1805
1844
  // Start the event handler
1806
1845
  const eventHandlerPromise = eventHandler()
1807
1846
 
1808
- let response: { data?: unknown }
1847
+ let response: { data?: unknown; error?: unknown; response: Response }
1809
1848
  if (parsedCommand?.isCommand) {
1810
1849
  sessionLogger.log(
1811
1850
  `[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`,
@@ -1838,18 +1877,33 @@ async function handleOpencodeSession({
1838
1877
  signal: abortController.signal,
1839
1878
  })
1840
1879
  }
1880
+
1881
+ if (response.error) {
1882
+ const errorMessage = (() => {
1883
+ const err = response.error
1884
+ if (err && typeof err === 'object') {
1885
+ if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
1886
+ return String(err.data.message)
1887
+ }
1888
+ if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
1889
+ return JSON.stringify(err.errors)
1890
+ }
1891
+ }
1892
+ return JSON.stringify(err)
1893
+ })()
1894
+ throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`)
1895
+ }
1896
+
1841
1897
  abortController.abort('finished')
1842
1898
 
1843
1899
  sessionLogger.log(`Successfully sent prompt, got response`)
1844
1900
 
1845
- // Update reaction to success
1846
1901
  if (originalMessage) {
1847
1902
  try {
1848
1903
  await originalMessage.reactions.removeAll()
1849
1904
  await originalMessage.react('✅')
1850
- discordLogger.log(`Added success reaction to message`)
1851
1905
  } catch (e) {
1852
- discordLogger.log(`Could not update reaction:`, e)
1906
+ discordLogger.log(`Could not update reactions:`, e)
1853
1907
  }
1854
1908
  }
1855
1909
 
@@ -2091,14 +2145,18 @@ export async function startDiscordBot({
2091
2145
  messageContent = transcription
2092
2146
  }
2093
2147
 
2094
- const images = getImageAttachments(message)
2148
+ const fileAttachments = getFileAttachments(message)
2149
+ const textAttachmentsContent = await getTextAttachments(message)
2150
+ const promptWithAttachments = textAttachmentsContent
2151
+ ? `${messageContent}\n\n${textAttachmentsContent}`
2152
+ : messageContent
2095
2153
  const parsedCommand = parseSlashCommand(messageContent)
2096
2154
  await handleOpencodeSession({
2097
- prompt: messageContent,
2155
+ prompt: promptWithAttachments,
2098
2156
  thread,
2099
2157
  projectDirectory,
2100
2158
  originalMessage: message,
2101
- images,
2159
+ images: fileAttachments,
2102
2160
  parsedCommand,
2103
2161
  })
2104
2162
  return
@@ -2188,14 +2246,18 @@ export async function startDiscordBot({
2188
2246
  messageContent = transcription
2189
2247
  }
2190
2248
 
2191
- const images = getImageAttachments(message)
2249
+ const fileAttachments = getFileAttachments(message)
2250
+ const textAttachmentsContent = await getTextAttachments(message)
2251
+ const promptWithAttachments = textAttachmentsContent
2252
+ ? `${messageContent}\n\n${textAttachmentsContent}`
2253
+ : messageContent
2192
2254
  const parsedCommand = parseSlashCommand(messageContent)
2193
2255
  await handleOpencodeSession({
2194
- prompt: messageContent,
2256
+ prompt: promptWithAttachments,
2195
2257
  thread,
2196
2258
  projectDirectory,
2197
2259
  originalMessage: message,
2198
- images,
2260
+ images: fileAttachments,
2199
2261
  parsedCommand,
2200
2262
  })
2201
2263
  } else {
@@ -2783,6 +2845,94 @@ export async function startDiscordBot({
2783
2845
  `Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
2784
2846
  )
2785
2847
  }
2848
+ } else if (command.commandName === 'add-new-project') {
2849
+ await command.deferReply({ ephemeral: false })
2850
+
2851
+ const projectName = command.options.getString('name', true)
2852
+ const guild = command.guild
2853
+ const channel = command.channel
2854
+
2855
+ if (!guild) {
2856
+ await command.editReply('This command can only be used in a guild')
2857
+ return
2858
+ }
2859
+
2860
+ if (!channel || channel.type !== ChannelType.GuildText) {
2861
+ await command.editReply('This command can only be used in a text channel')
2862
+ return
2863
+ }
2864
+
2865
+ const sanitizedName = projectName
2866
+ .toLowerCase()
2867
+ .replace(/[^a-z0-9-]/g, '-')
2868
+ .replace(/-+/g, '-')
2869
+ .replace(/^-|-$/g, '')
2870
+ .slice(0, 100)
2871
+
2872
+ if (!sanitizedName) {
2873
+ await command.editReply('Invalid project name')
2874
+ return
2875
+ }
2876
+
2877
+ const kimakiDir = path.join(os.homedir(), 'kimaki')
2878
+ const projectDirectory = path.join(kimakiDir, sanitizedName)
2879
+
2880
+ try {
2881
+ if (!fs.existsSync(kimakiDir)) {
2882
+ fs.mkdirSync(kimakiDir, { recursive: true })
2883
+ discordLogger.log(`Created kimaki directory: ${kimakiDir}`)
2884
+ }
2885
+
2886
+ if (fs.existsSync(projectDirectory)) {
2887
+ await command.editReply(`Project directory already exists: ${projectDirectory}`)
2888
+ return
2889
+ }
2890
+
2891
+ fs.mkdirSync(projectDirectory, { recursive: true })
2892
+ discordLogger.log(`Created project directory: ${projectDirectory}`)
2893
+
2894
+ const { execSync } = await import('node:child_process')
2895
+ execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
2896
+ discordLogger.log(`Initialized git in: ${projectDirectory}`)
2897
+
2898
+ const { textChannelId, voiceChannelId, channelName } =
2899
+ await createProjectChannels({
2900
+ guild,
2901
+ projectDirectory,
2902
+ appId: currentAppId!,
2903
+ })
2904
+
2905
+ const textChannel = await guild.channels.fetch(textChannelId) as TextChannel
2906
+
2907
+ await command.editReply(
2908
+ `✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`,
2909
+ )
2910
+
2911
+ const starterMessage = await textChannel.send({
2912
+ content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
2913
+ })
2914
+
2915
+ const thread = await starterMessage.startThread({
2916
+ name: `Init: ${sanitizedName}`,
2917
+ autoArchiveDuration: 1440,
2918
+ reason: 'New project session',
2919
+ })
2920
+
2921
+ await handleOpencodeSession({
2922
+ prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
2923
+ thread,
2924
+ projectDirectory,
2925
+ })
2926
+
2927
+ discordLogger.log(
2928
+ `Created new project ${channelName} at ${projectDirectory}`,
2929
+ )
2930
+ } catch (error) {
2931
+ voiceLogger.error('[ADD-NEW-PROJECT] Error:', error)
2932
+ await command.editReply(
2933
+ `Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`,
2934
+ )
2935
+ }
2786
2936
  } else if (
2787
2937
  command.commandName === 'accept' ||
2788
2938
  command.commandName === 'accept-always'
@@ -2976,6 +3126,79 @@ export async function startDiscordBot({
2976
3126
  ephemeral: true,
2977
3127
  })
2978
3128
  }
3129
+ } else if (command.commandName === 'share') {
3130
+ const channel = command.channel
3131
+
3132
+ if (!channel) {
3133
+ await command.reply({
3134
+ content: 'This command can only be used in a channel',
3135
+ ephemeral: true,
3136
+ })
3137
+ return
3138
+ }
3139
+
3140
+ const isThread = [
3141
+ ChannelType.PublicThread,
3142
+ ChannelType.PrivateThread,
3143
+ ChannelType.AnnouncementThread,
3144
+ ].includes(channel.type)
3145
+
3146
+ if (!isThread) {
3147
+ await command.reply({
3148
+ content: 'This command can only be used in a thread with an active session',
3149
+ ephemeral: true,
3150
+ })
3151
+ return
3152
+ }
3153
+
3154
+ const textChannel = resolveTextChannel(channel as ThreadChannel)
3155
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel)
3156
+
3157
+ if (!directory) {
3158
+ await command.reply({
3159
+ content: 'Could not determine project directory for this channel',
3160
+ ephemeral: true,
3161
+ })
3162
+ return
3163
+ }
3164
+
3165
+ const row = getDatabase()
3166
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
3167
+ .get(channel.id) as { session_id: string } | undefined
3168
+
3169
+ if (!row?.session_id) {
3170
+ await command.reply({
3171
+ content: 'No active session in this thread',
3172
+ ephemeral: true,
3173
+ })
3174
+ return
3175
+ }
3176
+
3177
+ const sessionId = row.session_id
3178
+
3179
+ try {
3180
+ const getClient = await initializeOpencodeForDirectory(directory)
3181
+ const response = await getClient().session.share({
3182
+ path: { id: sessionId },
3183
+ })
3184
+
3185
+ if (!response.data?.share?.url) {
3186
+ await command.reply({
3187
+ content: 'Failed to generate share URL',
3188
+ ephemeral: true,
3189
+ })
3190
+ return
3191
+ }
3192
+
3193
+ await command.reply(`🔗 **Session shared:** ${response.data.share.url}`)
3194
+ sessionLogger.log(`Session ${sessionId} shared: ${response.data.share.url}`)
3195
+ } catch (error) {
3196
+ voiceLogger.error('[SHARE] Error:', error)
3197
+ await command.reply({
3198
+ content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
3199
+ ephemeral: true,
3200
+ })
3201
+ }
2979
3202
  }
2980
3203
  }
2981
3204
  } catch (error) {
@@ -3261,7 +3484,7 @@ export async function startDiscordBot({
3261
3484
 
3262
3485
  await discordClient.login(token)
3263
3486
 
3264
- const handleShutdown = async (signal: string) => {
3487
+ const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
3265
3488
  discordLogger.log(`Received ${signal}, cleaning up...`)
3266
3489
 
3267
3490
  // Prevent multiple shutdown calls
@@ -3310,11 +3533,15 @@ export async function startDiscordBot({
3310
3533
  discordLogger.log('Destroying Discord client...')
3311
3534
  discordClient.destroy()
3312
3535
 
3313
- discordLogger.log('Cleanup complete, exiting.')
3314
- process.exit(0)
3536
+ discordLogger.log('Cleanup complete.')
3537
+ if (!skipExit) {
3538
+ process.exit(0)
3539
+ }
3315
3540
  } catch (error) {
3316
3541
  voiceLogger.error('[SHUTDOWN] Error during cleanup:', error)
3317
- process.exit(1)
3542
+ if (!skipExit) {
3543
+ process.exit(1)
3544
+ }
3318
3545
  }
3319
3546
  }
3320
3547
 
@@ -3337,6 +3564,23 @@ export async function startDiscordBot({
3337
3564
  }
3338
3565
  })
3339
3566
 
3567
+ process.on('SIGUSR2', async () => {
3568
+ discordLogger.log('Received SIGUSR2, restarting after cleanup...')
3569
+ try {
3570
+ await handleShutdown('SIGUSR2', { skipExit: true })
3571
+ } catch (error) {
3572
+ voiceLogger.error('[SIGUSR2] Error during shutdown:', error)
3573
+ }
3574
+ const { spawn } = await import('node:child_process')
3575
+ spawn(process.argv[0]!, [...process.execArgv, ...process.argv.slice(1)], {
3576
+ stdio: 'inherit',
3577
+ detached: true,
3578
+ cwd: process.cwd(),
3579
+ env: process.env,
3580
+ }).unref()
3581
+ process.exit(0)
3582
+ })
3583
+
3340
3584
  // Prevent unhandled promise rejections from crashing the process during shutdown
3341
3585
  process.on('unhandledRejection', (reason, promise) => {
3342
3586
  if ((global as any).shuttingDown) {
@@ -5,9 +5,9 @@ import path from 'node:path'
5
5
  import { Resampler } from '@purinton/resampler'
6
6
  import * as prism from 'prism-media'
7
7
  import { startGenAiSession } from './genai.js'
8
+ import type { Session } from '@google/genai'
8
9
  import { getTools } from './tools.js'
9
10
  import type { WorkerInMessage, WorkerOutMessage } from './worker-types.js'
10
- import type { Session } from '@google/genai'
11
11
  import { createLogger } from './logger.js'
12
12
 
13
13
  if (!parentPort) {
package/src/genai.ts CHANGED
@@ -255,7 +255,7 @@ export async function startGenAiSession({
255
255
  apiKey,
256
256
  })
257
257
 
258
- const model = 'models/gemini-2.5-flash-live-preview'
258
+ const model = 'gemini-2.5-flash-native-audio-preview-12-2025'
259
259
 
260
260
  session = await ai.live.connect({
261
261
  model,
@@ -1,12 +0,0 @@
1
- ---
2
- description: Send current session to Discord
3
- ---
4
- Run the following command to send this session to Discord:
5
-
6
- ```bash
7
- npx -y kimaki send-to-discord <sessionId>
8
- ```
9
-
10
- Replace `<sessionId>` with your current OpenCode session ID (available in the system prompt).
11
-
12
- The command will create a Discord thread with your session history and return the Discord URL.