kimaki 0.4.1 → 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 ''
1098
+
1099
+ if (part.state.status === 'error') {
1100
+ return part.state.error || 'Unknown error'
1101
+ }
1035
1102
 
1036
- let toolTitle =
1037
- part.state.status === 'completed' ? part.state.title || '' : 'error'
1038
- if (toolTitle) {
1039
- toolTitle = `\`${escapeInlineCode(toolTitle)}\``
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 = ?')
@@ -1175,10 +1285,13 @@ async function handleOpencodeSession(
1175
1285
  if (abortControllers.has(session.id)) {
1176
1286
  abortControllers.get(session.id)?.abort(new Error('new reply'))
1177
1287
  }
1178
- const promptAbortController = new AbortController()
1179
- abortControllers.set(session.id, promptAbortController)
1288
+ const abortController = new AbortController()
1289
+ // Store this controller for this session
1290
+ abortControllers.set(session.id, abortController)
1180
1291
 
1181
- const eventsResult = await getClient().event.subscribe({})
1292
+ const eventsResult = await getClient().event.subscribe({
1293
+ signal: abortController.signal,
1294
+ })
1182
1295
  const events = eventsResult.stream
1183
1296
  sessionLogger.log(`Subscribed to OpenCode events`)
1184
1297
 
@@ -1206,6 +1319,7 @@ async function handleOpencodeSession(
1206
1319
 
1207
1320
  let currentParts: Part[] = []
1208
1321
  let stopTyping: (() => void) | null = null
1322
+ let usedModel: string | undefined
1209
1323
 
1210
1324
  const sendPartMessage = async (part: Part) => {
1211
1325
  const content = formatPart(part) + '\n\n'
@@ -1250,7 +1364,7 @@ async function handleOpencodeSession(
1250
1364
  let typingInterval: NodeJS.Timeout | null = null
1251
1365
 
1252
1366
  function startTyping(thread: ThreadChannel): () => void {
1253
- if (promptAbortController.signal.aborted) {
1367
+ if (abortController.signal.aborted) {
1254
1368
  discordLogger.log(`Not starting typing, already aborted`)
1255
1369
  return () => {}
1256
1370
  }
@@ -1276,8 +1390,8 @@ async function handleOpencodeSession(
1276
1390
  }, 8000)
1277
1391
 
1278
1392
  // Only add listener if not already aborted
1279
- if (!promptAbortController.signal.aborted) {
1280
- promptAbortController.signal.addEventListener(
1393
+ if (!abortController.signal.aborted) {
1394
+ abortController.signal.addEventListener(
1281
1395
  'abort',
1282
1396
  () => {
1283
1397
  if (typingInterval) {
@@ -1319,6 +1433,10 @@ async function handleOpencodeSession(
1319
1433
  // Track assistant message ID
1320
1434
  if (msg.role === 'assistant') {
1321
1435
  assistantMessageId = msg.id
1436
+
1437
+
1438
+ usedModel = msg.modelID
1439
+
1322
1440
  voiceLogger.log(
1323
1441
  `[EVENT] Tracking assistant message ${assistantMessageId}`,
1324
1442
  )
@@ -1375,7 +1493,7 @@ async function handleOpencodeSession(
1375
1493
  }
1376
1494
  // start typing in a moment, so that if the session finished, because step-finish is at the end of the message, we do not show typing status
1377
1495
  setTimeout(() => {
1378
- if (promptAbortController.signal.aborted) return
1496
+ if (abortController.signal.aborted) return
1379
1497
  stopTyping = startTyping(thread)
1380
1498
  }, 300)
1381
1499
  }
@@ -1415,6 +1533,12 @@ async function handleOpencodeSession(
1415
1533
  }
1416
1534
  }
1417
1535
  } catch (e) {
1536
+ if (isAbortError(e, abortController.signal)) {
1537
+ sessionLogger.log(
1538
+ 'AbortController aborted event handling (normal exit)',
1539
+ )
1540
+ return
1541
+ }
1418
1542
  sessionLogger.error(`Unexpected error in event handling code`, e)
1419
1543
  throw e
1420
1544
  } finally {
@@ -1454,17 +1578,19 @@ async function handleOpencodeSession(
1454
1578
 
1455
1579
  // Only send duration message if request was not aborted or was aborted with 'finished' reason
1456
1580
  if (
1457
- !promptAbortController.signal.aborted ||
1458
- promptAbortController.signal.reason === 'finished'
1581
+ !abortController.signal.aborted ||
1582
+ abortController.signal.reason === 'finished'
1459
1583
  ) {
1460
1584
  const sessionDuration = prettyMilliseconds(
1461
1585
  Date.now() - sessionStartTime,
1462
1586
  )
1463
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}_`)
1464
- 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}`)
1465
1591
  } else {
1466
1592
  sessionLogger.log(
1467
- `Session was aborted (reason: ${promptAbortController.signal.reason}), skipping duration message`,
1593
+ `Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
1468
1594
  )
1469
1595
  }
1470
1596
  }
@@ -1483,14 +1609,12 @@ async function handleOpencodeSession(
1483
1609
  body: {
1484
1610
  parts: [{ type: 'text', text: prompt }],
1485
1611
  },
1486
- signal: promptAbortController.signal,
1612
+ signal: abortController.signal,
1487
1613
  })
1488
- promptAbortController.abort(new Error('finished'))
1614
+ abortController.abort('finished')
1489
1615
 
1490
1616
  sessionLogger.log(`Successfully sent prompt, got response`)
1491
1617
 
1492
- abortControllers.delete(session.id)
1493
-
1494
1618
  // Update reaction to success
1495
1619
  if (originalMessage) {
1496
1620
  try {
@@ -1502,12 +1626,12 @@ async function handleOpencodeSession(
1502
1626
  }
1503
1627
  }
1504
1628
 
1505
- return { sessionID: session.id, result: response.data }
1629
+ return { sessionID: session.id, result: response.data, port }
1506
1630
  } catch (error) {
1507
1631
  sessionLogger.error(`ERROR: Failed to send prompt:`, error)
1508
1632
 
1509
- if (!isAbortError(error, promptAbortController.signal)) {
1510
- promptAbortController.abort(new Error('error'))
1633
+ if (!isAbortError(error, abortController.signal)) {
1634
+ abortController.abort('error')
1511
1635
 
1512
1636
  if (originalMessage) {
1513
1637
  try {
@@ -1518,7 +1642,6 @@ async function handleOpencodeSession(
1518
1642
  discordLogger.log(`Could not update reaction:`, e)
1519
1643
  }
1520
1644
  }
1521
- // Always log the error's constructor name (if any) and make error reporting more readable
1522
1645
  const errorName =
1523
1646
  error &&
1524
1647
  typeof error === 'object' &&
@@ -1919,10 +2042,24 @@ export async function startDiscordBot({
1919
2042
  .includes(focusedValue.toLowerCase()),
1920
2043
  )
1921
2044
  .slice(0, 25) // Discord limit
1922
- .map((session) => ({
1923
- name: `${session.title} (${new Date(session.time.updated).toLocaleString()})`,
1924
- value: session.id,
1925
- }))
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
+ })
1926
2063
 
1927
2064
  await interaction.respond(sessions)
1928
2065
  } catch (error) {
@@ -1994,7 +2131,6 @@ export async function startDiscordBot({
1994
2131
 
1995
2132
  // Map to Discord autocomplete format
1996
2133
  const choices = files
1997
- .slice(0, 25) // Discord limit
1998
2134
  .map((file: string) => {
1999
2135
  const fullValue = prefix + file
2000
2136
  // Get all basenames for display
@@ -2012,6 +2148,10 @@ export async function startDiscordBot({
2012
2148
  value: fullValue,
2013
2149
  }
2014
2150
  })
2151
+ // Discord API limits choice value to 100 characters
2152
+ .filter((choice) => choice.value.length <= 100)
2153
+ .slice(0, 25) // Discord limit
2154
+
2015
2155
 
2016
2156
  await interaction.respond(choices)
2017
2157
  } catch (error) {
@@ -2019,6 +2159,61 @@ export async function startDiscordBot({
2019
2159
  await interaction.respond([])
2020
2160
  }
2021
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
+ }
2022
2217
  }
2023
2218
  }
2024
2219
 
@@ -2281,6 +2476,77 @@ export async function startDiscordBot({
2281
2476
  `Failed to resume session: ${error instanceof Error ? error.message : 'Unknown error'}`,
2282
2477
  )
2283
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
+ }
2284
2550
  }
2285
2551
  }
2286
2552
  } catch (error) {