kimaki 0.4.12 → 0.4.14

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
@@ -54,9 +54,34 @@ import { setGlobalDispatcher, Agent } from 'undici'
54
54
  // disables the automatic 5 minutes abort after no body
55
55
  setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }))
56
56
 
57
- export const OPENCODE_SYSTEM_MESSAGE = `
57
+ type ParsedCommand = {
58
+ isCommand: true
59
+ command: string
60
+ arguments: string
61
+ } | {
62
+ isCommand: false
63
+ }
64
+
65
+ function parseSlashCommand(text: string): ParsedCommand {
66
+ const trimmed = text.trim()
67
+ if (!trimmed.startsWith('/')) {
68
+ return { isCommand: false }
69
+ }
70
+ const match = trimmed.match(/^\/(\S+)(?:\s+(.*))?$/)
71
+ if (!match) {
72
+ return { isCommand: false }
73
+ }
74
+ const command = match[1]!
75
+ const args = match[2]?.trim() || ''
76
+ return { isCommand: true, command, arguments: args }
77
+ }
78
+
79
+ export function getOpencodeSystemMessage({ sessionId }: { sessionId: string }) {
80
+ return `
58
81
  The user is reading your messages from inside Discord, via kimaki.xyz
59
82
 
83
+ Your current OpenCode session ID is: ${sessionId}
84
+
60
85
  After each message, if you implemented changes, you can show the user a diff via an url running the command, to show the changes in working directory:
61
86
 
62
87
  bunx critique web
@@ -98,6 +123,7 @@ code blocks for tables and diagrams MUST have Max length of 85 characters. other
98
123
 
99
124
  you can create diagrams wrapping them in code blocks too.
100
125
  `
126
+ }
101
127
 
102
128
  const discordLogger = createLogger('DISCORD')
103
129
  const voiceLogger = createLogger('VOICE')
@@ -213,11 +239,13 @@ async function setupVoiceHandling({
213
239
  guildId,
214
240
  channelId,
215
241
  appId,
242
+ discordClient,
216
243
  }: {
217
244
  connection: VoiceConnection
218
245
  guildId: string
219
246
  channelId: string
220
247
  appId: string
248
+ discordClient: Client
221
249
  }) {
222
250
  voiceLogger.log(
223
251
  `Setting up voice handling for guild ${guildId}, channel ${channelId}`,
@@ -330,8 +358,28 @@ async function setupVoiceHandling({
330
358
 
331
359
  genAiWorker.sendTextInput(text)
332
360
  },
333
- onError(error) {
361
+ async onError(error) {
334
362
  voiceLogger.error('GenAI worker error:', error)
363
+ const textChannelRow = getDatabase()
364
+ .prepare(
365
+ `SELECT cd2.channel_id FROM channel_directories cd1
366
+ JOIN channel_directories cd2 ON cd1.directory = cd2.directory
367
+ WHERE cd1.channel_id = ? AND cd1.channel_type = 'voice' AND cd2.channel_type = 'text'`,
368
+ )
369
+ .get(channelId) as { channel_id: string } | undefined
370
+
371
+ if (textChannelRow) {
372
+ try {
373
+ const textChannel = await discordClient.channels.fetch(
374
+ textChannelRow.channel_id,
375
+ )
376
+ if (textChannel?.isTextBased() && 'send' in textChannel) {
377
+ await textChannel.send(`⚠️ Voice session error: ${error}`)
378
+ }
379
+ } catch (e) {
380
+ voiceLogger.error('Failed to send error to text channel:', e)
381
+ }
382
+ }
335
383
  },
336
384
  })
337
385
 
@@ -1212,30 +1260,31 @@ function getToolOutputToDisplay(part: Part): string {
1212
1260
  return part.state.error || 'Unknown error'
1213
1261
  }
1214
1262
 
1215
- if (part.tool === 'todowrite') {
1216
- const todos =
1217
- (part.state.input?.todos as {
1218
- content: string
1219
- status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
1220
- }[]) || []
1221
- return todos
1222
- .map((todo) => {
1223
- let statusIcon = '▢'
1224
- if (todo.status === 'in_progress') {
1225
- statusIcon = '●'
1226
- }
1227
- if (todo.status === 'completed' || todo.status === 'cancelled') {
1228
- statusIcon = '■'
1229
- }
1230
- return `\`${statusIcon}\` ${todo.content}`
1231
- })
1232
- .filter(Boolean)
1233
- .join('\n')
1234
- }
1235
-
1236
1263
  return ''
