kimaki 0.4.2 → 0.4.4

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
@@ -16,6 +16,7 @@ import {
16
16
  Partials,
17
17
  PermissionsBitField,
18
18
  ThreadAutoArchiveDuration,
19
+ type CategoryChannel,
19
20
  type Guild,
20
21
  type Interaction,
21
22
  type Message,
@@ -459,7 +460,6 @@ function frameMono16khz(): Transform {
459
460
 
460
461
  export function getDatabase(): Database.Database {
461
462
  if (!db) {
462
- // Create ~/.kimaki directory if it doesn't exist
463
463
  const kimakiDir = path.join(os.homedir(), '.kimaki')
464
464
 
465
465
  try {
@@ -473,7 +473,6 @@ export function getDatabase(): Database.Database {
473
473
  dbLogger.log(`Opening database at: ${dbPath}`)
474
474
  db = new Database(dbPath)
475
475
 
476
- // Initialize tables
477
476
  db.exec(`
478
477
  CREATE TABLE IF NOT EXISTS thread_sessions (
479
478
  thread_id TEXT PRIMARY KEY,
@@ -520,6 +519,76 @@ export function getDatabase(): Database.Database {
520
519
  return db
521
520
  }
522
521
 
522
+ export async function ensureKimakiCategory(guild: Guild): Promise<CategoryChannel> {
523
+ const existingCategory = guild.channels.cache.find(
524
+ (channel): channel is CategoryChannel => {
525
+ if (channel.type !== ChannelType.GuildCategory) {
526
+ return false
527
+ }
528
+
529
+ return channel.name.toLowerCase() === 'kimaki'
530
+ },
531
+ )
532
+
533
+ if (existingCategory) {
534
+ return existingCategory
535
+ }
536
+
537
+ return guild.channels.create({
538
+ name: 'Kimaki',
539
+ type: ChannelType.GuildCategory,
540
+ })
541
+ }
542
+
543
+ export async function createProjectChannels({
544
+ guild,
545
+ projectDirectory,
546
+ appId,
547
+ }: {
548
+ guild: Guild
549
+ projectDirectory: string
550
+ appId: string
551
+ }): Promise<{ textChannelId: string; voiceChannelId: string; channelName: string }> {
552
+ const baseName = path.basename(projectDirectory)
553
+ const channelName = `${baseName}`
554
+ .toLowerCase()
555
+ .replace(/[^a-z0-9-]/g, '-')
556
+ .slice(0, 100)
557
+
558
+ const kimakiCategory = await ensureKimakiCategory(guild)
559
+
560
+ const textChannel = await guild.channels.create({
561
+ name: channelName,
562
+ type: ChannelType.GuildText,
563
+ parent: kimakiCategory,
564
+ topic: `<kimaki><directory>${projectDirectory}</directory><app>${appId}</app></kimaki>`,
565
+ })
566
+
567
+ const voiceChannel = await guild.channels.create({
568
+ name: channelName,
569
+ type: ChannelType.GuildVoice,
570
+ parent: kimakiCategory,
571
+ })
572
+
573
+ getDatabase()
574
+ .prepare(
575
+ 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
576
+ )
577
+ .run(textChannel.id, projectDirectory, 'text')
578
+
579
+ getDatabase()
580
+ .prepare(
581
+ 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
582
+ )
583
+ .run(voiceChannel.id, projectDirectory, 'voice')
584
+
585
+ return {
586
+ textChannelId: textChannel.id,
587
+ voiceChannelId: voiceChannel.id,
588
+ channelName,
589
+ }
590
+ }
591
+
523
592
  async function getOpenPort(): Promise<number> {
524
593
  return new Promise((resolve, reject) => {
525
594
  const server = net.createServer()
@@ -550,6 +619,8 @@ async function sendThreadMessage(
550
619
  ): Promise<Message> {
551
620
  const MAX_LENGTH = 2000
552
621
 
622
+ content = escapeBackticksInCodeBlocks(content)
623
+
553
624
  // Simple case: content fits in one message
554
625
  if (content.length <= MAX_LENGTH) {
555
626
  return await thread.send(content)
@@ -742,6 +813,24 @@ async function processVoiceAttachment({
742
813
  return transcription
743
814
  }
744
815
 
816
+ export function escapeBackticksInCodeBlocks(markdown: string): string {
817
+ const lexer = new Lexer()
818
+ const tokens = lexer.lex(markdown)
819
+
820
+ let result = ''
821
+
822
+ for (const token of tokens) {
823
+ if (token.type === 'code') {
824
+ const escapedCode = token.text.replace(/`/g, '\\`')
825
+ result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n'
826
+ } else {
827
+ result += token.raw
828
+ }
829
+ }
830
+
831
+ return result
832
+ }
833
+
745
834
  /**
746
835
  * Escape Discord formatting characters to prevent breaking code blocks and inline code
747
836
  */
@@ -945,129 +1034,146 @@ export async function initializeOpencodeForDirectory(directory: string) {
945
1034
  }
946
1035
  }
947
1036
 
1037
+ function getToolSummaryText(part: Part): string {
1038
+ if (part.type !== 'tool') return ''
1039
+ if (part.state.status !== 'completed' && part.state.status !== 'error') return ''
948
1040
 
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
- }
1041
+ if (part.tool === 'bash') {
1042
+ const output = part.state.status === 'completed' ? part.state.output : part.state.error
1043
+ const lines = (output || '').split('\n').filter((l: string) => l.trim())
1044
+ return `(${lines.length} line${lines.length === 1 ? '' : 's'})`
1045
+ }
1046
+
1047
+ if (part.tool === 'edit') {
1048
+ const newString = (part.state.input?.newString as string) || ''
1049
+ const oldString = (part.state.input?.oldString as string) || ''
1050
+ const added = newString.split('\n').length
1051
+ const removed = oldString.split('\n').length
1052
+ return `(+${added}-${removed})`
1053
+ }
1054
+
1055
+ if (part.tool === 'write') {
1056
+ const content = (part.state.input?.content as string) || ''
1057
+ const lines = content.split('\n').length
1058
+ return `(${lines} line${lines === 1 ? '' : 's'})`
1059
+ }
1060
+
1061
+ if (part.tool === 'webfetch') {
1062
+ const url = (part.state.input?.url as string) || ''
1063
+ const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
1064
+ return urlWithoutProtocol ? `(${urlWithoutProtocol})` : ''
1065
+ }
1066
+
1067
+ if (
1068
+ part.tool === 'read' ||
1069
+ part.tool === 'list' ||
1070
+ part.tool === 'glob' ||
1071
+ part.tool === 'grep' ||
1072
+ part.tool === 'task' ||
1073
+ part.tool === 'todoread' ||
1074
+ part.tool === 'todowrite'
1075
+ ) {
1076
+ return ''
1077
+ }
1078
+
1079
+ if (!part.state.input) return ''
1080
+
1081
+ const inputFields = Object.entries(part.state.input)
1082
+ .map(([key, value]) => {
1083
+ if (value === null || value === undefined) return null
1084
+ const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
1085
+ const truncatedValue = stringValue.length > 100 ? stringValue.slice(0, 100) + '…' : stringValue
1086
+ return `${key}: ${truncatedValue}`
1087
+ })
1088
+ .filter(Boolean)
1089
+
1090
+ if (inputFields.length === 0) return ''
1091
+
1092
+ return `(${inputFields.join(', ')})`
1093
+ }
1094
+
1095
+ function getToolOutputToDisplay(part: Part): string {
1096
+ if (part.type !== 'tool') return ''
1097
+ if (part.state.status !== 'completed' && part.state.status !== 'error') return ''
1035
1098
 
1036
- let toolTitle =
1037
- part.state.status === 'completed' ? part.state.title || '' : 'error'
1038
- if (toolTitle) {
1039
- toolTitle = `\`${escapeInlineCode(toolTitle)}\``
1099
+ if (part.state.status === 'error') {
1100
+ return part.state.error || 'Unknown error'
1101
+ }
1102
+
1103
+ if (part.tool === 'todowrite') {
1104
+ const todos =
1105
+ (part.state.input?.todos as {
1106
+ content: string
1107
+ status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
1108
+ }[]) || []
1109
+ return todos
1110
+ .map((todo) => {
1111
+ let statusIcon = '▢'
1112
+ if (todo.status === 'in_progress') {
1113
+ statusIcon = '●'
1040
1114
  }
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
1115
+ if (todo.status === 'completed' || todo.status === 'cancelled') {
1116
+ statusIcon = ''
1053
1117
  }
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)
1118
+ return `\`${statusIcon}\` ${todo.content}`
1119
+ })
1120
+ .filter(Boolean)
1121
+ .join('\n')
1122
+ }
1123
+
1124
+ return ''
1125
+ }
1126
+
1127
+ function formatPart(part: Part): string {
1128
+ if (part.type === 'text') {
1129
+ return part.text || ''
1130
+ }
1131
+
1132
+ if (part.type === 'reasoning') {
1133
+ if (!part.text?.trim()) return ''
1134
+ return `◼︎ thinking`
1135
+ }
1136
+
1137
+ if (part.type === 'file') {
1138
+ return `📄 ${part.filename || 'File'}`
1139
+ }
1140
+
1141
+ if (part.type === 'step-start' || part.type === 'step-finish' || part.type === 'patch') {
1142
+ return ''
1143
+ }
1144
+
1145
+ if (part.type === 'agent') {
1146
+ return `◼︎ agent ${part.id}`
1147
+ }
1148
+
1149
+ if (part.type === 'snapshot') {
1150
+ return `◼︎ snapshot ${part.snapshot}`
1151
+ }
1152
+
1153
+ if (part.type === 'tool') {
1154
+ if (part.state.status !== 'completed' && part.state.status !== 'error') {
1069
1155
  return ''
1156
+ }
1157
+
1158
+ const summaryText = getToolSummaryText(part)
1159
+ const outputToDisplay = getToolOutputToDisplay(part)
1160
+
1161
+ let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error'
1162
+ if (toolTitle) {
1163
+ toolTitle = `\`${escapeInlineCode(toolTitle)}\``
1164
+ }
1165
+
1166
+ const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : ''
1167
+ const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`
1168
+
1169
+ if (outputToDisplay) {
1170
+ return title + '\n\n' + outputToDisplay
1171
+ }
1172
+ return title
1070
1173
  }
1174
+
1175
+ discordLogger.warn('Unknown part type:', part)
1176
+ return ''
1071
1177
  }
1072
1178
 
1073
1179
  export async function createDiscordClient() {
@@ -1092,7 +1198,7 @@ async function handleOpencodeSession(
1092
1198
  thread: ThreadChannel,
1093
1199
  projectDirectory?: string,
1094
1200
  originalMessage?: Message,
1095
- ) {
1201
+ ): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
1096
1202
  voiceLogger.log(
1097
1203
  `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
1098
1204
  )
@@ -1118,6 +1224,10 @@ async function handleOpencodeSession(
1118
1224
 
1119
1225
  const getClient = await initializeOpencodeForDirectory(directory)
1120
1226
 
1227
+ // Get the port for this directory
1228
+ const serverEntry = opencodeServers.get(directory)
1229
+ const port = serverEntry?.port
1230
+
1121
1231
  // Get session ID from database
1122
1232
  const row = getDatabase()
1123
1233
  .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
@@ -1209,6 +1319,7 @@ async function handleOpencodeSession(
1209
1319
 
1210
1320
  let currentParts: Part[] = []
1211
1321
  let stopTyping: (() => void) | null = null
1322
+ let usedModel: string | undefined
1212
1323
 
1213
1324
  const sendPartMessage = async (part: Part) => {
1214
1325
  const content = formatPart(part) + '\n\n'
@@ -1322,6 +1433,10 @@ async function handleOpencodeSession(
1322
1433
  // Track assistant message ID
1323
1434
  if (msg.role === 'assistant') {
1324
1435
  assistantMessageId = msg.id
1436
+
1437
+
1438
+ usedModel = msg.modelID
1439
+
1325
1440
  voiceLogger.log(
1326
1441
  `[EVENT] Tracking assistant message ${assistantMessageId}`,
1327
1442
  )
@@ -1469,8 +1584,10 @@ async function handleOpencodeSession(
1469
1584
  const sessionDuration = prettyMilliseconds(
1470
1585
  Date.now() - sessionStartTime,
1471
1586
  )
1472
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}_`)
1473
- sessionLogger.log(`DURATION: Session completed in ${sessionDuration}`)
1587
+ const attachCommand = port ? ` ${session.id}` : ''
1588
+ const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
1589
+ await sendThreadMessage(thread, `_Completed in ${sessionDuration}_${attachCommand}${modelInfo}`)
1590
+ sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}`)
1474
1591
  } else {
1475
1592
  sessionLogger.log(
1476
1593
  `Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
@@ -1494,12 +1611,10 @@ async function handleOpencodeSession(
1494
1611
  },
1495
1612
  signal: abortController.signal,
1496
1613
  })
1497
- abortController.abort(new Error('finished'))
1614
+ abortController.abort('finished')
1498
1615
 
1499
1616
  sessionLogger.log(`Successfully sent prompt, got response`)
1500
1617
 
1501
- abortControllers.delete(session.id)
1502
-
1503
1618
  // Update reaction to success
1504
1619
  if (originalMessage) {
1505
1620
  try {
@@ -1511,12 +1626,12 @@ async function handleOpencodeSession(
1511
1626
  }
1512
1627
  }
1513
1628
 
1514
- return { sessionID: session.id, result: response.data }
1629
+ return { sessionID: session.id, result: response.data, port }
1515
1630
  } catch (error) {
1516
1631
  sessionLogger.error(`ERROR: Failed to send prompt:`, error)
1517
1632
 
1518
1633
  if (!isAbortError(error, abortController.signal)) {
1519
- abortController.abort(new Error('error'))
1634
+ abortController.abort('error')
1520
1635
 
1521
1636
  if (originalMessage) {
1522
1637
  try {
@@ -1527,7 +1642,6 @@ async function handleOpencodeSession(
1527
1642
  discordLogger.log(`Could not update reaction:`, e)
1528
1643
  }
1529
1644
  }
1530
- // Always log the error's constructor name (if any) and make error reporting more readable
1531
1645
  const errorName =
1532
1646
  error &&
1533
1647
  typeof error === 'object' &&
@@ -1928,10 +2042,24 @@ export async function startDiscordBot({
1928
2042
  .includes(focusedValue.toLowerCase()),
1929
2043
  )
1930
2044
  .slice(0, 25) // Discord limit
1931
- .map((session) => ({
1932
- name: `${session.title} (${new Date(session.time.updated).toLocaleString()})`,
1933
- value: session.id,
1934
- }))
2045
+ .map((session) => {
2046
+ const dateStr = new Date(
2047
+ session.time.updated,
2048
+ ).toLocaleString()
2049
+ const suffix = ` (${dateStr})`
2050
+ // Discord limit is 100 chars. Reserve space for suffix.
2051
+ const maxTitleLength = 100 - suffix.length
2052
+
2053
+ let title = session.title
2054
+ if (title.length > maxTitleLength) {
2055
+ title = title.slice(0, Math.max(0, maxTitleLength - 1)) + '…'
2056
+ }
2057
+
2058
+ return {
2059
+ name: `${title}${suffix}`,
2060
+ value: session.id,
2061
+ }
2062
+ })
1935
2063
 
1936
2064
  await interaction.respond(sessions)
1937
2065
  } catch (error) {
@@ -2003,7 +2131,6 @@ export async function startDiscordBot({
2003
2131
 
2004
2132
  // Map to Discord autocomplete format
2005
2133
  const choices = files
2006
- .slice(0, 25) // Discord limit
2007
2134
  .map((file: string) => {
2008
2135
  const fullValue = prefix + file
2009
2136
  // Get all basenames for display
@@ -2021,6 +2148,10 @@ export async function startDiscordBot({
2021
2148
  value: fullValue,
2022
2149
  }
2023
2150
  })
2151
+ // Discord API limits choice value to 100 characters
2152
+ .filter((choice) => choice.value.length <= 100)
2153
+ .slice(0, 25) // Discord limit
2154
+
2024
2155
 
2025
2156
  await interaction.respond(choices)
2026
2157
  } catch (error) {
@@ -2028,6 +2159,61 @@ export async function startDiscordBot({
2028
2159
  await interaction.respond([])
2029
2160
  }
2030
2161
  }
2162
+ } else if (interaction.commandName === 'add-project') {
2163
+ const focusedValue = interaction.options.getFocused()
2164
+
2165
+ try {
2166
+ const currentDir = process.cwd()
2167
+ const getClient = await initializeOpencodeForDirectory(currentDir)
2168
+
2169
+ const projectsResponse = await getClient().project.list({})
2170
+ if (!projectsResponse.data) {
2171
+ await interaction.respond([])
2172
+ return
2173
+ }
2174
+
2175
+ const db = getDatabase()
2176
+ const existingDirs = db
2177
+ .prepare(
2178
+ 'SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?',
2179
+ )
2180
+ .all('text') as { directory: string }[]
2181
+ const existingDirSet = new Set(
2182
+ existingDirs.map((row) => row.directory),
2183
+ )
2184
+
2185
+ const availableProjects = projectsResponse.data.filter(
2186
+ (project) => !existingDirSet.has(project.worktree),
2187
+ )
2188
+
2189
+ const projects = availableProjects
2190
+ .filter((project) => {
2191
+ const baseName = path.basename(project.worktree)
2192
+ const searchText = `${baseName} ${project.worktree}`.toLowerCase()
2193
+ return searchText.includes(focusedValue.toLowerCase())
2194
+ })
2195
+ .sort((a, b) => {
2196
+ const aTime = a.time.initialized || a.time.created
2197
+ const bTime = b.time.initialized || b.time.created
2198
+ return bTime - aTime
2199
+ })
2200
+ .slice(0, 25)
2201
+ .map((project) => {
2202
+ const name = `${path.basename(project.worktree)} (${project.worktree})`
2203
+ return {
2204
+ name: name.length > 100 ? name.slice(0, 99) + '…' : name,
2205
+ value: project.id,
2206
+ }
2207
+ })
2208
+
2209
+ await interaction.respond(projects)
2210
+ } catch (error) {
2211
+ voiceLogger.error(
2212
+ '[AUTOCOMPLETE] Error fetching projects:',
2213
+ error,
2214
+ )
2215
+ await interaction.respond([])
2216
+ }
2031
2217
  }
2032
2218
  }
2033
2219
 
@@ -2290,6 +2476,77 @@ export async function startDiscordBot({
2290
2476
  `Failed to resume session: ${error instanceof Error ? error.message : 'Unknown error'}`,
2291
2477
  )
2292
2478
  }
2479
+ } else if (command.commandName === 'add-project') {
2480
+ await command.deferReply({ ephemeral: false })
2481
+
2482
+ const projectId = command.options.getString('project', true)
2483
+ const guild = command.guild
2484
+
2485
+ if (!guild) {
2486
+ await command.editReply('This command can only be used in a guild')
2487
+ return
2488
+ }
2489
+
2490
+ try {
2491
+ const currentDir = process.cwd()
2492
+ const getClient = await initializeOpencodeForDirectory(currentDir)
2493
+
2494
+ const projectsResponse = await getClient().project.list({})
2495
+ if (!projectsResponse.data) {
2496
+ await command.editReply('Failed to fetch projects')
2497
+ return
2498
+ }
2499
+
2500
+ const project = projectsResponse.data.find(
2501
+ (p) => p.id === projectId,
2502
+ )
2503
+
2504
+ if (!project) {
2505
+ await command.editReply('Project not found')
2506
+ return
2507
+ }
2508
+
2509
+ const directory = project.worktree
2510
+
2511
+ if (!fs.existsSync(directory)) {
2512
+ await command.editReply(`Directory does not exist: ${directory}`)
2513
+ return
2514
+ }
2515
+
2516
+ const db = getDatabase()
2517
+ const existingChannel = db
2518
+ .prepare(
2519
+ 'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
2520
+ )
2521
+ .get(directory, 'text') as { channel_id: string } | undefined
2522
+
2523
+ if (existingChannel) {
2524
+ await command.editReply(
2525
+ `A channel already exists for this directory: <#${existingChannel.channel_id}>`,
2526
+ )
2527
+ return
2528
+ }
2529
+
2530
+ const { textChannelId, voiceChannelId, channelName } =
2531
+ await createProjectChannels({
2532
+ guild,
2533
+ projectDirectory: directory,
2534
+ appId: currentAppId!,
2535
+ })
2536
+
2537
+ await command.editReply(
2538
+ `✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``,
2539
+ )
2540
+
2541
+ discordLogger.log(
2542
+ `Created channels for project ${channelName} at ${directory}`,
2543
+ )
2544
+ } catch (error) {
2545
+ voiceLogger.error('[ADD-PROJECT] Error:', error)
2546
+ await command.editReply(
2547
+ `Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
2548
+ )
2549
+ }
2293
2550
  }
2294
2551
  }
2295
2552
  } catch (error) {