shuvmaki 0.4.26

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.
Files changed (94) hide show
  1. package/bin.js +70 -0
  2. package/dist/ai-tool-to-genai.js +210 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/channel-management.js +97 -0
  5. package/dist/cli.js +709 -0
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +184 -0
  22. package/dist/discord-bot.js +384 -0
  23. package/dist/discord-utils.js +217 -0
  24. package/dist/escape-backticks.test.js +410 -0
  25. package/dist/format-tables.js +96 -0
  26. package/dist/format-tables.test.js +418 -0
  27. package/dist/genai-worker-wrapper.js +109 -0
  28. package/dist/genai-worker.js +297 -0
  29. package/dist/genai.js +232 -0
  30. package/dist/interaction-handler.js +144 -0
  31. package/dist/logger.js +51 -0
  32. package/dist/markdown.js +310 -0
  33. package/dist/markdown.test.js +262 -0
  34. package/dist/message-formatting.js +273 -0
  35. package/dist/message-formatting.test.js +73 -0
  36. package/dist/openai-realtime.js +228 -0
  37. package/dist/opencode.js +216 -0
  38. package/dist/session-handler.js +580 -0
  39. package/dist/system-message.js +61 -0
  40. package/dist/tools.js +356 -0
  41. package/dist/utils.js +85 -0
  42. package/dist/voice-handler.js +541 -0
  43. package/dist/voice.js +314 -0
  44. package/dist/worker-types.js +4 -0
  45. package/dist/xml.js +92 -0
  46. package/dist/xml.test.js +32 -0
  47. package/package.json +60 -0
  48. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  49. package/src/__snapshots__/compact-session-context.md +47 -0
  50. package/src/ai-tool-to-genai.test.ts +296 -0
  51. package/src/ai-tool-to-genai.ts +255 -0
  52. package/src/channel-management.ts +161 -0
  53. package/src/cli.ts +1010 -0
  54. package/src/commands/abort.ts +94 -0
  55. package/src/commands/add-project.ts +139 -0
  56. package/src/commands/agent.ts +201 -0
  57. package/src/commands/ask-question.ts +276 -0
  58. package/src/commands/create-new-project.ts +111 -0
  59. package/src/commands/fork.ts +257 -0
  60. package/src/commands/model.ts +402 -0
  61. package/src/commands/permissions.ts +146 -0
  62. package/src/commands/queue.ts +181 -0
  63. package/src/commands/resume.ts +230 -0
  64. package/src/commands/session.ts +184 -0
  65. package/src/commands/share.ts +96 -0
  66. package/src/commands/types.ts +25 -0
  67. package/src/commands/undo-redo.ts +213 -0
  68. package/src/commands/user-command.ts +178 -0
  69. package/src/database.ts +220 -0
  70. package/src/discord-bot.ts +513 -0
  71. package/src/discord-utils.ts +282 -0
  72. package/src/escape-backticks.test.ts +447 -0
  73. package/src/format-tables.test.ts +440 -0
  74. package/src/format-tables.ts +110 -0
  75. package/src/genai-worker-wrapper.ts +160 -0
  76. package/src/genai-worker.ts +366 -0
  77. package/src/genai.ts +321 -0
  78. package/src/interaction-handler.ts +187 -0
  79. package/src/logger.ts +57 -0
  80. package/src/markdown.test.ts +358 -0
  81. package/src/markdown.ts +365 -0
  82. package/src/message-formatting.test.ts +81 -0
  83. package/src/message-formatting.ts +340 -0
  84. package/src/openai-realtime.ts +363 -0
  85. package/src/opencode.ts +277 -0
  86. package/src/session-handler.ts +758 -0
  87. package/src/system-message.ts +62 -0
  88. package/src/tools.ts +428 -0
  89. package/src/utils.ts +118 -0
  90. package/src/voice-handler.ts +760 -0
  91. package/src/voice.ts +432 -0
  92. package/src/worker-types.ts +66 -0
  93. package/src/xml.test.ts +37 -0
  94. package/src/xml.ts +121 -0
