kimaki 0.4.10 → 0.4.13

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.
@@ -22,6 +22,50 @@ import { isAbortError } from './utils.js';
22
22
  import { setGlobalDispatcher, Agent } from 'undici';
23
23
  // disables the automatic 5 minutes abort after no body
24
24
  setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }));
25
+ export const OPENCODE_SYSTEM_MESSAGE = `
26
+ The user is reading your messages from inside Discord, via kimaki.xyz
27
+
28
+ 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:
29
+
30
+ bunx critique web
31
+
32
+ you can also show latest commit changes using
33
+
34
+ bunx critique web HEAD~1
35
+
36
+ do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
37
+
38
+ ## markdown
39
+
40
+ discord does support basic markdown features like code blocks, code blocks languages, inline code, bold, italic, quotes, etc.
41
+
42
+ the max heading level is 3, so do not use ####
43
+
44
+ headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
45
+
46
+ ## tables
47
+
48
+ discord does NOT support markdown gfm tables.
49
+
50
+ so instead of using full markdown tables ALWAYS show code snippets with space aligned cells:
51
+
52
+ \`\`\`
53
+ Item Qty Price
54
+ ---------- --- -----
55
+ Apples 10 $5
56
+ Oranges 3 $2
57
+ \`\`\`
58
+
59
+ Using code blocks will make the content use monospaced font so that space will be aligned correctly
60
+
61
+ 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
62
+
63
+ code blocks for tables and diagrams MUST have Max length of 85 characters. otherwise the content will wrap
64
+
65
+ ## diagrams
66
+
67
+ you can create diagrams wrapping them in code blocks too.
68
+ `;
25
69
  const discordLogger = createLogger('DISCORD');
26
70
  const voiceLogger = createLogger('VOICE');
27
71
  const opencodeLogger = createLogger('OPENCODE');
@@ -85,7 +129,7 @@ async function createUserAudioLogStream(guildId, channelId) {
85
129
  }
86
130
  }
87
131
  // Set up voice handling for a connection (called once per connection)
88
- async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
132
+ async function setupVoiceHandling({ connection, guildId, channelId, appId, discordClient, }) {
89
133
  voiceLogger.log(`Setting up voice handling for guild ${guildId}, channel ${channelId}`);
90
134
  // Check if this voice channel has an associated directory
91
135
  const channelDirRow = getDatabase()
@@ -183,8 +227,24 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
183
227
  : `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`;
184
228
  genAiWorker.sendTextInput(text);
185
229
  },
186
- onError(error) {
230
+ async onError(error) {
187
231
  voiceLogger.error('GenAI worker error:', error);
232
+ const textChannelRow = getDatabase()
233
+ .prepare(`SELECT cd2.channel_id FROM channel_directories cd1
234
+ JOIN channel_directories cd2 ON cd1.directory = cd2.directory
235
+ WHERE cd1.channel_id = ? AND cd1.channel_type = 'voice' AND cd2.channel_type = 'text'`)
236
+ .get(channelId);
237
+ if (textChannelRow) {
238
+ try {
239
+ const textChannel = await discordClient.channels.fetch(textChannelRow.channel_id);
240
+ if (textChannel?.isTextBased() && 'send' in textChannel) {
241
+ await textChannel.send(`⚠️ Voice session error: ${error}`);
242
+ }
243
+ }
244
+ catch (e) {
245
+ voiceLogger.error('Failed to send error to text channel:', e);
246
+ }
247
+ }
188
248
  },
189
249
  });
190
250
  // Stop any existing GenAI worker before storing new one
