kimaki 0.4.21 → 0.4.23
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/channel-management.js +92 -0
- package/dist/cli.js +10 -2
- package/dist/database.js +130 -0
- package/dist/discord-bot.js +381 -0
- package/dist/discord-utils.js +151 -0
- package/dist/discordBot.js +60 -31
- package/dist/escape-backticks.test.js +1 -1
- package/dist/fork.js +163 -0
- package/dist/format-tables.js +93 -0
- package/dist/format-tables.test.js +418 -0
- package/dist/interaction-handler.js +750 -0
- package/dist/markdown.js +3 -3
- package/dist/message-formatting.js +188 -0
- package/dist/model-command.js +293 -0
- package/dist/opencode.js +135 -0
- package/dist/session-handler.js +467 -0
- package/dist/system-message.js +92 -0
- package/dist/tools.js +3 -5
- package/dist/utils.js +31 -0
- package/dist/voice-handler.js +528 -0
- package/dist/voice.js +257 -35
- package/package.json +3 -2
- package/src/channel-management.ts +145 -0
- package/src/cli.ts +10 -2
- package/src/database.ts +155 -0
- package/src/discord-bot.ts +506 -0
- package/src/discord-utils.ts +208 -0
- package/src/escape-backticks.test.ts +1 -1
- package/src/fork.ts +224 -0
- package/src/format-tables.test.ts +440 -0
- package/src/format-tables.ts +106 -0
- package/src/interaction-handler.ts +1000 -0
- package/src/markdown.ts +3 -3
- package/src/message-formatting.ts +227 -0
- package/src/model-command.ts +380 -0
- package/src/opencode.ts +180 -0
- package/src/session-handler.ts +601 -0
- package/src/system-message.ts +92 -0
- package/src/tools.ts +3 -5
- package/src/utils.ts +37 -0
- package/src/voice-handler.ts +745 -0
- package/src/voice.ts +354 -36
- package/src/discordBot.ts +0 -3643
package/src/discordBot.ts
DELETED
|
@@ -1,3643 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createOpencodeClient,
|
|
3
|
-
type OpencodeClient,
|
|
4
|
-
type Part,
|
|
5
|
-
type Config,
|
|
6
|
-
type FilePartInput,
|
|
7
|
-
type Permission,
|
|
8
|
-
} from '@opencode-ai/sdk'
|
|
9
|
-
|
|
10
|
-
import { createGenAIWorker, type GenAIWorker } from './genai-worker-wrapper.js'
|
|
11
|
-
|
|
12
|
-
import Database from 'better-sqlite3'
|
|
13
|
-
import {
|
|
14
|
-
ChannelType,
|
|
15
|
-
Client,
|
|
16
|
-
Events,
|
|
17
|
-
GatewayIntentBits,
|
|
18
|
-
Partials,
|
|
19
|
-
PermissionsBitField,
|
|
20
|
-
ThreadAutoArchiveDuration,
|
|
21
|
-
type CategoryChannel,
|
|
22
|
-
type Guild,
|
|
23
|
-
type Interaction,
|
|
24
|
-
type Message,
|
|
25
|
-
type TextChannel,
|
|
26
|
-
type ThreadChannel,
|
|
27
|
-
type VoiceChannel,
|
|
28
|
-
} from 'discord.js'
|
|
29
|
-
import {
|
|
30
|
-
joinVoiceChannel,
|
|
31
|
-
VoiceConnectionStatus,
|
|
32
|
-
entersState,
|
|
33
|
-
EndBehaviorType,
|
|
34
|
-
type VoiceConnection,
|
|
35
|
-
} from '@discordjs/voice'
|
|
36
|
-
import { Lexer } from 'marked'
|
|
37
|
-
import { spawn, exec, type ChildProcess } from 'node:child_process'
|
|
38
|
-
import fs, { createWriteStream } from 'node:fs'
|
|
39
|
-
import { mkdir } from 'node:fs/promises'
|
|
40
|
-
import net from 'node:net'
|
|
41
|
-
import os from 'node:os'
|
|
42
|
-
import path from 'node:path'
|
|
43
|
-
import { promisify } from 'node:util'
|
|
44
|
-
import { PassThrough, Transform, type TransformCallback } from 'node:stream'
|
|
45
|
-
import * as prism from 'prism-media'
|
|
46
|
-
import dedent from 'string-dedent'
|
|
47
|
-
import { transcribeAudio } from './voice.js'
|
|
48
|
-
import { extractTagsArrays, extractNonXmlContent } from './xml.js'
|
|
49
|
-
import prettyMilliseconds from 'pretty-ms'
|
|
50
|
-
import type { Session } from '@google/genai'
|
|
51
|
-
import { createLogger } from './logger.js'
|
|
52
|
-
import { isAbortError } from './utils.js'
|
|
53
|
-
import { setGlobalDispatcher, Agent } from 'undici'
|
|
54
|
-
// disables the automatic 5 minutes abort after no body
|
|
55
|
-
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }))
|
|
56
|
-
|
|
57
|
-
type ParsedCommand = {
|
|
58
|
-
isCommand: true
|
|
59
|
-
command: string
|
|
60
|
-
arguments: string
|
|
61
|
-
} | {
|
|
62
|
-
isCommand: false
|
|
63
|
-
}
|
|
64
|
-
function parseSlashCommand(text: string): ParsedCommand {
|
|
65
|
-
const trimmed = text.trim()
|
|
66
|
-
if (!trimmed.startsWith('/')) {
|
|
67
|
-
return { isCommand: false }
|
|
68
|
-
}
|
|
69
|
-
const match = trimmed.match(/^\/(\S+)(?:\s+(.*))?$/)
|
|
70
|
-
if (!match) {
|
|
71
|
-
return { isCommand: false }
|
|
72
|
-
}
|
|
73
|
-
const command = match[1]!
|
|
74
|
-
const args = match[2]?.trim() || ''
|
|
75
|
-
return { isCommand: true, command, arguments: args }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function getOpencodeSystemMessage({ sessionId }: { sessionId: string }) {
|
|
79
|
-
return `
|
|
80
|
-
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
81
|
-
|
|
82
|
-
The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
|
|
83
|
-
|
|
84
|
-
Your current OpenCode session ID is: ${sessionId}
|
|
85
|
-
|
|
86
|
-
## permissions
|
|
87
|
-
|
|
88
|
-
Only users with these Discord permissions can send messages to the bot:
|
|
89
|
-
- Server Owner
|
|
90
|
-
- Administrator permission
|
|
91
|
-
- Manage Server permission
|
|
92
|
-
- "Kimaki" role (case-insensitive)
|
|
93
|
-
|
|
94
|
-
## changing the model
|
|
95
|
-
|
|
96
|
-
To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
|
|
97
|
-
|
|
98
|
-
\`\`\`json
|
|
99
|
-
{
|
|
100
|
-
"model": "anthropic/claude-sonnet-4-20250514"
|
|
101
|
-
}
|
|
102
|
-
\`\`\`
|
|
103
|
-
|
|
104
|
-
Examples:
|
|
105
|
-
- \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
|
|
106
|
-
- \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
|
|
107
|
-
- \`"openai/gpt-4o"\` - GPT-4o
|
|
108
|
-
- \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
|
|
109
|
-
|
|
110
|
-
Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
|
|
111
|
-
|
|
112
|
-
## uploading files to discord
|
|
113
|
-
|
|
114
|
-
To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
|
|
115
|
-
|
|
116
|
-
npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
|
|
117
|
-
|
|
118
|
-
## showing diffs
|
|
119
|
-
|
|
120
|
-
After each message, if you implemented changes, you can show the user a diff via an url running the command, to show the changes in working directory:
|
|
121
|
-
|
|
122
|
-
bunx critique web
|
|
123
|
-
|
|
124
|
-
you can also show latest commit changes using
|
|
125
|
-
|
|
126
|
-
bunx critique web HEAD~1
|
|
127
|
-
|
|
128
|
-
do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
|
|
129
|
-
|
|
130
|
-
## markdown
|
|
131
|
-
|
|
132
|
-
discord does support basic markdown features like code blocks, code blocks languages, inline code, bold, italic, quotes, etc.
|
|
133
|
-
|
|
134
|
-
the max heading level is 3, so do not use ####
|
|
135
|
-
|
|
136
|
-
headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
|
|
137
|
-
|
|
138
|
-
## tables
|
|
139
|
-
|
|
140
|
-
discord does NOT support markdown gfm tables.
|
|
141
|
-
|
|
142
|
-
so instead of using full markdown tables ALWAYS show code snippets with space aligned cells:
|
|
143
|
-
|
|
144
|
-
\`\`\`
|
|
145
|
-
Item Qty Price
|
|
146
|
-
---------- --- -----
|
|
147
|
-
Apples 10 $5
|
|
148
|
-
Oranges 3 $2
|
|
149
|
-
\`\`\`
|
|
150
|
-
|
|
151
|
-
Using code blocks will make the content use monospaced font so that space will be aligned correctly
|
|
152
|
-
|
|
153
|
-
IMPORTANT: add enough space characters to align the table! otherwise the content will not look good and will be difficult to understand for the user
|
|
154
|
-
|
|
155
|
-
code blocks for tables and diagrams MUST have Max length of 85 characters. otherwise the content will wrap
|
|
156
|
-
|
|
157
|
-
## diagrams
|
|
158
|
-
|
|
159
|
-
you can create diagrams wrapping them in code blocks too.
|
|
160
|
-
`
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const discordLogger = createLogger('DISCORD')
|
|
164
|
-
const voiceLogger = createLogger('VOICE')
|
|
165
|
-
const opencodeLogger = createLogger('OPENCODE')
|
|
166
|
-
const sessionLogger = createLogger('SESSION')
|
|
167
|
-
const dbLogger = createLogger('DB')
|
|
168
|
-
|
|
169
|
-
type StartOptions = {
|
|
170
|
-
token: string
|
|
171
|
-
appId?: string
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Map of project directory to OpenCode server process and client
|
|
175
|
-
const opencodeServers = new Map<
|
|
176
|
-
string,
|
|
177
|
-
{
|
|
178
|
-
process: ChildProcess
|
|
179
|
-
client: OpencodeClient
|
|
180
|
-
port: number
|
|
181
|
-
}
|
|
182
|
-
>()
|
|
183
|
-
|
|
184
|
-
// Map of session ID to current AbortController
|
|
185
|
-
const abortControllers = new Map<string, AbortController>()
|
|
186
|
-
|
|
187
|
-
// Map of guild ID to voice connection and GenAI worker
|
|
188
|
-
const voiceConnections = new Map<
|
|
189
|
-
string,
|
|
190
|
-
{
|
|
191
|
-
connection: VoiceConnection
|
|
192
|
-
genAiWorker?: GenAIWorker
|
|
193
|
-
userAudioStream?: fs.WriteStream
|
|
194
|
-
}
|
|
195
|
-
>()
|
|
196
|
-
|
|
197
|
-
// Map of directory to retry count for server restarts
|
|
198
|
-
const serverRetryCount = new Map<string, number>()
|
|
199
|
-
|
|
200
|
-
// Map of thread ID to pending permission (only one pending permission per thread)
|
|
201
|
-
const pendingPermissions = new Map<
|
|
202
|
-
string,
|
|
203
|
-
{ permission: Permission; messageId: string; directory: string }
|
|
204
|
-
>()
|
|
205
|
-
|
|
206
|
-
let db: Database.Database | null = null
|
|
207
|
-
|
|
208
|
-
function convertToMono16k(buffer: Buffer): Buffer {
|
|
209
|
-
// Parameters
|
|
210
|
-
const inputSampleRate = 48000
|
|
211
|
-
const outputSampleRate = 16000
|
|
212
|
-
const ratio = inputSampleRate / outputSampleRate
|
|
213
|
-
const inputChannels = 2 // Stereo
|
|
214
|
-
const bytesPerSample = 2 // 16-bit
|
|
215
|
-
|
|
216
|
-
// Calculate output buffer size
|
|
217
|
-
const inputSamples = buffer.length / (bytesPerSample * inputChannels)
|
|
218
|
-
const outputSamples = Math.floor(inputSamples / ratio)
|
|
219
|
-
const outputBuffer = Buffer.alloc(outputSamples * bytesPerSample)
|
|
220
|
-
|
|
221
|
-
// Process each output sample
|
|
222
|
-
for (let i = 0; i < outputSamples; i++) {
|
|
223
|
-
// Find the corresponding input sample
|
|
224
|
-
const inputIndex = Math.floor(i * ratio) * inputChannels * bytesPerSample
|
|
225
|
-
|
|
226
|
-
// Average the left and right channels for mono conversion
|
|
227
|
-
if (inputIndex + 3 < buffer.length) {
|
|
228
|
-
const leftSample = buffer.readInt16LE(inputIndex)
|
|
229
|
-
const rightSample = buffer.readInt16LE(inputIndex + 2)
|
|
230
|
-
const monoSample = Math.round((leftSample + rightSample) / 2)
|
|
231
|
-
|
|
232
|
-
// Write to output buffer
|
|
233
|
-
outputBuffer.writeInt16LE(monoSample, i * bytesPerSample)
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return outputBuffer
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Create user audio log stream for debugging
|
|
241
|
-
async function createUserAudioLogStream(
|
|
242
|
-
guildId: string,
|
|
243
|
-
channelId: string,
|
|
244
|
-
): Promise<fs.WriteStream | undefined> {
|
|
245
|
-
if (!process.env.DEBUG) return undefined
|
|
246
|
-
|
|
247
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
248
|
-
const audioDir = path.join(
|
|
249
|
-
process.cwd(),
|
|
250
|
-
'discord-audio-logs',
|
|
251
|
-
guildId,
|
|
252
|
-
channelId,
|
|
253
|
-
)
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
await mkdir(audioDir, { recursive: true })
|
|
257
|
-
|
|
258
|
-
// Create stream for user audio (16kHz mono s16le PCM)
|
|
259
|
-
const inputFileName = `user_${timestamp}.16.pcm`
|
|
260
|
-
const inputFilePath = path.join(audioDir, inputFileName)
|
|
261
|
-
const inputAudioStream = createWriteStream(inputFilePath)
|
|
262
|
-
voiceLogger.log(`Created user audio log: ${inputFilePath}`)
|
|
263
|
-
|
|
264
|
-
return inputAudioStream
|
|
265
|
-
} catch (error) {
|
|
266
|
-
voiceLogger.error('Failed to create audio log directory:', error)
|
|
267
|
-
return undefined
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Set up voice handling for a connection (called once per connection)
|
|
272
|
-
async function setupVoiceHandling({
|
|
273
|
-
connection,
|
|
274
|
-
guildId,
|
|
275
|
-
channelId,
|
|
276
|
-
appId,
|
|
277
|
-
discordClient,
|
|
278
|
-
}: {
|
|
279
|
-
connection: VoiceConnection
|
|
280
|
-
guildId: string
|
|
281
|
-
channelId: string
|
|
282
|
-
appId: string
|
|
283
|
-
discordClient: Client
|
|
284
|
-
}) {
|
|
285
|
-
voiceLogger.log(
|
|
286
|
-
`Setting up voice handling for guild ${guildId}, channel ${channelId}`,
|
|
287
|
-
)
|
|
288
|
-
|
|
289
|
-
// Check if this voice channel has an associated directory
|
|
290
|
-
const channelDirRow = getDatabase()
|
|
291
|
-
.prepare(
|
|
292
|
-
'SELECT directory FROM channel_directories WHERE channel_id = ? AND channel_type = ?',
|
|
293
|
-
)
|
|
294
|
-
.get(channelId, 'voice') as { directory: string } | undefined
|
|
295
|
-
|
|
296
|
-
if (!channelDirRow) {
|
|
297
|
-
voiceLogger.log(
|
|
298
|
-
`Voice channel ${channelId} has no associated directory, skipping setup`,
|
|
299
|
-
)
|
|
300
|
-
return
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const directory = channelDirRow.directory
|
|
304
|
-
voiceLogger.log(`Found directory for voice channel: ${directory}`)
|
|
305
|
-
|
|
306
|
-
// Get voice data
|
|
307
|
-
const voiceData = voiceConnections.get(guildId)
|
|
308
|
-
if (!voiceData) {
|
|
309
|
-
voiceLogger.error(`No voice data found for guild ${guildId}`)
|
|
310
|
-
return
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Create user audio stream for debugging
|
|
314
|
-
voiceData.userAudioStream = await createUserAudioLogStream(guildId, channelId)
|
|
315
|
-
|
|
316
|
-
// Get API keys from database
|
|
317
|
-
const apiKeys = getDatabase()
|
|
318
|
-
.prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
|
|
319
|
-
.get(appId) as { gemini_api_key: string | null } | undefined
|
|
320
|
-
|
|
321
|
-
// Create GenAI worker
|
|
322
|
-
const genAiWorker = await createGenAIWorker({
|
|
323
|
-
directory,
|
|
324
|
-
guildId,
|
|
325
|
-
channelId,
|
|
326
|
-
appId,
|
|
327
|
-
geminiApiKey: apiKeys?.gemini_api_key,
|
|
328
|
-
systemMessage: dedent`
|
|
329
|
-
You are Kimaki, an AI similar to Jarvis: you help your user (an engineer) controlling his coding agent, just like Jarvis controls Ironman armor and machines. Speak fast.
|
|
330
|
-
|
|
331
|
-
You should talk like Jarvis, British accent, satirical, joking and calm. Be short and concise. Speak fast.
|
|
332
|
-
|
|
333
|
-
After tool calls give a super short summary of the assistant message, you should say what the assistant message writes.
|
|
334
|
-
|
|
335
|
-
Before starting a new session ask for confirmation if it is not clear if the user finished describing it. ask "message ready, send?"
|
|
336
|
-
|
|
337
|
-
NEVER repeat the whole tool call parameters or message.
|
|
338
|
-
|
|
339
|
-
Your job is to manage many opencode agent chat instances. Opencode is the agent used to write the code, it is similar to Claude Code.
|
|
340
|
-
|
|
341
|
-
For everything the user asks it is implicit that the user is asking for you to proxy the requests to opencode sessions.
|
|
342
|
-
|
|
343
|
-
You can
|
|
344
|
-
- start new chats on a given project
|
|
345
|
-
- read the chats to report progress to the user
|
|
346
|
-
- submit messages to the chat
|
|
347
|
-
- list files for a given projects, so you can translate imprecise user prompts to precise messages that mention filename paths using @
|
|
348
|
-
|
|
349
|
-
Common patterns
|
|
350
|
-
- to get the last session use the listChats tool
|
|
351
|
-
- when user asks you to do something you submit a new session to do it. it's implicit that you proxy requests to the agents chat!
|
|
352
|
-
- when you submit a session assume the session will take a minute or 2 to complete the task
|
|
353
|
-
|
|
354
|
-
Rules
|
|
355
|
-
- never spell files by mentioning dots, letters, etc. instead give a brief description of the filename
|
|
356
|
-
- NEVER spell hashes or IDs
|
|
357
|
-
- never read session ids or other ids
|
|
358
|
-
|
|
359
|
-
Your voice is calm and monotone, NEVER excited and goofy. But you speak without jargon or bs and do veiled short jokes.
|
|
360
|
-
You speak like you knew something other don't. You are cool and cold.
|
|
361
|
-
`,
|
|
362
|
-
onAssistantOpusPacket(packet) {
|
|
363
|
-
// Opus packets are sent at 20ms intervals from worker, play directly
|
|
364
|
-
if (connection.state.status !== VoiceConnectionStatus.Ready) {
|
|
365
|
-
voiceLogger.log('Skipping packet: connection not ready')
|
|
366
|
-
return
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
try {
|
|
370
|
-
connection.setSpeaking(true)
|
|
371
|
-
connection.playOpusPacket(Buffer.from(packet))
|
|
372
|
-
} catch (error) {
|
|
373
|
-
voiceLogger.error('Error sending packet:', error)
|
|
374
|
-
}
|
|
375
|
-
},
|
|
376
|
-
onAssistantStartSpeaking() {
|
|
377
|
-
voiceLogger.log('Assistant started speaking')
|
|
378
|
-
connection.setSpeaking(true)
|
|
379
|
-
},
|
|
380
|
-
onAssistantStopSpeaking() {
|
|
381
|
-
voiceLogger.log('Assistant stopped speaking (natural finish)')
|
|
382
|
-
connection.setSpeaking(false)
|
|
383
|
-
},
|
|
384
|
-
onAssistantInterruptSpeaking() {
|
|
385
|
-
voiceLogger.log('Assistant interrupted while speaking')
|
|
386
|
-
genAiWorker.interrupt()
|
|
387
|
-
connection.setSpeaking(false)
|
|
388
|
-
},
|
|
389
|
-
onToolCallCompleted(params) {
|
|
390
|
-
const text = params.error
|
|
391
|
-
? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${params.error?.message || String(params.error)}\n</systemMessage>`
|
|
392
|
-
: `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`
|
|
393
|
-
|
|
394
|
-
genAiWorker.sendTextInput(text)
|
|
395
|
-
},
|
|
396
|
-
async onError(error) {
|
|
397
|
-
voiceLogger.error('GenAI worker error:', error)
|
|
398
|
-
const textChannelRow = getDatabase()
|
|
399
|
-
.prepare(
|
|
400
|
-
`SELECT cd2.channel_id FROM channel_directories cd1
|
|
401
|
-
JOIN channel_directories cd2 ON cd1.directory = cd2.directory
|
|
402
|
-
WHERE cd1.channel_id = ? AND cd1.channel_type = 'voice' AND cd2.channel_type = 'text'`,
|
|
403
|
-
)
|
|
404
|
-
.get(channelId) as { channel_id: string } | undefined
|
|
405
|
-
|
|
406
|
-
if (textChannelRow) {
|
|
407
|
-
try {
|
|
408
|
-
const textChannel = await discordClient.channels.fetch(
|
|
409
|
-
textChannelRow.channel_id,
|
|
410
|
-
)
|
|
411
|
-
if (textChannel?.isTextBased() && 'send' in textChannel) {
|
|
412
|
-
await textChannel.send(`⚠️ Voice session error: ${error}`)
|
|
413
|
-
}
|
|
414
|
-
} catch (e) {
|
|
415
|
-
voiceLogger.error('Failed to send error to text channel:', e)
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
},
|
|
419
|
-
})
|
|
420
|
-
|
|
421
|
-
// Stop any existing GenAI worker before storing new one
|
|
422
|
-
if (voiceData.genAiWorker) {
|
|
423
|
-
voiceLogger.log('Stopping existing GenAI worker before creating new one')
|
|
424
|
-
await voiceData.genAiWorker.stop()
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// Send initial greeting
|
|
428
|
-
genAiWorker.sendTextInput(
|
|
429
|
-
`<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`,
|
|
430
|
-
)
|
|
431
|
-
|
|
432
|
-
voiceData.genAiWorker = genAiWorker
|
|
433
|
-
|
|
434
|
-
// Set up voice receiver for user input
|
|
435
|
-
const receiver = connection.receiver
|
|
436
|
-
|
|
437
|
-
// Remove all existing listeners to prevent accumulation
|
|
438
|
-
receiver.speaking.removeAllListeners('start')
|
|
439
|
-
|
|
440
|
-
// Counter to track overlapping speaking sessions
|
|
441
|
-
let speakingSessionCount = 0
|
|
442
|
-
|
|
443
|
-
receiver.speaking.on('start', (userId) => {
|
|
444
|
-
voiceLogger.log(`User ${userId} started speaking`)
|
|
445
|
-
|
|
446
|
-
// Increment session count for this new speaking session
|
|
447
|
-
speakingSessionCount++
|
|
448
|
-
const currentSessionCount = speakingSessionCount
|
|
449
|
-
voiceLogger.log(`Speaking session ${currentSessionCount} started`)
|
|
450
|
-
|
|
451
|
-
const audioStream = receiver.subscribe(userId, {
|
|
452
|
-
end: { behavior: EndBehaviorType.AfterSilence, duration: 500 },
|
|
453
|
-
})
|
|
454
|
-
|
|
455
|
-
const decoder = new prism.opus.Decoder({
|
|
456
|
-
rate: 48000,
|
|
457
|
-
channels: 2,
|
|
458
|
-
frameSize: 960,
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
// Add error handler to prevent crashes from corrupted data
|
|
462
|
-
decoder.on('error', (error) => {
|
|
463
|
-
voiceLogger.error(`Opus decoder error for user ${userId}:`, error)
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
// Transform to downsample 48k stereo -> 16k mono
|
|
467
|
-
const downsampleTransform = new Transform({
|
|
468
|
-
transform(chunk: Buffer, _encoding, callback) {
|
|
469
|
-
try {
|
|
470
|
-
const downsampled = convertToMono16k(chunk)
|
|
471
|
-
callback(null, downsampled)
|
|
472
|
-
} catch (error) {
|
|
473
|
-
callback(error as Error)
|
|
474
|
-
}
|
|
475
|
-
},
|
|
476
|
-
})
|
|
477
|
-
|
|
478
|
-
const framer = frameMono16khz()
|
|
479
|
-
|
|
480
|
-
const pipeline = audioStream
|
|
481
|
-
.pipe(decoder)
|
|
482
|
-
.pipe(downsampleTransform)
|
|
483
|
-
.pipe(framer)
|
|
484
|
-
|
|
485
|
-
pipeline
|
|
486
|
-
.on('data', (frame: Buffer) => {
|
|
487
|
-
// Check if a newer speaking session has started
|
|
488
|
-
if (currentSessionCount !== speakingSessionCount) {
|
|
489
|
-
// voiceLogger.log(
|
|
490
|
-
// `Skipping audio frame from session ${currentSessionCount} because newer session ${speakingSessionCount} has started`,
|
|
491
|
-
// )
|
|
492
|
-
return
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
if (!voiceData.genAiWorker) {
|
|
496
|
-
voiceLogger.warn(
|
|
497
|
-
`[VOICE] Received audio frame but no GenAI worker active for guild ${guildId}`,
|
|
498
|
-
)
|
|
499
|
-
return
|
|
500
|
-
}
|
|
501
|
-
// voiceLogger.debug('User audio chunk length', frame.length)
|
|
502
|
-
|
|
503
|
-
// Write to PCM file if stream exists
|
|
504
|
-
voiceData.userAudioStream?.write(frame)
|
|
505
|
-
|
|
506
|
-
// stream incrementally — low latency
|
|
507
|
-
voiceData.genAiWorker.sendRealtimeInput({
|
|
508
|
-
audio: {
|
|
509
|
-
mimeType: 'audio/pcm;rate=16000',
|
|
510
|
-
data: frame.toString('base64'),
|
|
511
|
-
},
|
|
512
|
-
})
|
|
513
|
-
})
|
|
514
|
-
.on('end', () => {
|
|
515
|
-
// Only send audioStreamEnd if this is still the current session
|
|
516
|
-
if (currentSessionCount === speakingSessionCount) {
|
|
517
|
-
voiceLogger.log(
|
|
518
|
-
`User ${userId} stopped speaking (session ${currentSessionCount})`,
|
|
519
|
-
)
|
|
520
|
-
voiceData.genAiWorker?.sendRealtimeInput({
|
|
521
|
-
audioStreamEnd: true,
|
|
522
|
-
})
|
|
523
|
-
} else {
|
|
524
|
-
voiceLogger.log(
|
|
525
|
-
`User ${userId} stopped speaking (session ${currentSessionCount}), but skipping audioStreamEnd because newer session ${speakingSessionCount} exists`,
|
|
526
|
-
)
|
|
527
|
-
}
|
|
528
|
-
})
|
|
529
|
-
.on('error', (error) => {
|
|
530
|
-
voiceLogger.error(`Pipeline error for user ${userId}:`, error)
|
|
531
|
-
})
|
|
532
|
-
|
|
533
|
-
// Also add error handlers to individual stream components
|
|
534
|
-
audioStream.on('error', (error) => {
|
|
535
|
-
voiceLogger.error(`Audio stream error for user ${userId}:`, error)
|
|
536
|
-
})
|
|
537
|
-
|
|
538
|
-
downsampleTransform.on('error', (error) => {
|
|
539
|
-
voiceLogger.error(`Downsample transform error for user ${userId}:`, error)
|
|
540
|
-
})
|
|
541
|
-
|
|
542
|
-
framer.on('error', (error) => {
|
|
543
|
-
voiceLogger.error(`Framer error for user ${userId}:`, error)
|
|
544
|
-
})
|
|
545
|
-
})
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function frameMono16khz(): Transform {
|
|
549
|
-
// Hardcoded: 16 kHz, mono, 16-bit PCM, 20 ms -> 320 samples -> 640 bytes
|
|
550
|
-
const FRAME_BYTES =
|
|
551
|
-
(100 /*ms*/ * 16_000 /*Hz*/ * 1 /*channels*/ * 2) /*bytes per sample*/ /
|
|
552
|
-
1000
|
|
553
|
-
let stash: Buffer = Buffer.alloc(0)
|
|
554
|
-
let offset = 0
|
|
555
|
-
|
|
556
|
-
return new Transform({
|
|
557
|
-
readableObjectMode: false,
|
|
558
|
-
writableObjectMode: false,
|
|
559
|
-
|
|
560
|
-
transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback) {
|
|
561
|
-
// Normalize stash so offset is always 0 before appending
|
|
562
|
-
if (offset > 0) {
|
|
563
|
-
// Drop already-consumed prefix without copying the rest twice
|
|
564
|
-
stash = stash.subarray(offset)
|
|
565
|
-
offset = 0
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// Append new data (single concat per incoming chunk)
|
|
569
|
-
stash = stash.length ? Buffer.concat([stash, chunk]) : chunk
|
|
570
|
-
|
|
571
|
-
// Emit as many full 20 ms frames as we can
|
|
572
|
-
while (stash.length - offset >= FRAME_BYTES) {
|
|
573
|
-
this.push(stash.subarray(offset, offset + FRAME_BYTES))
|
|
574
|
-
offset += FRAME_BYTES
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// If everything was consumed exactly, reset to empty buffer
|
|
578
|
-
if (offset === stash.length) {
|
|
579
|
-
stash = Buffer.alloc(0)
|
|
580
|
-
offset = 0
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
cb()
|
|
584
|
-
},
|
|
585
|
-
|
|
586
|
-
flush(cb: TransformCallback) {
|
|
587
|
-
// We intentionally drop any trailing partial (< 20 ms) to keep framing strict.
|
|
588
|
-
// If you prefer to emit it, uncomment the next line:
|
|
589
|
-
// if (stash.length - offset > 0) this.push(stash.subarray(offset));
|
|
590
|
-
stash = Buffer.alloc(0)
|
|
591
|
-
offset = 0
|
|
592
|
-
cb()
|
|
593
|
-
},
|
|
594
|
-
})
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
export function getDatabase(): Database.Database {
|
|
598
|
-
if (!db) {
|
|
599
|
-
const kimakiDir = path.join(os.homedir(), '.kimaki')
|
|
600
|
-
|
|
601
|
-
try {
|
|
602
|
-
fs.mkdirSync(kimakiDir, { recursive: true })
|
|
603
|
-
} catch (error) {
|
|
604
|
-
dbLogger.error('Failed to create ~/.kimaki directory:', error)
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const dbPath = path.join(kimakiDir, 'discord-sessions.db')
|
|
608
|
-
|
|
609
|
-
dbLogger.log(`Opening database at: ${dbPath}`)
|
|
610
|
-
db = new Database(dbPath)
|
|
611
|
-
|
|
612
|
-
db.exec(`
|
|
613
|
-
CREATE TABLE IF NOT EXISTS thread_sessions (
|
|
614
|
-
thread_id TEXT PRIMARY KEY,
|
|
615
|
-
session_id TEXT NOT NULL,
|
|
616
|
-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
617
|
-
)
|
|
618
|
-
`)
|
|
619
|
-
|
|
620
|
-
db.exec(`
|
|
621
|
-
CREATE TABLE IF NOT EXISTS part_messages (
|
|
622
|
-
part_id TEXT PRIMARY KEY,
|
|
623
|
-
message_id TEXT NOT NULL,
|
|
624
|
-
thread_id TEXT NOT NULL,
|
|
625
|
-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
626
|
-
)
|
|
627
|
-
`)
|
|
628
|
-
|
|
629
|
-
db.exec(`
|
|
630
|
-
CREATE TABLE IF NOT EXISTS bot_tokens (
|
|
631
|
-
app_id TEXT PRIMARY KEY,
|
|
632
|
-
token TEXT NOT NULL,
|
|
633
|
-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
634
|
-
)
|
|
635
|
-
`)
|
|
636
|
-
|
|
637
|
-
db.exec(`
|
|
638
|
-
CREATE TABLE IF NOT EXISTS channel_directories (
|
|
639
|
-
channel_id TEXT PRIMARY KEY,
|
|
640
|
-
directory TEXT NOT NULL,
|
|
641
|
-
channel_type TEXT NOT NULL,
|
|
642
|
-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
643
|
-
)
|
|
644
|
-
`)
|
|
645
|
-
|
|
646
|
-
db.exec(`
|
|
647
|
-
CREATE TABLE IF NOT EXISTS bot_api_keys (
|
|
648
|
-
app_id TEXT PRIMARY KEY,
|
|
649
|
-
gemini_api_key TEXT,
|
|
650
|
-
xai_api_key TEXT,
|
|
651
|
-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
652
|
-
)
|
|
653
|
-
`)
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
return db
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
export async function ensureKimakiCategory(guild: Guild): Promise<CategoryChannel> {
|
|
660
|
-
const existingCategory = guild.channels.cache.find(
|
|
661
|
-
(channel): channel is CategoryChannel => {
|
|
662
|
-
if (channel.type !== ChannelType.GuildCategory) {
|
|
663
|
-
return false
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
return channel.name.toLowerCase() === 'kimaki'
|
|
667
|
-
},
|
|
668
|
-
)
|
|
669
|
-
|
|
670
|
-
if (existingCategory) {
|
|
671
|
-
return existingCategory
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
return guild.channels.create({
|
|
675
|
-
name: 'Kimaki',
|
|
676
|
-
type: ChannelType.GuildCategory,
|
|
677
|
-
})
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
export async function createProjectChannels({
|
|
681
|
-
guild,
|
|
682
|
-
projectDirectory,
|
|
683
|
-
appId,
|
|
684
|
-
}: {
|
|
685
|
-
guild: Guild
|
|
686
|
-
projectDirectory: string
|
|
687
|
-
appId: string
|
|
688
|
-
}): Promise<{ textChannelId: string; voiceChannelId: string; channelName: string }> {
|
|
689
|
-
const baseName = path.basename(projectDirectory)
|
|
690
|
-
const channelName = `${baseName}`
|
|
691
|
-
.toLowerCase()
|
|
692
|
-
.replace(/[^a-z0-9-]/g, '-')
|
|
693
|
-
.slice(0, 100)
|
|
694
|
-
|
|
695
|
-
const kimakiCategory = await ensureKimakiCategory(guild)
|
|
696
|
-
|
|
697
|
-
const textChannel = await guild.channels.create({
|
|
698
|
-
name: channelName,
|
|
699
|
-
type: ChannelType.GuildText,
|
|
700
|
-
parent: kimakiCategory,
|
|
701
|
-
topic: `<kimaki><directory>${projectDirectory}</directory><app>${appId}</app></kimaki>`,
|
|
702
|
-
})
|
|
703
|
-
|
|
704
|
-
const voiceChannel = await guild.channels.create({
|
|
705
|
-
name: channelName,
|
|
706
|
-
type: ChannelType.GuildVoice,
|
|
707
|
-
parent: kimakiCategory,
|
|
708
|
-
})
|
|
709
|
-
|
|
710
|
-
getDatabase()
|
|
711
|
-
.prepare(
|
|
712
|
-
'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
|
|
713
|
-
)
|
|
714
|
-
.run(textChannel.id, projectDirectory, 'text')
|
|
715
|
-
|
|
716
|
-
getDatabase()
|
|
717
|
-
.prepare(
|
|
718
|
-
'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
|
|
719
|
-
)
|
|
720
|
-
.run(voiceChannel.id, projectDirectory, 'voice')
|
|
721
|
-
|
|
722
|
-
return {
|
|
723
|
-
textChannelId: textChannel.id,
|
|
724
|
-
voiceChannelId: voiceChannel.id,
|
|
725
|
-
channelName,
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
async function getOpenPort(): Promise<number> {
|
|
730
|
-
return new Promise((resolve, reject) => {
|
|
731
|
-
const server = net.createServer()
|
|
732
|
-
server.listen(0, () => {
|
|
733
|
-
const address = server.address()
|
|
734
|
-
if (address && typeof address === 'object') {
|
|
735
|
-
const port = address.port
|
|
736
|
-
server.close(() => {
|
|
737
|
-
resolve(port)
|
|
738
|
-
})
|
|
739
|
-
} else {
|
|
740
|
-
reject(new Error('Failed to get port'))
|
|
741
|
-
}
|
|
742
|
-
})
|
|
743
|
-
server.on('error', reject)
|
|
744
|
-
})
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
/**
|
|
748
|
-
* Send a message to a Discord thread, automatically splitting long messages
|
|
749
|
-
* @param thread - The thread channel to send to
|
|
750
|
-
* @param content - The content to send (can be longer than 2000 chars)
|
|
751
|
-
* @returns The first message sent
|
|
752
|
-
*/
|
|
753
|
-
async function sendThreadMessage(
|
|
754
|
-
thread: ThreadChannel,
|
|
755
|
-
content: string,
|
|
756
|
-
): Promise<Message> {
|
|
757
|
-
const MAX_LENGTH = 2000
|
|
758
|
-
|
|
759
|
-
content = escapeBackticksInCodeBlocks(content)
|
|
760
|
-
|
|
761
|
-
const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH })
|
|
762
|
-
|
|
763
|
-
if (chunks.length > 1) {
|
|
764
|
-
discordLogger.log(
|
|
765
|
-
`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`,
|
|
766
|
-
)
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
let firstMessage: Message | undefined
|
|
770
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
771
|
-
const chunk = chunks[i]
|
|
772
|
-
if (!chunk) {
|
|
773
|
-
continue
|
|
774
|
-
}
|
|
775
|
-
const message = await thread.send(chunk)
|
|
776
|
-
if (i === 0) {
|
|
777
|
-
firstMessage = message
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
return firstMessage!
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
async function waitForServer(port: number, maxAttempts = 30): Promise<boolean> {
|
|
785
|
-
for (let i = 0; i < maxAttempts; i++) {
|
|
786
|
-
try {
|
|
787
|
-
const endpoints = [
|
|
788
|
-
`http://localhost:${port}/api/health`,
|
|
789
|
-
`http://localhost:${port}/`,
|
|
790
|
-
`http://localhost:${port}/api`,
|
|
791
|
-
]
|
|
792
|
-
|
|
793
|
-
for (const endpoint of endpoints) {
|
|
794
|
-
try {
|
|
795
|
-
const response = await fetch(endpoint)
|
|
796
|
-
if (response.status < 500) {
|
|
797
|
-
opencodeLogger.log(`Server ready on port `)
|
|
798
|
-
return true
|
|
799
|
-
}
|
|
800
|
-
} catch (e) {}
|
|
801
|
-
}
|
|
802
|
-
} catch (e) {}
|
|
803
|
-
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
804
|
-
}
|
|
805
|
-
throw new Error(
|
|
806
|
-
`Server did not start on port ${port} after ${maxAttempts} seconds`,
|
|
807
|
-
)
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
async function processVoiceAttachment({
|
|
811
|
-
message,
|
|
812
|
-
thread,
|
|
813
|
-
projectDirectory,
|
|
814
|
-
isNewThread = false,
|
|
815
|
-
appId,
|
|
816
|
-
}: {
|
|
817
|
-
message: Message
|
|
818
|
-
thread: ThreadChannel
|
|
819
|
-
projectDirectory?: string
|
|
820
|
-
isNewThread?: boolean
|
|
821
|
-
appId?: string
|
|
822
|
-
}): Promise<string | null> {
|
|
823
|
-
const audioAttachment = Array.from(message.attachments.values()).find(
|
|
824
|
-
(attachment) => attachment.contentType?.startsWith('audio/'),
|
|
825
|
-
)
|
|
826
|
-
|
|
827
|
-
if (!audioAttachment) return null
|
|
828
|
-
|
|
829
|
-
voiceLogger.log(
|
|
830
|
-
`Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`,
|
|
831
|
-
)
|
|
832
|
-
|
|
833
|
-
await sendThreadMessage(thread, '🎤 Transcribing voice message...')
|
|
834
|
-
|
|
835
|
-
const audioResponse = await fetch(audioAttachment.url)
|
|
836
|
-
const audioBuffer = Buffer.from(await audioResponse.arrayBuffer())
|
|
837
|
-
|
|
838
|
-
voiceLogger.log(`Downloaded ${audioBuffer.length} bytes, transcribing...`)
|
|
839
|
-
|
|
840
|
-
// Get project file tree for context if directory is provided
|
|
841
|
-
let transcriptionPrompt = 'Discord voice message transcription'
|
|
842
|
-
|
|
843
|
-
if (projectDirectory) {
|
|
844
|
-
try {
|
|
845
|
-
voiceLogger.log(`Getting project file tree from ${projectDirectory}`)
|
|
846
|
-
// Use git ls-files to get tracked files, then pipe to tree
|
|
847
|
-
const execAsync = promisify(exec)
|
|
848
|
-
const { stdout } = await execAsync('git ls-files | tree --fromfile -a', {
|
|
849
|
-
cwd: projectDirectory,
|
|
850
|
-
})
|
|
851
|
-
const result = stdout
|
|
852
|
-
|
|
853
|
-
if (result) {
|
|
854
|
-
transcriptionPrompt = `Discord voice message transcription. Project file structure:\n${result}\n\nPlease transcribe file names and paths accurately based on this context.`
|
|
855
|
-
voiceLogger.log(`Added project context to transcription prompt`)
|
|
856
|
-
}
|
|
857
|
-
} catch (e) {
|
|
858
|
-
voiceLogger.log(`Could not get project tree:`, e)
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// Get Gemini API key from database if appId is provided
|
|
863
|
-
let geminiApiKey: string | undefined
|
|
864
|
-
if (appId) {
|
|
865
|
-
const apiKeys = getDatabase()
|
|
866
|
-
.prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
|
|
867
|
-
.get(appId) as { gemini_api_key: string | null } | undefined
|
|
868
|
-
|
|
869
|
-
if (apiKeys?.gemini_api_key) {
|
|
870
|
-
geminiApiKey = apiKeys.gemini_api_key
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
const transcription = await transcribeAudio({
|
|
875
|
-
audio: audioBuffer,
|
|
876
|
-
prompt: transcriptionPrompt,
|
|
877
|
-
geminiApiKey,
|
|
878
|
-
})
|
|
879
|
-
|
|
880
|
-
voiceLogger.log(
|
|
881
|
-
`Transcription successful: "${transcription.slice(0, 50)}${transcription.length > 50 ? '...' : ''}"`,
|
|
882
|
-
)
|
|
883
|
-
|
|
884
|
-
// Update thread name with transcribed content only for new threads
|
|
885
|
-
if (isNewThread) {
|
|
886
|
-
const threadName = transcription.replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
887
|
-
if (threadName) {
|
|
888
|
-
try {
|
|
889
|
-
await Promise.race([
|
|
890
|
-
thread.setName(threadName),
|
|
891
|
-
new Promise((resolve) => setTimeout(resolve, 2000)),
|
|
892
|
-
])
|
|
893
|
-
discordLogger.log(`Updated thread name to: "${threadName}"`)
|
|
894
|
-
} catch (e) {
|
|
895
|
-
discordLogger.log(`Could not update thread name:`, e)
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
await sendThreadMessage(
|
|
901
|
-
thread,
|
|
902
|
-
`📝 **Transcribed message:** ${escapeDiscordFormatting(transcription)}`,
|
|
903
|
-
)
|
|
904
|
-
return transcription
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
const TEXT_MIME_TYPES = [
|
|
908
|
-
'text/',
|
|
909
|
-
'application/json',
|
|
910
|
-
'application/xml',
|
|
911
|
-
'application/javascript',
|
|
912
|
-
'application/typescript',
|
|
913
|
-
'application/x-yaml',
|
|
914
|
-
'application/toml',
|
|
915
|
-
]
|
|
916
|
-
|
|
917
|
-
function isTextMimeType(contentType: string | null): boolean {
|
|
918
|
-
if (!contentType) {
|
|
919
|
-
return false
|
|
920
|
-
}
|
|
921
|
-
return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix))
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
async function getTextAttachments(message: Message): Promise<string> {
|
|
925
|
-
const textAttachments = Array.from(message.attachments.values()).filter(
|
|
926
|
-
(attachment) => isTextMimeType(attachment.contentType),
|
|
927
|
-
)
|
|
928
|
-
|
|
929
|
-
if (textAttachments.length === 0) {
|
|
930
|
-
return ''
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
const textContents = await Promise.all(
|
|
934
|
-
textAttachments.map(async (attachment) => {
|
|
935
|
-
try {
|
|
936
|
-
const response = await fetch(attachment.url)
|
|
937
|
-
if (!response.ok) {
|
|
938
|
-
return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`
|
|
939
|
-
}
|
|
940
|
-
const text = await response.text()
|
|
941
|
-
return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`
|
|
942
|
-
} catch (error) {
|
|
943
|
-
const errMsg = error instanceof Error ? error.message : String(error)
|
|
944
|
-
return `<attachment filename="${attachment.name}" error="${errMsg}" />`
|
|
945
|
-
}
|
|
946
|
-
}),
|
|
947
|
-
)
|
|
948
|
-
|
|
949
|
-
return textContents.join('\n\n')
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
function getFileAttachments(message: Message): FilePartInput[] {
|
|
953
|
-
const fileAttachments = Array.from(message.attachments.values()).filter(
|
|
954
|
-
(attachment) => {
|
|
955
|
-
const contentType = attachment.contentType || ''
|
|
956
|
-
return (
|
|
957
|
-
contentType.startsWith('image/') || contentType === 'application/pdf'
|
|
958
|
-
)
|
|
959
|
-
},
|
|
960
|
-
)
|
|
961
|
-
|
|
962
|
-
return fileAttachments.map((attachment) => ({
|
|
963
|
-
type: 'file' as const,
|
|
964
|
-
mime: attachment.contentType || 'application/octet-stream',
|
|
965
|
-
filename: attachment.name,
|
|
966
|
-
url: attachment.url,
|
|
967
|
-
}))
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
export function escapeBackticksInCodeBlocks(markdown: string): string {
|
|
971
|
-
const lexer = new Lexer()
|
|
972
|
-
const tokens = lexer.lex(markdown)
|
|
973
|
-
|
|
974
|
-
let result = ''
|
|
975
|
-
|
|
976
|
-
for (const token of tokens) {
|
|
977
|
-
if (token.type === 'code') {
|
|
978
|
-
const escapedCode = token.text.replace(/`/g, '\\`')
|
|
979
|
-
result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n'
|
|
980
|
-
} else {
|
|
981
|
-
result += token.raw
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
return result
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
type LineInfo = {
|
|
989
|
-
text: string
|
|
990
|
-
inCodeBlock: boolean
|
|
991
|
-
lang: string
|
|
992
|
-
isOpeningFence: boolean
|
|
993
|
-
isClosingFence: boolean
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
export function splitMarkdownForDiscord({
|
|
997
|
-
content,
|
|
998
|
-
maxLength,
|
|
999
|
-
}: {
|
|
1000
|
-
content: string
|
|
1001
|
-
maxLength: number
|
|
1002
|
-
}): string[] {
|
|
1003
|
-
if (content.length <= maxLength) {
|
|
1004
|
-
return [content]
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
const lexer = new Lexer()
|
|
1008
|
-
const tokens = lexer.lex(content)
|
|
1009
|
-
|
|
1010
|
-
const lines: LineInfo[] = []
|
|
1011
|
-
for (const token of tokens) {
|
|
1012
|
-
if (token.type === 'code') {
|
|
1013
|
-
const lang = token.lang || ''
|
|
1014
|
-
lines.push({ text: '```' + lang + '\n', inCodeBlock: false, lang, isOpeningFence: true, isClosingFence: false })
|
|
1015
|
-
const codeLines = token.text.split('\n')
|
|
1016
|
-
for (const codeLine of codeLines) {
|
|
1017
|
-
lines.push({ text: codeLine + '\n', inCodeBlock: true, lang, isOpeningFence: false, isClosingFence: false })
|
|
1018
|
-
}
|
|
1019
|
-
lines.push({ text: '```\n', inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: true })
|
|
1020
|
-
} else {
|
|
1021
|
-
const rawLines = token.raw.split('\n')
|
|
1022
|
-
for (let i = 0; i < rawLines.length; i++) {
|
|
1023
|
-
const isLast = i === rawLines.length - 1
|
|
1024
|
-
const text = isLast ? rawLines[i]! : rawLines[i]! + '\n'
|
|
1025
|
-
if (text) {
|
|
1026
|
-
lines.push({ text, inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: false })
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
const chunks: string[] = []
|
|
1033
|
-
let currentChunk = ''
|
|
1034
|
-
let currentLang: string | null = null
|
|
1035
|
-
|
|
1036
|
-
for (const line of lines) {
|
|
1037
|
-
const wouldExceed = currentChunk.length + line.text.length > maxLength
|
|
1038
|
-
|
|
1039
|
-
if (wouldExceed && currentChunk) {
|
|
1040
|
-
if (currentLang !== null) {
|
|
1041
|
-
currentChunk += '```\n'
|
|
1042
|
-
}
|
|
1043
|
-
chunks.push(currentChunk)
|
|
1044
|
-
|
|
1045
|
-
if (line.isClosingFence && currentLang !== null) {
|
|
1046
|
-
currentChunk = ''
|
|
1047
|
-
currentLang = null
|
|
1048
|
-
continue
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
if (line.inCodeBlock || line.isOpeningFence) {
|
|
1052
|
-
const lang = line.lang
|
|
1053
|
-
currentChunk = '```' + lang + '\n'
|
|
1054
|
-
if (!line.isOpeningFence) {
|
|
1055
|
-
currentChunk += line.text
|
|
1056
|
-
}
|
|
1057
|
-
currentLang = lang
|
|
1058
|
-
} else {
|
|
1059
|
-
currentChunk = line.text
|
|
1060
|
-
currentLang = null
|
|
1061
|
-
}
|
|
1062
|
-
} else {
|
|
1063
|
-
currentChunk += line.text
|
|
1064
|
-
if (line.inCodeBlock || line.isOpeningFence) {
|
|
1065
|
-
currentLang = line.lang
|
|
1066
|
-
} else if (line.isClosingFence) {
|
|
1067
|
-
currentLang = null
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
if (currentChunk) {
|
|
1073
|
-
chunks.push(currentChunk)
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
return chunks
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
/**
|
|
1080
|
-
* Escape Discord formatting characters to prevent breaking code blocks and inline code
|
|
1081
|
-
*/
|
|
1082
|
-
function escapeDiscordFormatting(text: string): string {
|
|
1083
|
-
return text
|
|
1084
|
-
.replace(/```/g, '\\`\\`\\`') // Triple backticks
|
|
1085
|
-
.replace(/````/g, '\\`\\`\\`\\`') // Quadruple backticks
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
function escapeInlineCode(text: string): string {
|
|
1089
|
-
return text
|
|
1090
|
-
.replace(/``/g, '\\`\\`') // Double backticks
|
|
1091
|
-
.replace(/(?<!\\)`(?!`)/g, '\\`') // Single backticks (not already escaped or part of double/triple)
|
|
1092
|
-
.replace(/\|\|/g, '\\|\\|') // Double pipes (spoiler syntax)
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
function resolveTextChannel(
|
|
1096
|
-
channel: TextChannel | ThreadChannel | null | undefined,
|
|
1097
|
-
): TextChannel | null {
|
|
1098
|
-
if (!channel) {
|
|
1099
|
-
return null
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
if (channel.type === ChannelType.GuildText) {
|
|
1103
|
-
return channel as TextChannel
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
if (
|
|
1107
|
-
channel.type === ChannelType.PublicThread ||
|
|
1108
|
-
channel.type === ChannelType.PrivateThread ||
|
|
1109
|
-
channel.type === ChannelType.AnnouncementThread
|
|
1110
|
-
) {
|
|
1111
|
-
const parent = channel.parent
|
|
1112
|
-
if (parent?.type === ChannelType.GuildText) {
|
|
1113
|
-
return parent as TextChannel
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
return null
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
function getKimakiMetadata(textChannel: TextChannel | null): {
|
|
1121
|
-
projectDirectory?: string
|
|
1122
|
-
channelAppId?: string
|
|
1123
|
-
} {
|
|
1124
|
-
if (!textChannel?.topic) {
|
|
1125
|
-
return {}
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
const extracted = extractTagsArrays({
|
|
1129
|
-
xml: textChannel.topic,
|
|
1130
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
1131
|
-
})
|
|
1132
|
-
|
|
1133
|
-
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
1134
|
-
const channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
1135
|
-
|
|
1136
|
-
return { projectDirectory, channelAppId }
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
export async function initializeOpencodeForDirectory(directory: string) {
|
|
1140
|
-
// console.log(`[OPENCODE] Initializing for directory: ${directory}`)
|
|
1141
|
-
|
|
1142
|
-
// Check if we already have a server for this directory
|
|
1143
|
-
const existing = opencodeServers.get(directory)
|
|
1144
|
-
if (existing && !existing.process.killed) {
|
|
1145
|
-
opencodeLogger.log(
|
|
1146
|
-
`Reusing existing server on port ${existing.port} for directory: ${directory}`,
|
|
1147
|
-
)
|
|
1148
|
-
return () => {
|
|
1149
|
-
const entry = opencodeServers.get(directory)
|
|
1150
|
-
if (!entry?.client) {
|
|
1151
|
-
throw new Error(
|
|
1152
|
-
`OpenCode server for directory "${directory}" is in an error state (no client available)`,
|
|
1153
|
-
)
|
|
1154
|
-
}
|
|
1155
|
-
return entry.client
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
const port = await getOpenPort()
|
|
1160
|
-
// console.log(
|
|
1161
|
-
// `[OPENCODE] Starting new server on port ${port} for directory: ${directory}`,
|
|
1162
|
-
// )
|
|
1163
|
-
|
|
1164
|
-
const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
|
|
1165
|
-
|
|
1166
|
-
const serverProcess = spawn(
|
|
1167
|
-
opencodeCommand,
|
|
1168
|
-
['serve', '--port', port.toString()],
|
|
1169
|
-
{
|
|
1170
|
-
stdio: 'pipe',
|
|
1171
|
-
detached: false,
|
|
1172
|
-
cwd: directory,
|
|
1173
|
-
env: {
|
|
1174
|
-
...process.env,
|
|
1175
|
-
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
1176
|
-
$schema: 'https://opencode.ai/config.json',
|
|
1177
|
-
lsp: {
|
|
1178
|
-
typescript: { disabled: true },
|
|
1179
|
-
eslint: { disabled: true },
|
|
1180
|
-
gopls: { disabled: true },
|
|
1181
|
-
'ruby-lsp': { disabled: true },
|
|
1182
|
-
pyright: { disabled: true },
|
|
1183
|
-
'elixir-ls': { disabled: true },
|
|
1184
|
-
zls: { disabled: true },
|
|
1185
|
-
csharp: { disabled: true },
|
|
1186
|
-
vue: { disabled: true },
|
|
1187
|
-
rust: { disabled: true },
|
|
1188
|
-
clangd: { disabled: true },
|
|
1189
|
-
svelte: { disabled: true },
|
|
1190
|
-
},
|
|
1191
|
-
formatter: {
|
|
1192
|
-
prettier: { disabled: true },
|
|
1193
|
-
biome: { disabled: true },
|
|
1194
|
-
gofmt: { disabled: true },
|
|
1195
|
-
mix: { disabled: true },
|
|
1196
|
-
zig: { disabled: true },
|
|
1197
|
-
'clang-format': { disabled: true },
|
|
1198
|
-
ktlint: { disabled: true },
|
|
1199
|
-
ruff: { disabled: true },
|
|
1200
|
-
rubocop: { disabled: true },
|
|
1201
|
-
standardrb: { disabled: true },
|
|
1202
|
-
htmlbeautifier: { disabled: true },
|
|
1203
|
-
},
|
|
1204
|
-
permission: {
|
|
1205
|
-
edit: 'allow',
|
|
1206
|
-
bash: 'allow',
|
|
1207
|
-
webfetch: 'allow',
|
|
1208
|
-
},
|
|
1209
|
-
} satisfies Config),
|
|
1210
|
-
OPENCODE_PORT: port.toString(),
|
|
1211
|
-
},
|
|
1212
|
-
},
|
|
1213
|
-
)
|
|
1214
|
-
|
|
1215
|
-
serverProcess.stdout?.on('data', (data) => {
|
|
1216
|
-
opencodeLogger.log(`opencode ${directory}: ${data.toString().trim()}`)
|
|
1217
|
-
})
|
|
1218
|
-
|
|
1219
|
-
serverProcess.stderr?.on('data', (data) => {
|
|
1220
|
-
opencodeLogger.error(`opencode ${directory}: ${data.toString().trim()}`)
|
|
1221
|
-
})
|
|
1222
|
-
|
|
1223
|
-
serverProcess.on('error', (error) => {
|
|
1224
|
-
opencodeLogger.error(`Failed to start server on port :`, port, error)
|
|
1225
|
-
})
|
|
1226
|
-
|
|
1227
|
-
serverProcess.on('exit', (code) => {
|
|
1228
|
-
opencodeLogger.log(
|
|
1229
|
-
`Opencode server on ${directory} exited with code:`,
|
|
1230
|
-
code,
|
|
1231
|
-
)
|
|
1232
|
-
opencodeServers.delete(directory)
|
|
1233
|
-
if (code !== 0) {
|
|
1234
|
-
const retryCount = serverRetryCount.get(directory) || 0
|
|
1235
|
-
if (retryCount < 5) {
|
|
1236
|
-
serverRetryCount.set(directory, retryCount + 1)
|
|
1237
|
-
opencodeLogger.log(
|
|
1238
|
-
`Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`,
|
|
1239
|
-
)
|
|
1240
|
-
initializeOpencodeForDirectory(directory).catch((e) => {
|
|
1241
|
-
opencodeLogger.error(`Failed to restart opencode server:`, e)
|
|
1242
|
-
})
|
|
1243
|
-
} else {
|
|
1244
|
-
opencodeLogger.error(
|
|
1245
|
-
`Server for ${directory} crashed too many times (5), not restarting`,
|
|
1246
|
-
)
|
|
1247
|
-
}
|
|
1248
|
-
} else {
|
|
1249
|
-
// Reset retry count on clean exit
|
|
1250
|
-
serverRetryCount.delete(directory)
|
|
1251
|
-
}
|
|
1252
|
-
})
|
|
1253
|
-
|
|
1254
|
-
await waitForServer(port)
|
|
1255
|
-
|
|
1256
|
-
const client = createOpencodeClient({
|
|
1257
|
-
baseUrl: `http://localhost:${port}`,
|
|
1258
|
-
fetch: (request: Request) =>
|
|
1259
|
-
fetch(request, {
|
|
1260
|
-
// @ts-ignore
|
|
1261
|
-
timeout: false,
|
|
1262
|
-
}),
|
|
1263
|
-
})
|
|
1264
|
-
|
|
1265
|
-
opencodeServers.set(directory, {
|
|
1266
|
-
process: serverProcess,
|
|
1267
|
-
client,
|
|
1268
|
-
port,
|
|
1269
|
-
})
|
|
1270
|
-
|
|
1271
|
-
return () => {
|
|
1272
|
-
const entry = opencodeServers.get(directory)
|
|
1273
|
-
if (!entry?.client) {
|
|
1274
|
-
throw new Error(
|
|
1275
|
-
`OpenCode server for directory "${directory}" is in an error state (no client available)`,
|
|
1276
|
-
)
|
|
1277
|
-
}
|
|
1278
|
-
return entry.client
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
function getToolSummaryText(part: Part): string {
|
|
1283
|
-
if (part.type !== 'tool') return ''
|
|
1284
|
-
|
|
1285
|
-
if (part.tool === 'edit') {
|
|
1286
|
-
const filePath = (part.state.input?.filePath as string) || ''
|
|
1287
|
-
const newString = (part.state.input?.newString as string) || ''
|
|
1288
|
-
const oldString = (part.state.input?.oldString as string) || ''
|
|
1289
|
-
const added = newString.split('\n').length
|
|
1290
|
-
const removed = oldString.split('\n').length
|
|
1291
|
-
const fileName = filePath.split('/').pop() || ''
|
|
1292
|
-
return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
if (part.tool === 'write') {
|
|
1296
|
-
const filePath = (part.state.input?.filePath as string) || ''
|
|
1297
|
-
const content = (part.state.input?.content as string) || ''
|
|
1298
|
-
const lines = content.split('\n').length
|
|
1299
|
-
const fileName = filePath.split('/').pop() || ''
|
|
1300
|
-
return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
if (part.tool === 'webfetch') {
|
|
1304
|
-
const url = (part.state.input?.url as string) || ''
|
|
1305
|
-
const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
|
|
1306
|
-
return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : ''
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
if (part.tool === 'read') {
|
|
1310
|
-
const filePath = (part.state.input?.filePath as string) || ''
|
|
1311
|
-
const fileName = filePath.split('/').pop() || ''
|
|
1312
|
-
return fileName ? `*${fileName}*` : ''
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
if (part.tool === 'list') {
|
|
1316
|
-
const path = (part.state.input?.path as string) || ''
|
|
1317
|
-
const dirName = path.split('/').pop() || path
|
|
1318
|
-
return dirName ? `*${dirName}*` : ''
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
if (part.tool === 'glob') {
|
|
1322
|
-
const pattern = (part.state.input?.pattern as string) || ''
|
|
1323
|
-
return pattern ? `*${pattern}*` : ''
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
if (part.tool === 'grep') {
|
|
1327
|
-
const pattern = (part.state.input?.pattern as string) || ''
|
|
1328
|
-
return pattern ? `*${pattern}*` : ''
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
if (part.tool === 'bash' || part.tool === 'task' || part.tool === 'todoread' || part.tool === 'todowrite') {
|
|
1332
|
-
return ''
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
if (!part.state.input) return ''
|
|
1336
|
-
|
|
1337
|
-
const inputFields = Object.entries(part.state.input)
|
|
1338
|
-
.map(([key, value]) => {
|
|
1339
|
-
if (value === null || value === undefined) return null
|
|
1340
|
-
const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
|
|
1341
|
-
const truncatedValue = stringValue.length > 300 ? stringValue.slice(0, 300) + '…' : stringValue
|
|
1342
|
-
return `${key}: ${truncatedValue}`
|
|
1343
|
-
})
|
|
1344
|
-
.filter(Boolean)
|
|
1345
|
-
|
|
1346
|
-
if (inputFields.length === 0) return ''
|
|
1347
|
-
|
|
1348
|
-
return `(${inputFields.join(', ')})`
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
function formatTodoList(part: Part): string {
|
|
1352
|
-
if (part.type !== 'tool' || part.tool !== 'todowrite') return ''
|
|
1353
|
-
const todos =
|
|
1354
|
-
(part.state.input?.todos as {
|
|
1355
|
-
content: string
|
|
1356
|
-
status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
|
1357
|
-
}[]) || []
|
|
1358
|
-
if (todos.length === 0) return ''
|
|
1359
|
-
return todos
|
|
1360
|
-
.map((todo, i) => {
|
|
1361
|
-
const num = `${i + 1}.`
|
|
1362
|
-
if (todo.status === 'in_progress') {
|
|
1363
|
-
return `${num} **${todo.content}**`
|
|
1364
|
-
}
|
|
1365
|
-
if (todo.status === 'completed' || todo.status === 'cancelled') {
|
|
1366
|
-
return `${num} ~~${todo.content}~~`
|
|
1367
|
-
}
|
|
1368
|
-
return `${num} ${todo.content}`
|
|
1369
|
-
})
|
|
1370
|
-
.join('\n')
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
function formatPart(part: Part): string {
|
|
1374
|
-
if (part.type === 'text') {
|
|
1375
|
-
return part.text || ''
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
if (part.type === 'reasoning') {
|
|
1379
|
-
if (!part.text?.trim()) return ''
|
|
1380
|
-
return `◼︎ thinking`
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
if (part.type === 'file') {
|
|
1384
|
-
return `📄 ${part.filename || 'File'}`
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
if (part.type === 'step-start' || part.type === 'step-finish' || part.type === 'patch') {
|
|
1388
|
-
return ''
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
if (part.type === 'agent') {
|
|
1392
|
-
return `◼︎ agent ${part.id}`
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
if (part.type === 'snapshot') {
|
|
1396
|
-
return `◼︎ snapshot ${part.snapshot}`
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
if (part.type === 'tool') {
|
|
1400
|
-
if (part.tool === 'todowrite') {
|
|
1401
|
-
return formatTodoList(part)
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
if (part.state.status === 'pending') {
|
|
1405
|
-
return ''
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
const summaryText = getToolSummaryText(part)
|
|
1409
|
-
const stateTitle = 'title' in part.state ? part.state.title : undefined
|
|
1410
|
-
|
|
1411
|
-
let toolTitle = ''
|
|
1412
|
-
if (part.state.status === 'error') {
|
|
1413
|
-
toolTitle = part.state.error || 'error'
|
|
1414
|
-
} else if (part.tool === 'bash') {
|
|
1415
|
-
const command = (part.state.input?.command as string) || ''
|
|
1416
|
-
const description = (part.state.input?.description as string) || ''
|
|
1417
|
-
const isSingleLine = !command.includes('\n')
|
|
1418
|
-
const hasBackticks = command.includes('`')
|
|
1419
|
-
if (isSingleLine && !hasBackticks && command.length <= 50) {
|
|
1420
|
-
toolTitle = `\`${command}\``
|
|
1421
|
-
} else if (description) {
|
|
1422
|
-
toolTitle = `_${description}_`
|
|
1423
|
-
} else if (stateTitle) {
|
|
1424
|
-
toolTitle = `_${stateTitle}_`
|
|
1425
|
-
}
|
|
1426
|
-
} else if (stateTitle) {
|
|
1427
|
-
toolTitle = `_${stateTitle}_`
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
const icon = part.state.status === 'error' ? '⨯' : '◼︎'
|
|
1431
|
-
return `${icon} ${part.tool} ${toolTitle} ${summaryText}`
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
discordLogger.warn('Unknown part type:', part)
|
|
1435
|
-
return ''
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
export async function createDiscordClient() {
|
|
1439
|
-
return new Client({
|
|
1440
|
-
intents: [
|
|
1441
|
-
GatewayIntentBits.Guilds,
|
|
1442
|
-
GatewayIntentBits.GuildMessages,
|
|
1443
|
-
GatewayIntentBits.MessageContent,
|
|
1444
|
-
GatewayIntentBits.GuildVoiceStates,
|
|
1445
|
-
],
|
|
1446
|
-
partials: [
|
|
1447
|
-
Partials.Channel,
|
|
1448
|
-
Partials.Message,
|
|
1449
|
-
Partials.User,
|
|
1450
|
-
Partials.ThreadMember,
|
|
1451
|
-
],
|
|
1452
|
-
})
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
async function handleOpencodeSession({
|
|
1456
|
-
prompt,
|
|
1457
|
-
thread,
|
|
1458
|
-
projectDirectory,
|
|
1459
|
-
originalMessage,
|
|
1460
|
-
images = [],
|
|
1461
|
-
parsedCommand,
|
|
1462
|
-
}: {
|
|
1463
|
-
prompt: string
|
|
1464
|
-
thread: ThreadChannel
|
|
1465
|
-
projectDirectory?: string
|
|
1466
|
-
originalMessage?: Message
|
|
1467
|
-
images?: FilePartInput[]
|
|
1468
|
-
parsedCommand?: ParsedCommand
|
|
1469
|
-
}): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
|
|
1470
|
-
voiceLogger.log(
|
|
1471
|
-
`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
|
|
1472
|
-
)
|
|
1473
|
-
|
|
1474
|
-
// Track session start time
|
|
1475
|
-
const sessionStartTime = Date.now()
|
|
1476
|
-
|
|
1477
|
-
// Use default directory if not specified
|
|
1478
|
-
const directory = projectDirectory || process.cwd()
|
|
1479
|
-
sessionLogger.log(`Using directory: ${directory}`)
|
|
1480
|
-
|
|
1481
|
-
// Note: We'll cancel the existing request after we have the session ID
|
|
1482
|
-
|
|
1483
|
-
const getClient = await initializeOpencodeForDirectory(directory)
|
|
1484
|
-
|
|
1485
|
-
// Get the port for this directory
|
|
1486
|
-
const serverEntry = opencodeServers.get(directory)
|
|
1487
|
-
const port = serverEntry?.port
|
|
1488
|
-
|
|
1489
|
-
// Get session ID from database
|
|
1490
|
-
const row = getDatabase()
|
|
1491
|
-
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
1492
|
-
.get(thread.id) as { session_id: string } | undefined
|
|
1493
|
-
let sessionId = row?.session_id
|
|
1494
|
-
let session
|
|
1495
|
-
|
|
1496
|
-
if (sessionId) {
|
|
1497
|
-
sessionLogger.log(`Attempting to reuse existing session ${sessionId}`)
|
|
1498
|
-
try {
|
|
1499
|
-
const sessionResponse = await getClient().session.get({
|
|
1500
|
-
path: { id: sessionId },
|
|
1501
|
-
})
|
|
1502
|
-
session = sessionResponse.data
|
|
1503
|
-
sessionLogger.log(`Successfully reused session ${sessionId}`)
|
|
1504
|
-
} catch (error) {
|
|
1505
|
-
voiceLogger.log(
|
|
1506
|
-
`[SESSION] Session ${sessionId} not found, will create new one`,
|
|
1507
|
-
)
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
if (!session) {
|
|
1512
|
-
const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80)
|
|
1513
|
-
voiceLogger.log(
|
|
1514
|
-
`[SESSION] Creating new session with title: "${sessionTitle}"`,
|
|
1515
|
-
)
|
|
1516
|
-
const sessionResponse = await getClient().session.create({
|
|
1517
|
-
body: { title: sessionTitle },
|
|
1518
|
-
})
|
|
1519
|
-
session = sessionResponse.data
|
|
1520
|
-
sessionLogger.log(`Created new session ${session?.id}`)
|
|
1521
|
-
}
|
|
1522
|
-
|
|
1523
|
-
if (!session) {
|
|
1524
|
-
throw new Error('Failed to create or get session')
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
// Store session ID in database
|
|
1528
|
-
getDatabase()
|
|
1529
|
-
.prepare(
|
|
1530
|
-
'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
|
|
1531
|
-
)
|
|
1532
|
-
.run(thread.id, session.id)
|
|
1533
|
-
dbLogger.log(`Stored session ${session.id} for thread ${thread.id}`)
|
|
1534
|
-
|
|
1535
|
-
// Cancel any existing request for this session
|
|
1536
|
-
const existingController = abortControllers.get(session.id)
|
|
1537
|
-
if (existingController) {
|
|
1538
|
-
voiceLogger.log(
|
|
1539
|
-
`[ABORT] Cancelling existing request for session: ${session.id}`,
|
|
1540
|
-
)
|
|
1541
|
-
existingController.abort(new Error('New request started'))
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
const abortController = new AbortController()
|
|
1545
|
-
abortControllers.set(session.id, abortController)
|
|
1546
|
-
|
|
1547
|
-
if (existingController) {
|
|
1548
|
-
await new Promise((resolve) => { setTimeout(resolve, 200) })
|
|
1549
|
-
if (abortController.signal.aborted) {
|
|
1550
|
-
sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`)
|
|
1551
|
-
return
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
if (abortController.signal.aborted) {
|
|
1556
|
-
sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`)
|
|
1557
|
-
return
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
const eventsResult = await getClient().event.subscribe({
|
|
1561
|
-
signal: abortController.signal,
|
|
1562
|
-
})
|
|
1563
|
-
|
|
1564
|
-
if (abortController.signal.aborted) {
|
|
1565
|
-
sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`)
|
|
1566
|
-
return
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
const events = eventsResult.stream
|
|
1570
|
-
sessionLogger.log(`Subscribed to OpenCode events`)
|
|
1571
|
-
|
|
1572
|
-
const sentPartIds = new Set<string>(
|
|
1573
|
-
(getDatabase()
|
|
1574
|
-
.prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
|
|
1575
|
-
.all(thread.id) as { part_id: string }[])
|
|
1576
|
-
.map((row) => row.part_id)
|
|
1577
|
-
)
|
|
1578
|
-
|
|
1579
|
-
let currentParts: Part[] = []
|
|
1580
|
-
let stopTyping: (() => void) | null = null
|
|
1581
|
-
let usedModel: string | undefined
|
|
1582
|
-
let usedProviderID: string | undefined
|
|
1583
|
-
let tokensUsedInSession = 0
|
|
1584
|
-
|
|
1585
|
-
let typingInterval: NodeJS.Timeout | null = null
|
|
1586
|
-
|
|
1587
|
-
function startTyping(): () => void {
|
|
1588
|
-
if (abortController.signal.aborted) {
|
|
1589
|
-
discordLogger.log(`Not starting typing, already aborted`)
|
|
1590
|
-
return () => {}
|
|
1591
|
-
}
|
|
1592
|
-
if (typingInterval) {
|
|
1593
|
-
clearInterval(typingInterval)
|
|
1594
|
-
typingInterval = null
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
thread.sendTyping().catch((e) => {
|
|
1598
|
-
discordLogger.log(`Failed to send initial typing: ${e}`)
|
|
1599
|
-
})
|
|
1600
|
-
|
|
1601
|
-
typingInterval = setInterval(() => {
|
|
1602
|
-
thread.sendTyping().catch((e) => {
|
|
1603
|
-
discordLogger.log(`Failed to send periodic typing: ${e}`)
|
|
1604
|
-
})
|
|
1605
|
-
}, 8000)
|
|
1606
|
-
|
|
1607
|
-
if (!abortController.signal.aborted) {
|
|
1608
|
-
abortController.signal.addEventListener(
|
|
1609
|
-
'abort',
|
|
1610
|
-
() => {
|
|
1611
|
-
if (typingInterval) {
|
|
1612
|
-
clearInterval(typingInterval)
|
|
1613
|
-
typingInterval = null
|
|
1614
|
-
}
|
|
1615
|
-
},
|
|
1616
|
-
{ once: true },
|
|
1617
|
-
)
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
return () => {
|
|
1621
|
-
if (typingInterval) {
|
|
1622
|
-
clearInterval(typingInterval)
|
|
1623
|
-
typingInterval = null
|
|
1624
|
-
}
|
|
1625
|
-
}
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
const sendPartMessage = async (part: Part) => {
|
|
1629
|
-
const content = formatPart(part) + '\n\n'
|
|
1630
|
-
if (!content.trim() || content.length === 0) {
|
|
1631
|
-
discordLogger.log(`SKIP: Part ${part.id} has no content`)
|
|
1632
|
-
return
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
// Skip if already sent
|
|
1636
|
-
if (sentPartIds.has(part.id)) {
|
|
1637
|
-
return
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
try {
|
|
1641
|
-
const firstMessage = await sendThreadMessage(thread, content)
|
|
1642
|
-
sentPartIds.add(part.id)
|
|
1643
|
-
|
|
1644
|
-
// Store part-message mapping in database
|
|
1645
|
-
getDatabase()
|
|
1646
|
-
.prepare(
|
|
1647
|
-
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
1648
|
-
)
|
|
1649
|
-
.run(part.id, firstMessage.id, thread.id)
|
|
1650
|
-
} catch (error) {
|
|
1651
|
-
discordLogger.error(`ERROR: Failed to send part ${part.id}:`, error)
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
const eventHandler = async () => {
|
|
1656
|
-
try {
|
|
1657
|
-
let assistantMessageId: string | undefined
|
|
1658
|
-
|
|
1659
|
-
for await (const event of events) {
|
|
1660
|
-
if (event.type === 'message.updated') {
|
|
1661
|
-
const msg = event.properties.info
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
if (msg.sessionID !== session.id) {
|
|
1666
|
-
continue
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
|
-
// Track assistant message ID
|
|
1670
|
-
if (msg.role === 'assistant') {
|
|
1671
|
-
const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
|
1672
|
-
if (newTokensTotal > 0) {
|
|
1673
|
-
tokensUsedInSession = newTokensTotal
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
assistantMessageId = msg.id
|
|
1677
|
-
usedModel = msg.modelID
|
|
1678
|
-
usedProviderID = msg.providerID
|
|
1679
|
-
}
|
|
1680
|
-
} else if (event.type === 'message.part.updated') {
|
|
1681
|
-
const part = event.properties.part
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
if (part.sessionID !== session.id) {
|
|
1685
|
-
continue
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
// Only process parts from assistant messages
|
|
1689
|
-
if (part.messageID !== assistantMessageId) {
|
|
1690
|
-
continue
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
const existingIndex = currentParts.findIndex(
|
|
1694
|
-
(p: Part) => p.id === part.id,
|
|
1695
|
-
)
|
|
1696
|
-
if (existingIndex >= 0) {
|
|
1697
|
-
currentParts[existingIndex] = part
|
|
1698
|
-
} else {
|
|
1699
|
-
currentParts.push(part)
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
// Start typing on step-start
|
|
1705
|
-
if (part.type === 'step-start') {
|
|
1706
|
-
stopTyping = startTyping()
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
// Send tool parts immediately when they start running
|
|
1710
|
-
if (part.type === 'tool' && part.state.status === 'running') {
|
|
1711
|
-
await sendPartMessage(part)
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
|
-
// Send reasoning parts immediately (shows "◼︎ thinking" indicator early)
|
|
1715
|
-
if (part.type === 'reasoning') {
|
|
1716
|
-
await sendPartMessage(part)
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
// Check if this is a step-finish part
|
|
1720
|
-
if (part.type === 'step-finish') {
|
|
1721
|
-
|
|
1722
|
-
// Send all parts accumulated so far to Discord
|
|
1723
|
-
for (const p of currentParts) {
|
|
1724
|
-
// Skip step-start and step-finish parts as they have no visual content
|
|
1725
|
-
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
1726
|
-
await sendPartMessage(p)
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1729
|
-
// 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
|
|
1730
|
-
setTimeout(() => {
|
|
1731
|
-
if (abortController.signal.aborted) return
|
|
1732
|
-
stopTyping = startTyping()
|
|
1733
|
-
}, 300)
|
|
1734
|
-
}
|
|
1735
|
-
} else if (event.type === 'session.error') {
|
|
1736
|
-
sessionLogger.error(`ERROR:`, event.properties)
|
|
1737
|
-
if (event.properties.sessionID === session.id) {
|
|
1738
|
-
const errorData = event.properties.error
|
|
1739
|
-
const errorMessage = errorData?.data?.message || 'Unknown error'
|
|
1740
|
-
sessionLogger.error(`Sending error to thread: ${errorMessage}`)
|
|
1741
|
-
await sendThreadMessage(
|
|
1742
|
-
thread,
|
|
1743
|
-
`✗ opencode session error: ${errorMessage}`,
|
|
1744
|
-
)
|
|
1745
|
-
|
|
1746
|
-
// Update reaction to error
|
|
1747
|
-
if (originalMessage) {
|
|
1748
|
-
try {
|
|
1749
|
-
await originalMessage.reactions.removeAll()
|
|
1750
|
-
await originalMessage.react('❌')
|
|
1751
|
-
voiceLogger.log(
|
|
1752
|
-
`[REACTION] Added error reaction due to session error`,
|
|
1753
|
-
)
|
|
1754
|
-
} catch (e) {
|
|
1755
|
-
discordLogger.log(`Could not update reaction:`, e)
|
|
1756
|
-
}
|
|
1757
|
-
}
|
|
1758
|
-
} else {
|
|
1759
|
-
voiceLogger.log(
|
|
1760
|
-
`[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${event.properties.sessionID})`,
|
|
1761
|
-
)
|
|
1762
|
-
}
|
|
1763
|
-
break
|
|
1764
|
-
} else if (event.type === 'permission.updated') {
|
|
1765
|
-
const permission = event.properties
|
|
1766
|
-
if (permission.sessionID !== session.id) {
|
|
1767
|
-
voiceLogger.log(
|
|
1768
|
-
`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
|
|
1769
|
-
)
|
|
1770
|
-
continue
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
sessionLogger.log(
|
|
1774
|
-
`Permission requested: type=${permission.type}, title=${permission.title}`,
|
|
1775
|
-
)
|
|
1776
|
-
|
|
1777
|
-
const patternStr = Array.isArray(permission.pattern)
|
|
1778
|
-
? permission.pattern.join(', ')
|
|
1779
|
-
: permission.pattern || ''
|
|
1780
|
-
|
|
1781
|
-
const permissionMessage = await sendThreadMessage(
|
|
1782
|
-
thread,
|
|
1783
|
-
`⚠️ **Permission Required**\n\n` +
|
|
1784
|
-
`**Type:** \`${permission.type}\`\n` +
|
|
1785
|
-
`**Action:** ${permission.title}\n` +
|
|
1786
|
-
(patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
|
|
1787
|
-
`\nUse \`/accept\` or \`/reject\` to respond.`,
|
|
1788
|
-
)
|
|
1789
|
-
|
|
1790
|
-
pendingPermissions.set(thread.id, {
|
|
1791
|
-
permission,
|
|
1792
|
-
messageId: permissionMessage.id,
|
|
1793
|
-
directory,
|
|
1794
|
-
})
|
|
1795
|
-
} else if (event.type === 'permission.replied') {
|
|
1796
|
-
const { permissionID, response, sessionID } = event.properties
|
|
1797
|
-
if (sessionID !== session.id) {
|
|
1798
|
-
continue
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
sessionLogger.log(
|
|
1802
|
-
`Permission ${permissionID} replied with: ${response}`,
|
|
1803
|
-
)
|
|
1804
|
-
|
|
1805
|
-
const pending = pendingPermissions.get(thread.id)
|
|
1806
|
-
if (pending && pending.permission.id === permissionID) {
|
|
1807
|
-
pendingPermissions.delete(thread.id)
|
|
1808
|
-
}
|
|
1809
|
-
}
|
|
1810
|
-
}
|
|
1811
|
-
} catch (e) {
|
|
1812
|
-
if (isAbortError(e, abortController.signal)) {
|
|
1813
|
-
sessionLogger.log(
|
|
1814
|
-
'AbortController aborted event handling (normal exit)',
|
|
1815
|
-
)
|
|
1816
|
-
return
|
|
1817
|
-
}
|
|
1818
|
-
sessionLogger.error(`Unexpected error in event handling code`, e)
|
|
1819
|
-
throw e
|
|
1820
|
-
} finally {
|
|
1821
|
-
// Send any remaining parts that weren't sent
|
|
1822
|
-
for (const part of currentParts) {
|
|
1823
|
-
if (!sentPartIds.has(part.id)) {
|
|
1824
|
-
try {
|
|
1825
|
-
await sendPartMessage(part)
|
|
1826
|
-
} catch (error) {
|
|
1827
|
-
sessionLogger.error(`Failed to send part ${part.id}:`, error)
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
// Stop typing when session ends
|
|
1833
|
-
if (stopTyping) {
|
|
1834
|
-
stopTyping()
|
|
1835
|
-
stopTyping = null
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
// Only send duration message if request was not aborted or was aborted with 'finished' reason
|
|
1839
|
-
if (
|
|
1840
|
-
!abortController.signal.aborted ||
|
|
1841
|
-
abortController.signal.reason === 'finished'
|
|
1842
|
-
) {
|
|
1843
|
-
const sessionDuration = prettyMilliseconds(
|
|
1844
|
-
Date.now() - sessionStartTime,
|
|
1845
|
-
)
|
|
1846
|
-
const attachCommand = port ? ` ⋅ ${session.id}` : ''
|
|
1847
|
-
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
|
|
1848
|
-
let contextInfo = ''
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
try {
|
|
1852
|
-
const providersResponse = await getClient().provider.list({ query: { directory } })
|
|
1853
|
-
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
|
|
1854
|
-
const model = provider?.models?.[usedModel || '']
|
|
1855
|
-
if (model?.limit?.context) {
|
|
1856
|
-
const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100)
|
|
1857
|
-
contextInfo = ` ⋅ ${percentage}%`
|
|
1858
|
-
}
|
|
1859
|
-
} catch (e) {
|
|
1860
|
-
sessionLogger.error('Failed to fetch provider info for context percentage:', e)
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`)
|
|
1864
|
-
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`)
|
|
1865
|
-
} else {
|
|
1866
|
-
sessionLogger.log(
|
|
1867
|
-
`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
|
|
1868
|
-
)
|
|
1869
|
-
}
|
|
1870
|
-
}
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
try {
|
|
1874
|
-
const eventHandlerPromise = eventHandler()
|
|
1875
|
-
|
|
1876
|
-
if (abortController.signal.aborted) {
|
|
1877
|
-
sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
|
|
1878
|
-
return
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
|
-
stopTyping = startTyping()
|
|
1882
|
-
|
|
1883
|
-
let response: { data?: unknown; error?: unknown; response: Response }
|
|
1884
|
-
if (parsedCommand?.isCommand) {
|
|
1885
|
-
sessionLogger.log(
|
|
1886
|
-
`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`,
|
|
1887
|
-
)
|
|
1888
|
-
response = await getClient().session.command({
|
|
1889
|
-
path: { id: session.id },
|
|
1890
|
-
body: {
|
|
1891
|
-
command: parsedCommand.command,
|
|
1892
|
-
arguments: parsedCommand.arguments,
|
|
1893
|
-
},
|
|
1894
|
-
signal: abortController.signal,
|
|
1895
|
-
})
|
|
1896
|
-
} else {
|
|
1897
|
-
voiceLogger.log(
|
|
1898
|
-
`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
|
|
1899
|
-
)
|
|
1900
|
-
if (images.length > 0) {
|
|
1901
|
-
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
|
|
1902
|
-
}
|
|
1903
|
-
|
|
1904
|
-
const parts = [{ type: 'text' as const, text: prompt }, ...images]
|
|
1905
|
-
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
|
|
1906
|
-
|
|
1907
|
-
response = await getClient().session.prompt({
|
|
1908
|
-
path: { id: session.id },
|
|
1909
|
-
body: {
|
|
1910
|
-
parts,
|
|
1911
|
-
system: getOpencodeSystemMessage({ sessionId: session.id }),
|
|
1912
|
-
},
|
|
1913
|
-
signal: abortController.signal,
|
|
1914
|
-
})
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
if (response.error) {
|
|
1918
|
-
const errorMessage = (() => {
|
|
1919
|
-
const err = response.error
|
|
1920
|
-
if (err && typeof err === 'object') {
|
|
1921
|
-
if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
|
|
1922
|
-
return String(err.data.message)
|
|
1923
|
-
}
|
|
1924
|
-
if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
|
|
1925
|
-
return JSON.stringify(err.errors)
|
|
1926
|
-
}
|
|
1927
|
-
}
|
|
1928
|
-
return JSON.stringify(err)
|
|
1929
|
-
})()
|
|
1930
|
-
throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`)
|
|
1931
|
-
}
|
|
1932
|
-
|
|
1933
|
-
abortController.abort('finished')
|
|
1934
|
-
|
|
1935
|
-
sessionLogger.log(`Successfully sent prompt, got response`)
|
|
1936
|
-
|
|
1937
|
-
if (originalMessage) {
|
|
1938
|
-
try {
|
|
1939
|
-
await originalMessage.reactions.removeAll()
|
|
1940
|
-
await originalMessage.react('✅')
|
|
1941
|
-
} catch (e) {
|
|
1942
|
-
discordLogger.log(`Could not update reactions:`, e)
|
|
1943
|
-
}
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
|
-
return { sessionID: session.id, result: response.data, port }
|
|
1947
|
-
} catch (error) {
|
|
1948
|
-
sessionLogger.error(`ERROR: Failed to send prompt:`, error)
|
|
1949
|
-
|
|
1950
|
-
if (!isAbortError(error, abortController.signal)) {
|
|
1951
|
-
abortController.abort('error')
|
|
1952
|
-
|
|
1953
|
-
if (originalMessage) {
|
|
1954
|
-
try {
|
|
1955
|
-
await originalMessage.reactions.removeAll()
|
|
1956
|
-
await originalMessage.react('❌')
|
|
1957
|
-
discordLogger.log(`Added error reaction to message`)
|
|
1958
|
-
} catch (e) {
|
|
1959
|
-
discordLogger.log(`Could not update reaction:`, e)
|
|
1960
|
-
}
|
|
1961
|
-
}
|
|
1962
|
-
const errorName =
|
|
1963
|
-
error &&
|
|
1964
|
-
typeof error === 'object' &&
|
|
1965
|
-
'constructor' in error &&
|
|
1966
|
-
error.constructor &&
|
|
1967
|
-
typeof error.constructor.name === 'string'
|
|
1968
|
-
? error.constructor.name
|
|
1969
|
-
: typeof error
|
|
1970
|
-
const errorMsg =
|
|
1971
|
-
error instanceof Error ? error.stack || error.message : String(error)
|
|
1972
|
-
await sendThreadMessage(
|
|
1973
|
-
thread,
|
|
1974
|
-
`✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`,
|
|
1975
|
-
)
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
}
|
|
1979
|
-
|
|
1980
|
-
export type ChannelWithTags = {
|
|
1981
|
-
id: string
|
|
1982
|
-
name: string
|
|
1983
|
-
description: string | null
|
|
1984
|
-
kimakiDirectory?: string
|
|
1985
|
-
kimakiApp?: string
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
export async function getChannelsWithDescriptions(
|
|
1989
|
-
guild: Guild,
|
|
1990
|
-
): Promise<ChannelWithTags[]> {
|
|
1991
|
-
const channels: ChannelWithTags[] = []
|
|
1992
|
-
|
|
1993
|
-
guild.channels.cache
|
|
1994
|
-
.filter((channel) => channel.isTextBased())
|
|
1995
|
-
.forEach((channel) => {
|
|
1996
|
-
const textChannel = channel as TextChannel
|
|
1997
|
-
const description = textChannel.topic || null
|
|
1998
|
-
|
|
1999
|
-
let kimakiDirectory: string | undefined
|
|
2000
|
-
let kimakiApp: string | undefined
|
|
2001
|
-
|
|
2002
|
-
if (description) {
|
|
2003
|
-
const extracted = extractTagsArrays({
|
|
2004
|
-
xml: description,
|
|
2005
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
2006
|
-
})
|
|
2007
|
-
|
|
2008
|
-
kimakiDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
2009
|
-
kimakiApp = extracted['kimaki.app']?.[0]?.trim()
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
channels.push({
|
|
2013
|
-
id: textChannel.id,
|
|
2014
|
-
name: textChannel.name,
|
|
2015
|
-
description,
|
|
2016
|
-
kimakiDirectory,
|
|
2017
|
-
kimakiApp,
|
|
2018
|
-
})
|
|
2019
|
-
})
|
|
2020
|
-
|
|
2021
|
-
return channels
|
|
2022
|
-
}
|
|
2023
|
-
|
|
2024
|
-
export async function startDiscordBot({
|
|
2025
|
-
token,
|
|
2026
|
-
appId,
|
|
2027
|
-
discordClient,
|
|
2028
|
-
}: StartOptions & { discordClient?: Client }) {
|
|
2029
|
-
if (!discordClient) {
|
|
2030
|
-
discordClient = await createDiscordClient()
|
|
2031
|
-
}
|
|
2032
|
-
|
|
2033
|
-
// Get the app ID for this bot instance
|
|
2034
|
-
let currentAppId: string | undefined = appId
|
|
2035
|
-
|
|
2036
|
-
discordClient.once(Events.ClientReady, async (c) => {
|
|
2037
|
-
discordLogger.log(`Discord bot logged in as ${c.user.tag}`)
|
|
2038
|
-
discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`)
|
|
2039
|
-
discordLogger.log(`Bot user ID: ${c.user.id}`)
|
|
2040
|
-
|
|
2041
|
-
// If appId wasn't provided, fetch it from the application
|
|
2042
|
-
if (!currentAppId) {
|
|
2043
|
-
await c.application?.fetch()
|
|
2044
|
-
currentAppId = c.application?.id
|
|
2045
|
-
|
|
2046
|
-
if (!currentAppId) {
|
|
2047
|
-
discordLogger.error('Could not get application ID')
|
|
2048
|
-
throw new Error('Failed to get bot application ID')
|
|
2049
|
-
}
|
|
2050
|
-
discordLogger.log(`Bot Application ID (fetched): ${currentAppId}`)
|
|
2051
|
-
} else {
|
|
2052
|
-
discordLogger.log(`Bot Application ID (provided): ${currentAppId}`)
|
|
2053
|
-
}
|
|
2054
|
-
|
|
2055
|
-
// List all guilds and channels that belong to this bot
|
|
2056
|
-
for (const guild of c.guilds.cache.values()) {
|
|
2057
|
-
discordLogger.log(`${guild.name} (${guild.id})`)
|
|
2058
|
-
|
|
2059
|
-
const channels = await getChannelsWithDescriptions(guild)
|
|
2060
|
-
// Only show channels that belong to this bot
|
|
2061
|
-
const kimakiChannels = channels.filter(
|
|
2062
|
-
(ch) =>
|
|
2063
|
-
ch.kimakiDirectory &&
|
|
2064
|
-
(!ch.kimakiApp || ch.kimakiApp === currentAppId),
|
|
2065
|
-
)
|
|
2066
|
-
|
|
2067
|
-
if (kimakiChannels.length > 0) {
|
|
2068
|
-
discordLogger.log(
|
|
2069
|
-
` Found ${kimakiChannels.length} channel(s) for this bot:`,
|
|
2070
|
-
)
|
|
2071
|
-
for (const channel of kimakiChannels) {
|
|
2072
|
-
discordLogger.log(` - #${channel.name}: ${channel.kimakiDirectory}`)
|
|
2073
|
-
}
|
|
2074
|
-
} else {
|
|
2075
|
-
discordLogger.log(` No channels for this bot`)
|
|
2076
|
-
}
|
|
2077
|
-
}
|
|
2078
|
-
|
|
2079
|
-
voiceLogger.log(
|
|
2080
|
-
`[READY] Bot is ready and will only respond to channels with app ID: ${currentAppId}`,
|
|
2081
|
-
)
|
|
2082
|
-
})
|
|
2083
|
-
|
|
2084
|
-
discordClient.on(Events.MessageCreate, async (message: Message) => {
|
|
2085
|
-
try {
|
|
2086
|
-
if (message.author?.bot) {
|
|
2087
|
-
return
|
|
2088
|
-
}
|
|
2089
|
-
if (message.partial) {
|
|
2090
|
-
discordLogger.log(`Fetching partial message ${message.id}`)
|
|
2091
|
-
try {
|
|
2092
|
-
await message.fetch()
|
|
2093
|
-
} catch (error) {
|
|
2094
|
-
discordLogger.log(
|
|
2095
|
-
`Failed to fetch partial message ${message.id}:`,
|
|
2096
|
-
error,
|
|
2097
|
-
)
|
|
2098
|
-
return
|
|
2099
|
-
}
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
// Check if user is authoritative (server owner, admin, manage server, or has Kimaki role)
|
|
2103
|
-
if (message.guild && message.member) {
|
|
2104
|
-
const isOwner = message.member.id === message.guild.ownerId
|
|
2105
|
-
const isAdmin = message.member.permissions.has(
|
|
2106
|
-
PermissionsBitField.Flags.Administrator,
|
|
2107
|
-
)
|
|
2108
|
-
const canManageServer = message.member.permissions.has(
|
|
2109
|
-
PermissionsBitField.Flags.ManageGuild,
|
|
2110
|
-
)
|
|
2111
|
-
const hasKimakiRole = message.member.roles.cache.some(
|
|
2112
|
-
(role) => role.name.toLowerCase() === 'kimaki',
|
|
2113
|
-
)
|
|
2114
|
-
|
|
2115
|
-
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
2116
|
-
return
|
|
2117
|
-
}
|
|
2118
|
-
}
|
|
2119
|
-
|
|
2120
|
-
const channel = message.channel
|
|
2121
|
-
const isThread = [
|
|
2122
|
-
ChannelType.PublicThread,
|
|
2123
|
-
ChannelType.PrivateThread,
|
|
2124
|
-
ChannelType.AnnouncementThread,
|
|
2125
|
-
].includes(channel.type)
|
|
2126
|
-
|
|
2127
|
-
// For existing threads, check if session exists
|
|
2128
|
-
if (isThread) {
|
|
2129
|
-
const thread = channel as ThreadChannel
|
|
2130
|
-
discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
|
|
2131
|
-
|
|
2132
|
-
const row = getDatabase()
|
|
2133
|
-
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
2134
|
-
.get(thread.id) as { session_id: string } | undefined
|
|
2135
|
-
|
|
2136
|
-
if (!row) {
|
|
2137
|
-
discordLogger.log(`No session found for thread ${thread.id}`)
|
|
2138
|
-
return
|
|
2139
|
-
}
|
|
2140
|
-
|
|
2141
|
-
voiceLogger.log(
|
|
2142
|
-
`[SESSION] Found session ${row.session_id} for thread ${thread.id}`,
|
|
2143
|
-
)
|
|
2144
|
-
|
|
2145
|
-
// Get project directory and app ID from parent channel
|
|
2146
|
-
const parent = thread.parent as TextChannel | null
|
|
2147
|
-
let projectDirectory: string | undefined
|
|
2148
|
-
let channelAppId: string | undefined
|
|
2149
|
-
|
|
2150
|
-
if (parent?.topic) {
|
|
2151
|
-
const extracted = extractTagsArrays({
|
|
2152
|
-
xml: parent.topic,
|
|
2153
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
2154
|
-
})
|
|
2155
|
-
|
|
2156
|
-
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
2157
|
-
channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
// Check if this channel belongs to current bot instance
|
|
2161
|
-
if (channelAppId && channelAppId !== currentAppId) {
|
|
2162
|
-
voiceLogger.log(
|
|
2163
|
-
`[IGNORED] Thread belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
|
|
2164
|
-
)
|
|
2165
|
-
return
|
|
2166
|
-
}
|
|
2167
|
-
|
|
2168
|
-
if (projectDirectory && !fs.existsSync(projectDirectory)) {
|
|
2169
|
-
discordLogger.error(`Directory does not exist: ${projectDirectory}`)
|
|
2170
|
-
await sendThreadMessage(
|
|
2171
|
-
thread,
|
|
2172
|
-
`✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
|
|
2173
|
-
)
|
|
2174
|
-
return
|
|
2175
|
-
}
|
|
2176
|
-
|
|
2177
|
-
// Handle voice message if present
|
|
2178
|
-
let messageContent = message.content || ''
|
|
2179
|
-
|
|
2180
|
-
const transcription = await processVoiceAttachment({
|
|
2181
|
-
message,
|
|
2182
|
-
thread,
|
|
2183
|
-
projectDirectory,
|
|
2184
|
-
appId: currentAppId,
|
|
2185
|
-
})
|
|
2186
|
-
if (transcription) {
|
|
2187
|
-
messageContent = transcription
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
|
-
const fileAttachments = getFileAttachments(message)
|
|
2191
|
-
const textAttachmentsContent = await getTextAttachments(message)
|
|
2192
|
-
const promptWithAttachments = textAttachmentsContent
|
|
2193
|
-
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
2194
|
-
: messageContent
|
|
2195
|
-
const parsedCommand = parseSlashCommand(messageContent)
|
|
2196
|
-
await handleOpencodeSession({
|
|
2197
|
-
prompt: promptWithAttachments,
|
|
2198
|
-
thread,
|
|
2199
|
-
projectDirectory,
|
|
2200
|
-
originalMessage: message,
|
|
2201
|
-
images: fileAttachments,
|
|
2202
|
-
parsedCommand,
|
|
2203
|
-
})
|
|
2204
|
-
return
|
|
2205
|
-
}
|
|
2206
|
-
|
|
2207
|
-
// For text channels, start new sessions with kimaki.directory tag
|
|
2208
|
-
if (channel.type === ChannelType.GuildText) {
|
|
2209
|
-
const textChannel = channel as TextChannel
|
|
2210
|
-
voiceLogger.log(
|
|
2211
|
-
`[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`,
|
|
2212
|
-
)
|
|
2213
|
-
|
|
2214
|
-
if (!textChannel.topic) {
|
|
2215
|
-
voiceLogger.log(
|
|
2216
|
-
`[IGNORED] Channel #${textChannel.name} has no description`,
|
|
2217
|
-
)
|
|
2218
|
-
return
|
|
2219
|
-
}
|
|
2220
|
-
|
|
2221
|
-
const extracted = extractTagsArrays({
|
|
2222
|
-
xml: textChannel.topic,
|
|
2223
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
2224
|
-
})
|
|
2225
|
-
|
|
2226
|
-
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
2227
|
-
const channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
2228
|
-
|
|
2229
|
-
if (!projectDirectory) {
|
|
2230
|
-
voiceLogger.log(
|
|
2231
|
-
`[IGNORED] Channel #${textChannel.name} has no kimaki.directory tag`,
|
|
2232
|
-
)
|
|
2233
|
-
return
|
|
2234
|
-
}
|
|
2235
|
-
|
|
2236
|
-
// Check if this channel belongs to current bot instance
|
|
2237
|
-
if (channelAppId && channelAppId !== currentAppId) {
|
|
2238
|
-
voiceLogger.log(
|
|
2239
|
-
`[IGNORED] Channel belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
|
|
2240
|
-
)
|
|
2241
|
-
return
|
|
2242
|
-
}
|
|
2243
|
-
|
|
2244
|
-
discordLogger.log(
|
|
2245
|
-
`DIRECTORY: Found kimaki.directory: ${projectDirectory}`,
|
|
2246
|
-
)
|
|
2247
|
-
if (channelAppId) {
|
|
2248
|
-
discordLogger.log(`APP: Channel app ID: ${channelAppId}`)
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
|
-
if (!fs.existsSync(projectDirectory)) {
|
|
2252
|
-
discordLogger.error(`Directory does not exist: ${projectDirectory}`)
|
|
2253
|
-
await message.reply(
|
|
2254
|
-
`✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
|
|
2255
|
-
)
|
|
2256
|
-
return
|
|
2257
|
-
}
|
|
2258
|
-
|
|
2259
|
-
// Determine if this is a voice message
|
|
2260
|
-
const hasVoice = message.attachments.some((a) =>
|
|
2261
|
-
a.contentType?.startsWith('audio/'),
|
|
2262
|
-
)
|
|
2263
|
-
|
|
2264
|
-
// Create thread
|
|
2265
|
-
const threadName = hasVoice
|
|
2266
|
-
? 'Voice Message'
|
|
2267
|
-
: message.content?.replace(/\s+/g, ' ').trim() || 'Claude Thread'
|
|
2268
|
-
|
|
2269
|
-
const thread = await message.startThread({
|
|
2270
|
-
name: threadName.slice(0, 80),
|
|
2271
|
-
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
2272
|
-
reason: 'Start Claude session',
|
|
2273
|
-
})
|
|
2274
|
-
|
|
2275
|
-
discordLogger.log(`Created thread "${thread.name}" (${thread.id})`)
|
|
2276
|
-
|
|
2277
|
-
// Handle voice message if present
|
|
2278
|
-
let messageContent = message.content || ''
|
|
2279
|
-
|
|
2280
|
-
const transcription = await processVoiceAttachment({
|
|
2281
|
-
message,
|
|
2282
|
-
thread,
|
|
2283
|
-
projectDirectory,
|
|
2284
|
-
isNewThread: true,
|
|
2285
|
-
appId: currentAppId,
|
|
2286
|
-
})
|
|
2287
|
-
if (transcription) {
|
|
2288
|
-
messageContent = transcription
|
|
2289
|
-
}
|
|
2290
|
-
|
|
2291
|
-
const fileAttachments = getFileAttachments(message)
|
|
2292
|
-
const textAttachmentsContent = await getTextAttachments(message)
|
|
2293
|
-
const promptWithAttachments = textAttachmentsContent
|
|
2294
|
-
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
2295
|
-
: messageContent
|
|
2296
|
-
const parsedCommand = parseSlashCommand(messageContent)
|
|
2297
|
-
await handleOpencodeSession({
|
|
2298
|
-
prompt: promptWithAttachments,
|
|
2299
|
-
thread,
|
|
2300
|
-
projectDirectory,
|
|
2301
|
-
originalMessage: message,
|
|
2302
|
-
images: fileAttachments,
|
|
2303
|
-
parsedCommand,
|
|
2304
|
-
})
|
|
2305
|
-
} else {
|
|
2306
|
-
discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
2307
|
-
}
|
|
2308
|
-
} catch (error) {
|
|
2309
|
-
voiceLogger.error('Discord handler error:', error)
|
|
2310
|
-
try {
|
|
2311
|
-
const errMsg = error instanceof Error ? error.message : String(error)
|
|
2312
|
-
await message.reply(`Error: ${errMsg}`)
|
|
2313
|
-
} catch {
|
|
2314
|
-
voiceLogger.error('Discord handler error (fallback):', error)
|
|
2315
|
-
}
|
|
2316
|
-
}
|
|
2317
|
-
})
|
|
2318
|
-
|
|
2319
|
-
// Handle slash command interactions
|
|
2320
|
-
discordClient.on(
|
|
2321
|
-
Events.InteractionCreate,
|
|
2322
|
-
async (interaction: Interaction) => {
|
|
2323
|
-
try {
|
|
2324
|
-
// Handle autocomplete
|
|
2325
|
-
if (interaction.isAutocomplete()) {
|
|
2326
|
-
if (interaction.commandName === 'resume') {
|
|
2327
|
-
const focusedValue = interaction.options.getFocused()
|
|
2328
|
-
|
|
2329
|
-
// Get the channel's project directory from its topic
|
|
2330
|
-
let projectDirectory: string | undefined
|
|
2331
|
-
if (
|
|
2332
|
-
interaction.channel &&
|
|
2333
|
-
interaction.channel.type === ChannelType.GuildText
|
|
2334
|
-
) {
|
|
2335
|
-
const textChannel = resolveTextChannel(
|
|
2336
|
-
interaction.channel as TextChannel | ThreadChannel | null,
|
|
2337
|
-
)
|
|
2338
|
-
if (textChannel) {
|
|
2339
|
-
const { projectDirectory: directory, channelAppId } =
|
|
2340
|
-
getKimakiMetadata(textChannel)
|
|
2341
|
-
if (channelAppId && channelAppId !== currentAppId) {
|
|
2342
|
-
await interaction.respond([])
|
|
2343
|
-
return
|
|
2344
|
-
}
|
|
2345
|
-
projectDirectory = directory
|
|
2346
|
-
}
|
|
2347
|
-
}
|
|
2348
|
-
|
|
2349
|
-
if (!projectDirectory) {
|
|
2350
|
-
await interaction.respond([])
|
|
2351
|
-
return
|
|
2352
|
-
}
|
|
2353
|
-
|
|
2354
|
-
try {
|
|
2355
|
-
// Get OpenCode client for this directory
|
|
2356
|
-
const getClient =
|
|
2357
|
-
await initializeOpencodeForDirectory(projectDirectory)
|
|
2358
|
-
|
|
2359
|
-
// List sessions
|
|
2360
|
-
const sessionsResponse = await getClient().session.list()
|
|
2361
|
-
if (!sessionsResponse.data) {
|
|
2362
|
-
await interaction.respond([])
|
|
2363
|
-
return
|
|
2364
|
-
}
|
|
2365
|
-
|
|
2366
|
-
// Filter and map sessions to choices
|
|
2367
|
-
const sessions = sessionsResponse.data
|
|
2368
|
-
.filter((session) =>
|
|
2369
|
-
session.title
|
|
2370
|
-
.toLowerCase()
|
|
2371
|
-
.includes(focusedValue.toLowerCase()),
|
|
2372
|
-
)
|
|
2373
|
-
.slice(0, 25) // Discord limit
|
|
2374
|
-
.map((session) => {
|
|
2375
|
-
const dateStr = new Date(
|
|
2376
|
-
session.time.updated,
|
|
2377
|
-
).toLocaleString()
|
|
2378
|
-
const suffix = ` (${dateStr})`
|
|
2379
|
-
// Discord limit is 100 chars. Reserve space for suffix.
|
|
2380
|
-
const maxTitleLength = 100 - suffix.length
|
|
2381
|
-
|
|
2382
|
-
let title = session.title
|
|
2383
|
-
if (title.length > maxTitleLength) {
|
|
2384
|
-
title = title.slice(0, Math.max(0, maxTitleLength - 1)) + '…'
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
return {
|
|
2388
|
-
name: `${title}${suffix}`,
|
|
2389
|
-
value: session.id,
|
|
2390
|
-
}
|
|
2391
|
-
})
|
|
2392
|
-
|
|
2393
|
-
await interaction.respond(sessions)
|
|
2394
|
-
} catch (error) {
|
|
2395
|
-
voiceLogger.error(
|
|
2396
|
-
'[AUTOCOMPLETE] Error fetching sessions:',
|
|
2397
|
-
error,
|
|
2398
|
-
)
|
|
2399
|
-
await interaction.respond([])
|
|
2400
|
-
}
|
|
2401
|
-
} else if (interaction.commandName === 'session') {
|
|
2402
|
-
const focusedOption = interaction.options.getFocused(true)
|
|
2403
|
-
|
|
2404
|
-
if (focusedOption.name === 'files') {
|
|
2405
|
-
const focusedValue = focusedOption.value
|
|
2406
|
-
|
|
2407
|
-
// Split by comma to handle multiple files
|
|
2408
|
-
const parts = focusedValue.split(',')
|
|
2409
|
-
const previousFiles = parts
|
|
2410
|
-
.slice(0, -1)
|
|
2411
|
-
.map((f) => f.trim())
|
|
2412
|
-
.filter((f) => f)
|
|
2413
|
-
const currentQuery = (parts[parts.length - 1] || '').trim()
|
|
2414
|
-
|
|
2415
|
-
// Get the channel's project directory from its topic
|
|
2416
|
-
let projectDirectory: string | undefined
|
|
2417
|
-
if (
|
|
2418
|
-
interaction.channel &&
|
|
2419
|
-
interaction.channel.type === ChannelType.GuildText
|
|
2420
|
-
) {
|
|
2421
|
-
const textChannel = resolveTextChannel(
|
|
2422
|
-
interaction.channel as TextChannel | ThreadChannel | null,
|
|
2423
|
-
)
|
|
2424
|
-
if (textChannel) {
|
|
2425
|
-
const { projectDirectory: directory, channelAppId } =
|
|
2426
|
-
getKimakiMetadata(textChannel)
|
|
2427
|
-
if (channelAppId && channelAppId !== currentAppId) {
|
|
2428
|
-
await interaction.respond([])
|
|
2429
|
-
return
|
|
2430
|
-
}
|
|
2431
|
-
projectDirectory = directory
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
|
|
2435
|
-
if (!projectDirectory) {
|
|
2436
|
-
await interaction.respond([])
|
|
2437
|
-
return
|
|
2438
|
-
}
|
|
2439
|
-
|
|
2440
|
-
try {
|
|
2441
|
-
// Get OpenCode client for this directory
|
|
2442
|
-
const getClient =
|
|
2443
|
-
await initializeOpencodeForDirectory(projectDirectory)
|
|
2444
|
-
|
|
2445
|
-
// Use find.files to search for files based on current query
|
|
2446
|
-
const response = await getClient().find.files({
|
|
2447
|
-
query: {
|
|
2448
|
-
query: currentQuery || '',
|
|
2449
|
-
},
|
|
2450
|
-
})
|
|
2451
|
-
|
|
2452
|
-
// Get file paths from the response
|
|
2453
|
-
const files = response.data || []
|
|
2454
|
-
|
|
2455
|
-
// Build the prefix with previous files
|
|
2456
|
-
const prefix =
|
|
2457
|
-
previousFiles.length > 0
|
|
2458
|
-
? previousFiles.join(', ') + ', '
|
|
2459
|
-
: ''
|
|
2460
|
-
|
|
2461
|
-
// Map to Discord autocomplete format
|
|
2462
|
-
const choices = files
|
|
2463
|
-
.map((file: string) => {
|
|
2464
|
-
const fullValue = prefix + file
|
|
2465
|
-
// Get all basenames for display
|
|
2466
|
-
const allFiles = [...previousFiles, file]
|
|
2467
|
-
const allBasenames = allFiles.map(
|
|
2468
|
-
(f) => f.split('/').pop() || f,
|
|
2469
|
-
)
|
|
2470
|
-
let displayName = allBasenames.join(', ')
|
|
2471
|
-
// Truncate if too long
|
|
2472
|
-
if (displayName.length > 100) {
|
|
2473
|
-
displayName = '…' + displayName.slice(-97)
|
|
2474
|
-
}
|
|
2475
|
-
return {
|
|
2476
|
-
name: displayName,
|
|
2477
|
-
value: fullValue,
|
|
2478
|
-
}
|
|
2479
|
-
})
|
|
2480
|
-
// Discord API limits choice value to 100 characters
|
|
2481
|
-
.filter((choice) => choice.value.length <= 100)
|
|
2482
|
-
.slice(0, 25) // Discord limit
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
await interaction.respond(choices)
|
|
2486
|
-
} catch (error) {
|
|
2487
|
-
voiceLogger.error('[AUTOCOMPLETE] Error fetching files:', error)
|
|
2488
|
-
await interaction.respond([])
|
|
2489
|
-
}
|
|
2490
|
-
}
|
|
2491
|
-
} else if (interaction.commandName === 'add-project') {
|
|
2492
|
-
const focusedValue = interaction.options.getFocused()
|
|
2493
|
-
|
|
2494
|
-
try {
|
|
2495
|
-
const currentDir = process.cwd()
|
|
2496
|
-
const getClient = await initializeOpencodeForDirectory(currentDir)
|
|
2497
|
-
|
|
2498
|
-
const projectsResponse = await getClient().project.list({})
|
|
2499
|
-
if (!projectsResponse.data) {
|
|
2500
|
-
await interaction.respond([])
|
|
2501
|
-
return
|
|
2502
|
-
}
|
|
2503
|
-
|
|
2504
|
-
const db = getDatabase()
|
|
2505
|
-
const existingDirs = db
|
|
2506
|
-
.prepare(
|
|
2507
|
-
'SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?',
|
|
2508
|
-
)
|
|
2509
|
-
.all('text') as { directory: string }[]
|
|
2510
|
-
const existingDirSet = new Set(
|
|
2511
|
-
existingDirs.map((row) => row.directory),
|
|
2512
|
-
)
|
|
2513
|
-
|
|
2514
|
-
const availableProjects = projectsResponse.data.filter(
|
|
2515
|
-
(project) => !existingDirSet.has(project.worktree),
|
|
2516
|
-
)
|
|
2517
|
-
|
|
2518
|
-
const projects = availableProjects
|
|
2519
|
-
.filter((project) => {
|
|
2520
|
-
const baseName = path.basename(project.worktree)
|
|
2521
|
-
const searchText = `${baseName} ${project.worktree}`.toLowerCase()
|
|
2522
|
-
return searchText.includes(focusedValue.toLowerCase())
|
|
2523
|
-
})
|
|
2524
|
-
.sort((a, b) => {
|
|
2525
|
-
const aTime = a.time.initialized || a.time.created
|
|
2526
|
-
const bTime = b.time.initialized || b.time.created
|
|
2527
|
-
return bTime - aTime
|
|
2528
|
-
})
|
|
2529
|
-
.slice(0, 25)
|
|
2530
|
-
.map((project) => {
|
|
2531
|
-
const name = `${path.basename(project.worktree)} (${project.worktree})`
|
|
2532
|
-
return {
|
|
2533
|
-
name: name.length > 100 ? name.slice(0, 99) + '…' : name,
|
|
2534
|
-
value: project.id,
|
|
2535
|
-
}
|
|
2536
|
-
})
|
|
2537
|
-
|
|
2538
|
-
await interaction.respond(projects)
|
|
2539
|
-
} catch (error) {
|
|
2540
|
-
voiceLogger.error(
|
|
2541
|
-
'[AUTOCOMPLETE] Error fetching projects:',
|
|
2542
|
-
error,
|
|
2543
|
-
)
|
|
2544
|
-
await interaction.respond([])
|
|
2545
|
-
}
|
|
2546
|
-
}
|
|
2547
|
-
}
|
|
2548
|
-
|
|
2549
|
-
// Handle slash commands
|
|
2550
|
-
if (interaction.isChatInputCommand()) {
|
|
2551
|
-
const command = interaction
|
|
2552
|
-
|
|
2553
|
-
if (command.commandName === 'session') {
|
|
2554
|
-
await command.deferReply({ ephemeral: false })
|
|
2555
|
-
|
|
2556
|
-
const prompt = command.options.getString('prompt', true)
|
|
2557
|
-
const filesString = command.options.getString('files') || ''
|
|
2558
|
-
const channel = command.channel
|
|
2559
|
-
|
|
2560
|
-
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
2561
|
-
await command.editReply(
|
|
2562
|
-
'This command can only be used in text channels',
|
|
2563
|
-
)
|
|
2564
|
-
return
|
|
2565
|
-
}
|
|
2566
|
-
|
|
2567
|
-
const textChannel = channel as TextChannel
|
|
2568
|
-
|
|
2569
|
-
// Get project directory from channel topic
|
|
2570
|
-
let projectDirectory: string | undefined
|
|
2571
|
-
let channelAppId: string | undefined
|
|
2572
|
-
|
|
2573
|
-
if (textChannel.topic) {
|
|
2574
|
-
const extracted = extractTagsArrays({
|
|
2575
|
-
xml: textChannel.topic,
|
|
2576
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
2577
|
-
})
|
|
2578
|
-
|
|
2579
|
-
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
2580
|
-
channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
2581
|
-
}
|
|
2582
|
-
|
|
2583
|
-
// Check if this channel belongs to current bot instance
|
|
2584
|
-
if (channelAppId && channelAppId !== currentAppId) {
|
|
2585
|
-
await command.editReply(
|
|
2586
|
-
'This channel is not configured for this bot',
|
|
2587
|
-
)
|
|
2588
|
-
return
|
|
2589
|
-
}
|
|
2590
|
-
|
|
2591
|
-
if (!projectDirectory) {
|
|
2592
|
-
await command.editReply(
|
|
2593
|
-
'This channel is not configured with a project directory',
|
|
2594
|
-
)
|
|
2595
|
-
return
|
|
2596
|
-
}
|
|
2597
|
-
|
|
2598
|
-
if (!fs.existsSync(projectDirectory)) {
|
|
2599
|
-
await command.editReply(
|
|
2600
|
-
`Directory does not exist: ${projectDirectory}`,
|
|
2601
|
-
)
|
|
2602
|
-
return
|
|
2603
|
-
}
|
|
2604
|
-
|
|
2605
|
-
try {
|
|
2606
|
-
// Initialize OpenCode client for the directory
|
|
2607
|
-
const getClient =
|
|
2608
|
-
await initializeOpencodeForDirectory(projectDirectory)
|
|
2609
|
-
|
|
2610
|
-
// Process file mentions - split by comma only
|
|
2611
|
-
const files = filesString
|
|
2612
|
-
.split(',')
|
|
2613
|
-
.map((f) => f.trim())
|
|
2614
|
-
.filter((f) => f)
|
|
2615
|
-
|
|
2616
|
-
// Build the full prompt with file mentions
|
|
2617
|
-
let fullPrompt = prompt
|
|
2618
|
-
if (files.length > 0) {
|
|
2619
|
-
fullPrompt = `${prompt}\n\n@${files.join(' @')}`
|
|
2620
|
-
}
|
|
2621
|
-
|
|
2622
|
-
// Send a message first, then create thread from it
|
|
2623
|
-
const starterMessage = await textChannel.send({
|
|
2624
|
-
content: `🚀 **Starting OpenCode session**\n📝 ${prompt.slice(0, 200)}${prompt.length > 200 ? '…' : ''}${files.length > 0 ? `\n📎 Files: ${files.join(', ')}` : ''}`,
|
|
2625
|
-
})
|
|
2626
|
-
|
|
2627
|
-
// Create thread from the message
|
|
2628
|
-
const thread = await starterMessage.startThread({
|
|
2629
|
-
name: prompt.slice(0, 100),
|
|
2630
|
-
autoArchiveDuration: 1440, // 24 hours
|
|
2631
|
-
reason: 'OpenCode session',
|
|
2632
|
-
})
|
|
2633
|
-
|
|
2634
|
-
await command.editReply(
|
|
2635
|
-
`Created new session in ${thread.toString()}`,
|
|
2636
|
-
)
|
|
2637
|
-
|
|
2638
|
-
// Start the OpenCode session
|
|
2639
|
-
const parsedCommand = parseSlashCommand(fullPrompt)
|
|
2640
|
-
await handleOpencodeSession({
|
|
2641
|
-
prompt: fullPrompt,
|
|
2642
|
-
thread,
|
|
2643
|
-
projectDirectory,
|
|
2644
|
-
parsedCommand,
|
|
2645
|
-
})
|
|
2646
|
-
} catch (error) {
|
|
2647
|
-
voiceLogger.error('[SESSION] Error:', error)
|
|
2648
|
-
await command.editReply(
|
|
2649
|
-
`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2650
|
-
)
|
|
2651
|
-
}
|
|
2652
|
-
} else if (command.commandName === 'resume') {
|
|
2653
|
-
await command.deferReply({ ephemeral: false })
|
|
2654
|
-
|
|
2655
|
-
const sessionId = command.options.getString('session', true)
|
|
2656
|
-
const channel = command.channel
|
|
2657
|
-
|
|
2658
|
-
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
2659
|
-
await command.editReply(
|
|
2660
|
-
'This command can only be used in text channels',
|
|
2661
|
-
)
|
|
2662
|
-
return
|
|
2663
|
-
}
|
|
2664
|
-
|
|
2665
|
-
const textChannel = channel as TextChannel
|
|
2666
|
-
|
|
2667
|
-
// Get project directory from channel topic
|
|
2668
|
-
let projectDirectory: string | undefined
|
|
2669
|
-
let channelAppId: string | undefined
|
|
2670
|
-
|
|
2671
|
-
if (textChannel.topic) {
|
|
2672
|
-
const extracted = extractTagsArrays({
|
|
2673
|
-
xml: textChannel.topic,
|
|
2674
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
2675
|
-
})
|
|
2676
|
-
|
|
2677
|
-
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
2678
|
-
channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
2679
|
-
}
|
|
2680
|
-
|
|
2681
|
-
// Check if this channel belongs to current bot instance
|
|
2682
|
-
if (channelAppId && channelAppId !== currentAppId) {
|
|
2683
|
-
await command.editReply(
|
|
2684
|
-
'This channel is not configured for this bot',
|
|
2685
|
-
)
|
|
2686
|
-
return
|
|
2687
|
-
}
|
|
2688
|
-
|
|
2689
|
-
if (!projectDirectory) {
|
|
2690
|
-
await command.editReply(
|
|
2691
|
-
'This channel is not configured with a project directory',
|
|
2692
|
-
)
|
|
2693
|
-
return
|
|
2694
|
-
}
|
|
2695
|
-
|
|
2696
|
-
if (!fs.existsSync(projectDirectory)) {
|
|
2697
|
-
await command.editReply(
|
|
2698
|
-
`Directory does not exist: ${projectDirectory}`,
|
|
2699
|
-
)
|
|
2700
|
-
return
|
|
2701
|
-
}
|
|
2702
|
-
|
|
2703
|
-
try {
|
|
2704
|
-
// Initialize OpenCode client for the directory
|
|
2705
|
-
const getClient =
|
|
2706
|
-
await initializeOpencodeForDirectory(projectDirectory)
|
|
2707
|
-
|
|
2708
|
-
// Get session title
|
|
2709
|
-
const sessionResponse = await getClient().session.get({
|
|
2710
|
-
path: { id: sessionId },
|
|
2711
|
-
})
|
|
2712
|
-
|
|
2713
|
-
if (!sessionResponse.data) {
|
|
2714
|
-
await command.editReply('Session not found')
|
|
2715
|
-
return
|
|
2716
|
-
}
|
|
2717
|
-
|
|
2718
|
-
const sessionTitle = sessionResponse.data.title
|
|
2719
|
-
|
|
2720
|
-
// Create thread for the resumed session
|
|
2721
|
-
const thread = await textChannel.threads.create({
|
|
2722
|
-
name: `Resume: ${sessionTitle}`.slice(0, 100),
|
|
2723
|
-
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
2724
|
-
reason: `Resuming session ${sessionId}`,
|
|
2725
|
-
})
|
|
2726
|
-
|
|
2727
|
-
// Store session ID in database
|
|
2728
|
-
getDatabase()
|
|
2729
|
-
.prepare(
|
|
2730
|
-
'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
|
|
2731
|
-
)
|
|
2732
|
-
.run(thread.id, sessionId)
|
|
2733
|
-
|
|
2734
|
-
voiceLogger.log(
|
|
2735
|
-
`[RESUME] Created thread ${thread.id} for session ${sessionId}`,
|
|
2736
|
-
)
|
|
2737
|
-
|
|
2738
|
-
// Fetch all messages for the session
|
|
2739
|
-
const messagesResponse = await getClient().session.messages({
|
|
2740
|
-
path: { id: sessionId },
|
|
2741
|
-
})
|
|
2742
|
-
|
|
2743
|
-
if (!messagesResponse.data) {
|
|
2744
|
-
throw new Error('Failed to fetch session messages')
|
|
2745
|
-
}
|
|
2746
|
-
|
|
2747
|
-
const messages = messagesResponse.data
|
|
2748
|
-
|
|
2749
|
-
await command.editReply(
|
|
2750
|
-
`Resumed session "${sessionTitle}" in ${thread.toString()}`,
|
|
2751
|
-
)
|
|
2752
|
-
|
|
2753
|
-
// Send initial message to thread
|
|
2754
|
-
await sendThreadMessage(
|
|
2755
|
-
thread,
|
|
2756
|
-
`📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`,
|
|
2757
|
-
)
|
|
2758
|
-
|
|
2759
|
-
// Collect all assistant parts first, then only render the last 30
|
|
2760
|
-
const allAssistantParts: { id: string; content: string }[] = []
|
|
2761
|
-
for (const message of messages) {
|
|
2762
|
-
if (message.info.role === 'assistant') {
|
|
2763
|
-
for (const part of message.parts) {
|
|
2764
|
-
const content = formatPart(part)
|
|
2765
|
-
if (content.trim()) {
|
|
2766
|
-
allAssistantParts.push({ id: part.id, content })
|
|
2767
|
-
}
|
|
2768
|
-
}
|
|
2769
|
-
}
|
|
2770
|
-
}
|
|
2771
|
-
|
|
2772
|
-
const partsToRender = allAssistantParts.slice(-30)
|
|
2773
|
-
const skippedCount = allAssistantParts.length - partsToRender.length
|
|
2774
|
-
|
|
2775
|
-
if (skippedCount > 0) {
|
|
2776
|
-
await sendThreadMessage(
|
|
2777
|
-
thread,
|
|
2778
|
-
`*Skipped ${skippedCount} older assistant parts...*`,
|
|
2779
|
-
)
|
|
2780
|
-
}
|
|
2781
|
-
|
|
2782
|
-
if (partsToRender.length > 0) {
|
|
2783
|
-
const combinedContent = partsToRender
|
|
2784
|
-
.map((p) => p.content)
|
|
2785
|
-
.join('\n\n')
|
|
2786
|
-
|
|
2787
|
-
const discordMessage = await sendThreadMessage(
|
|
2788
|
-
thread,
|
|
2789
|
-
combinedContent,
|
|
2790
|
-
)
|
|
2791
|
-
|
|
2792
|
-
const stmt = getDatabase().prepare(
|
|
2793
|
-
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
2794
|
-
)
|
|
2795
|
-
|
|
2796
|
-
const transaction = getDatabase().transaction(
|
|
2797
|
-
(parts: { id: string }[]) => {
|
|
2798
|
-
for (const part of parts) {
|
|
2799
|
-
stmt.run(part.id, discordMessage.id, thread.id)
|
|
2800
|
-
}
|
|
2801
|
-
},
|
|
2802
|
-
)
|
|
2803
|
-
|
|
2804
|
-
transaction(partsToRender)
|
|
2805
|
-
}
|
|
2806
|
-
|
|
2807
|
-
const messageCount = messages.length
|
|
2808
|
-
|
|
2809
|
-
await sendThreadMessage(
|
|
2810
|
-
thread,
|
|
2811
|
-
`✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`,
|
|
2812
|
-
)
|
|
2813
|
-
} catch (error) {
|
|
2814
|
-
voiceLogger.error('[RESUME] Error:', error)
|
|
2815
|
-
await command.editReply(
|
|
2816
|
-
`Failed to resume session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2817
|
-
)
|
|
2818
|
-
}
|
|
2819
|
-
} else if (command.commandName === 'add-project') {
|
|
2820
|
-
await command.deferReply({ ephemeral: false })
|
|
2821
|
-
|
|
2822
|
-
const projectId = command.options.getString('project', true)
|
|
2823
|
-
const guild = command.guild
|
|
2824
|
-
|
|
2825
|
-
if (!guild) {
|
|
2826
|
-
await command.editReply('This command can only be used in a guild')
|
|
2827
|
-
return
|
|
2828
|
-
}
|
|
2829
|
-
|
|
2830
|
-
try {
|
|
2831
|
-
const currentDir = process.cwd()
|
|
2832
|
-
const getClient = await initializeOpencodeForDirectory(currentDir)
|
|
2833
|
-
|
|
2834
|
-
const projectsResponse = await getClient().project.list({})
|
|
2835
|
-
if (!projectsResponse.data) {
|
|
2836
|
-
await command.editReply('Failed to fetch projects')
|
|
2837
|
-
return
|
|
2838
|
-
}
|
|
2839
|
-
|
|
2840
|
-
const project = projectsResponse.data.find(
|
|
2841
|
-
(p) => p.id === projectId,
|
|
2842
|
-
)
|
|
2843
|
-
|
|
2844
|
-
if (!project) {
|
|
2845
|
-
await command.editReply('Project not found')
|
|
2846
|
-
return
|
|
2847
|
-
}
|
|
2848
|
-
|
|
2849
|
-
const directory = project.worktree
|
|
2850
|
-
|
|
2851
|
-
if (!fs.existsSync(directory)) {
|
|
2852
|
-
await command.editReply(`Directory does not exist: ${directory}`)
|
|
2853
|
-
return
|
|
2854
|
-
}
|
|
2855
|
-
|
|
2856
|
-
const db = getDatabase()
|
|
2857
|
-
const existingChannel = db
|
|
2858
|
-
.prepare(
|
|
2859
|
-
'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
|
|
2860
|
-
)
|
|
2861
|
-
.get(directory, 'text') as { channel_id: string } | undefined
|
|
2862
|
-
|
|
2863
|
-
if (existingChannel) {
|
|
2864
|
-
await command.editReply(
|
|
2865
|
-
`A channel already exists for this directory: <#${existingChannel.channel_id}>`,
|
|
2866
|
-
)
|
|
2867
|
-
return
|
|
2868
|
-
}
|
|
2869
|
-
|
|
2870
|
-
const { textChannelId, voiceChannelId, channelName } =
|
|
2871
|
-
await createProjectChannels({
|
|
2872
|
-
guild,
|
|
2873
|
-
projectDirectory: directory,
|
|
2874
|
-
appId: currentAppId!,
|
|
2875
|
-
})
|
|
2876
|
-
|
|
2877
|
-
await command.editReply(
|
|
2878
|
-
`✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``,
|
|
2879
|
-
)
|
|
2880
|
-
|
|
2881
|
-
discordLogger.log(
|
|
2882
|
-
`Created channels for project ${channelName} at ${directory}`,
|
|
2883
|
-
)
|
|
2884
|
-
} catch (error) {
|
|
2885
|
-
voiceLogger.error('[ADD-PROJECT] Error:', error)
|
|
2886
|
-
await command.editReply(
|
|
2887
|
-
`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2888
|
-
)
|
|
2889
|
-
}
|
|
2890
|
-
} else if (command.commandName === 'add-new-project') {
|
|
2891
|
-
await command.deferReply({ ephemeral: false })
|
|
2892
|
-
|
|
2893
|
-
const projectName = command.options.getString('name', true)
|
|
2894
|
-
const guild = command.guild
|
|
2895
|
-
const channel = command.channel
|
|
2896
|
-
|
|
2897
|
-
if (!guild) {
|
|
2898
|
-
await command.editReply('This command can only be used in a guild')
|
|
2899
|
-
return
|
|
2900
|
-
}
|
|
2901
|
-
|
|
2902
|
-
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
2903
|
-
await command.editReply('This command can only be used in a text channel')
|
|
2904
|
-
return
|
|
2905
|
-
}
|
|
2906
|
-
|
|
2907
|
-
const sanitizedName = projectName
|
|
2908
|
-
.toLowerCase()
|
|
2909
|
-
.replace(/[^a-z0-9-]/g, '-')
|
|
2910
|
-
.replace(/-+/g, '-')
|
|
2911
|
-
.replace(/^-|-$/g, '')
|
|
2912
|
-
.slice(0, 100)
|
|
2913
|
-
|
|
2914
|
-
if (!sanitizedName) {
|
|
2915
|
-
await command.editReply('Invalid project name')
|
|
2916
|
-
return
|
|
2917
|
-
}
|
|
2918
|
-
|
|
2919
|
-
const kimakiDir = path.join(os.homedir(), 'kimaki')
|
|
2920
|
-
const projectDirectory = path.join(kimakiDir, sanitizedName)
|
|
2921
|
-
|
|
2922
|
-
try {
|
|
2923
|
-
if (!fs.existsSync(kimakiDir)) {
|
|
2924
|
-
fs.mkdirSync(kimakiDir, { recursive: true })
|
|
2925
|
-
discordLogger.log(`Created kimaki directory: ${kimakiDir}`)
|
|
2926
|
-
}
|
|
2927
|
-
|
|
2928
|
-
if (fs.existsSync(projectDirectory)) {
|
|
2929
|
-
await command.editReply(`Project directory already exists: ${projectDirectory}`)
|
|
2930
|
-
return
|
|
2931
|
-
}
|
|
2932
|
-
|
|
2933
|
-
fs.mkdirSync(projectDirectory, { recursive: true })
|
|
2934
|
-
discordLogger.log(`Created project directory: ${projectDirectory}`)
|
|
2935
|
-
|
|
2936
|
-
const { execSync } = await import('node:child_process')
|
|
2937
|
-
execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
|
|
2938
|
-
discordLogger.log(`Initialized git in: ${projectDirectory}`)
|
|
2939
|
-
|
|
2940
|
-
const { textChannelId, voiceChannelId, channelName } =
|
|
2941
|
-
await createProjectChannels({
|
|
2942
|
-
guild,
|
|
2943
|
-
projectDirectory,
|
|
2944
|
-
appId: currentAppId!,
|
|
2945
|
-
})
|
|
2946
|
-
|
|
2947
|
-
const textChannel = await guild.channels.fetch(textChannelId) as TextChannel
|
|
2948
|
-
|
|
2949
|
-
await command.editReply(
|
|
2950
|
-
`✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`,
|
|
2951
|
-
)
|
|
2952
|
-
|
|
2953
|
-
const starterMessage = await textChannel.send({
|
|
2954
|
-
content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
|
|
2955
|
-
})
|
|
2956
|
-
|
|
2957
|
-
const thread = await starterMessage.startThread({
|
|
2958
|
-
name: `Init: ${sanitizedName}`,
|
|
2959
|
-
autoArchiveDuration: 1440,
|
|
2960
|
-
reason: 'New project session',
|
|
2961
|
-
})
|
|
2962
|
-
|
|
2963
|
-
await handleOpencodeSession({
|
|
2964
|
-
prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
|
|
2965
|
-
thread,
|
|
2966
|
-
projectDirectory,
|
|
2967
|
-
})
|
|
2968
|
-
|
|
2969
|
-
discordLogger.log(
|
|
2970
|
-
`Created new project ${channelName} at ${projectDirectory}`,
|
|
2971
|
-
)
|
|
2972
|
-
} catch (error) {
|
|
2973
|
-
voiceLogger.error('[ADD-NEW-PROJECT] Error:', error)
|
|
2974
|
-
await command.editReply(
|
|
2975
|
-
`Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2976
|
-
)
|
|
2977
|
-
}
|
|
2978
|
-
} else if (
|
|
2979
|
-
command.commandName === 'accept' ||
|
|
2980
|
-
command.commandName === 'accept-always'
|
|
2981
|
-
) {
|
|
2982
|
-
const scope = command.commandName === 'accept-always' ? 'always' : 'once'
|
|
2983
|
-
const channel = command.channel
|
|
2984
|
-
|
|
2985
|
-
if (!channel) {
|
|
2986
|
-
await command.reply({
|
|
2987
|
-
content: 'This command can only be used in a channel',
|
|
2988
|
-
ephemeral: true,
|
|
2989
|
-
})
|
|
2990
|
-
return
|
|
2991
|
-
}
|
|
2992
|
-
|
|
2993
|
-
const isThread = [
|
|
2994
|
-
ChannelType.PublicThread,
|
|
2995
|
-
ChannelType.PrivateThread,
|
|
2996
|
-
ChannelType.AnnouncementThread,
|
|
2997
|
-
].includes(channel.type)
|
|
2998
|
-
|
|
2999
|
-
if (!isThread) {
|
|
3000
|
-
await command.reply({
|
|
3001
|
-
content: 'This command can only be used in a thread with an active session',
|
|
3002
|
-
ephemeral: true,
|
|
3003
|
-
})
|
|
3004
|
-
return
|
|
3005
|
-
}
|
|
3006
|
-
|
|
3007
|
-
const pending = pendingPermissions.get(channel.id)
|
|
3008
|
-
if (!pending) {
|
|
3009
|
-
await command.reply({
|
|
3010
|
-
content: 'No pending permission request in this thread',
|
|
3011
|
-
ephemeral: true,
|
|
3012
|
-
})
|
|
3013
|
-
return
|
|
3014
|
-
}
|
|
3015
|
-
|
|
3016
|
-
try {
|
|
3017
|
-
const getClient = await initializeOpencodeForDirectory(pending.directory)
|
|
3018
|
-
await getClient().postSessionIdPermissionsPermissionId({
|
|
3019
|
-
path: {
|
|
3020
|
-
id: pending.permission.sessionID,
|
|
3021
|
-
permissionID: pending.permission.id,
|
|
3022
|
-
},
|
|
3023
|
-
body: {
|
|
3024
|
-
response: scope,
|
|
3025
|
-
},
|
|
3026
|
-
})
|
|
3027
|
-
|
|
3028
|
-
pendingPermissions.delete(channel.id)
|
|
3029
|
-
const msg =
|
|
3030
|
-
scope === 'always'
|
|
3031
|
-
? `✅ Permission **accepted** (auto-approve similar requests)`
|
|
3032
|
-
: `✅ Permission **accepted**`
|
|
3033
|
-
await command.reply(msg)
|
|
3034
|
-
sessionLogger.log(
|
|
3035
|
-
`Permission ${pending.permission.id} accepted with scope: ${scope}`,
|
|
3036
|
-
)
|
|
3037
|
-
} catch (error) {
|
|
3038
|
-
voiceLogger.error('[ACCEPT] Error:', error)
|
|
3039
|
-
await command.reply({
|
|
3040
|
-
content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
3041
|
-
ephemeral: true,
|
|
3042
|
-
})
|
|
3043
|
-
}
|
|
3044
|
-
} else if (command.commandName === 'reject') {
|
|
3045
|
-
const channel = command.channel
|
|
3046
|
-
|
|
3047
|
-
if (!channel) {
|
|
3048
|
-
await command.reply({
|
|
3049
|
-
content: 'This command can only be used in a channel',
|
|
3050
|
-
ephemeral: true,
|
|
3051
|
-
})
|
|
3052
|
-
return
|
|
3053
|
-
}
|
|
3054
|
-
|
|
3055
|
-
const isThread = [
|
|
3056
|
-
ChannelType.PublicThread,
|
|
3057
|
-
ChannelType.PrivateThread,
|
|
3058
|
-
ChannelType.AnnouncementThread,
|
|
3059
|
-
].includes(channel.type)
|
|
3060
|
-
|
|
3061
|
-
if (!isThread) {
|
|
3062
|
-
await command.reply({
|
|
3063
|
-
content: 'This command can only be used in a thread with an active session',
|
|
3064
|
-
ephemeral: true,
|
|
3065
|
-
})
|
|
3066
|
-
return
|
|
3067
|
-
}
|
|
3068
|
-
|
|
3069
|
-
const pending = pendingPermissions.get(channel.id)
|
|
3070
|
-
if (!pending) {
|
|
3071
|
-
await command.reply({
|
|
3072
|
-
content: 'No pending permission request in this thread',
|
|
3073
|
-
ephemeral: true,
|
|
3074
|
-
})
|
|
3075
|
-
return
|
|
3076
|
-
}
|
|
3077
|
-
|
|
3078
|
-
try {
|
|
3079
|
-
const getClient = await initializeOpencodeForDirectory(pending.directory)
|
|
3080
|
-
await getClient().postSessionIdPermissionsPermissionId({
|
|
3081
|
-
path: {
|
|
3082
|
-
id: pending.permission.sessionID,
|
|
3083
|
-
permissionID: pending.permission.id,
|
|
3084
|
-
},
|
|
3085
|
-
body: {
|
|
3086
|
-
response: 'reject',
|
|
3087
|
-
},
|
|
3088
|
-
})
|
|
3089
|
-
|
|
3090
|
-
pendingPermissions.delete(channel.id)
|
|
3091
|
-
await command.reply(`❌ Permission **rejected**`)
|
|
3092
|
-
sessionLogger.log(`Permission ${pending.permission.id} rejected`)
|
|
3093
|
-
} catch (error) {
|
|
3094
|
-
voiceLogger.error('[REJECT] Error:', error)
|
|
3095
|
-
await command.reply({
|
|
3096
|
-
content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
3097
|
-
ephemeral: true,
|
|
3098
|
-
})
|
|
3099
|
-
}
|
|
3100
|
-
} else if (command.commandName === 'abort') {
|
|
3101
|
-
const channel = command.channel
|
|
3102
|
-
|
|
3103
|
-
if (!channel) {
|
|
3104
|
-
await command.reply({
|
|
3105
|
-
content: 'This command can only be used in a channel',
|
|
3106
|
-
ephemeral: true,
|
|
3107
|
-
})
|
|
3108
|
-
return
|
|
3109
|
-
}
|
|
3110
|
-
|
|
3111
|
-
const isThread = [
|
|
3112
|
-
ChannelType.PublicThread,
|
|
3113
|
-
ChannelType.PrivateThread,
|
|
3114
|
-
ChannelType.AnnouncementThread,
|
|
3115
|
-
].includes(channel.type)
|
|
3116
|
-
|
|
3117
|
-
if (!isThread) {
|
|
3118
|
-
await command.reply({
|
|
3119
|
-
content: 'This command can only be used in a thread with an active session',
|
|
3120
|
-
ephemeral: true,
|
|
3121
|
-
})
|
|
3122
|
-
return
|
|
3123
|
-
}
|
|
3124
|
-
|
|
3125
|
-
const textChannel = resolveTextChannel(channel as ThreadChannel)
|
|
3126
|
-
const { projectDirectory: directory } = getKimakiMetadata(textChannel)
|
|
3127
|
-
|
|
3128
|
-
if (!directory) {
|
|
3129
|
-
await command.reply({
|
|
3130
|
-
content: 'Could not determine project directory for this channel',
|
|
3131
|
-
ephemeral: true,
|
|
3132
|
-
})
|
|
3133
|
-
return
|
|
3134
|
-
}
|
|
3135
|
-
|
|
3136
|
-
const row = getDatabase()
|
|
3137
|
-
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
3138
|
-
.get(channel.id) as { session_id: string } | undefined
|
|
3139
|
-
|
|
3140
|
-
if (!row?.session_id) {
|
|
3141
|
-
await command.reply({
|
|
3142
|
-
content: 'No active session in this thread',
|
|
3143
|
-
ephemeral: true,
|
|
3144
|
-
})
|
|
3145
|
-
return
|
|
3146
|
-
}
|
|
3147
|
-
|
|
3148
|
-
const sessionId = row.session_id
|
|
3149
|
-
|
|
3150
|
-
try {
|
|
3151
|
-
const existingController = abortControllers.get(sessionId)
|
|
3152
|
-
if (existingController) {
|
|
3153
|
-
existingController.abort(new Error('User requested abort'))
|
|
3154
|
-
abortControllers.delete(sessionId)
|
|
3155
|
-
}
|
|
3156
|
-
|
|
3157
|
-
const getClient = await initializeOpencodeForDirectory(directory)
|
|
3158
|
-
await getClient().session.abort({
|
|
3159
|
-
path: { id: sessionId },
|
|
3160
|
-
})
|
|
3161
|
-
|
|
3162
|
-
await command.reply(`🛑 Request **aborted**`)
|
|
3163
|
-
sessionLogger.log(`Session ${sessionId} aborted by user`)
|
|
3164
|
-
} catch (error) {
|
|
3165
|
-
voiceLogger.error('[ABORT] Error:', error)
|
|
3166
|
-
await command.reply({
|
|
3167
|
-
content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
3168
|
-
ephemeral: true,
|
|
3169
|
-
})
|
|
3170
|
-
}
|
|
3171
|
-
} else if (command.commandName === 'share') {
|
|
3172
|
-
const channel = command.channel
|
|
3173
|
-
|
|
3174
|
-
if (!channel) {
|
|
3175
|
-
await command.reply({
|
|
3176
|
-
content: 'This command can only be used in a channel',
|
|
3177
|
-
ephemeral: true,
|
|
3178
|
-
})
|
|
3179
|
-
return
|
|
3180
|
-
}
|
|
3181
|
-
|
|
3182
|
-
const isThread = [
|
|
3183
|
-
ChannelType.PublicThread,
|
|
3184
|
-
ChannelType.PrivateThread,
|
|
3185
|
-
ChannelType.AnnouncementThread,
|
|
3186
|
-
].includes(channel.type)
|
|
3187
|
-
|
|
3188
|
-
if (!isThread) {
|
|
3189
|
-
await command.reply({
|
|
3190
|
-
content: 'This command can only be used in a thread with an active session',
|
|
3191
|
-
ephemeral: true,
|
|
3192
|
-
})
|
|
3193
|
-
return
|
|
3194
|
-
}
|
|
3195
|
-
|
|
3196
|
-
const textChannel = resolveTextChannel(channel as ThreadChannel)
|
|
3197
|
-
const { projectDirectory: directory } = getKimakiMetadata(textChannel)
|
|
3198
|
-
|
|
3199
|
-
if (!directory) {
|
|
3200
|
-
await command.reply({
|
|
3201
|
-
content: 'Could not determine project directory for this channel',
|
|
3202
|
-
ephemeral: true,
|
|
3203
|
-
})
|
|
3204
|
-
return
|
|
3205
|
-
}
|
|
3206
|
-
|
|
3207
|
-
const row = getDatabase()
|
|
3208
|
-
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
3209
|
-
.get(channel.id) as { session_id: string } | undefined
|
|
3210
|
-
|
|
3211
|
-
if (!row?.session_id) {
|
|
3212
|
-
await command.reply({
|
|
3213
|
-
content: 'No active session in this thread',
|
|
3214
|
-
ephemeral: true,
|
|
3215
|
-
})
|
|
3216
|
-
return
|
|
3217
|
-
}
|
|
3218
|
-
|
|
3219
|
-
const sessionId = row.session_id
|
|
3220
|
-
|
|
3221
|
-
try {
|
|
3222
|
-
const getClient = await initializeOpencodeForDirectory(directory)
|
|
3223
|
-
const response = await getClient().session.share({
|
|
3224
|
-
path: { id: sessionId },
|
|
3225
|
-
})
|
|
3226
|
-
|
|
3227
|
-
if (!response.data?.share?.url) {
|
|
3228
|
-
await command.reply({
|
|
3229
|
-
content: 'Failed to generate share URL',
|
|
3230
|
-
ephemeral: true,
|
|
3231
|
-
})
|
|
3232
|
-
return
|
|
3233
|
-
}
|
|
3234
|
-
|
|
3235
|
-
await command.reply(`🔗 **Session shared:** ${response.data.share.url}`)
|
|
3236
|
-
sessionLogger.log(`Session ${sessionId} shared: ${response.data.share.url}`)
|
|
3237
|
-
} catch (error) {
|
|
3238
|
-
voiceLogger.error('[SHARE] Error:', error)
|
|
3239
|
-
await command.reply({
|
|
3240
|
-
content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
3241
|
-
ephemeral: true,
|
|
3242
|
-
})
|
|
3243
|
-
}
|
|
3244
|
-
}
|
|
3245
|
-
}
|
|
3246
|
-
} catch (error) {
|
|
3247
|
-
voiceLogger.error('[INTERACTION] Error handling interaction:', error)
|
|
3248
|
-
}
|
|
3249
|
-
},
|
|
3250
|
-
)
|
|
3251
|
-
|
|
3252
|
-
// Helper function to clean up voice connection and associated resources
|
|
3253
|
-
async function cleanupVoiceConnection(guildId: string) {
|
|
3254
|
-
const voiceData = voiceConnections.get(guildId)
|
|
3255
|
-
if (!voiceData) return
|
|
3256
|
-
|
|
3257
|
-
voiceLogger.log(`Starting cleanup for guild ${guildId}`)
|
|
3258
|
-
|
|
3259
|
-
try {
|
|
3260
|
-
// Stop GenAI worker if exists (this is async!)
|
|
3261
|
-
if (voiceData.genAiWorker) {
|
|
3262
|
-
voiceLogger.log(`Stopping GenAI worker...`)
|
|
3263
|
-
await voiceData.genAiWorker.stop()
|
|
3264
|
-
voiceLogger.log(`GenAI worker stopped`)
|
|
3265
|
-
}
|
|
3266
|
-
|
|
3267
|
-
// Close user audio stream if exists
|
|
3268
|
-
if (voiceData.userAudioStream) {
|
|
3269
|
-
voiceLogger.log(`Closing user audio stream...`)
|
|
3270
|
-
await new Promise<void>((resolve) => {
|
|
3271
|
-
voiceData.userAudioStream!.end(() => {
|
|
3272
|
-
voiceLogger.log('User audio stream closed')
|
|
3273
|
-
resolve()
|
|
3274
|
-
})
|
|
3275
|
-
// Timeout after 2 seconds
|
|
3276
|
-
setTimeout(resolve, 2000)
|
|
3277
|
-
})
|
|
3278
|
-
}
|
|
3279
|
-
|
|
3280
|
-
// Destroy voice connection
|
|
3281
|
-
if (
|
|
3282
|
-
voiceData.connection.state.status !== VoiceConnectionStatus.Destroyed
|
|
3283
|
-
) {
|
|
3284
|
-
voiceLogger.log(`Destroying voice connection...`)
|
|
3285
|
-
voiceData.connection.destroy()
|
|
3286
|
-
}
|
|
3287
|
-
|
|
3288
|
-
// Remove from map
|
|
3289
|
-
voiceConnections.delete(guildId)
|
|
3290
|
-
voiceLogger.log(`Cleanup complete for guild ${guildId}`)
|
|
3291
|
-
} catch (error) {
|
|
3292
|
-
voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error)
|
|
3293
|
-
// Still remove from map even if there was an error
|
|
3294
|
-
voiceConnections.delete(guildId)
|
|
3295
|
-
}
|
|
3296
|
-
}
|
|
3297
|
-
|
|
3298
|
-
// Handle voice state updates
|
|
3299
|
-
discordClient.on(Events.VoiceStateUpdate, async (oldState, newState) => {
|
|
3300
|
-
try {
|
|
3301
|
-
const member = newState.member || oldState.member
|
|
3302
|
-
if (!member) return
|
|
3303
|
-
|
|
3304
|
-
// Check if user is admin, server owner, can manage server, or has Kimaki role
|
|
3305
|
-
const guild = newState.guild || oldState.guild
|
|
3306
|
-
const isOwner = member.id === guild.ownerId
|
|
3307
|
-
const isAdmin = member.permissions.has(
|
|
3308
|
-
PermissionsBitField.Flags.Administrator,
|
|
3309
|
-
)
|
|
3310
|
-
const canManageServer = member.permissions.has(
|
|
3311
|
-
PermissionsBitField.Flags.ManageGuild,
|
|
3312
|
-
)
|
|
3313
|
-
const hasKimakiRole = member.roles.cache.some(
|
|
3314
|
-
(role) => role.name.toLowerCase() === 'kimaki',
|
|
3315
|
-
)
|
|
3316
|
-
|
|
3317
|
-
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
3318
|
-
return
|
|
3319
|
-
}
|
|
3320
|
-
|
|
3321
|
-
// Handle admin leaving voice channel
|
|
3322
|
-
if (oldState.channelId !== null && newState.channelId === null) {
|
|
3323
|
-
voiceLogger.log(
|
|
3324
|
-
`Admin user ${member.user.tag} left voice channel: ${oldState.channel?.name}`,
|
|
3325
|
-
)
|
|
3326
|
-
|
|
3327
|
-
// Check if bot should leave too
|
|
3328
|
-
const guildId = guild.id
|
|
3329
|
-
const voiceData = voiceConnections.get(guildId)
|
|
3330
|
-
|
|
3331
|
-
if (
|
|
3332
|
-
voiceData &&
|
|
3333
|
-
voiceData.connection.joinConfig.channelId === oldState.channelId
|
|
3334
|
-
) {
|
|
3335
|
-
// Check if any other admin is still in the channel
|
|
3336
|
-
const voiceChannel = oldState.channel as VoiceChannel
|
|
3337
|
-
if (!voiceChannel) return
|
|
3338
|
-
|
|
3339
|
-
const hasOtherAdmins = voiceChannel.members.some((m) => {
|
|
3340
|
-
if (m.id === member.id || m.user.bot) return false
|
|
3341
|
-
return (
|
|
3342
|
-
m.id === guild.ownerId ||
|
|
3343
|
-
m.permissions.has(PermissionsBitField.Flags.Administrator) ||
|
|
3344
|
-
m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
|
|
3345
|
-
m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki')
|
|
3346
|
-
)
|
|
3347
|
-
})
|
|
3348
|
-
|
|
3349
|
-
if (!hasOtherAdmins) {
|
|
3350
|
-
voiceLogger.log(
|
|
3351
|
-
`No other admins in channel, bot leaving voice channel in guild: ${guild.name}`,
|
|
3352
|
-
)
|
|
3353
|
-
|
|
3354
|
-
// Properly clean up all resources
|
|
3355
|
-
await cleanupVoiceConnection(guildId)
|
|
3356
|
-
} else {
|
|
3357
|
-
voiceLogger.log(
|
|
3358
|
-
`Other admins still in channel, bot staying in voice channel`,
|
|
3359
|
-
)
|
|
3360
|
-
}
|
|
3361
|
-
}
|
|
3362
|
-
return
|
|
3363
|
-
}
|
|
3364
|
-
|
|
3365
|
-
// Handle admin moving between voice channels
|
|
3366
|
-
if (
|
|
3367
|
-
oldState.channelId !== null &&
|
|
3368
|
-
newState.channelId !== null &&
|
|
3369
|
-
oldState.channelId !== newState.channelId
|
|
3370
|
-
) {
|
|
3371
|
-
voiceLogger.log(
|
|
3372
|
-
`Admin user ${member.user.tag} moved from ${oldState.channel?.name} to ${newState.channel?.name}`,
|
|
3373
|
-
)
|
|
3374
|
-
|
|
3375
|
-
// Check if we need to follow the admin
|
|
3376
|
-
const guildId = guild.id
|
|
3377
|
-
const voiceData = voiceConnections.get(guildId)
|
|
3378
|
-
|
|
3379
|
-
if (
|
|
3380
|
-
voiceData &&
|
|
3381
|
-
voiceData.connection.joinConfig.channelId === oldState.channelId
|
|
3382
|
-
) {
|
|
3383
|
-
// Check if any other admin is still in the old channel
|
|
3384
|
-
const oldVoiceChannel = oldState.channel as VoiceChannel
|
|
3385
|
-
if (oldVoiceChannel) {
|
|
3386
|
-
const hasOtherAdmins = oldVoiceChannel.members.some((m) => {
|
|
3387
|
-
if (m.id === member.id || m.user.bot) return false
|
|
3388
|
-
return (
|
|
3389
|
-
m.id === guild.ownerId ||
|
|
3390
|
-
m.permissions.has(PermissionsBitField.Flags.Administrator) ||
|
|
3391
|
-
m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
|
|
3392
|
-
m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki')
|
|
3393
|
-
)
|
|
3394
|
-
})
|
|
3395
|
-
|
|
3396
|
-
if (!hasOtherAdmins) {
|
|
3397
|
-
voiceLogger.log(
|
|
3398
|
-
`Following admin to new channel: ${newState.channel?.name}`,
|
|
3399
|
-
)
|
|
3400
|
-
const voiceChannel = newState.channel as VoiceChannel
|
|
3401
|
-
if (voiceChannel) {
|
|
3402
|
-
voiceData.connection.rejoin({
|
|
3403
|
-
channelId: voiceChannel.id,
|
|
3404
|
-
selfDeaf: false,
|
|
3405
|
-
selfMute: false,
|
|
3406
|
-
})
|
|
3407
|
-
}
|
|
3408
|
-
} else {
|
|
3409
|
-
voiceLogger.log(
|
|
3410
|
-
`Other admins still in old channel, bot staying put`,
|
|
3411
|
-
)
|
|
3412
|
-
}
|
|
3413
|
-
}
|
|
3414
|
-
}
|
|
3415
|
-
}
|
|
3416
|
-
|
|
3417
|
-
// Handle admin joining voice channel (initial join)
|
|
3418
|
-
if (oldState.channelId === null && newState.channelId !== null) {
|
|
3419
|
-
voiceLogger.log(
|
|
3420
|
-
`Admin user ${member.user.tag} (Owner: ${isOwner}, Admin: ${isAdmin}) joined voice channel: ${newState.channel?.name}`,
|
|
3421
|
-
)
|
|
3422
|
-
}
|
|
3423
|
-
|
|
3424
|
-
// Only proceed with joining if this is a new join or channel move
|
|
3425
|
-
if (newState.channelId === null) return
|
|
3426
|
-
|
|
3427
|
-
const voiceChannel = newState.channel as VoiceChannel
|
|
3428
|
-
if (!voiceChannel) return
|
|
3429
|
-
|
|
3430
|
-
// Check if bot already has a connection in this guild
|
|
3431
|
-
const existingVoiceData = voiceConnections.get(newState.guild.id)
|
|
3432
|
-
if (
|
|
3433
|
-
existingVoiceData &&
|
|
3434
|
-
existingVoiceData.connection.state.status !==
|
|
3435
|
-
VoiceConnectionStatus.Destroyed
|
|
3436
|
-
) {
|
|
3437
|
-
voiceLogger.log(
|
|
3438
|
-
`Bot already connected to a voice channel in guild ${newState.guild.name}`,
|
|
3439
|
-
)
|
|
3440
|
-
|
|
3441
|
-
// If bot is in a different channel, move to the admin's channel
|
|
3442
|
-
if (
|
|
3443
|
-
existingVoiceData.connection.joinConfig.channelId !== voiceChannel.id
|
|
3444
|
-
) {
|
|
3445
|
-
voiceLogger.log(
|
|
3446
|
-
`Moving bot from channel ${existingVoiceData.connection.joinConfig.channelId} to ${voiceChannel.id}`,
|
|
3447
|
-
)
|
|
3448
|
-
existingVoiceData.connection.rejoin({
|
|
3449
|
-
channelId: voiceChannel.id,
|
|
3450
|
-
selfDeaf: false,
|
|
3451
|
-
selfMute: false,
|
|
3452
|
-
})
|
|
3453
|
-
}
|
|
3454
|
-
return
|
|
3455
|
-
}
|
|
3456
|
-
|
|
3457
|
-
try {
|
|
3458
|
-
// Join the voice channel
|
|
3459
|
-
voiceLogger.log(
|
|
3460
|
-
`Attempting to join voice channel: ${voiceChannel.name} (${voiceChannel.id})`,
|
|
3461
|
-
)
|
|
3462
|
-
|
|
3463
|
-
const connection = joinVoiceChannel({
|
|
3464
|
-
channelId: voiceChannel.id,
|
|
3465
|
-
guildId: newState.guild.id,
|
|
3466
|
-
adapterCreator: newState.guild.voiceAdapterCreator,
|
|
3467
|
-
selfDeaf: false,
|
|
3468
|
-
debug: true,
|
|
3469
|
-
daveEncryption: false,
|
|
3470
|
-
|
|
3471
|
-
selfMute: false, // Not muted so bot can speak
|
|
3472
|
-
})
|
|
3473
|
-
|
|
3474
|
-
// Store the connection
|
|
3475
|
-
voiceConnections.set(newState.guild.id, { connection })
|
|
3476
|
-
|
|
3477
|
-
// Wait for connection to be ready
|
|
3478
|
-
await entersState(connection, VoiceConnectionStatus.Ready, 30_000)
|
|
3479
|
-
voiceLogger.log(
|
|
3480
|
-
`Successfully joined voice channel: ${voiceChannel.name} in guild: ${newState.guild.name}`,
|
|
3481
|
-
)
|
|
3482
|
-
|
|
3483
|
-
// Set up voice handling (only once per connection)
|
|
3484
|
-
await setupVoiceHandling({
|
|
3485
|
-
connection,
|
|
3486
|
-
guildId: newState.guild.id,
|
|
3487
|
-
channelId: voiceChannel.id,
|
|
3488
|
-
appId: currentAppId!,
|
|
3489
|
-
discordClient,
|
|
3490
|
-
})
|
|
3491
|
-
|
|
3492
|
-
// Handle connection state changes
|
|
3493
|
-
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
|
3494
|
-
voiceLogger.log(
|
|
3495
|
-
`Disconnected from voice channel in guild: ${newState.guild.name}`,
|
|
3496
|
-
)
|
|
3497
|
-
try {
|
|
3498
|
-
// Try to reconnect
|
|
3499
|
-
await Promise.race([
|
|
3500
|
-
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
|
|
3501
|
-
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
|
|
3502
|
-
])
|
|
3503
|
-
voiceLogger.log(`Reconnecting to voice channel`)
|
|
3504
|
-
} catch (error) {
|
|
3505
|
-
// Seems to be a real disconnect, destroy the connection
|
|
3506
|
-
voiceLogger.log(`Failed to reconnect, destroying connection`)
|
|
3507
|
-
connection.destroy()
|
|
3508
|
-
voiceConnections.delete(newState.guild.id)
|
|
3509
|
-
}
|
|
3510
|
-
})
|
|
3511
|
-
|
|
3512
|
-
connection.on(VoiceConnectionStatus.Destroyed, async () => {
|
|
3513
|
-
voiceLogger.log(
|
|
3514
|
-
`Connection destroyed for guild: ${newState.guild.name}`,
|
|
3515
|
-
)
|
|
3516
|
-
// Use the cleanup function to ensure everything is properly closed
|
|
3517
|
-
await cleanupVoiceConnection(newState.guild.id)
|
|
3518
|
-
})
|
|
3519
|
-
|
|
3520
|
-
// Handle errors
|
|
3521
|
-
connection.on('error', (error) => {
|
|
3522
|
-
voiceLogger.error(
|
|
3523
|
-
`Connection error in guild ${newState.guild.name}:`,
|
|
3524
|
-
error,
|
|
3525
|
-
)
|
|
3526
|
-
})
|
|
3527
|
-
} catch (error) {
|
|
3528
|
-
voiceLogger.error(`Failed to join voice channel:`, error)
|
|
3529
|
-
await cleanupVoiceConnection(newState.guild.id)
|
|
3530
|
-
}
|
|
3531
|
-
} catch (error) {
|
|
3532
|
-
voiceLogger.error('Error in voice state update handler:', error)
|
|
3533
|
-
}
|
|
3534
|
-
})
|
|
3535
|
-
|
|
3536
|
-
await discordClient.login(token)
|
|
3537
|
-
|
|
3538
|
-
const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
|
|
3539
|
-
discordLogger.log(`Received ${signal}, cleaning up...`)
|
|
3540
|
-
|
|
3541
|
-
// Prevent multiple shutdown calls
|
|
3542
|
-
if ((global as any).shuttingDown) {
|
|
3543
|
-
discordLogger.log('Already shutting down, ignoring duplicate signal')
|
|
3544
|
-
return
|
|
3545
|
-
}
|
|
3546
|
-
;(global as any).shuttingDown = true
|
|
3547
|
-
|
|
3548
|
-
try {
|
|
3549
|
-
// Clean up all voice connections (this includes GenAI workers and audio streams)
|
|
3550
|
-
const cleanupPromises: Promise<void>[] = []
|
|
3551
|
-
for (const [guildId] of voiceConnections) {
|
|
3552
|
-
voiceLogger.log(
|
|
3553
|
-
`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
|
|
3554
|
-
)
|
|
3555
|
-
cleanupPromises.push(cleanupVoiceConnection(guildId))
|
|
3556
|
-
}
|
|
3557
|
-
|
|
3558
|
-
// Wait for all cleanups to complete
|
|
3559
|
-
if (cleanupPromises.length > 0) {
|
|
3560
|
-
voiceLogger.log(
|
|
3561
|
-
`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`,
|
|
3562
|
-
)
|
|
3563
|
-
await Promise.allSettled(cleanupPromises)
|
|
3564
|
-
discordLogger.log(`All voice connections cleaned up`)
|
|
3565
|
-
}
|
|
3566
|
-
|
|
3567
|
-
// Kill all OpenCode servers
|
|
3568
|
-
for (const [dir, server] of opencodeServers) {
|
|
3569
|
-
if (!server.process.killed) {
|
|
3570
|
-
voiceLogger.log(
|
|
3571
|
-
`[SHUTDOWN] Stopping OpenCode server on port ${server.port} for ${dir}`,
|
|
3572
|
-
)
|
|
3573
|
-
server.process.kill('SIGTERM')
|
|
3574
|
-
}
|
|
3575
|
-
}
|
|
3576
|
-
opencodeServers.clear()
|
|
3577
|
-
|
|
3578
|
-
discordLogger.log('Closing database...')
|
|
3579
|
-
if (db) {
|
|
3580
|
-
db.close()
|
|
3581
|
-
db = null
|
|
3582
|
-
}
|
|
3583
|
-
|
|
3584
|
-
discordLogger.log('Destroying Discord client...')
|
|
3585
|
-
discordClient.destroy()
|
|
3586
|
-
|
|
3587
|
-
discordLogger.log('Cleanup complete.')
|
|
3588
|
-
if (!skipExit) {
|
|
3589
|
-
process.exit(0)
|
|
3590
|
-
}
|
|
3591
|
-
} catch (error) {
|
|
3592
|
-
voiceLogger.error('[SHUTDOWN] Error during cleanup:', error)
|
|
3593
|
-
if (!skipExit) {
|
|
3594
|
-
process.exit(1)
|
|
3595
|
-
}
|
|
3596
|
-
}
|
|
3597
|
-
}
|
|
3598
|
-
|
|
3599
|
-
// Override default signal handlers to prevent immediate exit
|
|
3600
|
-
process.on('SIGTERM', async () => {
|
|
3601
|
-
try {
|
|
3602
|
-
await handleShutdown('SIGTERM')
|
|
3603
|
-
} catch (error) {
|
|
3604
|
-
voiceLogger.error('[SIGTERM] Error during shutdown:', error)
|
|
3605
|
-
process.exit(1)
|
|
3606
|
-
}
|
|
3607
|
-
})
|
|
3608
|
-
|
|
3609
|
-
process.on('SIGINT', async () => {
|
|
3610
|
-
try {
|
|
3611
|
-
await handleShutdown('SIGINT')
|
|
3612
|
-
} catch (error) {
|
|
3613
|
-
voiceLogger.error('[SIGINT] Error during shutdown:', error)
|
|
3614
|
-
process.exit(1)
|
|
3615
|
-
}
|
|
3616
|
-
})
|
|
3617
|
-
|
|
3618
|
-
process.on('SIGUSR2', async () => {
|
|
3619
|
-
discordLogger.log('Received SIGUSR2, restarting after cleanup...')
|
|
3620
|
-
try {
|
|
3621
|
-
await handleShutdown('SIGUSR2', { skipExit: true })
|
|
3622
|
-
} catch (error) {
|
|
3623
|
-
voiceLogger.error('[SIGUSR2] Error during shutdown:', error)
|
|
3624
|
-
}
|
|
3625
|
-
const { spawn } = await import('node:child_process')
|
|
3626
|
-
spawn(process.argv[0]!, [...process.execArgv, ...process.argv.slice(1)], {
|
|
3627
|
-
stdio: 'inherit',
|
|
3628
|
-
detached: true,
|
|
3629
|
-
cwd: process.cwd(),
|
|
3630
|
-
env: process.env,
|
|
3631
|
-
}).unref()
|
|
3632
|
-
process.exit(0)
|
|
3633
|
-
})
|
|
3634
|
-
|
|
3635
|
-
// Prevent unhandled promise rejections from crashing the process during shutdown
|
|
3636
|
-
process.on('unhandledRejection', (reason, promise) => {
|
|
3637
|
-
if ((global as any).shuttingDown) {
|
|
3638
|
-
discordLogger.log('Ignoring unhandled rejection during shutdown:', reason)
|
|
3639
|
-
return
|
|
3640
|
-
}
|
|
3641
|
-
discordLogger.error('Unhandled Rejection at:', promise, 'reason:', reason)
|
|
3642
|
-
})
|
|
3643
|
-
}
|