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