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/dist/cli.js +20 -37
- package/dist/discordBot.js +293 -135
- package/dist/escape-backticks.test.js +125 -0
- package/package.json +11 -13
- package/src/cli.ts +22 -49
- package/src/discordBot.ts +390 -133
- package/src/escape-backticks.test.ts +146 -0
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
-
|
|
1042
|
-
|
|
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
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
1473
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1933
|
-
|
|
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) {
|