kimaki 0.4.35 → 0.4.37

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 (76) hide show
  1. package/dist/ai-tool-to-genai.js +1 -3
  2. package/dist/channel-management.js +5 -5
  3. package/dist/cli.js +182 -46
  4. package/dist/commands/abort.js +1 -1
  5. package/dist/commands/add-project.js +1 -1
  6. package/dist/commands/agent.js +6 -2
  7. package/dist/commands/ask-question.js +2 -1
  8. package/dist/commands/fork.js +7 -7
  9. package/dist/commands/queue.js +2 -2
  10. package/dist/commands/remove-project.js +109 -0
  11. package/dist/commands/resume.js +3 -5
  12. package/dist/commands/session.js +2 -2
  13. package/dist/commands/share.js +1 -1
  14. package/dist/commands/undo-redo.js +2 -2
  15. package/dist/commands/user-command.js +3 -6
  16. package/dist/config.js +1 -1
  17. package/dist/database.js +7 -0
  18. package/dist/discord-bot.js +37 -20
  19. package/dist/discord-utils.js +33 -9
  20. package/dist/genai.js +4 -6
  21. package/dist/interaction-handler.js +8 -1
  22. package/dist/markdown.js +1 -3
  23. package/dist/message-formatting.js +7 -3
  24. package/dist/openai-realtime.js +3 -5
  25. package/dist/opencode.js +1 -1
  26. package/dist/session-handler.js +25 -15
  27. package/dist/system-message.js +10 -4
  28. package/dist/tools.js +9 -22
  29. package/dist/voice-handler.js +9 -12
  30. package/dist/voice.js +5 -3
  31. package/dist/xml.js +2 -4
  32. package/package.json +3 -2
  33. package/src/__snapshots__/compact-session-context-no-system.md +24 -24
  34. package/src/__snapshots__/compact-session-context.md +31 -31
  35. package/src/ai-tool-to-genai.ts +3 -11
  36. package/src/channel-management.ts +18 -29
  37. package/src/cli.ts +334 -205
  38. package/src/commands/abort.ts +1 -3
  39. package/src/commands/add-project.ts +8 -14
  40. package/src/commands/agent.ts +16 -9
  41. package/src/commands/ask-question.ts +8 -7
  42. package/src/commands/create-new-project.ts +8 -14
  43. package/src/commands/fork.ts +23 -27
  44. package/src/commands/model.ts +14 -11
  45. package/src/commands/permissions.ts +1 -1
  46. package/src/commands/queue.ts +6 -19
  47. package/src/commands/remove-project.ts +136 -0
  48. package/src/commands/resume.ts +11 -30
  49. package/src/commands/session.ts +4 -13
  50. package/src/commands/share.ts +1 -3
  51. package/src/commands/types.ts +1 -3
  52. package/src/commands/undo-redo.ts +6 -18
  53. package/src/commands/user-command.ts +8 -10
  54. package/src/config.ts +5 -5
  55. package/src/database.ts +17 -8
  56. package/src/discord-bot.ts +60 -58
  57. package/src/discord-utils.ts +35 -18
  58. package/src/escape-backticks.test.ts +0 -2
  59. package/src/format-tables.ts +1 -4
  60. package/src/genai-worker-wrapper.ts +3 -9
  61. package/src/genai-worker.ts +4 -19
  62. package/src/genai.ts +10 -42
  63. package/src/interaction-handler.ts +133 -121
  64. package/src/markdown.test.ts +10 -32
  65. package/src/markdown.ts +6 -14
  66. package/src/message-formatting.ts +13 -14
  67. package/src/openai-realtime.ts +25 -47
  68. package/src/opencode.ts +24 -34
  69. package/src/session-handler.ts +91 -61
  70. package/src/system-message.ts +18 -4
  71. package/src/tools.ts +13 -39
  72. package/src/utils.ts +1 -4
  73. package/src/voice-handler.ts +34 -78
  74. package/src/voice.ts +11 -19
  75. package/src/xml.test.ts +1 -1
  76. package/src/xml.ts +3 -12