@@ -451,60 +511,20 @@ async function getOpenPort() {
451
511
  async function sendThreadMessage(thread, content) {
452
512
  const MAX_LENGTH = 2000;
453
513
  content = escapeBackticksInCodeBlocks(content);
454
- // Simple case: content fits in one message
455
- if (content.length <= MAX_LENGTH) {
456
- return await thread.send(content);
514
+ const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH });
515
+ if (chunks.length > 1) {
516
+ discordLogger.log(`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`);
457
517
  }
458
- // Use marked's lexer to tokenize markdown content
459
- const lexer = new Lexer();
460
- const tokens = lexer.lex(content);
461
- const chunks = [];
462
- let currentChunk = '';
463
- // Process each token and add to chunks
464
- for (const token of tokens) {
465
- const tokenText = token.raw || '';
466
- // If adding this token would exceed limit and we have content, flush current chunk
467
- if (currentChunk && currentChunk.length + tokenText.length > MAX_LENGTH) {
468
- chunks.push(currentChunk);
469
- currentChunk = '';
470
- }
471
- // If this single token is longer than MAX_LENGTH, split it
472
- if (tokenText.length > MAX_LENGTH) {
473
- if (currentChunk) {
474
- chunks.push(currentChunk);
475
- currentChunk = '';
476
- }
477
- let remainingText = tokenText;
478
- while (remainingText.length > MAX_LENGTH) {
479
- // Try to split at a newline if possible
480
- let splitIndex = MAX_LENGTH;
481
- const newlineIndex = remainingText.lastIndexOf('\n', MAX_LENGTH - 1);
482
- if (newlineIndex > MAX_LENGTH * 0.7) {
483
- splitIndex = newlineIndex + 1;
484
- }
485
- chunks.push(remainingText.slice(0, splitIndex));
486
- remainingText = remainingText.slice(splitIndex);
487
- }
488
- currentChunk = remainingText;
489
- }
490
- else {
491
- currentChunk += tokenText;
492
- }
493
- }
494
- // Add any remaining content
495
- if (currentChunk) {
496
- chunks.push(currentChunk);
497
- }
498
- // Send all chunks
499
- discordLogger.log(`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`);
500
518
  let firstMessage;
501
519
  for (let i = 0; i < chunks.length; i++) {
502
520
  const chunk = chunks[i];
503
- if (!chunk)
521
+ if (!chunk) {
504
522
  continue;
523
+ }
505
524
  const message = await thread.send(chunk);
506
- if (i === 0)
525
+ if (i === 0) {
507
526
  firstMessage = message;
527
+ }
508
528
  }
509
529
  return firstMessage;
510
530
  }
@@ -621,6 +641,77 @@ export function escapeBackticksInCodeBlocks(markdown) {
621
641
  }
622
642
  return result;
623
643
  }
644
+ export function splitMarkdownForDiscord({ content, maxLength, }) {
645
+ if (content.length <= maxLength) {
646
+ return [content];
647
+ }
648
+ const lexer = new Lexer();
649
+ const tokens = lexer.lex(content);
650
+ const lines = [];
651
+ for (const token of tokens) {
652
+ if (token.type === 'code') {
653
+ const lang = token.lang || '';
654
+ lines.push({ text: '```' + lang + '\n', inCodeBlock: false, lang, isOpeningFence: true, isClosingFence: false });
655
+ const codeLines = token.text.split('\n');
656
+ for (const codeLine of codeLines) {
657
+ lines.push({ text: codeLine + '\n', inCodeBlock: true, lang, isOpeningFence: false, isClosingFence: false });
658
+ }
659
+ lines.push({ text: '```\n', inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: true });
660
+ }
661
+ else {
662
+ const rawLines = token.raw.split('\n');
663
+ for (let i = 0; i < rawLines.length; i++) {
664
+ const isLast = i === rawLines.length - 1;
665
+ const text = isLast ? rawLines[i] : rawLines[i] + '\n';
666
+ if (text) {
667
+ lines.push({ text, inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: false });
668
+ }
669
+ }
670
+ }
671
+ }
672
+ const chunks = [];
673
+ let currentChunk = '';
674
+ let currentLang = null;
675
+ for (const line of lines) {
676
+ const wouldExceed = currentChunk.length + line.text.length > maxLength;
677
+ if (wouldExceed && currentChunk) {
678
+ if (currentLang !== null) {
679
+ currentChunk += '```\n';
680
+ }
681
+ chunks.push(currentChunk);
682
+ if (line.isClosingFence && currentLang !== null) {
683
+ currentChunk = '';
684
+ currentLang = null;
685
+ continue;
686
+ }
687
+ if (line.inCodeBlock || line.isOpeningFence) {
688
+ const lang = line.lang;
689
+ currentChunk = '```' + lang + '\n';
690
+ if (!line.isOpeningFence) {
691
+ currentChunk += line.text;
692
+ }
693
+ currentLang = lang;
694
+ }
695
+ else {
696
+ currentChunk = line.text;
697
+ currentLang = null;
698
+ }
699
+ }
700
+ else {
701
+ currentChunk += line.text;
702
+ if (line.inCodeBlock || line.isOpeningFence) {
703
+ currentLang = line.lang;
704
+ }
705
+ else if (line.isClosingFence) {
706
+ currentLang = null;
707
+ }
708
+ }
709
+ }
710
+ if (currentChunk) {
711
+ chunks.push(currentChunk);
712
+ }
713
+ return chunks;
714
+ }
624
715
  /**
625
716
  * Escape Discord formatting characters to prevent breaking code blocks and inline code
626
717
  */
