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/dist/cli.js +20 -37
- package/dist/discordBot.js +312 -147
- package/dist/escape-backticks.test.js +125 -0
- package/package.json +11 -13
- package/src/cli.ts +22 -49
- package/src/discordBot.ts +411 -145
- 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
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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 = ?')
|
|
@@ -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
|
|
1179
|
-
|
|
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 (
|
|
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 (!
|
|
1280
|
-
|
|
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 (
|
|
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
|
-
!
|
|
1458
|
-
|
|
1581
|
+
!abortController.signal.aborted ||
|
|
1582
|
+
abortController.signal.reason === 'finished'
|
|
1459
1583
|
) {
|
|
1460
1584
|
const sessionDuration = prettyMilliseconds(
|
|
1461
1585
|
Date.now() - sessionStartTime,
|
|
1462
1586
|
)
|
|
1463
|
-
|
|
1464
|
-
|
|
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: ${
|
|
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:
|
|
1612
|
+
signal: abortController.signal,
|
|
1487
1613
|
})
|
|
1488
|
-
|
|
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,
|
|
1510
|
-
|
|
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
|
-
|
|
1924
|
-
|
|
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) {
|