@@ -13,10 +13,7 @@ import { createLogger } from '../logger.js'
13
13
 
14
14
  const logger = createLogger('SESSION')
15
15
 
16
- export async function handleSessionCommand({
17
- command,
18
- appId,
19
- }: CommandContext): Promise<void> {
16
+ export async function handleSessionCommand({ command, appId }: CommandContext): Promise<void> {
20
17
  await command.deferReply({ ephemeral: false })
21
18
 
22
19
  const prompt = command.options.getString('prompt', true)
@@ -50,9 +47,7 @@ export async function handleSessionCommand({
50
47
  }
51
48
 
52
49
  if (!projectDirectory) {
53
- await command.editReply(
54
- 'This channel is not configured with a project directory',
55
- )
50
+ await command.editReply('This channel is not configured with a project directory')
56
51
  return
57
52
  }
58
53
 
@@ -102,10 +97,7 @@ export async function handleSessionCommand({
102
97
  }
103
98
  }
104
99
 
105
- async function handleAgentAutocomplete({
106
- interaction,
107
- appId,
108
- }: AutocompleteContext): Promise<void> {
100
+ async function handleAgentAutocomplete({ interaction, appId }: AutocompleteContext): Promise<void> {
109
101
  const focusedValue = interaction.options.getFocused()
110
102
 
111
103
  let projectDirectory: string | undefined
@@ -224,8 +216,7 @@ export async function handleSessionAutocomplete({
224
216
 
225
217
  const files = response.data || []
226
218
 
227
- const prefix =
228
- previousFiles.length > 0 ? previousFiles.join(', ') + ', ' : ''
219
+ const prefix = previousFiles.length > 0 ? previousFiles.join(', ') + ', ' : ''
229
220
 
230
221
  const choices = files
231
222
  .map((file: string) => {
@@ -9,9 +9,7 @@ import { createLogger } from '../logger.js'
9
9
 
10
10
  const logger = createLogger('SHARE')
11
11
 
12
- export async function handleShareCommand({
13
- command,
14
- }: CommandContext): Promise<void> {
12
+ export async function handleShareCommand({ command }: CommandContext): Promise<void> {
15
13
  const channel = command.channel
16
14
 
17
15
  if (!channel) {
@@ -20,6 +20,4 @@ export type AutocompleteContext = {
20
20
 
21
21
  export type AutocompleteHandler = (ctx: AutocompleteContext) => Promise<void>
22
22
 
23
- export type SelectMenuHandler = (
24
- interaction: StringSelectMenuInteraction,
25
- ) => Promise<void>
23
+ export type SelectMenuHandler = (interaction: StringSelectMenuInteraction) => Promise<void>
@@ -9,9 +9,7 @@ import { createLogger } from '../logger.js'
9
9
 
10
10
  const logger = createLogger('UNDO-REDO')
11
11
 
12
- export async function handleUndoCommand({
13
- command,
14
- }: CommandContext): Promise<void> {
12
+ export async function handleUndoCommand({ command }: CommandContext): Promise<void> {
15
13
  const channel = command.channel
16
14
 
17
15
  if (!channel) {
@@ -96,9 +94,7 @@ export async function handleUndoCommand({
96
94
  })
97
95
 
98
96
  if (response.error) {
99
- await command.editReply(
100
- `Failed to undo: ${JSON.stringify(response.error)}`,
101
- )
97
+ await command.editReply(`Failed to undo: ${JSON.stringify(response.error)}`)
102
98
  return
103
99
  }
104
100
 
@@ -106,12 +102,8 @@ export async function handleUndoCommand({
106
102
  ? `\n\`\`\`diff\n${response.data.revert.diff.slice(0, 1500)}\n\`\`\``
107
103
  : ''
108
104
 
109
- await command.editReply(
110
- `⏪ **Undone** - reverted last assistant message${diffInfo}`,
111
- )
112
- logger.log(
113
- `Session ${sessionId} reverted message ${lastAssistantMessage.info.id}`,
114
- )
105
+ await command.editReply(`⏪ **Undone** - reverted last assistant message${diffInfo}`)
106
+ logger.log(`Session ${sessionId} reverted message ${lastAssistantMessage.info.id}`)
115
107
  } catch (error) {
116
108
  logger.error('[UNDO] Error:', error)
117
109
  await command.editReply(
@@ -120,9 +112,7 @@ export async function handleUndoCommand({
120
112
  }
121
113
  }
122
114
 
123
- export async function handleRedoCommand({
124
- command,
125
- }: CommandContext): Promise<void> {
115
+ export async function handleRedoCommand({ command }: CommandContext): Promise<void> {
126
116
  const channel = command.channel
127
117
 
128
118
  if (!channel) {
@@ -196,9 +186,7 @@ export async function handleRedoCommand({
196
186
  })
197
187
 
198
188
  if (response.error) {
199
- await command.editReply(
200
- `Failed to redo: ${JSON.stringify(response.error)}`,
201
- )
189
+ await command.editReply(`Failed to redo: ${JSON.stringify(response.error)}`)
202
190
  return
203
191
  }
204
192
 
@@ -12,10 +12,7 @@ import fs from 'node:fs'
12
12
 
13
13
  const userCommandLogger = createLogger('USER_CMD')
14
14
 
15
- export const handleUserCommand: CommandHandler = async ({
16
- command,
17
- appId,
18
- }: CommandContext) => {
15
+ export const handleUserCommand: CommandHandler = async ({ command, appId }: CommandContext) => {
19
16
  const discordCommandName = command.commandName
20
17
  // Strip the -cmd suffix to get the actual OpenCode command name
21
18
  const commandName = discordCommandName.replace(/-cmd$/, '')
@@ -31,11 +28,11 @@ export const handleUserCommand: CommandHandler = async ({
31
28
  `Channel info: type=${channel?.type}, id=${channel?.id}, isNull=${channel === null}`,
32
29
  )
33
30
 
34
- const isThread = channel && [
35
- ChannelType.PublicThread,
36
- ChannelType.PrivateThread,
37
- ChannelType.AnnouncementThread,
38
- ].includes(channel.type)
31
+ const isThread =
32
+ channel &&
33
+ [ChannelType.PublicThread, ChannelType.PrivateThread, ChannelType.AnnouncementThread].includes(
34
+ channel.type,
35
+ )
39
36
 
40
37
  const isTextChannel = channel?.type === ChannelType.GuildText
41
38
 
@@ -64,7 +61,8 @@ export const handleUserCommand: CommandHandler = async ({
64
61
 
65
62
  if (!row) {
66
63
  await command.reply({
67
- content: 'This thread does not have an active session. Use this command in a project channel to create a new thread.',
64
+ content:
65
+ 'This thread does not have an active session. Use this command in a project channel to create a new thread.',
68
66
  ephemeral: true,
69
67
  })
70
68
  return
package/src/config.ts CHANGED
@@ -28,11 +28,11 @@ export function getDataDir(): string {
28
28
  */
29
29
  export function setDataDir(dir: string): void {
30
30
  const resolvedDir = path.resolve(dir)
31
-
31
+
32
32
  if (!fs.existsSync(resolvedDir)) {
33
33
  fs.mkdirSync(resolvedDir, { recursive: true })
34
34
  }
35
-
35
+
36
36
  dataDir = resolvedDir
37
37
  }
38
38
 
@@ -53,17 +53,17 @@ const DEFAULT_LOCK_PORT = 29988
53
53
  */
54
54
  export function getLockPort(): number {
55
55
  const dir = getDataDir()
56
-
56
+
57
57
  // Use original port for default data dir (backwards compatible)
58
58
  if (dir === DEFAULT_DATA_DIR) {
59
59
  return DEFAULT_LOCK_PORT
60
60
  }
61
-
61
+
62
62
  // Hash-based port for custom data dirs
63
63
  let hash = 0
64
64
  for (let i = 0; i < dir.length; i++) {
65
65
  const char = dir.charCodeAt(i)
66
- hash = ((hash << 5) - hash) + char
66
+ hash = (hash << 5) - hash + char
67
67
  hash = hash & hash // Convert to 32bit integer
68
68
  }
69
69
  // Map to port range 30000-39999
package/src/database.ts CHANGED
@@ -61,6 +61,13 @@ export function getDatabase(): Database.Database {
61
61
  )
62
62
  `)
63
63
 
64
+ // Migration: add app_id column to channel_directories for multi-bot support
65
+ try {
66
+ db.exec(`ALTER TABLE channel_directories ADD COLUMN app_id TEXT`)
67
+ } catch {
68
+ // Column already exists, ignore
69
+ }
70
+
64
71
  db.exec(`
65
72
  CREATE TABLE IF NOT EXISTS bot_api_keys (
66
73
  app_id TEXT PRIMARY KEY,
@@ -141,7 +148,7 @@ export function setChannelModel(channelId: string, modelId: string): void {
141
148
  db.prepare(
142
149
  `INSERT INTO channel_models (channel_id, model_id, updated_at)
143
150
  VALUES (?, ?, CURRENT_TIMESTAMP)
144
- ON CONFLICT(channel_id) DO UPDATE SET model_id = ?, updated_at = CURRENT_TIMESTAMP`
151
+ ON CONFLICT(channel_id) DO UPDATE SET model_id = ?, updated_at = CURRENT_TIMESTAMP`,
145
152
  ).run(channelId, modelId, modelId)
146
153
  }
147
154
 
@@ -163,9 +170,10 @@ export function getSessionModel(sessionId: string): string | undefined {
163
170
  */
164
171
  export function setSessionModel(sessionId: string, modelId: string): void {
165
172
  const db = getDatabase()
166
- db.prepare(
167
- `INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`
168
- ).run(sessionId, modelId)
173
+ db.prepare(`INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`).run(
174
+ sessionId,
175
+ modelId,
176
+ )
169
177
  }
170
178
 
171
179
  /**
@@ -187,7 +195,7 @@ export function setChannelAgent(channelId: string, agentName: string): void {
187
195
  db.prepare(
188
196
  `INSERT INTO channel_agents (channel_id, agent_name, updated_at)
189
197
  VALUES (?, ?, CURRENT_TIMESTAMP)
190
- ON CONFLICT(channel_id) DO UPDATE SET agent_name = ?, updated_at = CURRENT_TIMESTAMP`
198
+ ON CONFLICT(channel_id) DO UPDATE SET agent_name = ?, updated_at = CURRENT_TIMESTAMP`,
191
199
  ).run(channelId, agentName, agentName)
192
200
  }
193
201
 
@@ -207,9 +215,10 @@ export function getSessionAgent(sessionId: string): string | undefined {
207
215
  */
208
216
  export function setSessionAgent(sessionId: string, agentName: string): void {
209
217
  const db = getDatabase()
210
- db.prepare(
211
- `INSERT OR REPLACE INTO session_agents (session_id, agent_name) VALUES (?, ?)`
212
- ).run(sessionId, agentName)
218
+ db.prepare(`INSERT OR REPLACE INTO session_agents (session_id, agent_name) VALUES (?, ?)`).run(
219
+ sessionId,
220
+ agentName,
221
+ )
213
222
  }
214
223
 
215
224
  export function closeDatabase(): void {
@@ -24,10 +24,7 @@ import {
24
24
  processVoiceAttachment,
25
25
  registerVoiceStateHandler,
26
26
  } from './voice-handler.js'
27
- import {
28
- getCompactSessionContext,
29
- getLastSessionId,
30
- } from './markdown.js'
27
+ import { getCompactSessionContext, getLastSessionId } from './markdown.js'
31
28
  import { handleOpencodeSession } from './session-handler.js'
32
29
  import { registerInteractionHandler } from './interaction-handler.js'
33
30
 
@@ -35,7 +32,12 @@ export { getDatabase, closeDatabase } from './database.js'
35
32
  export { initializeOpencodeForDirectory } from './opencode.js'
36
33
  export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discord-utils.js'
37
34
  export { getOpencodeSystemMessage } from './system-message.js'
38
- export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions } from './channel-management.js'
35
+ export {
36
+ ensureKimakiCategory,
37
+ ensureKimakiAudioCategory,
38
+ createProjectChannels,
39
+ getChannelsWithDescriptions,
40
+ } from './channel-management.js'
39
41
  export type { ChannelWithTags } from './channel-management.js'
40
42
 
41
43
  import {
@@ -73,12 +75,7 @@ export async function createDiscordClient() {
73
75
  GatewayIntentBits.MessageContent,
74
76
  GatewayIntentBits.GuildVoiceStates,
75
77
  ],
76
- partials: [
77
- Partials.Channel,
78
- Partials.Message,
79
- Partials.User,
80
- Partials.ThreadMember,
81
- ],
78
+ partials: [Partials.Channel, Partials.Message, Partials.User, Partials.ThreadMember],
82
79
  })
83
80
  }
84
81
 
@@ -116,15 +113,11 @@ export async function startDiscordBot({
116
113
 
117
114
  const channels = await getChannelsWithDescriptions(guild)
118
115
  const kimakiChannels = channels.filter(
119
- (ch) =>
120
- ch.kimakiDirectory &&
121
- (!ch.kimakiApp || ch.kimakiApp === currentAppId),
116
+ (ch) => ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === currentAppId),
122
117
  )
123
118
 
124
119
  if (kimakiChannels.length > 0) {
125
- discordLogger.log(
126
- ` Found ${kimakiChannels.length} channel(s) for this bot:`,
127
- )
120
+ discordLogger.log(` Found ${kimakiChannels.length} channel(s) for this bot:`)
128
121
  for (const channel of kimakiChannels) {
129
122
  discordLogger.log(` - #${channel.name}: ${channel.kimakiDirectory}`)
130
123
  }
@@ -159,19 +152,14 @@ export async function startDiscordBot({
159
152
  try {
160
153
  await message.fetch()
161
154
  } catch (error) {
162
- discordLogger.log(
163
- `Failed to fetch partial message ${message.id}:`,
164
- error,
165
- )
155
+ discordLogger.log(`Failed to fetch partial message ${message.id}:`, error)
166
156
  return
167
157
  }
168
158
  }
169
159
 
170
160
  if (message.guild && message.member) {
171
161
  const isOwner = message.member.id === message.guild.ownerId
172
- const isAdmin = message.member.permissions.has(
173
- PermissionsBitField.Flags.Administrator,
174
- )
162
+ const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator)
175
163
  const canManageServer = message.member.permissions.has(
176
164
  PermissionsBitField.Flags.ManageGuild,
177
165
  )
@@ -199,19 +187,6 @@ export async function startDiscordBot({
199
187
  const thread = channel as ThreadChannel
200
188
  discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
201
189
 
202
- const row = getDatabase()
203
- .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
204
- .get(thread.id) as { session_id: string } | undefined
205
-
206
- if (!row) {
207
- discordLogger.log(`No session found for thread ${thread.id}`)
208
- return
209
- }
210
-
211
- voiceLogger.log(
212
- `[SESSION] Found session ${row.session_id} for thread ${thread.id}`,
213
- )
214
-
215
190
  const parent = thread.parent as TextChannel | null
216
191
  let projectDirectory: string | undefined
217
192
  let channelAppId: string | undefined
@@ -242,6 +217,43 @@ export async function startDiscordBot({
242
217
  return
243
218
  }
244
219
 
220
+ const row = getDatabase()
221
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
222
+ .get(thread.id) as { session_id: string } | undefined
223
+
224
+ // No existing session - start a new one (e.g., replying to a notification thread)
225
+ if (!row) {
226
+ discordLogger.log(`No session for thread ${thread.id}, starting new session`)
227
+
228
+ if (!projectDirectory) {
229
+ discordLogger.log(`Cannot start session: no project directory for thread ${thread.id}`)
230
+ return
231
+ }
232
+
233
+ // Include starter message (notification) as context for the session
234
+ let prompt = message.content
235
+ const starterMessage = await thread.fetchStarterMessage().catch(() => null)
236
+ if (starterMessage?.content) {
237
+ // Strip notification prefix if present
238
+ const notificationContent = starterMessage.content
239
+ .replace(/^📢 \*\*Notification\*\*\n?/, '')
240
+ .trim()
241
+ if (notificationContent) {
242
+ prompt = `Context from notification:\n${notificationContent}\n\nUser request:\n${message.content}`
243
+ }
244
+ }
245
+
246
+ await handleOpencodeSession({
247
+ prompt,
248
+ thread,
249
+ projectDirectory,
250
+ channelId: parent?.id || '',
251
+ })
252
+ return
253
+ }
254
+
255
+ voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`)
256
+
245
257
  let messageContent = message.content || ''
246
258
 
247
259
  let currentSessionContext: string | undefined
@@ -315,9 +327,7 @@ export async function startDiscordBot({
315
327
  )
316
328
 
317
329
  if (!textChannel.topic) {
318
- voiceLogger.log(
319
- `[IGNORED] Channel #${textChannel.name} has no description`,
320
- )
330
+ voiceLogger.log(`[IGNORED] Channel #${textChannel.name} has no description`)
321
331
  return
322
332
  }
323
333
 
@@ -330,9 +340,7 @@ export async function startDiscordBot({
330
340
  const channelAppId = extracted['kimaki.app']?.[0]?.trim()
331
341
 
332
342
  if (!projectDirectory) {
333
- voiceLogger.log(
334
- `[IGNORED] Channel #${textChannel.name} has no kimaki.directory tag`,
335
- )
343
+ voiceLogger.log(`[IGNORED] Channel #${textChannel.name} has no kimaki.directory tag`)
336
344
  return
337
345
  }
338
346
 
@@ -343,9 +351,7 @@ export async function startDiscordBot({
343
351
  return
344
352
  }
345
353
 
346
- discordLogger.log(
347
- `DIRECTORY: Found kimaki.directory: ${projectDirectory}`,
348
- )
354
+ discordLogger.log(`DIRECTORY: Found kimaki.directory: ${projectDirectory}`)
349
355
  if (channelAppId) {
350
356
  discordLogger.log(`APP: Channel app ID: ${channelAppId}`)
351
357
  }
@@ -359,9 +365,7 @@ export async function startDiscordBot({
359
365
  return
360
366
  }
361
367
 
362
- const hasVoice = message.attachments.some((a) =>
363
- a.contentType?.startsWith('audio/'),
364
- )
368
+ const hasVoice = message.attachments.some((a) => a.contentType?.startsWith('audio/'))
365
369
 
366
370
  const threadName = hasVoice
367
371
  ? 'Voice Message'
@@ -415,10 +419,10 @@ export async function startDiscordBot({
415
419
  }
416
420
  })
417
421
 
418
- // Magic prefix used by `kimaki start-session` CLI command to initiate sessions
422
+ // Magic prefix used by `kimaki send` CLI command to initiate sessions
419
423
  const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
420
424
 
421
- // Handle bot-initiated threads created by `kimaki start-session`
425
+ // Handle bot-initiated threads created by `kimaki send`
422
426
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
423
427
  try {
424
428
  if (!newlyCreated) {
@@ -489,7 +493,9 @@ export async function startDiscordBot({
489
493
  return
490
494
  }
491
495
 
492
- discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`)
496
+ discordLogger.log(
497
+ `[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`,
498
+ )
493
499
 
494
500
  await handleOpencodeSession({
495
501
  prompt,
@@ -522,9 +528,7 @@ export async function startDiscordBot({
522
528
  try {
523
529
  const cleanupPromises: Promise<void>[] = []
524
530
  for (const [guildId] of voiceConnections) {
525
- voiceLogger.log(
526
- `[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
527
- )
531
+ voiceLogger.log(`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`)
528
532
  cleanupPromises.push(cleanupVoiceConnection(guildId))
529
533
  }
530
534
 
@@ -538,9 +542,7 @@ export async function startDiscordBot({
538
542
 
539
543
  for (const [dir, server] of getOpencodeServers()) {
540
544
  if (!server.process.killed) {
541
- voiceLogger.log(
542
- `[SHUTDOWN] Stopping OpenCode server on port ${server.port} for ${dir}`,
543
- )
545
+ voiceLogger.log(`[SHUTDOWN] Stopping OpenCode server on port ${server.port} for ${dir}`)
544
546
  server.process.kill('SIGTERM')
545
547
  }
546
548
  }
@@ -2,12 +2,7 @@
2
2
  // Handles markdown splitting for Discord's 2000-char limit, code block escaping,
3
3
  // thread message sending, and channel metadata extraction from topic tags.
4
4
 
5
- import {
6
- ChannelType,
7
- type Message,
8
- type TextChannel,
9
- type ThreadChannel,
10
- } from 'discord.js'
5
+ import { ChannelType, type Message, type TextChannel, type ThreadChannel } from 'discord.js'
11
6
  import { Lexer } from 'marked'
12
7
  import { extractTagsArrays } from './xml.js'
13
8
  import { formatMarkdownTables } from './format-tables.js'
@@ -65,19 +60,43 @@ export function splitMarkdownForDiscord({
65
60
  for (const token of tokens) {
66
61
  if (token.type === 'code') {
67
62
  const lang = token.lang || ''
68
- lines.push({ text: '```' + lang + '\n', inCodeBlock: false, lang, isOpeningFence: true, isClosingFence: false })
63
+ lines.push({
64
+ text: '```' + lang + '\n',
65
+ inCodeBlock: false,
66
+ lang,
67
+ isOpeningFence: true,
68
+ isClosingFence: false,
69
+ })
69
70
  const codeLines = token.text.split('\n')
70
71
  for (const codeLine of codeLines) {
71
- lines.push({ text: codeLine + '\n', inCodeBlock: true, lang, isOpeningFence: false, isClosingFence: false })
72
+ lines.push({
73
+ text: codeLine + '\n',
74
+ inCodeBlock: true,
75
+ lang,
76
+ isOpeningFence: false,
77
+ isClosingFence: false,
78
+ })
72
79
  }
73
- lines.push({ text: '```\n', inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: true })
80
+ lines.push({
81
+ text: '```\n',
82
+ inCodeBlock: false,
83
+ lang: '',
84
+ isOpeningFence: false,
85
+ isClosingFence: true,
86
+ })
74
87
  } else {
75
88
  const rawLines = token.raw.split('\n')
76
89
  for (let i = 0; i < rawLines.length; i++) {
77
90
  const isLast = i === rawLines.length - 1
78
91
  const text = isLast ? rawLines[i]! : rawLines[i]! + '\n'
79
92
  if (text) {
80
- lines.push({ text, inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: false })
93
+ lines.push({
94
+ text,
95
+ inCodeBlock: false,
96
+ lang: '',
97
+ isOpeningFence: false,
98
+ isClosingFence: false,
99
+ })
81
100
  }
82
101
  }
83
102
  }
@@ -126,7 +145,9 @@ export function splitMarkdownForDiscord({
126
145
  }
127
146
 
128
147
  // calculate overhead for code block markers
129
- const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0
148
+ const codeBlockOverhead = line.inCodeBlock
149
+ ? ('```' + line.lang + '\n').length + '```\n'.length
150
+ : 0
130
151
  // ensure at least 10 chars available, even if maxLength is very small
131
152
  const availablePerChunk = Math.max(10, maxLength - codeBlockOverhead - 50)
132
153
 
@@ -196,7 +217,7 @@ export function splitMarkdownForDiscord({
196
217
  export async function sendThreadMessage(
197
218
  thread: ThreadChannel,
198
219
  content: string,
199
- options?: { flags?: number }
220
+ options?: { flags?: number },
200
221
  ): Promise<Message> {
201
222
  const MAX_LENGTH = 2000
202
223
 
@@ -213,9 +234,7 @@ export async function sendThreadMessage(
213
234
  const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH })
214
235
 
215
236
  if (chunks.length > 1) {
216
- discordLogger.log(
217
- `MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`,
218
- )
237
+ discordLogger.log(`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`)
219
238
  }
220
239
 
221
240
  let firstMessage: Message | undefined
@@ -262,9 +281,7 @@ export async function resolveTextChannel(
262
281
  }
263
282
 
264
283
  export function escapeDiscordFormatting(text: string): string {
265
- return text
266
- .replace(/```/g, '\\`\\`\\`')
267
- .replace(/````/g, '\\`\\`\\`\\`')
284
+ return text.replace(/```/g, '\\`\\`\\`').replace(/````/g, '\\`\\`\\`\\`')
268
285
  }
269
286
 
270
287
  export function getKimakiMetadata(textChannel: TextChannel | null): {
@@ -2,8 +2,6 @@ import { test, expect } from 'vitest'
2
2
  import { Lexer } from 'marked'
3
3
  import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discord-utils.js'
4
4
 
5
-
6
-
7
5
  test('escapes single backticks in code blocks', () => {
8
6
  const input = '```js\nconst x = `hello`\n```'
9
7
  const result = escapeBackticksInCodeBlocks(input)
@@ -78,10 +78,7 @@ function extractTokenText(token: Token): string {
78
78
  }
79
79
  }
80
80
 
81
- function calculateColumnWidths(
82
- headers: string[],
83
- rows: string[][],
84
- ): number[] {
81
+ function calculateColumnWidths(headers: string[], rows: string[][]): number[] {
85
82
  const widths = headers.map((h) => {
86
83
  return h.length
87
84
  })
@@ -41,13 +41,9 @@ export interface GenAIWorker {
41
41
  stop(): Promise<void>
42
42
  }
43
43
 
44
- export function createGenAIWorker(
45
- options: GenAIWorkerOptions,
46
- ): Promise<GenAIWorker> {
44
+ export function createGenAIWorker(options: GenAIWorkerOptions): Promise<GenAIWorker> {
47
45
  return new Promise((resolve, reject) => {
48
- const worker = new Worker(
49
- new URL('../dist/genai-worker.js', import.meta.url),
50
- )
46
+ const worker = new Worker(new URL('../dist/genai-worker.js', import.meta.url))
51
47
 
52
48
  // Handle messages from worker
53
49
  worker.on('message', (message: WorkerOutMessage) => {
@@ -106,9 +102,7 @@ export function createGenAIWorker(
106
102
  worker.once('exit', (code) => {
107
103
  if (!resolved) {
108
104
  resolved = true
109
- genaiWrapperLogger.log(
110
- `[GENAI WORKER WRAPPER] Worker exited with code ${code}`,
111
- )
105
+ genaiWrapperLogger.log(`[GENAI WORKER WRAPPER] Worker exited with code ${code}`)
112
106
  resolve()
113
107
  }
114
108
  })