@@ -837,24 +928,27 @@ function getToolOutputToDisplay(part) {
837
928
  if (part.state.status === 'error') {
838
929
  return part.state.error || 'Unknown error';
839
930
  }
840
- if (part.tool === 'todowrite') {
841
- const todos = part.state.input?.todos || [];
842
- return todos
843
- .map((todo) => {
844
- let statusIcon = '▢';
845
- if (todo.status === 'in_progress') {
846
- statusIcon = '●';
847
- }
848
- if (todo.status === 'completed' || todo.status === 'cancelled') {
849
- statusIcon = '■';
850
- }
851
- return `\`${statusIcon}\` ${todo.content}`;
852
- })
853
- .filter(Boolean)
854
- .join('\n');
855
- }
856
931
  return '';
857
932
  }
933
+ function formatTodoList(part) {
934
+ if (part.type !== 'tool' || part.tool !== 'todowrite')
935
+ return '';
936
+ const todos = part.state.input?.todos || [];
937
+ if (todos.length === 0)
938
+ return '';
939
+ return todos
940
+ .map((todo, i) => {
941
+ const num = `${i + 1}.`;
942
+ if (todo.status === 'in_progress') {
943
+ return `${num} **${todo.content}**`;
944
+ }
945
+ if (todo.status === 'completed' || todo.status === 'cancelled') {
946
+ return `${num} ~~${todo.content}~~`;
947
+ }
948
+ return `${num} ${todo.content}`;
949
+ })
950
+ .join('\n');
951
+ }
858
952
  function formatPart(part) {
859
953
  if (part.type === 'text') {
860
954
  return part.text || '';
@@ -877,14 +971,31 @@ function formatPart(part) {
877
971
  return `◼︎ snapshot ${part.snapshot}`;
878
972
  }
879
973
  if (part.type === 'tool') {
974
+ if (part.tool === 'todowrite') {
975
+ return formatTodoList(part);
976
+ }
880
977
  if (part.state.status !== 'completed' && part.state.status !== 'error') {
881
978
  return '';
882
979
  }
883
980
  const summaryText = getToolSummaryText(part);
884
981
  const outputToDisplay = getToolOutputToDisplay(part);
885
- let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error';
886
- if (toolTitle) {
887
- toolTitle = `*${toolTitle}*`;
982
+ let toolTitle = '';
983
+ if (part.state.status === 'error') {
984
+ toolTitle = 'error';
985
+ }
986
+ else if (part.tool === 'bash') {
987
+ const command = part.state.input?.command || '';
988
+ const isSingleLine = !command.includes('\n');
989
+ const hasBackticks = command.includes('`');
990
+ if (isSingleLine && command.length <= 120 && !hasBackticks) {
991
+ toolTitle = `\`${command}\``;
992
+ }
993
+ else {
994
+ toolTitle = part.state.title ? `*${part.state.title}*` : '';
995
+ }
996
+ }
997
+ else if (part.state.title) {
998
+ toolTitle = `*${part.state.title}*`;
888
999
  }
889
1000
  const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : '';
890
1001
  const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
@@ -1265,6 +1376,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1265
1376
  path: { id: session.id },
1266
1377
  body: {
1267
1378
  parts,
1379
+ system: OPENCODE_SYSTEM_MESSAGE,
1268
1380
  },
1269
1381
  signal: abortController.signal,
1270
1382
  });
@@ -1861,52 +1973,37 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1861
1973
  await command.editReply(`Resumed session "${sessionTitle}" in ${thread.toString()}`);
1862
1974
  // Send initial message to thread
1863
1975
  await sendThreadMessage(thread, `📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`);
1864
- // Render all existing messages
1865
- let messageCount = 0;
1976
+ // Collect all assistant parts first, then only render the last 30
1977
+ const allAssistantParts = [];
1866
1978
  for (const message of messages) {
1867
- if (message.info.role === 'user') {
1868
- // Render user messages
1869
- const userParts = message.parts.filter((p) => p.type === 'text' && !p.synthetic);
1870
- const userTexts = userParts
1871
- .map((p) => {
1872
- if (p.type === 'text') {
1873
- return p.text;
1874
- }
1875
- return '';
1876
- })
1877
- .filter((t) => t.trim());
1878
- const userText = userTexts.join('\n\n');
1879
- if (userText) {
1880
- // Escape backticks in user messages to prevent formatting issues
1881
- const escapedText = escapeDiscordFormatting(userText);
1882
- await sendThreadMessage(thread, `**User:**\n${escapedText}`);
1883
- }
1884
- }
1885
- else if (message.info.role === 'assistant') {
1886
- // Render assistant parts
1887
- const partsToRender = [];
1979
+ if (message.info.role === 'assistant') {
1888
1980
  for (const part of message.parts) {
1889
1981
  const content = formatPart(part);
1890
1982
  if (content.trim()) {
1891
- partsToRender.push({ id: part.id, content });
1983
+ allAssistantParts.push({ id: part.id, content });
1892
1984
  }
1893
1985
  }
1894
- if (partsToRender.length > 0) {
1895
- const combinedContent = partsToRender
1896
- .map((p) => p.content)
1897
- .join('\n\n');
1898
- const discordMessage = await sendThreadMessage(thread, combinedContent);
1899
- const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
1900
- const transaction = getDatabase().transaction((parts) => {
1901
- for (const part of parts) {
1902
- stmt.run(part.id, discordMessage.id, thread.id);
1903
- }
1904
- });
1905
- transaction(partsToRender);
1906
- }
1907
1986
  }
1908
- messageCount++;
1909
1987
  }
1988
+ const partsToRender = allAssistantParts.slice(-30);
1989
+ const skippedCount = allAssistantParts.length - partsToRender.length;
1990
+ if (skippedCount > 0) {
1991
+ await sendThreadMessage(thread, `*Skipped ${skippedCount} older assistant parts...*`);
1992
+ }
1993
+ if (partsToRender.length > 0) {
1994
+ const combinedContent = partsToRender
1995
+ .map((p) => p.content)
1996
+ .join('\n\n');
1997
+ const discordMessage = await sendThreadMessage(thread, combinedContent);
1998
+ const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
1999
+ const transaction = getDatabase().transaction((parts) => {
2000
+ for (const part of parts) {
2001
+ stmt.run(part.id, discordMessage.id, thread.id);
2002
+ }
2003
+ });
2004
+ transaction(partsToRender);
2005
+ }
2006
+ const messageCount = messages.length;
1910
2007
  await sendThreadMessage(thread, `✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`);
1911
2008
  }
1912
2009
  catch (error) {
@@ -2245,6 +2342,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2245
2342
  guildId: newState.guild.id,
2246
2343
  channelId: voiceChannel.id,
2247
2344
  appId: currentAppId,
2345
+ discordClient,
2248
2346
  });
2249
2347
  // Handle connection state changes
2250
2348
  connection.on(VoiceConnectionStatus.Disconnected, async () => {
@@ -1,6 +1,6 @@
1
1
  import { test, expect } from 'vitest';
2
2
  import { Lexer } from 'marked';
3
- import { escapeBackticksInCodeBlocks } from './discordBot.js';
3
+ import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discordBot.js';
4
4
  test('escapes single backticks in code blocks', () => {
5
5
  const input = '```js\nconst x = `hello`\n```';
6
6
  const result = escapeBackticksInCodeBlocks(input);
@@ -123,3 +123,288 @@ const x = \\\`hello\\\`
123
123
  "
124
124
  `);
125
125
  });
126
+ test('splitMarkdownForDiscord returns single chunk for short content', () => {
127
+ const result = splitMarkdownForDiscord({
128
+ content: 'Hello world',
129
+ maxLength: 100,
130
+ });
131
+ expect(result).toMatchInlineSnapshot(`
132
+ [
133
+ "Hello world",
134
+ ]
135
+ `);
136
+ });
137
+ test('splitMarkdownForDiscord splits at line boundaries', () => {
138
+ const result = splitMarkdownForDiscord({
139
+ content: 'Line 1\nLine 2\nLine 3\nLine 4',
140
+ maxLength: 15,
141
+ });
142
+ expect(result).toMatchInlineSnapshot(`
143
+ [
144
+ "Line 1
145
+ Line 2
146
+ ",
147
+ "Line 3
148
+ Line 4",
149
+ ]
150
+ `);
151
+ });
152
+ test('splitMarkdownForDiscord preserves code blocks when not split', () => {
153
+ const result = splitMarkdownForDiscord({
154
+ content: '```js\nconst x = 1\n```',
155
+ maxLength: 100,
156
+ });
157
+ expect(result).toMatchInlineSnapshot(`
158
+ [
159
+ "\`\`\`js
160
+ const x = 1
161
+ \`\`\`",
162
+ ]
163
+ `);
164
+ });
165
+ test('splitMarkdownForDiscord adds closing and opening fences when splitting code block', () => {
166
+ const result = splitMarkdownForDiscord({
167
+ content: '```js\nline1\nline2\nline3\nline4\n```',
168
+ maxLength: 20,
169
+ });
170
+ expect(result).toMatchInlineSnapshot(`
171
+ [
172
+ "\`\`\`js
173
+ line1
174
+ line2
175
+ \`\`\`
176
+ ",
177
+ "\`\`\`js
178
+ line3
179
+ line4
180
+ \`\`\`
181
+ ",
182
+ ]
183
+ `);
184
+ });
185
+ test('splitMarkdownForDiscord handles code block with language', () => {
186
+ const result = splitMarkdownForDiscord({
187
+ content: '```typescript\nconst a = 1\nconst b = 2\n```',
188
+ maxLength: 30,
189
+ });
190
+ expect(result).toMatchInlineSnapshot(`
191
+ [
192
+ "\`\`\`typescript
193
+ const a = 1
194
+ \`\`\`
195
+ ",
196
+ "\`\`\`typescript
197
+ const b = 2
198
+ \`\`\`
199
+ ",
200
+ ]
201
+ `);
202
+ });
203
+ test('splitMarkdownForDiscord handles mixed content with code blocks', () => {
204
+ const result = splitMarkdownForDiscord({
205
+ content: 'Text before\n```js\ncode\n```\nText after',
206
+ maxLength: 25,
207
+ });
208
+ expect(result).toMatchInlineSnapshot(`
209
+ [
210
+ "Text before
211
+ \`\`\`js
212
+ code
213
+ \`\`\`
214
+ ",
215
+ "Text after",
216
+ ]
217
+ `);
218
+ });
219
+ test('splitMarkdownForDiscord handles code block without language', () => {
220
+ const result = splitMarkdownForDiscord({
221
+ content: '```\nline1\nline2\n```',
222
+ maxLength: 12,
223
+ });
224
+ expect(result).toMatchInlineSnapshot(`
225
+ [
226
+ "\`\`\`
227
+ line1
228
+ \`\`\`
229
+ ",
230
+ "\`\`\`
231
+ line2
232
+ \`\`\`
233
+ ",
234
+ ]
235
+ `);
236
+ });
237
+ test('splitMarkdownForDiscord handles multiple consecutive code blocks', () => {
238
+ const result = splitMarkdownForDiscord({
239
+ content: '```js\nfoo\n```\n```py\nbar\n```',
240
+ maxLength: 20,
241
+ });
242
+ expect(result).toMatchInlineSnapshot(`
243
+ [
244
+ "\`\`\`js
245
+ foo
246
+ \`\`\`
247
+ \`\`\`py
248
+ \`\`\`
249
+ ",
250
+ "\`\`\`py
251
+ bar
252
+ \`\`\`
253
+ ",
254
+ ]
255
+ `);
256
+ });
257
+ test('splitMarkdownForDiscord handles empty code block', () => {
258
+ const result = splitMarkdownForDiscord({
259
+ content: 'before\n```\n```\nafter',
260
+ maxLength: 50,
261
+ });
262
+ expect(result).toMatchInlineSnapshot(`
263
+ [
264
+ "before
265
+ \`\`\`
266
+ \`\`\`
267
+ after",
268
+ ]
269
+ `);
270
+ });
271
+ test('splitMarkdownForDiscord handles content exactly at maxLength', () => {
272
+ const result = splitMarkdownForDiscord({
273
+ content: '12345678901234567890',
274
+ maxLength: 20,
275
+ });
276
+ expect(result).toMatchInlineSnapshot(`
277
+ [
278
+ "12345678901234567890",
279
+ ]
280
+ `);
281
+ });
282
+ test('splitMarkdownForDiscord handles code block only', () => {
283
+ const result = splitMarkdownForDiscord({
284
+ content: '```ts\nconst x = 1\n```',
285
+ maxLength: 15,
286
+ });
287
+ expect(result).toMatchInlineSnapshot(`
288
+ [
289
+ "\`\`\`ts
290
+ \`\`\`
291
+ ",
292
+ "\`\`\`ts
293
+ const x = 1
294
+ \`\`\`
295
+ ",
296
+ ]
297
+ `);
298
+ });
299
+ test('splitMarkdownForDiscord handles code block at start with text after', () => {
300
+ const result = splitMarkdownForDiscord({
301
+ content: '```js\ncode\n```\nSome text after',
302
+ maxLength: 20,
303
+ });
304
+ expect(result).toMatchInlineSnapshot(`
305
+ [
306
+ "\`\`\`js
307
+ code
308
+ \`\`\`
309
+ ",
310
+ "Some text after",
311
+ ]
312
+ `);
313
+ });
314
+ test('splitMarkdownForDiscord handles text before code block at end', () => {
315
+ const result = splitMarkdownForDiscord({
316
+ content: 'Some text before\n```js\ncode\n```',
317
+ maxLength: 25,
318
+ });
319
+ expect(result).toMatchInlineSnapshot(`
320
+ [
321
+ "Some text before
322
+ \`\`\`js
323
+ \`\`\`
324
+ ",
325
+ "\`\`\`js
326
+ code
327
+ \`\`\`
328
+ ",
329
+ ]
330
+ `);
331
+ });
332
+ test('splitMarkdownForDiscord handles very long line inside code block', () => {
333
+ const result = splitMarkdownForDiscord({
334
+ content: '```js\nshort\nveryverylonglinethatexceedsmaxlength\nshort\n```',
335
+ maxLength: 25,
336
+ });
337
+ expect(result).toMatchInlineSnapshot(`
338
+ [
339
+ "\`\`\`js
340
+ short
341
+ \`\`\`
342
+ ",
343
+ "\`\`\`js
344
+ veryverylonglinethatexceedsmaxlength
345
+ \`\`\`
346
+ ",
347
+ "\`\`\`js
348
+ short
349
+ \`\`\`
350
+ ",
351
+ ]
352
+ `);
353
+ });
354
+ test('splitMarkdownForDiscord handles realistic long markdown with code block', () => {
355
+ const content = `Here is some explanation text before the code.
356
+
357
+ \`\`\`typescript
358
+ export function calculateTotal(items: Item[]): number {
359
+ let total = 0
360
+ for (const item of items) {
361
+ total += item.price * item.quantity
362
+ }
363
+ return total
364
+ }
365
+
366
+ export function formatCurrency(amount: number): string {
367
+ return new Intl.NumberFormat('en-US', {
368
+ style: 'currency',
369
+ currency: 'USD',
370
+ }).format(amount)
371
+ }
372
+ \`\`\`
373
+
374
+ And here is some text after the code block.`;
375
+ const result = splitMarkdownForDiscord({
376
+ content,
377
+ maxLength: 200,
378
+ });
379
+ expect(result).toMatchInlineSnapshot(`
380
+ [
381
+ "Here is some explanation text before the code.
382
+
383
+ \`\`\`typescript
384
+ export function calculateTotal(items: Item[]): number {
385
+ let total = 0
386
+ for (const item of items) {
387
+ \`\`\`
388
+ ",
389
+ "\`\`\`typescript
390
+ total += item.price * item.quantity
391
+ }
392
+ return total
393
+ }
394
+
395
+ export function formatCurrency(amount: number): string {
396
+ return new Intl.NumberFormat('en-US', {
397
+ style: 'currency',
398
+ \`\`\`
399
+ ",
400
+ "\`\`\`typescript
401
+ currency: 'USD',
402
+ }).format(amount)
403
+ }
404
+ \`\`\`
405
+
406
+
407
+ And here is some text after the code block.",
408
+ ]
409
+ `);
410
+ });