kimaki 0.4.13 → 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/cli.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  REST,
34
34
  Routes,
35
35
  SlashCommandBuilder,
36
+ AttachmentBuilder,
36
37
  } from 'discord.js'
37
38
  import path from 'node:path'
38
39
  import fs from 'node:fs'
@@ -134,12 +135,16 @@ async function registerCommands(token: string, appId: string) {
134
135
  .toJSON(),
135
136
  new SlashCommandBuilder()
136
137
  .setName('accept-always')
137
- .setDescription('Accept and auto-approve future requests matching this pattern (e.g. "git *" approves all git commands)')
138
+ .setDescription('Accept and auto-approve future requests matching this pattern')
138
139
  .toJSON(),
139
140
  new SlashCommandBuilder()
140
141
  .setName('reject')
141
142
  .setDescription('Reject a pending permission request')
142
143
  .toJSON(),
144
+ new SlashCommandBuilder()
145
+ .setName('abort')
146
+ .setDescription('Abort the current OpenCode request in this thread')
147
+ .toJSON(),
143
148
  ]
144
149
 
145
150
  const rest = new REST().setToken(token)
@@ -832,28 +837,120 @@ cli
832
837
  })
833
838
 
834
839
  cli
835
- .command('install-plugin', 'Install the OpenCode plugin for /send-to-kimaki-discord command')
840
+ .command('upload-to-discord [...files]', 'Upload files to a Discord thread for a session')
841
+ .option('-s, --session <sessionId>', 'OpenCode session ID')
842
+ .action(async (files: string[], options: { session?: string }) => {
843
+ try {
844
+ const { session: sessionId } = options
845
+
846
+ if (!sessionId) {
847
+ cliLogger.error('Session ID is required. Use --session <sessionId>')
848
+ process.exit(EXIT_NO_RESTART)
849
+ }
850
+
851
+ if (!files || files.length === 0) {
852
+ cliLogger.error('At least one file path is required')
853
+ process.exit(EXIT_NO_RESTART)
854
+ }
855
+
856
+ const resolvedFiles = files.map((f) => path.resolve(f))
857
+ for (const file of resolvedFiles) {
858
+ if (!fs.existsSync(file)) {
859
+ cliLogger.error(`File not found: ${file}`)
860
+ process.exit(EXIT_NO_RESTART)
861
+ }
862
+ }
863
+
864
+ const db = getDatabase()
865
+
866
+ const threadRow = db
867
+ .prepare('SELECT thread_id FROM thread_sessions WHERE session_id = ?')
868
+ .get(sessionId) as { thread_id: string } | undefined
869
+
870
+ if (!threadRow) {
871
+ cliLogger.error(`No Discord thread found for session: ${sessionId}`)
872
+ cliLogger.error('Make sure the session has been sent to Discord first using /send-to-kimaki-discord')
873
+ process.exit(EXIT_NO_RESTART)
874
+ }
875
+
876
+ const botRow = db
877
+ .prepare(
878
+ 'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
879
+ )
880
+ .get() as { app_id: string; token: string } | undefined
881
+
882
+ if (!botRow) {
883
+ cliLogger.error('No bot credentials found. Run `kimaki` first to set up the bot.')
884
+ process.exit(EXIT_NO_RESTART)
885
+ }
886
+
887
+ const s = spinner()
888
+ s.start(`Uploading ${resolvedFiles.length} file(s)...`)
889
+
890
+ for (const file of resolvedFiles) {
891
+ const buffer = fs.readFileSync(file)
892
+
893
+ const formData = new FormData()
894
+ formData.append('payload_json', JSON.stringify({
895
+ attachments: [{ id: 0, filename: path.basename(file) }]
896
+ }))
897
+ formData.append('files[0]', new Blob([buffer]), path.basename(file))
898
+
899
+ const response = await fetch(
900
+ `https://discord.com/api/v10/channels/${threadRow.thread_id}/messages`,
901
+ {
902
+ method: 'POST',
903
+ headers: {
904
+ 'Authorization': `Bot ${botRow.token}`,
905
+ },
906
+ body: formData,
907
+ }
908
+ )
909
+
910
+ if (!response.ok) {
911
+ const error = await response.text()
912
+ throw new Error(`Discord API error: ${response.status} - ${error}`)
913
+ }
914
+ }
915
+
916
+ s.stop(`Uploaded ${resolvedFiles.length} file(s)!`)
917
+
918
+ note(
919
+ `Files uploaded to Discord thread!\n\nFiles: ${resolvedFiles.map((f) => path.basename(f)).join(', ')}`,
920
+ '✅ Success',
921
+ )
922
+
923
+ process.exit(0)
924
+ } catch (error) {
925
+ cliLogger.error(
926
+ 'Error:',
927
+ error instanceof Error ? error.message : String(error),
928
+ )
929
+ process.exit(EXIT_NO_RESTART)
930
+ }
931
+ })
932
+
933
+ cli
934
+ .command('install-plugin', 'Install the OpenCode commands for kimaki Discord integration')
836
935
  .action(async () => {
837
936
  try {
838
937
  const require = createRequire(import.meta.url)
839
- const pluginSrc = require.resolve('./opencode-plugin.ts')
840
- const commandSrc = require.resolve('./opencode-command.md')
938
+ const sendCommandSrc = require.resolve('./opencode-command-send-to-discord.md')
939
+ const uploadCommandSrc = require.resolve('./opencode-command-upload-to-discord.md')
841
940
 
842
941
  const opencodeConfig = path.join(os.homedir(), '.config', 'opencode')
843
- const pluginDir = path.join(opencodeConfig, 'plugin')
844
942
  const commandDir = path.join(opencodeConfig, 'command')
845
943
 
846
- fs.mkdirSync(pluginDir, { recursive: true })
847
944
  fs.mkdirSync(commandDir, { recursive: true })
848
945
 
849
- const pluginDest = path.join(pluginDir, 'send-to-kimaki-discord.ts')
850
- const commandDest = path.join(commandDir, 'send-to-kimaki-discord.md')
946
+ const sendCommandDest = path.join(commandDir, 'send-to-kimaki-discord.md')
947
+ const uploadCommandDest = path.join(commandDir, 'upload-to-discord.md')
851
948
 
852
- fs.copyFileSync(pluginSrc, pluginDest)
853
- fs.copyFileSync(commandSrc, commandDest)
949
+ fs.copyFileSync(sendCommandSrc, sendCommandDest)
950
+ fs.copyFileSync(uploadCommandSrc, uploadCommandDest)
854
951
 
855
952
  note(
856
- `Plugin: ${pluginDest}\nCommand: ${commandDest}\n\nUse /send-to-kimaki-discord in OpenCode to send the current session to Discord.`,
953
+ `Commands installed:\n- ${sendCommandDest}\n- ${uploadCommandDest}\n\nUse /send-to-kimaki-discord to send session to Discord.\nUse /upload-to-discord to upload files to the thread.`,
857
954
  '✅ Installed',
858
955
  )
859
956
 
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')
@@ -1349,12 +1375,14 @@ async function handleOpencodeSession({
1349
1375
  projectDirectory,
1350
1376
  originalMessage,
1351
1377
  images = [],
1378
+ parsedCommand,
1352
1379
  }: {
1353
1380
  prompt: string
1354
1381
  thread: ThreadChannel
1355
1382
  projectDirectory?: string
1356
1383
  originalMessage?: Message
1357
1384
  images?: FilePartInput[]
1385
+ parsedCommand?: ParsedCommand
1358
1386
  }): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
1359
1387
  voiceLogger.log(
1360
1388
  `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
@@ -1477,6 +1505,8 @@ async function handleOpencodeSession({
1477
1505
  let currentParts: Part[] = []
1478
1506
  let stopTyping: (() => void) | null = null
1479
1507
  let usedModel: string | undefined
1508
+ let usedProviderID: string | undefined
1509
+ let inputTokens = 0
1480
1510
 
1481
1511
  const sendPartMessage = async (part: Part) => {
1482
1512
  const content = formatPart(part) + '\n\n'
@@ -1487,22 +1517,12 @@ async function handleOpencodeSession({
1487
1517
 
1488
1518
  // Skip if already sent
1489
1519
  if (partIdToMessage.has(part.id)) {
1490
- voiceLogger.log(
1491
- `[SEND SKIP] Part ${part.id} already sent as message ${partIdToMessage.get(part.id)?.id}`,
1492
- )
1493
1520
  return
1494
1521
  }
1495
1522
 
1496
1523
  try {
1497
- voiceLogger.log(
1498
- `[SEND] Sending part ${part.id} (type: ${part.type}) to Discord, content length: ${content.length}`,
1499
- )
1500
-
1501
1524
  const firstMessage = await sendThreadMessage(thread, content)
1502
1525
  partIdToMessage.set(part.id, firstMessage)
1503
- voiceLogger.log(
1504
- `[SEND SUCCESS] Part ${part.id} sent as message ${firstMessage.id}`,
1505
- )
1506
1526
 
1507
1527
  // Store part-message mapping in database
1508
1528
  getDatabase()
@@ -1525,13 +1545,10 @@ async function handleOpencodeSession({
1525
1545
  discordLogger.log(`Not starting typing, already aborted`)
1526
1546
  return () => {}
1527
1547
  }
1528
- discordLogger.log(`Starting typing for thread ${thread.id}`)
1529
-
1530
1548
  // Clear any previous typing interval
1531
1549
  if (typingInterval) {
1532
1550
  clearInterval(typingInterval)
1533
1551
  typingInterval = null
1534
- discordLogger.log(`Cleared previous typing interval`)
1535
1552
  }
1536
1553
 
1537
1554
  // Send initial typing
@@ -1567,7 +1584,6 @@ async function handleOpencodeSession({
1567
1584
  if (typingInterval) {
1568
1585
  clearInterval(typingInterval)
1569
1586
  typingInterval = null
1570
- discordLogger.log(`Stopped typing for thread ${thread.id}`)
1571
1587
  }
1572
1588
  }
1573
1589
  }
@@ -1576,45 +1592,31 @@ async function handleOpencodeSession({
1576
1592
  let assistantMessageId: string | undefined
1577
1593
 
1578
1594
  for await (const event of events) {
1579
- sessionLogger.log(`Received: ${event.type}`)
1580
1595
  if (event.type === 'message.updated') {
1581
1596
  const msg = event.properties.info
1582
1597
 
1583
1598
  if (msg.sessionID !== session.id) {
1584
- voiceLogger.log(
1585
- `[EVENT IGNORED] Message from different session (expected: ${session.id}, got: ${msg.sessionID})`,
1586
- )
1587
1599
  continue
1588
1600
  }
1589
1601
 
1590
1602
  // Track assistant message ID
1591
1603
  if (msg.role === 'assistant') {
1592
1604
  assistantMessageId = msg.id
1593
-
1594
-
1595
1605
  usedModel = msg.modelID
1596
-
1597
- voiceLogger.log(
1598
- `[EVENT] Tracking assistant message ${assistantMessageId}`,
1599
- )
1600
- } else {
1601
- sessionLogger.log(`Message role: ${msg.role}`)
1606
+ usedProviderID = msg.providerID
1607
+ if (msg.tokens.input > 0) {
1608
+ inputTokens = msg.tokens.input
1609
+ }
1602
1610
  }
1603
1611
  } else if (event.type === 'message.part.updated') {
1604
1612
  const part = event.properties.part
1605
1613
 
1606
1614
  if (part.sessionID !== session.id) {
1607
- voiceLogger.log(
1608
- `[EVENT IGNORED] Part from different session (expected: ${session.id}, got: ${part.sessionID})`,
1609
- )
1610
1615
  continue
1611
1616
  }
1612
1617
 
1613
1618
  // Only process parts from assistant messages
1614
1619
  if (part.messageID !== assistantMessageId) {
1615
- voiceLogger.log(
1616
- `[EVENT IGNORED] Part from non-assistant message (expected: ${assistantMessageId}, got: ${part.messageID})`,
1617
- )
1618
1620
  continue
1619
1621
  }
1620
1622
 
@@ -1627,9 +1629,7 @@ async function handleOpencodeSession({
1627
1629
  currentParts.push(part)
1628
1630
  }
1629
1631
 
1630
- voiceLogger.log(
1631
- `[PART] Update: id=${part.id}, type=${part.type}, text=${'text' in part && typeof part.text === 'string' ? part.text.slice(0, 50) : ''}`,
1632
- )
1632
+
1633
1633
 
1634
1634
  // Start typing on step-start
1635
1635
  if (part.type === 'step-start') {
@@ -1638,10 +1638,12 @@ async function handleOpencodeSession({
1638
1638
 
1639
1639
  // Check if this is a step-finish part
1640
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
+ }
1641
1646
  // Send all parts accumulated so far to Discord
1642
- voiceLogger.log(
1643
- `[STEP-FINISH] Sending ${currentParts.length} parts to Discord`,
1644
- )
1645
1647
  for (const p of currentParts) {
1646
1648
  // Skip step-start and step-finish parts as they have no visual content
1647
1649
  if (p.type !== 'step-start' && p.type !== 'step-finish') {
@@ -1728,10 +1730,6 @@ async function handleOpencodeSession({
1728
1730
  if (pending && pending.permission.id === permissionID) {
1729
1731
  pendingPermissions.delete(thread.id)
1730
1732
  }
1731
- } else if (event.type === 'file.edited') {
1732
- sessionLogger.log(`File edited event received`)
1733
- } else {
1734
- sessionLogger.log(`Unhandled event type: ${event.type}`)
1735
1733
  }
1736
1734
  }
1737
1735
  } catch (e) {
@@ -1745,37 +1743,20 @@ async function handleOpencodeSession({
1745
1743
  throw e
1746
1744
  } finally {
1747
1745
  // Send any remaining parts that weren't sent
1748
- voiceLogger.log(
1749
- `[CLEANUP] Checking ${currentParts.length} parts for unsent messages`,
1750
- )
1751
- let unsentCount = 0
1752
1746
  for (const part of currentParts) {
1753
1747
  if (!partIdToMessage.has(part.id)) {
1754
- unsentCount++
1755
- voiceLogger.log(
1756
- `[CLEANUP] Sending unsent part: id=${part.id}, type=${part.type}`,
1757
- )
1758
1748
  try {
1759
1749
  await sendPartMessage(part)
1760
1750
  } catch (error) {
1761
- sessionLogger.log(
1762
- `Failed to send part ${part.id} during cleanup:`,
1763
- error,
1764
- )
1751
+ sessionLogger.error(`Failed to send part ${part.id}:`, error)
1765
1752
  }
1766
1753
  }
1767
1754
  }
1768
- if (unsentCount === 0) {
1769
- sessionLogger.log(`All parts were already sent`)
1770
- } else {
1771
- sessionLogger.log(`Sent ${unsentCount} previously unsent parts`)
1772
- }
1773
1755
 
1774
1756
  // Stop typing when session ends
1775
1757
  if (stopTyping) {
1776
1758
  stopTyping()
1777
1759
  stopTyping = null
1778
- sessionLogger.log(`Stopped typing for session`)
1779
1760
  }
1780
1761
 
1781
1762
  // Only send duration message if request was not aborted or was aborted with 'finished' reason
@@ -1788,8 +1769,22 @@ async function handleOpencodeSession({
1788
1769
  )
1789
1770
  const attachCommand = port ? ` ⋅ ${session.id}` : ''
1790
1771
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
1791
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}_${attachCommand}${modelInfo}`)
1792
- 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}`)
1793
1788
  } else {
1794
1789
  sessionLogger.log(
1795
1790
  `Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
@@ -1799,27 +1794,42 @@ async function handleOpencodeSession({
1799
1794
  }
1800
1795
 
1801
1796
  try {
1802
- voiceLogger.log(
1803
- `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
1804
- )
1805
- if (images.length > 0) {
1806
- sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
1807
- }
1808
-
1809
1797
  // Start the event handler
1810
1798
  const eventHandlerPromise = eventHandler()
1811
1799
 
1812
- const parts = [{ type: 'text' as const, text: prompt }, ...images]
1813
- 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
+ }
1814
1820
 
1815
- const response = await getClient().session.prompt({
1816
- path: { id: session.id },
1817
- body: {
1818
- parts,
1819
- system: OPENCODE_SYSTEM_MESSAGE,
1820
- },
1821
- signal: abortController.signal,
1822
- })
1821
+ const parts = [{ type: 'text' as const, text: prompt }, ...images]
1822
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
1823
+
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
+ }
1823
1833
  abortController.abort('finished')
1824
1834
 
1825
1835
  sessionLogger.log(`Successfully sent prompt, got response`)
@@ -1976,9 +1986,6 @@ export async function startDiscordBot({
1976
1986
  discordClient.on(Events.MessageCreate, async (message: Message) => {
1977
1987
  try {
1978
1988
  if (message.author?.bot) {
1979
- voiceLogger.log(
1980
- `[IGNORED] Bot message from ${message.author.tag} in channel ${message.channelId}`,
1981
- )
1982
1989
  return
1983
1990
  }
1984
1991
  if (message.partial) {
@@ -2002,15 +2009,8 @@ export async function startDiscordBot({
2002
2009
  )
2003
2010
 
2004
2011
  if (!isOwner && !isAdmin) {
2005
- voiceLogger.log(
2006
- `[IGNORED] Non-authoritative user ${message.author.tag} (ID: ${message.author.id}) - not owner or admin`,
2007
- )
2008
2012
  return
2009
2013
  }
2010
-
2011
- voiceLogger.log(
2012
- `[AUTHORIZED] Message from ${message.author.tag} (Owner: ${isOwner}, Admin: ${isAdmin})`,
2013
- )
2014
2014
  }
2015
2015
 
2016
2016
  const channel = message.channel
@@ -2084,12 +2084,14 @@ export async function startDiscordBot({
2084
2084
  }
2085
2085
 
2086
2086
  const images = getImageAttachments(message)
2087
+ const parsedCommand = parseSlashCommand(messageContent)
2087
2088
  await handleOpencodeSession({
2088
2089
  prompt: messageContent,
2089
2090
  thread,
2090
2091
  projectDirectory,
2091
2092
  originalMessage: message,
2092
2093
  images,
2094
+ parsedCommand,
2093
2095
  })
2094
2096
  return
2095
2097
  }
@@ -2179,12 +2181,14 @@ export async function startDiscordBot({
2179
2181
  }
2180
2182
 
2181
2183
  const images = getImageAttachments(message)
2184
+ const parsedCommand = parseSlashCommand(messageContent)
2182
2185
  await handleOpencodeSession({
2183
2186
  prompt: messageContent,
2184
2187
  thread,
2185
2188
  projectDirectory,
2186
2189
  originalMessage: message,
2187
2190
  images,
2191
+ parsedCommand,
2188
2192
  })
2189
2193
  } else {
2190
2194
  discordLogger.log(`Channel type ${channel.type} is not supported`)
@@ -2520,10 +2524,12 @@ export async function startDiscordBot({
2520
2524
  )
2521
2525
 
2522
2526
  // Start the OpenCode session
2527
+ const parsedCommand = parseSlashCommand(fullPrompt)
2523
2528
  await handleOpencodeSession({
2524
2529
  prompt: fullPrompt,
2525
2530
  thread,
2526
2531
  projectDirectory,
2532
+ parsedCommand,
2527
2533
  })
2528
2534
  } catch (error) {
2529
2535
  voiceLogger.error('[SESSION] Error:', error)
@@ -2891,6 +2897,77 @@ export async function startDiscordBot({
2891
2897
  ephemeral: true,
2892
2898
  })
2893
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
+ }
2894
2971
  }
2895
2972
  }
2896
2973
  } catch (error) {
@@ -0,0 +1,12 @@
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.
@@ -0,0 +1,22 @@
1
+ ---
2
+ description: Upload files to Discord thread
3
+ ---
4
+ Upload files to the current Discord thread by running:
5
+
6
+ ```bash
7
+ npx -y kimaki upload-to-discord --session <sessionId> <file1> [file2] [file3] ...
8
+ ```
9
+
10
+ Replace `<sessionId>` with your current OpenCode session ID (available in the system prompt).
11
+
12
+ Examples:
13
+
14
+ ```bash
15
+ # Upload a single file
16
+ npx -y kimaki upload-to-discord --session ses_abc123 ./screenshot.png
17
+
18
+ # Upload multiple files
19
+ npx -y kimaki upload-to-discord --session ses_abc123 ./image1.png ./image2.jpg ./document.pdf
20
+ ```
21
+
22
+ The session must have been sent to Discord first using `/send-to-kimaki-discord`.
package/src/tools.ts CHANGED
@@ -17,7 +17,7 @@ import { ShareMarkdown } from './markdown.js'
17
17
  import pc from 'picocolors'
18
18
  import {
19
19
  initializeOpencodeForDirectory,
20
- OPENCODE_SYSTEM_MESSAGE,
20
+ getOpencodeSystemMessage,
21
21
  } from './discordBot.js'
22
22
 
23
23
  export async function getTools({
@@ -78,7 +78,7 @@ export async function getTools({
78
78
  body: {
79
79
  parts: [{ type: 'text', text: message }],
80
80
  model: sessionModel,
81
- system: OPENCODE_SYSTEM_MESSAGE,
81
+ system: getOpencodeSystemMessage({ sessionId }),
82
82
  },
83
83
  })
84
84
  .then(async (response) => {
@@ -152,7 +152,7 @@ export async function getTools({
152
152
  path: { id: session.data.id },
153
153
  body: {
154
154
  parts: [{ type: 'text', text: message }],
155
- system: OPENCODE_SYSTEM_MESSAGE,
155
+ system: getOpencodeSystemMessage({ sessionId: session.data.id }),
156
156
  },
157
157
  })
158
158
  .then(async (response) => {
@@ -1,4 +0,0 @@
1
- ---
2
- description: Create Discord thread for current session
3
- ---
4
- Creating Discord thread for this session...