@@ -0,0 +1,513 @@
1
+ // Core Discord bot module that handles message events and bot lifecycle.
2
+ // Bridges Discord messages to OpenCode sessions, manages voice connections,
3
+ // and orchestrates the main event loop for the Kimaki bot.
4
+
5
+ import { getDatabase, closeDatabase } from './database.js'
6
+ import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js'
7
+ import {
8
+ escapeBackticksInCodeBlocks,
9
+ splitMarkdownForDiscord,
10
+ SILENT_MESSAGE_FLAGS,
11
+ } from './discord-utils.js'
12
+ import { getOpencodeSystemMessage } from './system-message.js'
13
+ import { getFileAttachments, getTextAttachments } from './message-formatting.js'
14
+ import {
15
+ ensureKimakiCategory,
16
+ ensureKimakiAudioCategory,
17
+ createProjectChannels,
18
+ getChannelsWithDescriptions,
19
+ type ChannelWithTags,
20
+ } from './channel-management.js'
21
+ import {
22
+ voiceConnections,
23
+ cleanupVoiceConnection,
24
+ processVoiceAttachment,
25
+ registerVoiceStateHandler,
26
+ } from './voice-handler.js'
27
+ import {
28
+ getCompactSessionContext,
29
+ getLastSessionId,
30
+ } from './markdown.js'
31
+ import { handleOpencodeSession } from './session-handler.js'
32
+ import { registerInteractionHandler } from './interaction-handler.js'
33
+
34
+ export { getDatabase, closeDatabase } from './database.js'
35
+ export { initializeOpencodeForDirectory } from './opencode.js'
36
+ export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discord-utils.js'
37
+ export { getOpencodeSystemMessage } from './system-message.js'
38
+ export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions } from './channel-management.js'
39
+ export type { ChannelWithTags } from './channel-management.js'
40
+
41
+ import {
42
+ ChannelType,
43
+ Client,
44
+ Events,
45
+ GatewayIntentBits,
46
+ Partials,
47
+ PermissionsBitField,
48
+ ThreadAutoArchiveDuration,
49
+ type Message,
50
+ type TextChannel,
51
+ type ThreadChannel,
52
+ } from 'discord.js'
53
+ import fs from 'node:fs'
54
+ import { extractTagsArrays } from './xml.js'
55
+ import { createLogger } from './logger.js'
56
+ import { setGlobalDispatcher, Agent } from 'undici'
57
+
58
+ setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }))
59
+
60
+ const discordLogger = createLogger('DISCORD')
61
+ const voiceLogger = createLogger('VOICE')
62
+
63
+ type StartOptions = {
64
+ token: string
65
+ appId?: string
66
+ }
67
+
68
+ export async function createDiscordClient() {
69
+ return new Client({
70
+ intents: [
71
+ GatewayIntentBits.Guilds,
72
+ GatewayIntentBits.GuildMessages,
73
+ GatewayIntentBits.MessageContent,
74
+ GatewayIntentBits.GuildVoiceStates,
75
+ ],
76
+ partials: [
77
+ Partials.Channel,
78
+ Partials.Message,
79
+ Partials.User,
80
+ Partials.ThreadMember,
81
+ ],
82
+ })
83
+ }
84
+
85
+ export async function startDiscordBot({
86
+ token,
87
+ appId,
88
+ discordClient,
89
+ }: StartOptions & { discordClient?: Client }) {
90
+ if (!discordClient) {
91
+ discordClient = await createDiscordClient()
92
+ }
93
+
94
+ let currentAppId: string | undefined = appId
95
+
96
+ const setupHandlers = async (c: Client<true>) => {
97
+ discordLogger.log(`Discord bot logged in as ${c.user.tag}`)
98
+ discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`)
99
+ discordLogger.log(`Bot user ID: ${c.user.id}`)
100
+
101
+ if (!currentAppId) {
102
+ await c.application?.fetch()
103
+ currentAppId = c.application?.id
104
+
105
+ if (!currentAppId) {
106
+ discordLogger.error('Could not get application ID')
107
+ throw new Error('Failed to get bot application ID')
108
+ }
109
+ discordLogger.log(`Bot Application ID (fetched): ${currentAppId}`)
110
+ } else {
111
+ discordLogger.log(`Bot Application ID (provided): ${currentAppId}`)
112
+ }
113
+
114
+ for (const guild of c.guilds.cache.values()) {
115
+ discordLogger.log(`${guild.name} (${guild.id})`)
116
+
117
+ const channels = await getChannelsWithDescriptions(guild)
118
+ const kimakiChannels = channels.filter(
119
+ (ch) =>
120
+ ch.kimakiDirectory &&
121
+ (!ch.kimakiApp || ch.kimakiApp === currentAppId),
122
+ )
123
+
124
+ if (kimakiChannels.length > 0) {
125
+ discordLogger.log(
126
+ ` Found ${kimakiChannels.length} channel(s) for this bot:`,
127
+ )
128
+ for (const channel of kimakiChannels) {
129
+ discordLogger.log(` - #${channel.name}: ${channel.kimakiDirectory}`)
130
+ }
131
+ } else {
132
+ discordLogger.log(` No channels for this bot`)
133
+ }
134
+ }
135
+
136
+ voiceLogger.log(
137
+ `[READY] Bot is ready and will only respond to channels with app ID: ${currentAppId}`,
138
+ )
139
+
140
+ registerInteractionHandler({ discordClient: c, appId: currentAppId })
141
+ registerVoiceStateHandler({ discordClient: c, appId: currentAppId })
142
+ }
143
+
144
+ // If client is already ready (was logged in before being passed to us),
145
+ // run setup immediately. Otherwise wait for the ClientReady event.
146
+ if (discordClient.isReady()) {
147
+ await setupHandlers(discordClient)
148
+ } else {
149
+ discordClient.once(Events.ClientReady, setupHandlers)
150
+ }
151
+
152
+ discordClient.on(Events.MessageCreate, async (message: Message) => {
153
+ try {
154
+ if (message.author?.bot) {
155
+ return
156
+ }
157
+ if (message.partial) {
158
+ discordLogger.log(`Fetching partial message ${message.id}`)
159
+ try {
160
+ await message.fetch()
161
+ } catch (error) {
162
+ discordLogger.log(
163
+ `Failed to fetch partial message ${message.id}:`,
164
+ error,
165
+ )
166
+ return
167
+ }
168
+ }
169
+
170
+ if (message.guild && message.member) {
171
+ const isOwner = message.member.id === message.guild.ownerId
172
+ const isAdmin = message.member.permissions.has(
173
+ PermissionsBitField.Flags.Administrator,
174
+ )
175
+ const canManageServer = message.member.permissions.has(
176
+ PermissionsBitField.Flags.ManageGuild,
177
+ )
178
+ const hasKimakiRole = message.member.roles.cache.some(
179
+ (role) => role.name.toLowerCase() === 'kimaki',
180
+ )
181
+
182
+ if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
183
+ await message.react('🔒')
184
+ return
185
+ }
186
+ }
187
+
188
+ const channel = message.channel
189
+ const isThread = [
190
+ ChannelType.PublicThread,
191
+ ChannelType.PrivateThread,
192
+ ChannelType.AnnouncementThread,
193
+ ].includes(channel.type)
194
+
195
+ if (isThread) {
196
+ const thread = channel as ThreadChannel
197
+ discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
198
+
199
+ const row = getDatabase()
200
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
201
+ .get(thread.id) as { session_id: string } | undefined
202
+
203
+ if (!row) {
204
+ discordLogger.log(`No session found for thread ${thread.id}`)
205
+ return
206
+ }
207
+
208
+ voiceLogger.log(
209
+ `[SESSION] Found session ${row.session_id} for thread ${thread.id}`,
210
+ )
211
+
212
+ const parent = thread.parent as TextChannel | null
213
+ let projectDirectory: string | undefined
214
+ let channelAppId: string | undefined
215
+
216
+ if (parent?.topic) {
217
+ const extracted = extractTagsArrays({
218
+ xml: parent.topic,
219
+ tags: ['kimaki.directory', 'kimaki.app'],
220
+ })
221
+
222
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
223
+ channelAppId = extracted['kimaki.app']?.[0]?.trim()
224
+ }
225
+
226
+ if (channelAppId && channelAppId !== currentAppId) {
227
+ voiceLogger.log(
228
+ `[IGNORED] Thread belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
229
+ )
230
+ return
231
+ }
232
+
233
+ if (projectDirectory && !fs.existsSync(projectDirectory)) {
234
+ discordLogger.error(`Directory does not exist: ${projectDirectory}`)
235
+ await message.reply({
236
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
237
+ flags: SILENT_MESSAGE_FLAGS,
238
+ })
239
+ return
240
+ }
241
+
242
+ let messageContent = message.content || ''
243
+
244
+ let currentSessionContext: string | undefined
245
+ let lastSessionContext: string | undefined
246
+
247
+ if (projectDirectory) {
248
+ try {
249
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
250
+ const client = getClient()
251
+
252
+ // get current session context (without system prompt, it would be duplicated)
253
+ if (row.session_id) {
254
+ currentSessionContext = await getCompactSessionContext({
255
+ client,
256
+ sessionId: row.session_id,
257
+ includeSystemPrompt: false,
258
+ maxMessages: 15,
259
+ })
260
+ }
261
+
262
+ // get last session context (with system prompt for project context)
263
+ const lastSessionId = await getLastSessionId({
264
+ client,
265
+ excludeSessionId: row.session_id,
266
+ })
267
+ if (lastSessionId) {
268
+ lastSessionContext = await getCompactSessionContext({
269
+ client,
270
+ sessionId: lastSessionId,
271
+ includeSystemPrompt: true,
272
+ maxMessages: 10,
273
+ })
274
+ }
275
+ } catch (e) {
276
+ voiceLogger.error(`Could not get session context:`, e)
277
+ }
278
+ }
279
+
280
+ const transcription = await processVoiceAttachment({
281
+ message,
282
+ thread,
283
+ projectDirectory,
284
+ appId: currentAppId,
285
+ currentSessionContext,
286
+ lastSessionContext,
287
+ })
288
+ if (transcription) {
289
+ messageContent = transcription
290
+ }
291
+
292
+ const fileAttachments = await getFileAttachments(message)
293
+ const textAttachmentsContent = await getTextAttachments(message)
294
+ const promptWithAttachments = textAttachmentsContent
295
+ ? `${messageContent}\n\n${textAttachmentsContent}`
296
+ : messageContent
297
+ await handleOpencodeSession({
298
+ prompt: promptWithAttachments,
299
+ thread,
300
+ projectDirectory,
301
+ originalMessage: message,
302
+ images: fileAttachments,
303
+ channelId: parent?.id,
304
+ })
305
+ return
306
+ }
307
+
308
+ if (channel.type === ChannelType.GuildText) {
309
+ const textChannel = channel as TextChannel
310
+ voiceLogger.log(
311
+ `[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`,
312
+ )
313
+
314
+ if (!textChannel.topic) {
315
+ voiceLogger.log(
316
+ `[IGNORED] Channel #${textChannel.name} has no description`,
317
+ )
318
+ return
319
+ }
320
+
321
+ const extracted = extractTagsArrays({
322
+ xml: textChannel.topic,
323
+ tags: ['kimaki.directory', 'kimaki.app'],
324
+ })
325
+
326
+ const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
327
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim()
328
+
329
+ if (!projectDirectory) {
330
+ voiceLogger.log(
331
+ `[IGNORED] Channel #${textChannel.name} has no kimaki.directory tag`,
332
+ )
333
+ return
334
+ }
335
+
336
+ if (channelAppId && channelAppId !== currentAppId) {
337
+ voiceLogger.log(
338
+ `[IGNORED] Channel belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
339
+ )
340
+ return
341
+ }
342
+
343
+ discordLogger.log(
344
+ `DIRECTORY: Found kimaki.directory: ${projectDirectory}`,
345
+ )
346
+ if (channelAppId) {
347
+ discordLogger.log(`APP: Channel app ID: ${channelAppId}`)
348
+ }
349
+
350
+ if (!fs.existsSync(projectDirectory)) {
351
+ discordLogger.error(`Directory does not exist: ${projectDirectory}`)
352
+ await message.reply({
353
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
354
+ flags: SILENT_MESSAGE_FLAGS,
355
+ })
356
+ return
357
+ }
358
+
359
+ const hasVoice = message.attachments.some((a) =>
360
+ a.contentType?.startsWith('audio/'),
361
+ )
362
+
363
+ const threadName = hasVoice
364
+ ? 'Voice Message'
365
+ : message.content?.replace(/\s+/g, ' ').trim() || 'Claude Thread'
366
+
367
+ const thread = await message.startThread({
368
+ name: threadName.slice(0, 80),
369
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
370
+ reason: 'Start Claude session',
371
+ })
372
+
373
+ discordLogger.log(`Created thread "${thread.name}" (${thread.id})`)
374
+
375
+ let messageContent = message.content || ''
376
+
377
+ const transcription = await processVoiceAttachment({
378
+ message,
379
+ thread,
380
+ projectDirectory,
381
+ isNewThread: true,
382
+ appId: currentAppId,
383
+ })
384
+ if (transcription) {
385
+ messageContent = transcription
386
+ }
387
+
388
+ const fileAttachments = await getFileAttachments(message)
389
+ const textAttachmentsContent = await getTextAttachments(message)
390
+ const promptWithAttachments = textAttachmentsContent
391
+ ? `${messageContent}\n\n${textAttachmentsContent}`
392
+ : messageContent
393
+ await handleOpencodeSession({
394
+ prompt: promptWithAttachments,
395
+ thread,
396
+ projectDirectory,
397
+ originalMessage: message,
398
+ images: fileAttachments,
399
+ channelId: textChannel.id,
400
+ })
401
+ } else {
402
+ discordLogger.log(`Channel type ${channel.type} is not supported`)
403
+ }
404
+ } catch (error) {
405
+ voiceLogger.error('Discord handler error:', error)
406
+ try {
407
+ const errMsg = error instanceof Error ? error.message : String(error)
408
+ await message.reply({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS })
409
+ } catch {
410
+ voiceLogger.error('Discord handler error (fallback):', error)
411
+ }
412
+ }
413
+ })
414
+
415
+ await discordClient.login(token)
416
+
417
+ const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
418
+ discordLogger.log(`Received ${signal}, cleaning up...`)
419
+
420
+ if ((global as any).shuttingDown) {
421
+ discordLogger.log('Already shutting down, ignoring duplicate signal')
422
+ return
423
+ }
424
+ ;(global as any).shuttingDown = true
425
+
426
+ try {
427
+ const cleanupPromises: Promise<void>[] = []
428
+ for (const [guildId] of voiceConnections) {
429
+ voiceLogger.log(
430
+ `[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
431
+ )
432
+ cleanupPromises.push(cleanupVoiceConnection(guildId))
433
+ }
434
+
435
+ if (cleanupPromises.length > 0) {
436
+ voiceLogger.log(
437
+ `[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`,
438
+ )
439
+ await Promise.allSettled(cleanupPromises)
440
+ discordLogger.log(`All voice connections cleaned up`)
441
+ }
442
+
443
+ for (const [dir, server] of getOpencodeServers()) {
444
+ if (!server.process.killed) {
445
+ voiceLogger.log(
446
+ `[SHUTDOWN] Stopping OpenCode server on port ${server.port} for ${dir}`,
447
+ )
448
+ server.process.kill('SIGTERM')
449
+ }
450
+ }
451
+ getOpencodeServers().clear()
452
+
453
+ discordLogger.log('Closing database...')
454
+ closeDatabase()
455
+
456
+ discordLogger.log('Destroying Discord client...')
457
+ discordClient.destroy()
458
+
459
+ discordLogger.log('Cleanup complete.')
460
+ if (!skipExit) {
461
+ process.exit(0)
462
+ }
463
+ } catch (error) {
464
+ voiceLogger.error('[SHUTDOWN] Error during cleanup:', error)
465
+ if (!skipExit) {
466
+ process.exit(1)
467
+ }
468
+ }
469
+ }
470
+
471
+ process.on('SIGTERM', async () => {
472
+ try {
473
+ await handleShutdown('SIGTERM')
474
+ } catch (error) {
475
+ voiceLogger.error('[SIGTERM] Error during shutdown:', error)
476
+ process.exit(1)
477
+ }
478
+ })
479
+
480
+ process.on('SIGINT', async () => {
481
+ try {
482
+ await handleShutdown('SIGINT')
483
+ } catch (error) {
484
+ voiceLogger.error('[SIGINT] Error during shutdown:', error)
485
+ process.exit(1)
486
+ }
487
+ })
488
+
489
+ process.on('SIGUSR2', async () => {
490
+ discordLogger.log('Received SIGUSR2, restarting after cleanup...')
491
+ try {
492
+ await handleShutdown('SIGUSR2', { skipExit: true })
493
+ } catch (error) {
494
+ voiceLogger.error('[SIGUSR2] Error during shutdown:', error)
495
+ }
496
+ const { spawn } = await import('node:child_process')
497
+ spawn(process.argv[0]!, [...process.execArgv, ...process.argv.slice(1)], {
498
+ stdio: 'inherit',
499
+ detached: true,
500
+ cwd: process.cwd(),
501
+ env: process.env,
502
+ }).unref()
503
+ process.exit(0)
504
+ })
505
+
506
+ process.on('unhandledRejection', (reason, promise) => {
507
+ if ((global as any).shuttingDown) {
508
+ discordLogger.log('Ignoring unhandled rejection during shutdown:', reason)
509
+ return
510
+ }
511
+ discordLogger.error('Unhandled Rejection at:', promise, 'reason:', reason)
512
+ })
513
+ }