1237
1264
  }
1238
1265
 
1266
+ function formatTodoList(part: Part): string {
1267
+ if (part.type !== 'tool' || part.tool !== 'todowrite') return ''
1268
+ const todos =
1269
+ (part.state.input?.todos as {
1270
+ content: string
1271
+ status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
1272
+ }[]) || []
1273
+ if (todos.length === 0) return ''
1274
+ return todos
1275
+ .map((todo, i) => {
1276
+ const num = `${i + 1}.`
1277
+ if (todo.status === 'in_progress') {
1278
+ return `${num} **${todo.content}**`
1279
+ }
1280
+ if (todo.status === 'completed' || todo.status === 'cancelled') {
1281
+ return `${num} ~~${todo.content}~~`
1282
+ }
1283
+ return `${num} ${todo.content}`
1284
+ })
1285
+ .join('\n')
1286
+ }
1287
+
1239
1288
  function formatPart(part: Part): string {
1240
1289
  if (part.type === 'text') {
1241
1290
  return part.text || ''
@@ -1263,6 +1312,10 @@ function formatPart(part: Part): string {
1263
1312
  }
1264
1313
 
1265
1314
  if (part.type === 'tool') {
1315
+ if (part.tool === 'todowrite') {
1316
+ return formatTodoList(part)
1317
+ }
1318
+
1266
1319
  if (part.state.status !== 'completed' && part.state.status !== 'error') {
1267
1320
  return ''
1268
1321
  }
@@ -1270,9 +1323,20 @@ function formatPart(part: Part): string {
1270
1323
  const summaryText = getToolSummaryText(part)
1271
1324
  const outputToDisplay = getToolOutputToDisplay(part)
1272
1325
 
1273
- let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error'
1274
- if (toolTitle) {
1275
- toolTitle = `*${toolTitle}*`
1326
+ let toolTitle = ''
1327
+ if (part.state.status === 'error') {
1328
+ toolTitle = 'error'
1329
+ } else if (part.tool === 'bash') {
1330
+ const command = (part.state.input?.command as string) || ''
1331
+ const isSingleLine = !command.includes('\n')
1332
+ const hasBackticks = command.includes('`')
1333
+ if (isSingleLine && command.length <= 120 && !hasBackticks) {
1334
+ toolTitle = `\`${command}\``
1335
+ } else {
1336
+ toolTitle = part.state.title ? `*${part.state.title}*` : ''
1337
+ }
1338
+ } else if (part.state.title) {
1339
+ toolTitle = `*${part.state.title}*`
1276
1340
  }
1277
1341
 
1278
1342
  const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : ''
@@ -1311,12 +1375,14 @@ async function handleOpencodeSession({
1311
1375
  projectDirectory,
1312
1376
  originalMessage,
1313
1377
  images = [],
1378
+ parsedCommand,
1314
1379
  }: {
1315
1380
  prompt: string
1316
1381
  thread: ThreadChannel
1317
1382
  projectDirectory?: string
1318
1383
  originalMessage?: Message
1319
1384
  images?: FilePartInput[]
1385
+ parsedCommand?: ParsedCommand
1320
1386
  }): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
1321
1387
  voiceLogger.log(
1322
1388
  `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
@@ -1439,6 +1505,8 @@ async function handleOpencodeSession({
1439
1505
  let currentParts: Part[] = []
1440
1506
  let stopTyping: (() => void) | null = null
1441
1507
  let usedModel: string | undefined
1508
+ let usedProviderID: string | undefined
1509
+ let inputTokens = 0
1442
1510
 
1443
1511
  const sendPartMessage = async (part: Part) => {
1444
1512
  const content = formatPart(part) + '\n\n'
@@ -1449,22 +1517,12 @@ async function handleOpencodeSession({
1449
1517
 
1450
1518
  // Skip if already sent
1451
1519
  if (partIdToMessage.has(part.id)) {
1452
- voiceLogger.log(
1453
- `[SEND SKIP] Part ${part.id} already sent as message ${partIdToMessage.get(part.id)?.id}`,
1454
- )
1455
1520
  return
1456
1521
  }
1457
1522
 
1458
1523
  try {
1459
- voiceLogger.log(
1460
- `[SEND] Sending part ${part.id} (type: ${part.type}) to Discord, content length: ${content.length}`,
1461
- )
1462
-
1463
1524
  const firstMessage = await sendThreadMessage(thread, content)
1464
1525
  partIdToMessage.set(part.id, firstMessage)
1465
- voiceLogger.log(
1466
- `[SEND SUCCESS] Part ${part.id} sent as message ${firstMessage.id}`,
1467
- )
1468
1526
 
1469
1527
  // Store part-message mapping in database
1470
1528
  getDatabase()
@@ -1487,13 +1545,10 @@ async function handleOpencodeSession({
1487
1545
  discordLogger.log(`Not starting typing, already aborted`)
1488
1546
  return () => {}
1489
1547
  }
1490
- discordLogger.log(`Starting typing for thread ${thread.id}`)
1491
-
1492
1548
  // Clear any previous typing interval
1493
1549
  if (typingInterval) {
1494
1550
  clearInterval(typingInterval)
1495
1551
  typingInterval = null
1496
- discordLogger.log(`Cleared previous typing interval`)
1497
1552
  }
1498
1553
 
1499
1554
  // Send initial typing
@@ -1529,7 +1584,6 @@ async function handleOpencodeSession({
1529
1584
  if (typingInterval) {
1530
1585
  clearInterval(typingInterval)
1531
1586
  typingInterval = null
1532
- discordLogger.log(`Stopped typing for thread ${thread.id}`)
1533
1587
  }
1534
1588
  }
1535
1589
  }
@@ -1538,45 +1592,31 @@ async function handleOpencodeSession({
1538
1592
  let assistantMessageId: string | undefined
1539
1593
 
1540
1594
  for await (const event of events) {
1541
- sessionLogger.log(`Received: ${event.type}`)
1542
1595
  if (event.type === 'message.updated') {
1543
1596
  const msg = event.properties.info
1544
1597
 
1545
1598
  if (msg.sessionID !== session.id) {
1546
- voiceLogger.log(
1547
- `[EVENT IGNORED] Message from different session (expected: ${session.id}, got: ${msg.sessionID})`,
1548
- )
1549
1599
  continue
1550
1600
  }
1551
1601
 
1552
1602
  // Track assistant message ID
1553
1603
  if (msg.role === 'assistant') {
1554
1604
  assistantMessageId = msg.id
1555
-
1556
-
1557
1605
  usedModel = msg.modelID
1558
-
1559
- voiceLogger.log(
1560
- `[EVENT] Tracking assistant message ${assistantMessageId}`,
1561
- )
1562
- } else {
1563
- sessionLogger.log(`Message role: ${msg.role}`)
1606
+ usedProviderID = msg.providerID
1607
+ if (msg.tokens.input > 0) {
1608
+ inputTokens = msg.tokens.input
1609
+ }
1564
1610
  }
1565
1611
  } else if (event.type === 'message.part.updated') {
1566
1612
  const part = event.properties.part
1567
1613
 
1568
1614
  if (part.sessionID !== session.id) {
1569
- voiceLogger.log(
1570
- `[EVENT IGNORED] Part from different session (expected: ${session.id}, got: ${part.sessionID})`,
1571
- )
1572
1615
  continue
1573
1616
  }
1574
1617
 
1575
1618
  // Only process parts from assistant messages
1576
1619
  if (part.messageID !== assistantMessageId) {
1577
- voiceLogger.log(
1578
- `[EVENT IGNORED] Part from non-assistant message (expected: ${assistantMessageId}, got: ${part.messageID})`,
1579
- )
1580
1620
  continue
1581
1621
  }
1582
1622
 
@@ -1589,9 +1629,7 @@ async function handleOpencodeSession({
1589
1629
  currentParts.push(part)
1590
1630
  }
1591
1631
 
1592
- voiceLogger.log(
1593
- `[PART] Update: id=${part.id}, type=${part.type}, text=${'text' in part && typeof part.text === 'string' ? part.text.slice(0, 50) : ''}`,
1594
- )
1632
+
1595
1633
 
1596
1634
  // Start typing on step-start
1597
1635
  if (part.type === 'step-start') {
@@ -1600,10 +1638,12 @@ async function handleOpencodeSession({
1600
1638
 
1601
1639
  // Check if this is a step-finish part
1602
1640
  if (part.type === 'step-finish') {
1641
+ // Track tokens from step-finish part
1642
+ if (part.tokens?.input && part.tokens.input > 0) {
1643
+ inputTokens = part.tokens.input
1644
+ voiceLogger.log(`[STEP-FINISH] Captured tokens: ${inputTokens}`)
1645
+ }
1603
1646
  // Send all parts accumulated so far to Discord
1604
- voiceLogger.log(
1605
- `[STEP-FINISH] Sending ${currentParts.length} parts to Discord`,
1606
- )
1607
1647
  for (const p of currentParts) {
1608
1648
  // Skip step-start and step-finish parts as they have no visual content
1609
1649
  if (p.type !== 'step-start' && p.type !== 'step-finish') {
@@ -1690,10 +1730,6 @@ async function handleOpencodeSession({
1690
1730
  if (pending && pending.permission.id === permissionID) {
1691
1731
  pendingPermissions.delete(thread.id)
1692
1732
  }
1693
- } else if (event.type === 'file.edited') {
1694
- sessionLogger.log(`File edited event received`)
1695
- } else {
1696
- sessionLogger.log(`Unhandled event type: ${event.type}`)
1697
1733
  }
1698
1734
  }
1699
1735
  } catch (e) {
@@ -1707,37 +1743,20 @@ async function handleOpencodeSession({
1707
1743
  throw e
1708
1744
  } finally {
1709
1745
  // Send any remaining parts that weren't sent
1710
- voiceLogger.log(
1711
- `[CLEANUP] Checking ${currentParts.length} parts for unsent messages`,
1712
- )
1713
- let unsentCount = 0
1714
1746
  for (const part of currentParts) {
1715
1747
  if (!partIdToMessage.has(part.id)) {
1716
- unsentCount++
1717
- voiceLogger.log(
1718
- `[CLEANUP] Sending unsent part: id=${part.id}, type=${part.type}`,
1719
- )
1720
1748
  try {
1721
1749
  await sendPartMessage(part)
1722
1750
  } catch (error) {
1723
- sessionLogger.log(
1724
- `Failed to send part ${part.id} during cleanup:`,
1725
- error,
1726
- )
1751
+ sessionLogger.error(`Failed to send part ${part.id}:`, error)
1727
1752
  }
1728
1753
  }
1729
1754
  }
1730
- if (unsentCount === 0) {
1731
- sessionLogger.log(`All parts were already sent`)
1732
- } else {
1733
- sessionLogger.log(`Sent ${unsentCount} previously unsent parts`)
1734
- }
1735
1755
 
1736
1756
  // Stop typing when session ends
1737
1757
  if (stopTyping) {
1738
1758
  stopTyping()
1739
1759
  stopTyping = null
1740
- sessionLogger.log(`Stopped typing for session`)
1741
1760
  }
1742
1761
 
1743
1762
  // Only send duration message if request was not aborted or was aborted with 'finished' reason
@@ -1750,8 +1769,22 @@ async function handleOpencodeSession({
1750
1769
  )
1751
1770
  const attachCommand = port ? ` ⋅ ${session.id}` : ''
1752
1771
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
1753
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}_${attachCommand}${modelInfo}`)
1754
- sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}`)
1772
+ let contextInfo = ''
1773
+ if (inputTokens > 0 && usedProviderID && usedModel) {
1774
+ try {
1775
+ const providersResponse = await getClient().provider.list({ query: { directory } })
1776
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
1777
+ const model = provider?.models?.[usedModel]
1778
+ if (model?.limit?.context) {
1779
+ const percentage = Math.round((inputTokens / model.limit.context) * 100)
1780
+ contextInfo = ` ⋅ ${percentage}%`
1781
+ }
1782
+ } catch (e) {
1783
+ sessionLogger.error('Failed to fetch provider info for context percentage:', e)
1784
+ }
1785
+ }
1786
+ await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`)
1787
+ sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${inputTokens}`)
1755
1788
  } else {
1756
1789
  sessionLogger.log(
1757
1790
  `Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
@@ -1761,27 +1794,42 @@ async function handleOpencodeSession({
1761
1794
  }
1762
1795
 
1763
1796
  try {
1764
- voiceLogger.log(
1765
- `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
1766
- )
1767
- if (images.length > 0) {
1768
- sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
1769
- }
1770
-
1771
1797
  // Start the event handler
1772
1798
  const eventHandlerPromise = eventHandler()
1773
1799
 
1774
- const parts = [{ type: 'text' as const, text: prompt }, ...images]
1775
- sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
1800
+ let response: { data?: unknown }
1801
+ if (parsedCommand?.isCommand) {
1802
+ sessionLogger.log(
1803
+ `[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`,
1804
+ )
1805
+ response = await getClient().session.command({
1806
+ path: { id: session.id },
1807
+ body: {
1808
+ command: parsedCommand.command,
1809
+ arguments: parsedCommand.arguments,
1810
+ },
1811
+ signal: abortController.signal,
1812
+ })
1813
+ } else {
1814
+ voiceLogger.log(
1815
+ `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
1816
+ )
1817
+ if (images.length > 0) {
1818
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
1819
+ }
1820
+
1821
+ const parts = [{ type: 'text' as const, text: prompt }, ...images]
1822
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
1776
1823
 
1777
- const response = await getClient().session.prompt({
1778
- path: { id: session.id },
1779
- body: {
1780
- parts,
1781
- system: OPENCODE_SYSTEM_MESSAGE,
1782
- },
1783
- signal: abortController.signal,
1784
- })
1824
+ response = await getClient().session.prompt({
1825
+ path: { id: session.id },
1826
+ body: {
1827
+ parts,
1828
+ system: getOpencodeSystemMessage({ sessionId: session.id }),
1829
+ },
1830
+ signal: abortController.signal,
1831
+ })
1832
+ }
1785
1833
  abortController.abort('finished')
1786
1834
 
1787
1835
  sessionLogger.log(`Successfully sent prompt, got response`)
@@ -1938,9 +1986,6 @@ export async function startDiscordBot({
1938
1986
  discordClient.on(Events.MessageCreate, async (message: Message) => {
1939
1987
  try {
1940
1988
  if (message.author?.bot) {
1941
- voiceLogger.log(
1942
- `[IGNORED] Bot message from ${message.author.tag} in channel ${message.channelId}`,
1943
- )
1944
1989
  return
1945
1990
  }
1946
1991
  if (message.partial) {
@@ -1964,15 +2009,8 @@ export async function startDiscordBot({
1964
2009
  )
1965
2010
 
1966
2011
  if (!isOwner && !isAdmin) {
1967
- voiceLogger.log(
1968
- `[IGNORED] Non-authoritative user ${message.author.tag} (ID: ${message.author.id}) - not owner or admin`,
1969
- )
1970
2012
  return
1971
2013
  }
1972
-
1973
- voiceLogger.log(
1974
- `[AUTHORIZED] Message from ${message.author.tag} (Owner: ${isOwner}, Admin: ${isAdmin})`,
1975
- )
1976
2014
  }
1977
2015
 
1978
2016
  const channel = message.channel
@@ -2046,12 +2084,14 @@ export async function startDiscordBot({
2046
2084
  }
2047
2085
 
2048
2086
  const images = getImageAttachments(message)
2087
+ const parsedCommand = parseSlashCommand(messageContent)
2049
2088
  await handleOpencodeSession({
2050
2089
  prompt: messageContent,
2051
2090
  thread,
2052
2091
  projectDirectory,
2053
2092
  originalMessage: message,
2054
2093
  images,
2094
+ parsedCommand,
2055
2095
  })
2056
2096
  return
2057
2097
  }
@@ -2141,12 +2181,14 @@ export async function startDiscordBot({
2141
2181
  }
2142
2182
 
2143
2183
  const images = getImageAttachments(message)
2184
+ const parsedCommand = parseSlashCommand(messageContent)
2144
2185
  await handleOpencodeSession({
2145
2186
  prompt: messageContent,
2146
2187
  thread,
2147
2188
  projectDirectory,
2148
2189
  originalMessage: message,
2149
2190
  images,
2191
+ parsedCommand,
2150
2192
  })
2151
2193
  } else {
2152
2194
  discordLogger.log(`Channel type ${channel.type} is not supported`)
@@ -2482,10 +2524,12 @@ export async function startDiscordBot({
2482
2524
  )
2483
2525
 
2484
2526
  // Start the OpenCode session
2527
+ const parsedCommand = parseSlashCommand(fullPrompt)
2485
2528
  await handleOpencodeSession({
2486
2529
  prompt: fullPrompt,
2487
2530
  thread,
2488
2531
  projectDirectory,
2532
+ parsedCommand,
2489
2533
  })
2490
2534
  } catch (error) {
2491
2535
  voiceLogger.error('[SESSION] Error:', error)
@@ -2600,68 +2644,56 @@ export async function startDiscordBot({
2600
2644
  `📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`,
2601
2645
  )
2602
2646
 
2603
- // Render all existing messages
2604
- let messageCount = 0
2647
+ // Collect all assistant parts first, then only render the last 30
2648
+ const allAssistantParts: { id: string; content: string }[] = []
2605
2649
  for (const message of messages) {
2606
- if (message.info.role === 'user') {
2607
- // Render user messages
2608
- const userParts = message.parts.filter(
2609
- (p) => p.type === 'text' && !p.synthetic,
2610
- )
2611
- const userTexts = userParts
2612
- .map((p) => {
2613
- if (p.type === 'text') {
2614
- return p.text
2615
- }
2616
- return ''
2617
- })
2618
- .filter((t) => t.trim())
2619
-
2620
- const userText = userTexts.join('\n\n')
2621
- if (userText) {
2622
- // Escape backticks in user messages to prevent formatting issues
2623
- const escapedText = escapeDiscordFormatting(userText)
2624
- await sendThreadMessage(thread, `**User:**\n${escapedText}`)
2625
- }
2626
- } else if (message.info.role === 'assistant') {
2627
- // Render assistant parts
2628
- const partsToRender: { id: string; content: string }[] = []
2629
-
2650
+ if (message.info.role === 'assistant') {
2630
2651
  for (const part of message.parts) {
2631
2652
  const content = formatPart(part)
2632
2653
  if (content.trim()) {
2633
- partsToRender.push({ id: part.id, content })
2654
+ allAssistantParts.push({ id: part.id, content })
2634
2655
  }
2635
2656
  }
2657
+ }
2658
+ }
2636
2659
 
2637
- if (partsToRender.length > 0) {
2638
- const combinedContent = partsToRender
2639
- .map((p) => p.content)
2640
- .join('\n\n')
2660
+ const partsToRender = allAssistantParts.slice(-30)
2661
+ const skippedCount = allAssistantParts.length - partsToRender.length
2641
2662
 
2642
- const discordMessage = await sendThreadMessage(
2643
- thread,
2644
- combinedContent,
2645
- )
2663
+ if (skippedCount > 0) {
2664
+ await sendThreadMessage(
2665
+ thread,
2666
+ `*Skipped ${skippedCount} older assistant parts...*`,
2667
+ )
2668
+ }
2646
2669
 
2647
- const stmt = getDatabase().prepare(
2648
- 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
2649
- )
2670
+ if (partsToRender.length > 0) {
2671
+ const combinedContent = partsToRender
2672
+ .map((p) => p.content)
2673
+ .join('\n\n')
2650
2674
 
2651
- const transaction = getDatabase().transaction(
2652
- (parts: { id: string }[]) => {
2653
- for (const part of parts) {
2654
- stmt.run(part.id, discordMessage.id, thread.id)
2655
- }
2656
- },
2657
- )
2675
+ const discordMessage = await sendThreadMessage(
2676
+ thread,
2677
+ combinedContent,
2678
+ )
2658
2679
 
2659
- transaction(partsToRender)
2660
- }
2661
- }
2662
- messageCount++
2680
+ const stmt = getDatabase().prepare(
2681
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
2682
+ )
2683
+
2684
+ const transaction = getDatabase().transaction(
2685
+ (parts: { id: string }[]) => {
2686
+ for (const part of parts) {
2687
+ stmt.run(part.id, discordMessage.id, thread.id)
2688
+ }
2689
+ },
2690
+ )
2691
+
2692
+ transaction(partsToRender)
2663
2693
  }
2664
2694
 
2695
+ const messageCount = messages.length
2696
+
2665
2697
  await sendThreadMessage(
2666
2698
  thread,
2667
2699
  `✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`,
@@ -2865,6 +2897,77 @@ export async function startDiscordBot({
2865
2897
  ephemeral: true,
2866
2898
  })
2867
2899
  }
2900
+ } else if (command.commandName === 'abort') {
2901
+ const channel = command.channel
2902
+
2903
+ if (!channel) {
2904
+ await command.reply({
2905
+ content: 'This command can only be used in a channel',
2906
+ ephemeral: true,
2907
+ })
2908
+ return
2909
+ }
2910
+
2911
+ const isThread = [
2912
+ ChannelType.PublicThread,
2913
+ ChannelType.PrivateThread,
2914
+ ChannelType.AnnouncementThread,
2915
+ ].includes(channel.type)
2916
+
2917
+ if (!isThread) {
2918
+ await command.reply({
2919
+ content: 'This command can only be used in a thread with an active session',
2920
+ ephemeral: true,
2921
+ })
2922
+ return
2923
+ }
2924
+
2925
+ const textChannel = resolveTextChannel(channel as ThreadChannel)
2926
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel)
2927
+
2928
+ if (!directory) {
2929
+ await command.reply({
2930
+ content: 'Could not determine project directory for this channel',
2931
+ ephemeral: true,
2932
+ })
2933
+ return
2934
+ }
2935
+
2936
+ const row = getDatabase()
2937
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
2938
+ .get(channel.id) as { session_id: string } | undefined
2939
+
2940
+ if (!row?.session_id) {
2941
+ await command.reply({
2942
+ content: 'No active session in this thread',
2943
+ ephemeral: true,
2944
+ })
2945
+ return
2946
+ }
2947
+
2948
+ const sessionId = row.session_id
2949
+
2950
+ try {
2951
+ const existingController = abortControllers.get(sessionId)
2952
+ if (existingController) {
2953
+ existingController.abort(new Error('User requested abort'))
2954
+ abortControllers.delete(sessionId)
2955
+ }
2956
+
2957
+ const getClient = await initializeOpencodeForDirectory(directory)
2958
+ await getClient().session.abort({
2959
+ path: { id: sessionId },
2960
+ })
2961
+
2962
+ await command.reply(`🛑 Request **aborted**`)
2963
+ sessionLogger.log(`Session ${sessionId} aborted by user`)
2964
+ } catch (error) {
2965
+ voiceLogger.error('[ABORT] Error:', error)
2966
+ await command.reply({
2967
+ content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
2968
+ ephemeral: true,
2969
+ })
2970
+ }
2868
2971
  }
2869
2972
  }
2870
2973
  } catch (error) {
@@ -3101,6 +3204,7 @@ export async function startDiscordBot({
3101
3204
  guildId: newState.guild.id,
3102
3205
  channelId: voiceChannel.id,
3103
3206
  appId: currentAppId!,
3207
+ discordClient,
3104
3208
  })
3105
3209
 
3106
3210
  // Handle connection state changes