kimaki 0.4.29 → 0.4.31

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/src/cli.ts CHANGED
@@ -46,6 +46,7 @@ import { createLogger } from './logger.js'
46
46
  import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
47
47
  import http from 'node:http'
48
48
  import { setDataDir, getDataDir, getLockPort } from './config.js'
49
+ import { extractTagsArrays } from './xml.js'
49
50
 
50
51
  const cliLogger = createLogger('CLI')
51
52
  const cli = cac('kimaki')
@@ -528,8 +529,14 @@ async function run({ restart, addChannels }: CliOptions) {
528
529
  }
529
530
 
530
531
  const s = spinner()
531
- s.start('Creating Discord client and connecting...')
532
532
 
533
+ // Start OpenCode server EARLY - let it initialize in parallel with Discord login.
534
+ // This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
535
+ const currentDir = process.cwd()
536
+ s.start('Starting OpenCode server...')
537
+ const opencodePromise = initializeOpencodeForDirectory(currentDir)
538
+
539
+ s.message('Connecting to Discord...')
533
540
  const discordClient = await createDiscordClient()
534
541
 
535
542
  const guilds: Guild[] = []
@@ -541,15 +548,56 @@ async function run({ restart, addChannels }: CliOptions) {
541
548
  discordClient.once(Events.ClientReady, async (c) => {
542
549
  guilds.push(...Array.from(c.guilds.cache.values()))
543
550
 
544
- for (const guild of guilds) {
545
- const channels = await getChannelsWithDescriptions(guild)
546
- const kimakiChans = channels.filter(
547
- (ch) =>
548
- ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
549
- )
551
+ // Process all guilds in parallel for faster startup
552
+ const guildResults = await Promise.all(
553
+ guilds.map(async (guild) => {
554
+ // Create Kimaki role if it doesn't exist, or fix its position (fire-and-forget)
555
+ guild.roles
556
+ .fetch()
557
+ .then(async (roles) => {
558
+ const existingRole = roles.find(
559
+ (role) => role.name.toLowerCase() === 'kimaki',
560
+ )
561
+ if (existingRole) {
562
+ // Move to bottom if not already there
563
+ if (existingRole.position > 1) {
564
+ await existingRole.setPosition(1)
565
+ cliLogger.info(`Moved "Kimaki" role to bottom in ${guild.name}`)
566
+ }
567
+ return
568
+ }
569
+ return guild.roles.create({
570
+ name: 'Kimaki',
571
+ position: 1, // Place at bottom so anyone with Manage Roles can assign it
572
+ reason:
573
+ 'Kimaki bot permission role - assign to users who can start sessions, send messages in threads, and use voice features',
574
+ })
575
+ })
576
+ .then((role) => {
577
+ if (role) {
578
+ cliLogger.info(`Created "Kimaki" role in ${guild.name}`)
579
+ }
580
+ })
581
+ .catch((error) => {
582
+ cliLogger.warn(
583
+ `Could not create Kimaki role in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`,
584
+ )
585
+ })
586
+
587
+ const channels = await getChannelsWithDescriptions(guild)
588
+ const kimakiChans = channels.filter(
589
+ (ch) =>
590
+ ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
591
+ )
592
+
593
+ return { guild, channels: kimakiChans }
594
+ }),
595
+ )
550
596
 
551
- if (kimakiChans.length > 0) {
552
- kimakiChannels.push({ guild, channels: kimakiChans })
597
+ // Collect results
598
+ for (const result of guildResults) {
599
+ if (result.channels.length > 0) {
600
+ kimakiChannels.push(result)
553
601
  }
554
602
  }
555
603
 
@@ -612,32 +660,25 @@ async function run({ restart, addChannels }: CliOptions) {
612
660
  note(channelList, 'Existing Kimaki Channels')
613
661
  }
614
662
 
615
- s.start('Starting OpenCode server...')
616
-
617
- const currentDir = process.cwd()
618
- let getClient = await initializeOpencodeForDirectory(currentDir)
619
- s.stop('OpenCode server started!')
663
+ // Await the OpenCode server that was started in parallel with Discord login
664
+ s.start('Waiting for OpenCode server...')
665
+ const getClient = await opencodePromise
666
+ s.stop('OpenCode server ready!')
620
667
 
621
- s.start('Fetching OpenCode projects...')
668
+ s.start('Fetching OpenCode data...')
622
669
 
623
- let projects: Project[] = []
670
+ // Fetch projects and commands in parallel
671
+ const [projects, allUserCommands] = await Promise.all([
672
+ getClient().project.list({}).then((r) => r.data || []).catch((error) => {
673
+ s.stop('Failed to fetch projects')
674
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
675
+ discordClient.destroy()
676
+ process.exit(EXIT_NO_RESTART)
677
+ }),
678
+ getClient().command.list({ query: { directory: currentDir } }).then((r) => r.data || []).catch(() => []),
679
+ ])
624
680
 
625
- try {
626
- const projectsResponse = await getClient().project.list({})
627
- if (!projectsResponse.data) {
628
- throw new Error('Failed to fetch projects')
629
- }
630
- projects = projectsResponse.data
631
- s.stop(`Found ${projects.length} OpenCode project(s)`)
632
- } catch (error) {
633
- s.stop('Failed to fetch projects')
634
- cliLogger.error(
635
- 'Error:',
636
- error instanceof Error ? error.message : String(error),
637
- )
638
- discordClient.destroy()
639
- process.exit(EXIT_NO_RESTART)
640
- }
681
+ s.stop(`Found ${projects.length} OpenCode project(s)`)
641
682
 
642
683
  const existingDirs = kimakiChannels.flatMap(({ channels }) =>
643
684
  channels
@@ -745,19 +786,6 @@ async function run({ restart, addChannels }: CliOptions) {
745
786
  }
746
787
  }
747
788
 
748
- // Fetch user-defined commands using the already-running server
749
- const allUserCommands: OpencodeCommand[] = []
750
- try {
751
- const commandsResponse = await getClient().command.list({
752
- query: { directory: currentDir },
753
- })
754
- if (commandsResponse.data) {
755
- allUserCommands.push(...commandsResponse.data)
756
- }
757
- } catch {
758
- // Ignore errors fetching commands
759
- }
760
-
761
789
  // Log available user commands
762
790
  const registrableCommands = allUserCommands.filter(
763
791
  (cmd) => !SKIP_USER_COMMANDS.includes(cmd.name),
@@ -838,13 +866,31 @@ cli
838
866
  '--data-dir <path>',
839
867
  'Data directory for config and database (default: ~/.kimaki)',
840
868
  )
841
- .action(async (options: { restart?: boolean; addChannels?: boolean; dataDir?: string }) => {
869
+ .option('--install-url', 'Print the bot install URL and exit')
870
+ .action(async (options: { restart?: boolean; addChannels?: boolean; dataDir?: string; installUrl?: boolean }) => {
842
871
  try {
843
872
  // Set data directory early, before any database access
844
873
  if (options.dataDir) {
845
874
  setDataDir(options.dataDir)
846
875
  cliLogger.log(`Using data directory: ${getDataDir()}`)
847
876
  }
877
+
878
+ if (options.installUrl) {
879
+ const db = getDatabase()
880
+ const existingBot = db
881
+ .prepare(
882
+ 'SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
883
+ )
884
+ .get() as { app_id: string } | undefined
885
+
886
+ if (!existingBot) {
887
+ cliLogger.error('No bot configured yet. Run `kimaki` first to set up.')
888
+ process.exit(EXIT_NO_RESTART)
889
+ }
890
+
891
+ console.log(generateBotInstallUrl({ clientId: existingBot.app_id }))
892
+ process.exit(0)
893
+ }
848
894
 
849
895
  await checkSingleInstance()
850
896
  await startLockServer()
@@ -958,6 +1004,196 @@ cli
958
1004
  })
959
1005
 
960
1006
 
1007
+ // Magic prefix used to identify bot-initiated sessions.
1008
+ // The running bot will recognize this prefix and start a session.
1009
+ const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
1010
+
1011
+ cli
1012
+ .command('start-session', 'Start a new session in a Discord channel (creates thread, bot handles the rest)')
1013
+ .option('-c, --channel <channelId>', 'Discord channel ID')
1014
+ .option('-p, --prompt <prompt>', 'Initial prompt for the session')
1015
+ .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
1016
+ .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
1017
+ .action(async (options: { channel?: string; prompt?: string; name?: string; appId?: string }) => {
1018
+ try {
1019
+ const { channel: channelId, prompt, name, appId: optionAppId } = options
1020
+
1021
+ if (!channelId) {
1022
+ cliLogger.error('Channel ID is required. Use --channel <channelId>')
1023
+ process.exit(EXIT_NO_RESTART)
1024
+ }
1025
+
1026
+ if (!prompt) {
1027
+ cliLogger.error('Prompt is required. Use --prompt <prompt>')
1028
+ process.exit(EXIT_NO_RESTART)
1029
+ }
1030
+
1031
+ // Get bot token from env var or database
1032
+ const envToken = process.env.KIMAKI_BOT_TOKEN
1033
+ let botToken: string | undefined
1034
+ let appId: string | undefined = optionAppId
1035
+
1036
+ if (envToken) {
1037
+ botToken = envToken
1038
+ if (!appId) {
1039
+ // Try to get app_id from database if available (optional in CI)
1040
+ try {
1041
+ const db = getDatabase()
1042
+ const botRow = db
1043
+ .prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
1044
+ .get() as { app_id: string } | undefined
1045
+ appId = botRow?.app_id
1046
+ } catch {
1047
+ // Database might not exist in CI, that's ok
1048
+ }
1049
+ }
1050
+ } else {
1051
+ // Fall back to database
1052
+ try {
1053
+ const db = getDatabase()
1054
+ const botRow = db
1055
+ .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
1056
+ .get() as { app_id: string; token: string } | undefined
1057
+
1058
+ if (botRow) {
1059
+ botToken = botRow.token
1060
+ appId = appId || botRow.app_id
1061
+ }
1062
+ } catch (e) {
1063
+ // Database error - will fall through to the check below
1064
+ cliLogger.error('Database error:', e instanceof Error ? e.message : String(e))
1065
+ }
1066
+ }
1067
+
1068
+ if (!botToken) {
1069
+ cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.')
1070
+ process.exit(EXIT_NO_RESTART)
1071
+ }
1072
+
1073
+ const s = spinner()
1074
+ s.start('Fetching channel info...')
1075
+
1076
+ // Get channel info to extract directory from topic
1077
+ const channelResponse = await fetch(
1078
+ `https://discord.com/api/v10/channels/${channelId}`,
1079
+ {
1080
+ headers: {
1081
+ 'Authorization': `Bot ${botToken}`,
1082
+ },
1083
+ }
1084
+ )
1085
+
1086
+ if (!channelResponse.ok) {
1087
+ const error = await channelResponse.text()
1088
+ s.stop('Failed to fetch channel')
1089
+ throw new Error(`Discord API error: ${channelResponse.status} - ${error}`)
1090
+ }
1091
+
1092
+ const channelData = await channelResponse.json() as {
1093
+ id: string
1094
+ name: string
1095
+ topic?: string
1096
+ guild_id: string
1097
+ }
1098
+
1099
+ if (!channelData.topic) {
1100
+ s.stop('Channel has no topic')
1101
+ throw new Error(`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`)
1102
+ }
1103
+
1104
+ const extracted = extractTagsArrays({
1105
+ xml: channelData.topic,
1106
+ tags: ['kimaki.directory', 'kimaki.app'],
1107
+ })
1108
+
1109
+ const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
1110
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim()
1111
+
1112
+ if (!projectDirectory) {
1113
+ s.stop('No kimaki.directory tag found')
1114
+ throw new Error(`Channel #${channelData.name} has no <kimaki.directory> tag in topic.`)
1115
+ }
1116
+
1117
+ // Verify app ID matches if both are present
1118
+ if (channelAppId && appId && channelAppId !== appId) {
1119
+ s.stop('Channel belongs to different bot')
1120
+ throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`)
1121
+ }
1122
+
1123
+ s.message('Creating starter message...')
1124
+
1125
+ // Create starter message with magic prefix
1126
+ // The full prompt goes in the message so the bot can read it
1127
+ const starterMessageResponse = await fetch(
1128
+ `https://discord.com/api/v10/channels/${channelId}/messages`,
1129
+ {
1130
+ method: 'POST',
1131
+ headers: {
1132
+ 'Authorization': `Bot ${botToken}`,
1133
+ 'Content-Type': 'application/json',
1134
+ },
1135
+ body: JSON.stringify({
1136
+ content: `${BOT_SESSION_PREFIX}\n${prompt}`,
1137
+ }),
1138
+ }
1139
+ )
1140
+
1141
+ if (!starterMessageResponse.ok) {
1142
+ const error = await starterMessageResponse.text()
1143
+ s.stop('Failed to create message')
1144
+ throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`)
1145
+ }
1146
+
1147
+ const starterMessage = await starterMessageResponse.json() as { id: string }
1148
+
1149
+ s.message('Creating thread...')
1150
+
1151
+ // Create thread from the message
1152
+ const threadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt)
1153
+ const threadResponse = await fetch(
1154
+ `https://discord.com/api/v10/channels/${channelId}/messages/${starterMessage.id}/threads`,
1155
+ {
1156
+ method: 'POST',
1157
+ headers: {
1158
+ 'Authorization': `Bot ${botToken}`,
1159
+ 'Content-Type': 'application/json',
1160
+ },
1161
+ body: JSON.stringify({
1162
+ name: threadName.slice(0, 100),
1163
+ auto_archive_duration: 1440, // 1 day
1164
+ }),
1165
+ }
1166
+ )
1167
+
1168
+ if (!threadResponse.ok) {
1169
+ const error = await threadResponse.text()
1170
+ s.stop('Failed to create thread')
1171
+ throw new Error(`Discord API error: ${threadResponse.status} - ${error}`)
1172
+ }
1173
+
1174
+ const threadData = await threadResponse.json() as { id: string; name: string }
1175
+
1176
+ s.stop('Thread created!')
1177
+
1178
+ const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
1179
+
1180
+ note(
1181
+ `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`,
1182
+ '✅ Thread Created',
1183
+ )
1184
+
1185
+ console.log(threadUrl)
1186
+
1187
+ process.exit(0)
1188
+ } catch (error) {
1189
+ cliLogger.error(
1190
+ 'Error:',
1191
+ error instanceof Error ? error.message : String(error),
1192
+ )
1193
+ process.exit(EXIT_NO_RESTART)
1194
+ }
1195
+ })
1196
+
961
1197
 
962
1198
  cli.help()
963
1199
  cli.parse()
@@ -10,7 +10,7 @@ import {
10
10
  } from 'discord.js'
11
11
  import crypto from 'node:crypto'
12
12
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js'
13
- import { getOpencodeServerPort } from '../opencode.js'
13
+ import { getOpencodeClientV2 } from '../opencode.js'
14
14
  import { createLogger } from '../logger.js'
15
15
 
16
16
  const logger = createLogger('ASK_QUESTION')
@@ -200,31 +200,20 @@ async function submitQuestionAnswers(
200
200
  context: PendingQuestionContext
201
201
  ): Promise<void> {
202
202
  try {
203
- // Build answers array: each element is an array of selected labels for that question
204
- const answersPayload = context.questions.map((_, i) => {
205
- return context.answers[i] || []
206
- })
207
-
208
- // Reply to the question using direct HTTP call to OpenCode API
209
- // (v1 SDK doesn't have question.reply, so we call it directly)
210
- const port = getOpencodeServerPort(context.directory)
211
- if (!port) {
203
+ const clientV2 = getOpencodeClientV2(context.directory)
204
+ if (!clientV2) {
212
205
  throw new Error('OpenCode server not found for directory')
213
206
  }
214
207
 
215
- const response = await fetch(
216
- `http://127.0.0.1:${port}/question/${context.requestId}/reply`,
217
- {
218
- method: 'POST',
219
- headers: { 'Content-Type': 'application/json' },
220
- body: JSON.stringify({ answers: answersPayload }),
221
- }
222
- )
208
+ // Build answers array: each element is an array of selected labels for that question
209
+ const answers = context.questions.map((_, i) => {
210
+ return context.answers[i] || []
211
+ })
223
212
 
224
- if (!response.ok) {
225
- const text = await response.text()
226
- throw new Error(`Failed to reply to question: ${response.status} ${text}`)
227
- }
213
+ await clientV2.question.reply({
214
+ requestID: context.requestId,
215
+ answers,
216
+ })
228
217
 
229
218
  logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`)
230
219
  } catch (error) {
@@ -275,3 +264,49 @@ export function parseAskUserQuestionTool(part: {
275
264
 
276
265
  return input
277
266
  }
267
+
268
+ /**
269
+ * Cancel a pending question for a thread (e.g., when user sends a new message).
270
+ * Sends cancellation response to OpenCode so the session can continue.
271
+ */
272
+ export async function cancelPendingQuestion(threadId: string): Promise<boolean> {
273
+ // Find pending question for this thread
274
+ let contextHash: string | undefined
275
+ let context: PendingQuestionContext | undefined
276
+ for (const [hash, ctx] of pendingQuestionContexts) {
277
+ if (ctx.thread.id === threadId) {
278
+ contextHash = hash
279
+ context = ctx
280
+ break
281
+ }
282
+ }
283
+
284
+ if (!contextHash || !context) {
285
+ return false
286
+ }
287
+
288
+ try {
289
+ const clientV2 = getOpencodeClientV2(context.directory)
290
+ if (!clientV2) {
291
+ throw new Error('OpenCode server not found for directory')
292
+ }
293
+
294
+ // Preserve already-answered questions, mark unanswered as cancelled
295
+ const answers = context.questions.map((_, i) => {
296
+ return context.answers[i] || ['(cancelled - user sent new message)']
297
+ })
298
+
299
+ await clientV2.question.reply({
300
+ requestID: context.requestId,
301
+ answers,
302
+ })
303
+
304
+ logger.log(`Cancelled question ${context.requestId} due to new user message`)
305
+ } catch (error) {
306
+ logger.error('Failed to cancel question:', error)
307
+ }
308
+
309
+ // Clean up regardless of whether the API call succeeded
310
+ pendingQuestionContexts.delete(contextHash)
311
+ return true
312
+ }
@@ -180,7 +180,10 @@ export async function startDiscordBot({
180
180
  )
181
181
 
182
182
  if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
183
- await message.react('🔒')
183
+ await message.reply({
184
+ content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
185
+ flags: SILENT_MESSAGE_FLAGS,
186
+ })
184
187
  return
185
188
  }
186
189
  }
@@ -412,6 +415,99 @@ export async function startDiscordBot({
412
415
  }
413
416
  })
414
417
 
418
+ // Magic prefix used by `kimaki start-session` CLI command to initiate sessions
419
+ const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
420
+
421
+ // Handle bot-initiated threads created by `kimaki start-session`
422
+ discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
423
+ try {
424
+ if (!newlyCreated) {
425
+ return
426
+ }
427
+
428
+ // Only handle threads in text channels
429
+ const parent = thread.parent as TextChannel | null
430
+ if (!parent || parent.type !== ChannelType.GuildText) {
431
+ return
432
+ }
433
+
434
+ // Get the starter message to check for magic prefix
435
+ const starterMessage = await thread.fetchStarterMessage().catch(() => null)
436
+ if (!starterMessage) {
437
+ discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`)
438
+ return
439
+ }
440
+
441
+ // Only handle messages from this bot with the magic prefix
442
+ if (starterMessage.author.id !== discordClient.user?.id) {
443
+ return
444
+ }
445
+
446
+ if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
447
+ return
448
+ }
449
+
450
+ discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
451
+
452
+ // Extract the prompt (everything after the prefix)
453
+ const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim()
454
+ if (!prompt) {
455
+ discordLogger.log(`[BOT_SESSION] No prompt found in starter message`)
456
+ return
457
+ }
458
+
459
+ // Extract directory from parent channel topic
460
+ if (!parent.topic) {
461
+ discordLogger.log(`[BOT_SESSION] Parent channel has no topic`)
462
+ return
463
+ }
464
+
465
+ const extracted = extractTagsArrays({
466
+ xml: parent.topic,
467
+ tags: ['kimaki.directory', 'kimaki.app'],
468
+ })
469
+
470
+ const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
471
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim()
472
+
473
+ if (!projectDirectory) {
474
+ discordLogger.log(`[BOT_SESSION] No kimaki.directory in parent channel topic`)
475
+ return
476
+ }
477
+
478
+ if (channelAppId && channelAppId !== currentAppId) {
479
+ discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`)
480
+ return
481
+ }
482
+
483
+ if (!fs.existsSync(projectDirectory)) {
484
+ discordLogger.error(`[BOT_SESSION] Directory does not exist: ${projectDirectory}`)
485
+ await thread.send({
486
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
487
+ flags: SILENT_MESSAGE_FLAGS,
488
+ })
489
+ return
490
+ }
491
+
492
+ discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`)
493
+
494
+ await handleOpencodeSession({
495
+ prompt,
496
+ thread,
497
+ projectDirectory,
498
+ channelId: parent.id,
499
+ })
500
+ } catch (error) {
501
+ voiceLogger.error('[BOT_SESSION] Error handling bot-initiated thread:', error)
502
+ try {
503
+ const errMsg = error instanceof Error ? error.message : String(error)
504
+ await thread.send({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS })
505
+ } catch {
506
+ // Ignore send errors
507
+ }
508
+ }
509
+ })
510
+
415
511
  await discordClient.login(token)
416
512
 
417
513
  const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
@@ -11,6 +11,7 @@ import {
11
11
  import { Lexer } from 'marked'
12
12
  import { extractTagsArrays } from './xml.js'
13
13
  import { formatMarkdownTables } from './format-tables.js'
14
+ import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
14
15
  import { createLogger } from './logger.js'
15
16
 
16
17
  const discordLogger = createLogger('DISCORD')
@@ -125,7 +126,8 @@ export function splitMarkdownForDiscord({
125
126
 
126
127
  // calculate overhead for code block markers
127
128
  const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0
128
- const availablePerChunk = maxLength - codeBlockOverhead - 50 // safety margin
129
+ // ensure at least 10 chars available, even if maxLength is very small
130
+ const availablePerChunk = Math.max(10, maxLength - codeBlockOverhead - 50)
129
131
 
130
132
  const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock)
131
133
 
@@ -198,6 +200,7 @@ export async function sendThreadMessage(
198
200
  const MAX_LENGTH = 2000
199
201
 
200
202
  content = formatMarkdownTables(content)
203
+ content = unnestCodeBlocksFromLists(content)
201
204
  content = escapeBackticksInCodeBlocks(content)
202
205
 
203
206
  // If custom flags provided, send as single message (no chunking)
@@ -376,11 +376,19 @@ test('splitMarkdownForDiscord handles very long line inside code block', () => {
376
376
  \`\`\`
377
377
  ",
378
378
  "\`\`\`js
379
- veryverylonglinethatexceedsmaxlength
380
- \`\`\`
379
+ veryverylo\`\`\`
381
380
  ",
382
381
  "\`\`\`js
383
- short
382
+ nglinethat\`\`\`
383
+ ",
384
+ "\`\`\`js
385
+ exceedsmax\`\`\`
386
+ ",
387
+ "\`\`\`js
388
+ length
389
+ \`\`\`
390
+ ",
391
+ "short
384
392
  \`\`\`
385
393
  ",
386
394
  ]