kimaki 0.4.2 → 0.4.6

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
@@ -3,6 +3,7 @@ import {
3
3
  type OpencodeClient,
4
4
  type Part,
5
5
  type Config,
6
+ type FilePartInput,
6
7
  } from '@opencode-ai/sdk'
7
8
 
8
9
  import { createGenAIWorker, type GenAIWorker } from './genai-worker-wrapper.js'
@@ -16,6 +17,7 @@ import {
16
17
  Partials,
17
18
  PermissionsBitField,
18
19
  ThreadAutoArchiveDuration,
20
+ type CategoryChannel,
19
21
  type Guild,
20
22
  type Interaction,
21
23
  type Message,
@@ -459,7 +461,6 @@ function frameMono16khz(): Transform {
459
461
 
460
462
  export function getDatabase(): Database.Database {
461
463
  if (!db) {
462
- // Create ~/.kimaki directory if it doesn't exist
463
464
  const kimakiDir = path.join(os.homedir(), '.kimaki')
464
465
 
465
466
  try {
@@ -473,7 +474,6 @@ export function getDatabase(): Database.Database {
473
474
  dbLogger.log(`Opening database at: ${dbPath}`)
474
475
  db = new Database(dbPath)
475
476
 
476
- // Initialize tables
477
477
  db.exec(`
478
478
  CREATE TABLE IF NOT EXISTS thread_sessions (
479
479
  thread_id TEXT PRIMARY KEY,
@@ -520,6 +520,76 @@ export function getDatabase(): Database.Database {
520
520
  return db
521
521
  }
522
522
 
523
+ export async function ensureKimakiCategory(guild: Guild): Promise<CategoryChannel> {
524
+ const existingCategory = guild.channels.cache.find(
525
+ (channel): channel is CategoryChannel => {
526
+ if (channel.type !== ChannelType.GuildCategory) {
527
+ return false
528
+ }
529
+
530
+ return channel.name.toLowerCase() === 'kimaki'
531
+ },
532
+ )
533
+
534
+ if (existingCategory) {
535
+ return existingCategory
536
+ }
537
+
538
+ return guild.channels.create({
539
+ name: 'Kimaki',
540
+ type: ChannelType.GuildCategory,
541
+ })
542
+ }
543
+
544
+ export async function createProjectChannels({
545
+ guild,
546
+ projectDirectory,
547
+ appId,
548
+ }: {
549
+ guild: Guild
550
+ projectDirectory: string
551
+ appId: string
552
+ }): Promise<{ textChannelId: string; voiceChannelId: string; channelName: string }> {
553
+ const baseName = path.basename(projectDirectory)
554
+ const channelName = `${baseName}`
555
+ .toLowerCase()
556
+ .replace(/[^a-z0-9-]/g, '-')
557
+ .slice(0, 100)
558
+
559
+ const kimakiCategory = await ensureKimakiCategory(guild)
560
+
561
+ const textChannel = await guild.channels.create({
562
+ name: channelName,
563
+ type: ChannelType.GuildText,
564
+ parent: kimakiCategory,
565
+ topic: `<kimaki><directory>${projectDirectory}</directory><app>${appId}</app></kimaki>`,
566
+ })
567
+
568
+ const voiceChannel = await guild.channels.create({
569
+ name: channelName,
570
+ type: ChannelType.GuildVoice,
571
+ parent: kimakiCategory,
572
+ })
573
+
574
+ getDatabase()
575
+ .prepare(
576
+ 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
577
+ )
578
+ .run(textChannel.id, projectDirectory, 'text')
579
+
580
+ getDatabase()
581
+ .prepare(
582
+ 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
583
+ )
584
+ .run(voiceChannel.id, projectDirectory, 'voice')
585
+
586
+ return {
587
+ textChannelId: textChannel.id,
588
+ voiceChannelId: voiceChannel.id,
589
+ channelName,
590
+ }
591
+ }
592
+
523
593
  async function getOpenPort(): Promise<number> {
524
594
  return new Promise((resolve, reject) => {
525
595
  const server = net.createServer()
@@ -550,6 +620,8 @@ async function sendThreadMessage(
550
620
  ): Promise<Message> {
551
621
  const MAX_LENGTH = 2000
552
622
 
623
+ content = escapeBackticksInCodeBlocks(content)
624
+
553
625
  // Simple case: content fits in one message
554
626
  if (content.length <= MAX_LENGTH) {
555
627
  return await thread.send(content)
@@ -742,6 +814,37 @@ async function processVoiceAttachment({
742
814
  return transcription
743
815
  }
744
816
 
817
+ function getImageAttachments(message: Message): FilePartInput[] {
818
+ const imageAttachments = Array.from(message.attachments.values()).filter(
819
+ (attachment) => attachment.contentType?.startsWith('image/'),
820
+ )
821
+
822
+ return imageAttachments.map((attachment) => ({
823
+ type: 'file' as const,
824
+ mime: attachment.contentType || 'image/png',
825
+ filename: attachment.name,
826
+ url: attachment.url,
827
+ }))
828
+ }
829
+
830
+ export function escapeBackticksInCodeBlocks(markdown: string): string {
831
+ const lexer = new Lexer()
832
+ const tokens = lexer.lex(markdown)
833
+
834
+ let result = ''
835
+
836
+ for (const token of tokens) {
837
+ if (token.type === 'code') {
838
+ const escapedCode = token.text.replace(/`/g, '\\`')
839
+ result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n'
840
+ } else {
841
+ result += token.raw
842
+ }
843
+ }
844
+
845
+ return result
846
+ }
847
+
745
848
  /**
746
849
  * Escape Discord formatting characters to prevent breaking code blocks and inline code
747
850
  */
@@ -945,129 +1048,146 @@ export async function initializeOpencodeForDirectory(directory: string) {
945
1048
  }
946
1049
  }
947
1050
 
1051
+ function getToolSummaryText(part: Part): string {
1052
+ if (part.type !== 'tool') return ''
1053
+ if (part.state.status !== 'completed' && part.state.status !== 'error') return ''
948
1054
 
949
- function formatPart(part: Part): string {
950
- switch (part.type) {
951
- case 'text':
952
- return escapeDiscordFormatting(part.text || '')
953
- case 'reasoning':
954
- if (!part.text?.trim()) return ''
955
- return `ā–ŖļøŽ thinking: ${escapeDiscordFormatting(part.text || '')}`
956
- case 'tool':
957
- if (part.state.status === 'completed' || part.state.status === 'error') {
958
- let outputToDisplay = ''
959
- let summaryText = ''
960
-
961
- if (part.tool === 'bash') {
962
- const output =
963
- part.state.status === 'completed'
964
- ? part.state.output
965
- : part.state.error
966
- const lines = (output || '').split('\n').filter((l) => l.trim())
967
- summaryText = `(${lines.length} line${lines.length === 1 ? '' : 's'})`
968
- } else if (part.tool === 'edit') {
969
- const newString = (part.state.input?.newString as string) || ''
970
- const oldString = (part.state.input?.oldString as string) || ''
971
- const added = newString.split('\n').length
972
- const removed = oldString.split('\n').length
973
- summaryText = `(+${added}-${removed})`
974
- } else if (part.tool === 'write') {
975
- const content = (part.state.input?.content as string) || ''
976
- const lines = content.split('\n').length
977
- summaryText = `(${lines} line${lines === 1 ? '' : 's'})`
978
- } else if (part.tool === 'read') {
979
- } else if (part.tool === 'write') {
980
- } else if (part.tool === 'edit') {
981
- } else if (part.tool === 'list') {
982
- } else if (part.tool === 'glob') {
983
- } else if (part.tool === 'grep') {
984
- } else if (part.tool === 'task') {
985
- } else if (part.tool === 'todoread') {
986
- // Special handling for read - don't show arguments
987
- } else if (part.tool === 'todowrite') {
988
- const todos =
989
- (part.state.input?.todos as {
990
- content: string
991
- status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
992
- }[]) || []
993
- outputToDisplay = todos
994
- .map((todo) => {
995
- let statusIcon = 'ā–¢'
996
- switch (todo.status) {
997
- case 'pending':
998
- statusIcon = 'ā–¢'
999
- break
1000
- case 'in_progress':
1001
- statusIcon = 'ā—'
1002
- break
1003
- case 'completed':
1004
- statusIcon = 'ā– '
1005
- break
1006
- case 'cancelled':
1007
- statusIcon = 'ā– '
1008
- break
1009
- }
1010
- return `\`${statusIcon}\` ${todo.content}`
1011
- })
1012
- .filter(Boolean)
1013
- .join('\n')
1014
- } else if (part.tool === 'webfetch') {
1015
- const url = (part.state.input?.url as string) || ''
1016
- const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
1017
- summaryText = urlWithoutProtocol ? `(${urlWithoutProtocol})` : ''
1018
- } else if (part.state.input) {
1019
- const inputFields = Object.entries(part.state.input)
1020
- .map(([key, value]) => {
1021
- if (value === null || value === undefined) return null
1022
- const stringValue =
1023
- typeof value === 'string' ? value : JSON.stringify(value)
1024
- const truncatedValue =
1025
- stringValue.length > 100
1026
- ? stringValue.slice(0, 100) + '…'
1027
- : stringValue
1028
- return `${key}: ${truncatedValue}`
1029
- })
1030
- .filter(Boolean)
1031
- if (inputFields.length > 0) {
1032
- outputToDisplay = inputFields.join(', ')
1033
- }
1034
- }
1055
+ if (part.tool === 'bash') {
1056
+ const output = part.state.status === 'completed' ? part.state.output : part.state.error
1057
+ const lines = (output || '').split('\n').filter((l: string) => l.trim())
1058
+ return `(${lines.length} line${lines.length === 1 ? '' : 's'})`
1059
+ }
1060
+
1061
+ if (part.tool === 'edit') {
1062
+ const newString = (part.state.input?.newString as string) || ''
1063
+ const oldString = (part.state.input?.oldString as string) || ''
1064
+ const added = newString.split('\n').length
1065
+ const removed = oldString.split('\n').length
1066
+ return `(+${added}-${removed})`
1067
+ }
1068
+
1069
+ if (part.tool === 'write') {
1070
+ const content = (part.state.input?.content as string) || ''
1071
+ const lines = content.split('\n').length
1072
+ return `(${lines} line${lines === 1 ? '' : 's'})`
1073
+ }
1074
+
1075
+ if (part.tool === 'webfetch') {
1076
+ const url = (part.state.input?.url as string) || ''
1077
+ const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
1078
+ return urlWithoutProtocol ? `(${urlWithoutProtocol})` : ''
1079
+ }
1035
1080
 
1036
- let toolTitle =
1037
- part.state.status === 'completed' ? part.state.title || '' : 'error'
1038
- if (toolTitle) {
1039
- toolTitle = `\`${escapeInlineCode(toolTitle)}\``
1081
+ if (
1082
+ part.tool === 'read' ||
1083
+ part.tool === 'list' ||
1084
+ part.tool === 'glob' ||
1085
+ part.tool === 'grep' ||
1086
+ part.tool === 'task' ||
1087
+ part.tool === 'todoread' ||
1088
+ part.tool === 'todowrite'
1089
+ ) {
1090
+ return ''
1091
+ }
1092
+
1093
+ if (!part.state.input) return ''
1094
+
1095
+ const inputFields = Object.entries(part.state.input)
1096
+ .map(([key, value]) => {
1097
+ if (value === null || value === undefined) return null
1098
+ const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
1099
+ const truncatedValue = stringValue.length > 100 ? stringValue.slice(0, 100) + '…' : stringValue
1100
+ return `${key}: ${truncatedValue}`
1101
+ })
1102
+ .filter(Boolean)
1103
+
1104
+ if (inputFields.length === 0) return ''
1105
+
1106
+ return `(${inputFields.join(', ')})`
1107
+ }
1108
+
1109
+ function getToolOutputToDisplay(part: Part): string {
1110
+ if (part.type !== 'tool') return ''
1111
+ if (part.state.status !== 'completed' && part.state.status !== 'error') return ''
1112
+
1113
+ if (part.state.status === 'error') {
1114
+ return part.state.error || 'Unknown error'
1115
+ }
1116
+
1117
+ if (part.tool === 'todowrite') {
1118
+ const todos =
1119
+ (part.state.input?.todos as {
1120
+ content: string
1121
+ status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
1122
+ }[]) || []
1123
+ return todos
1124
+ .map((todo) => {
1125
+ let statusIcon = 'ā–¢'
1126
+ if (todo.status === 'in_progress') {
1127
+ statusIcon = 'ā—'
1040
1128
  }
1041
- const icon =
1042
- part.state.status === 'completed'
1043
- ? 'ā—¼ļøŽ'
1044
- : part.state.status === 'error'
1045
- ? '⨯'
1046
- : ''
1047
- const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`
1048
-
1049
- let text = title
1050
-
1051
- if (outputToDisplay) {
1052
- text += '\n\n' + outputToDisplay
1129
+ if (todo.status === 'completed' || todo.status === 'cancelled') {
1130
+ statusIcon = 'ā– '
1053
1131
  }
1054
- return text
1055
- }
1056
- return ''
1057
- case 'file':
1058
- return `šŸ“„ ${part.filename || 'File'}`
1059
- case 'step-start':
1060
- case 'step-finish':
1061
- case 'patch':
1062
- return ''
1063
- case 'agent':
1064
- return `ā—¼ļøŽ agent ${part.id}`
1065
- case 'snapshot':
1066
- return `ā—¼ļøŽ snapshot ${part.snapshot}`
1067
- default:
1068
- discordLogger.warn('Unknown part type:', part)
1132
+ return `\`${statusIcon}\` ${todo.content}`
1133
+ })
1134
+ .filter(Boolean)
1135
+ .join('\n')
1136
+ }
1137
+
1138
+ return ''
1139
+ }
1140
+
1141
+ function formatPart(part: Part): string {
1142
+ if (part.type === 'text') {
1143
+ return part.text || ''
1144
+ }
1145
+
1146
+ if (part.type === 'reasoning') {
1147
+ if (!part.text?.trim()) return ''
1148
+ return `ā—¼ļøŽ thinking`
1149
+ }
1150
+
1151
+ if (part.type === 'file') {
1152
+ return `šŸ“„ ${part.filename || 'File'}`
1153
+ }
1154
+
1155
+ if (part.type === 'step-start' || part.type === 'step-finish' || part.type === 'patch') {
1156
+ return ''
1157
+ }
1158
+
1159
+ if (part.type === 'agent') {
1160
+ return `ā—¼ļøŽ agent ${part.id}`
1161
+ }
1162
+
1163
+ if (part.type === 'snapshot') {
1164
+ return `ā—¼ļøŽ snapshot ${part.snapshot}`
1165
+ }
1166
+
1167
+ if (part.type === 'tool') {
1168
+ if (part.state.status !== 'completed' && part.state.status !== 'error') {
1069
1169
  return ''
1170
+ }
1171
+
1172
+ const summaryText = getToolSummaryText(part)
1173
+ const outputToDisplay = getToolOutputToDisplay(part)
1174
+
1175
+ let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error'
1176
+ if (toolTitle) {
1177
+ toolTitle = `*${toolTitle}*`
1178
+ }
1179
+
1180
+ const icon = part.state.status === 'completed' ? 'ā—¼ļøŽ' : part.state.status === 'error' ? '⨯' : ''
1181
+ const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`
1182
+
1183
+ if (outputToDisplay) {
1184
+ return title + '\n\n' + outputToDisplay
1185
+ }
1186
+ return title
1070
1187
  }
1188
+
1189
+ discordLogger.warn('Unknown part type:', part)
1190
+ return ''
1071
1191
  }
1072
1192
 
1073
1193
  export async function createDiscordClient() {
@@ -1087,12 +1207,19 @@ export async function createDiscordClient() {
1087
1207
  })
1088
1208
  }
1089
1209
 
1090
- async function handleOpencodeSession(
1091
- prompt: string,
1092
- thread: ThreadChannel,
1093
- projectDirectory?: string,
1094
- originalMessage?: Message,
1095
- ) {
1210
+ async function handleOpencodeSession({
1211
+ prompt,
1212
+ thread,
1213
+ projectDirectory,
1214
+ originalMessage,
1215
+ images = [],
1216
+ }: {
1217
+ prompt: string
1218
+ thread: ThreadChannel
1219
+ projectDirectory?: string
1220
+ originalMessage?: Message
1221
+ images?: FilePartInput[]
1222
+ }): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
1096
1223
  voiceLogger.log(
1097
1224
  `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
1098
1225
  )
@@ -1118,6 +1245,10 @@ async function handleOpencodeSession(
1118
1245
 
1119
1246
  const getClient = await initializeOpencodeForDirectory(directory)
1120
1247
 
1248
+ // Get the port for this directory
1249
+ const serverEntry = opencodeServers.get(directory)
1250
+ const port = serverEntry?.port
1251
+
1121
1252
  // Get session ID from database
1122
1253
  const row = getDatabase()
1123
1254
  .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
@@ -1209,6 +1340,7 @@ async function handleOpencodeSession(
1209
1340
 
1210
1341
  let currentParts: Part[] = []
1211
1342
  let stopTyping: (() => void) | null = null
1343
+ let usedModel: string | undefined
1212
1344
 
1213
1345
  const sendPartMessage = async (part: Part) => {
1214
1346
  const content = formatPart(part) + '\n\n'
@@ -1322,6 +1454,10 @@ async function handleOpencodeSession(
1322
1454
  // Track assistant message ID
1323
1455
  if (msg.role === 'assistant') {
1324
1456
  assistantMessageId = msg.id
1457
+
1458
+
1459
+ usedModel = msg.modelID
1460
+
1325
1461
  voiceLogger.log(
1326
1462
  `[EVENT] Tracking assistant message ${assistantMessageId}`,
1327
1463
  )
@@ -1469,8 +1605,10 @@ async function handleOpencodeSession(
1469
1605
  const sessionDuration = prettyMilliseconds(
1470
1606
  Date.now() - sessionStartTime,
1471
1607
  )
1472
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}_`)
1473
- sessionLogger.log(`DURATION: Session completed in ${sessionDuration}`)
1608
+ const attachCommand = port ? ` ā‹… ${session.id}` : ''
1609
+ const modelInfo = usedModel ? ` ā‹… ${usedModel}` : ''
1610
+ await sendThreadMessage(thread, `_Completed in ${sessionDuration}_${attachCommand}${modelInfo}`)
1611
+ sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}`)
1474
1612
  } else {
1475
1613
  sessionLogger.log(
1476
1614
  `Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
@@ -1483,23 +1621,27 @@ async function handleOpencodeSession(
1483
1621
  voiceLogger.log(
1484
1622
  `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
1485
1623
  )
1624
+ if (images.length > 0) {
1625
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
1626
+ }
1486
1627
 
1487
1628
  // Start the event handler
1488
1629
  const eventHandlerPromise = eventHandler()
1489
1630
 
1631
+ const parts = [{ type: 'text' as const, text: prompt }, ...images]
1632
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
1633
+
1490
1634
  const response = await getClient().session.prompt({
1491
1635
  path: { id: session.id },
1492
1636
  body: {
1493
- parts: [{ type: 'text', text: prompt }],
1637
+ parts,
1494
1638
  },
1495
1639
  signal: abortController.signal,
1496
1640
  })
1497
- abortController.abort(new Error('finished'))
1641
+ abortController.abort('finished')
1498
1642
 
1499
1643
  sessionLogger.log(`Successfully sent prompt, got response`)
1500
1644
 
1501
- abortControllers.delete(session.id)
1502
-
1503
1645
  // Update reaction to success
1504
1646
  if (originalMessage) {
1505
1647
  try {
@@ -1511,12 +1653,12 @@ async function handleOpencodeSession(
1511
1653
  }
1512
1654
  }
1513
1655
 
1514
- return { sessionID: session.id, result: response.data }
1656
+ return { sessionID: session.id, result: response.data, port }
1515
1657
  } catch (error) {
1516
1658
  sessionLogger.error(`ERROR: Failed to send prompt:`, error)
1517
1659
 
1518
1660
  if (!isAbortError(error, abortController.signal)) {
1519
- abortController.abort(new Error('error'))
1661
+ abortController.abort('error')
1520
1662
 
1521
1663
  if (originalMessage) {
1522
1664
  try {
@@ -1527,7 +1669,6 @@ async function handleOpencodeSession(
1527
1669
  discordLogger.log(`Could not update reaction:`, e)
1528
1670
  }
1529
1671
  }
1530
- // Always log the error's constructor name (if any) and make error reporting more readable
1531
1672
  const errorName =
1532
1673
  error &&
1533
1674
  typeof error === 'object' &&
@@ -1760,12 +1901,14 @@ export async function startDiscordBot({
1760
1901
  messageContent = transcription
1761
1902
  }
1762
1903
 
1763
- await handleOpencodeSession(
1764
- messageContent,
1904
+ const images = getImageAttachments(message)
1905
+ await handleOpencodeSession({
1906
+ prompt: messageContent,
1765
1907
  thread,
1766
1908
  projectDirectory,
1767
- message,
1768
- )
1909
+ originalMessage: message,
1910
+ images,
1911
+ })
1769
1912
  return
1770
1913
  }
1771
1914
 
@@ -1853,12 +1996,14 @@ export async function startDiscordBot({
1853
1996
  messageContent = transcription
1854
1997
  }
1855
1998
 
1856
- await handleOpencodeSession(
1857
- messageContent,
1999
+ const images = getImageAttachments(message)
2000
+ await handleOpencodeSession({
2001
+ prompt: messageContent,
1858
2002
  thread,
1859
2003
  projectDirectory,
1860
- message,
1861
- )
2004
+ originalMessage: message,
2005
+ images,
2006
+ })
1862
2007
  } else {
1863
2008
  discordLogger.log(`Channel type ${channel.type} is not supported`)
1864
2009
  }
@@ -1928,10 +2073,24 @@ export async function startDiscordBot({
1928
2073
  .includes(focusedValue.toLowerCase()),
1929
2074
  )
1930
2075
  .slice(0, 25) // Discord limit
1931
- .map((session) => ({
1932
- name: `${session.title} (${new Date(session.time.updated).toLocaleString()})`,
1933
- value: session.id,
1934
- }))
2076
+ .map((session) => {
2077
+ const dateStr = new Date(
2078
+ session.time.updated,
2079
+ ).toLocaleString()
2080
+ const suffix = ` (${dateStr})`
2081
+ // Discord limit is 100 chars. Reserve space for suffix.
2082
+ const maxTitleLength = 100 - suffix.length
2083
+
2084
+ let title = session.title
2085
+ if (title.length > maxTitleLength) {
2086
+ title = title.slice(0, Math.max(0, maxTitleLength - 1)) + '…'
2087
+ }
2088
+
2089
+ return {
2090
+ name: `${title}${suffix}`,
2091
+ value: session.id,
2092
+ }
2093
+ })
1935
2094
 
1936
2095
  await interaction.respond(sessions)
1937
2096
  } catch (error) {
@@ -2003,7 +2162,6 @@ export async function startDiscordBot({
2003
2162
 
2004
2163
  // Map to Discord autocomplete format
2005
2164
  const choices = files
2006
- .slice(0, 25) // Discord limit
2007
2165
  .map((file: string) => {
2008
2166
  const fullValue = prefix + file
2009
2167
  // Get all basenames for display
@@ -2021,6 +2179,10 @@ export async function startDiscordBot({
2021
2179
  value: fullValue,
2022
2180
  }
2023
2181
  })
2182
+ // Discord API limits choice value to 100 characters
2183
+ .filter((choice) => choice.value.length <= 100)
2184
+ .slice(0, 25) // Discord limit
2185
+
2024
2186
 
2025
2187
  await interaction.respond(choices)
2026
2188
  } catch (error) {
@@ -2028,6 +2190,61 @@ export async function startDiscordBot({
2028
2190
  await interaction.respond([])
2029
2191
  }
2030
2192
  }
2193
+ } else if (interaction.commandName === 'add-project') {
2194
+ const focusedValue = interaction.options.getFocused()
2195
+
2196
+ try {
2197
+ const currentDir = process.cwd()
2198
+ const getClient = await initializeOpencodeForDirectory(currentDir)
2199
+
2200
+ const projectsResponse = await getClient().project.list({})
2201
+ if (!projectsResponse.data) {
2202
+ await interaction.respond([])
2203
+ return
2204
+ }
2205
+
2206
+ const db = getDatabase()
2207
+ const existingDirs = db
2208
+ .prepare(
2209
+ 'SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?',
2210
+ )
2211
+ .all('text') as { directory: string }[]
2212
+ const existingDirSet = new Set(
2213
+ existingDirs.map((row) => row.directory),
2214
+ )
2215
+
2216
+ const availableProjects = projectsResponse.data.filter(
2217
+ (project) => !existingDirSet.has(project.worktree),
2218
+ )
2219
+
2220
+ const projects = availableProjects
2221
+ .filter((project) => {
2222
+ const baseName = path.basename(project.worktree)
2223
+ const searchText = `${baseName} ${project.worktree}`.toLowerCase()
2224
+ return searchText.includes(focusedValue.toLowerCase())
2225
+ })
2226
+ .sort((a, b) => {
2227
+ const aTime = a.time.initialized || a.time.created
2228
+ const bTime = b.time.initialized || b.time.created
2229
+ return bTime - aTime
2230
+ })
2231
+ .slice(0, 25)
2232
+ .map((project) => {
2233
+ const name = `${path.basename(project.worktree)} (${project.worktree})`
2234
+ return {
2235
+ name: name.length > 100 ? name.slice(0, 99) + '…' : name,
2236
+ value: project.id,
2237
+ }
2238
+ })
2239
+
2240
+ await interaction.respond(projects)
2241
+ } catch (error) {
2242
+ voiceLogger.error(
2243
+ '[AUTOCOMPLETE] Error fetching projects:',
2244
+ error,
2245
+ )
2246
+ await interaction.respond([])
2247
+ }
2031
2248
  }
2032
2249
  }
2033
2250
 
@@ -2121,7 +2338,11 @@ export async function startDiscordBot({
2121
2338
  )
2122
2339
 
2123
2340
  // Start the OpenCode session
2124
- await handleOpencodeSession(fullPrompt, thread, projectDirectory)
2341
+ await handleOpencodeSession({
2342
+ prompt: fullPrompt,
2343
+ thread,
2344
+ projectDirectory,
2345
+ })
2125
2346
  } catch (error) {
2126
2347
  voiceLogger.error('[SESSION] Error:', error)
2127
2348
  await command.editReply(
@@ -2260,22 +2481,39 @@ export async function startDiscordBot({
2260
2481
  }
2261
2482
  } else if (message.info.role === 'assistant') {
2262
2483
  // Render assistant parts
2484
+ const partsToRender: { id: string; content: string }[] = []
2485
+
2263
2486
  for (const part of message.parts) {
2264
2487
  const content = formatPart(part)
2265
2488
  if (content.trim()) {
2266
- const discordMessage = await sendThreadMessage(
2267
- thread,
2268
- content,
2269
- )
2270
-
2271
- // Store part-message mapping in database
2272
- getDatabase()
2273
- .prepare(
2274
- 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
2275
- )
2276
- .run(part.id, discordMessage.id, thread.id)
2489
+ partsToRender.push({ id: part.id, content })
2277
2490
  }
2278
2491
  }
2492
+
2493
+ if (partsToRender.length > 0) {
2494
+ const combinedContent = partsToRender
2495
+ .map((p) => p.content)
2496
+ .join('\n\n')
2497
+
2498
+ const discordMessage = await sendThreadMessage(
2499
+ thread,
2500
+ combinedContent,
2501
+ )
2502
+
2503
+ const stmt = getDatabase().prepare(
2504
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
2505
+ )
2506
+
2507
+ const transaction = getDatabase().transaction(
2508
+ (parts: { id: string }[]) => {
2509
+ for (const part of parts) {
2510
+ stmt.run(part.id, discordMessage.id, thread.id)
2511
+ }
2512
+ },
2513
+ )
2514
+
2515
+ transaction(partsToRender)
2516
+ }
2279
2517
  }
2280
2518
  messageCount++
2281
2519
  }
@@ -2290,6 +2528,77 @@ export async function startDiscordBot({
2290
2528
  `Failed to resume session: ${error instanceof Error ? error.message : 'Unknown error'}`,
2291
2529
  )
2292
2530
  }
2531
+ } else if (command.commandName === 'add-project') {
2532
+ await command.deferReply({ ephemeral: false })
2533
+
2534
+ const projectId = command.options.getString('project', true)
2535
+ const guild = command.guild
2536
+
2537
+ if (!guild) {
2538
+ await command.editReply('This command can only be used in a guild')
2539
+ return
2540
+ }
2541
+
2542
+ try {
2543
+ const currentDir = process.cwd()
2544
+ const getClient = await initializeOpencodeForDirectory(currentDir)
2545
+
2546
+ const projectsResponse = await getClient().project.list({})
2547
+ if (!projectsResponse.data) {
2548
+ await command.editReply('Failed to fetch projects')
2549
+ return
2550
+ }
2551
+
2552
+ const project = projectsResponse.data.find(
2553
+ (p) => p.id === projectId,
2554
+ )
2555
+
2556
+ if (!project) {
2557
+ await command.editReply('Project not found')
2558
+ return
2559
+ }
2560
+
2561
+ const directory = project.worktree
2562
+
2563
+ if (!fs.existsSync(directory)) {
2564
+ await command.editReply(`Directory does not exist: ${directory}`)
2565
+ return
2566
+ }
2567
+
2568
+ const db = getDatabase()
2569
+ const existingChannel = db
2570
+ .prepare(
2571
+ 'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
2572
+ )
2573
+ .get(directory, 'text') as { channel_id: string } | undefined
2574
+
2575
+ if (existingChannel) {
2576
+ await command.editReply(
2577
+ `A channel already exists for this directory: <#${existingChannel.channel_id}>`,
2578
+ )
2579
+ return
2580
+ }
2581
+
2582
+ const { textChannelId, voiceChannelId, channelName } =
2583
+ await createProjectChannels({
2584
+ guild,
2585
+ projectDirectory: directory,
2586
+ appId: currentAppId!,
2587
+ })
2588
+
2589
+ await command.editReply(
2590
+ `āœ… Created channels for project:\nšŸ“ Text: <#${textChannelId}>\nšŸ”Š Voice: <#${voiceChannelId}>\nšŸ“ Directory: \`${directory}\``,
2591
+ )
2592
+
2593
+ discordLogger.log(
2594
+ `Created channels for project ${channelName} at ${directory}`,
2595
+ )
2596
+ } catch (error) {
2597
+ voiceLogger.error('[ADD-PROJECT] Error:', error)
2598
+ await command.editReply(
2599
+ `Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
2600
+ )
2601
+ }
2293
2602
  }
2294
2603
  }
2295
2604
  } catch (error) {