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,282 @@
1
+ // Discord-specific utility functions.
2
+ // Handles markdown splitting for Discord's 2000-char limit, code block escaping,
3
+ // thread message sending, and channel metadata extraction from topic tags.
4
+
5
+ import {
6
+ ChannelType,
7
+ type Message,
8
+ type TextChannel,
9
+ type ThreadChannel,
10
+ } from 'discord.js'
11
+ import { Lexer } from 'marked'
12
+ import { extractTagsArrays } from './xml.js'
13
+ import { formatMarkdownTables } from './format-tables.js'
14
+ import { createLogger } from './logger.js'
15
+
16
+ const discordLogger = createLogger('DISCORD')
17
+
18
+ export const SILENT_MESSAGE_FLAGS = 4 | 4096
19
+ // Same as SILENT but without SuppressNotifications - triggers badge/notification
20
+ export const NOTIFY_MESSAGE_FLAGS = 4
21
+
22
+ export function escapeBackticksInCodeBlocks(markdown: string): string {
23
+ const lexer = new Lexer()
24
+ const tokens = lexer.lex(markdown)
25
+
26
+ let result = ''
27
+
28
+ for (const token of tokens) {
29
+ if (token.type === 'code') {
30
+ const escapedCode = token.text.replace(/`/g, '\\`')
31
+ result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n'
32
+ } else {
33
+ result += token.raw
34
+ }
35
+ }
36
+
37
+ return result
38
+ }
39
+
40
+ type LineInfo = {
41
+ text: string
42
+ inCodeBlock: boolean
43
+ lang: string
44
+ isOpeningFence: boolean
45
+ isClosingFence: boolean
46
+ }
47
+
48
+ export function splitMarkdownForDiscord({
49
+ content,
50
+ maxLength,
51
+ }: {
52
+ content: string
53
+ maxLength: number
54
+ }): string[] {
55
+ if (content.length <= maxLength) {
56
+ return [content]
57
+ }
58
+
59
+ const lexer = new Lexer()
60
+ const tokens = lexer.lex(content)
61
+
62
+ const lines: LineInfo[] = []
63
+ for (const token of tokens) {
64
+ if (token.type === 'code') {
65
+ const lang = token.lang || ''
66
+ lines.push({ text: '```' + lang + '\n', inCodeBlock: false, lang, isOpeningFence: true, isClosingFence: false })
67
+ const codeLines = token.text.split('\n')
68
+ for (const codeLine of codeLines) {
69
+ lines.push({ text: codeLine + '\n', inCodeBlock: true, lang, isOpeningFence: false, isClosingFence: false })
70
+ }
71
+ lines.push({ text: '```\n', inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: true })
72
+ } else {
73
+ const rawLines = token.raw.split('\n')
74
+ for (let i = 0; i < rawLines.length; i++) {
75
+ const isLast = i === rawLines.length - 1
76
+ const text = isLast ? rawLines[i]! : rawLines[i]! + '\n'
77
+ if (text) {
78
+ lines.push({ text, inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: false })
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ const chunks: string[] = []
85
+ let currentChunk = ''
86
+ let currentLang: string | null = null
87
+
88
+ // helper to split a long line into smaller pieces at word boundaries or hard breaks
89
+ const splitLongLine = (text: string, available: number, inCode: boolean): string[] => {
90
+ const pieces: string[] = []
91
+ let remaining = text
92
+
93
+ while (remaining.length > available) {
94
+ let splitAt = available
95
+ // for non-code, try to split at word boundary
96
+ if (!inCode) {
97
+ const lastSpace = remaining.lastIndexOf(' ', available)
98
+ if (lastSpace > available * 0.5) {
99
+ splitAt = lastSpace + 1
100
+ }
101
+ }
102
+ pieces.push(remaining.slice(0, splitAt))
103
+ remaining = remaining.slice(splitAt)
104
+ }
105
+ if (remaining) {
106
+ pieces.push(remaining)
107
+ }
108
+ return pieces
109
+ }
110
+
111
+ for (const line of lines) {
112
+ const wouldExceed = currentChunk.length + line.text.length > maxLength
113
+
114
+ if (wouldExceed) {
115
+ // handle case where single line is longer than maxLength
116
+ if (line.text.length > maxLength) {
117
+ // first, flush current chunk if any
118
+ if (currentChunk) {
119
+ if (currentLang !== null) {
120
+ currentChunk += '```\n'
121
+ }
122
+ chunks.push(currentChunk)
123
+ currentChunk = ''
124
+ }
125
+
126
+ // calculate overhead for code block markers
127
+ const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0
128
+ const availablePerChunk = maxLength - codeBlockOverhead - 50 // safety margin
129
+
130
+ const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock)
131
+
132
+ for (let i = 0; i < pieces.length; i++) {
133
+ const piece = pieces[i]!
134
+ if (line.inCodeBlock) {
135
+ chunks.push('```' + line.lang + '\n' + piece + '```\n')
136
+ } else {
137
+ chunks.push(piece)
138
+ }
139
+ }
140
+
141
+ currentLang = null
142
+ continue
143
+ }
144
+
145
+ // normal case: line fits in a chunk but current chunk would overflow
146
+ if (currentChunk) {
147
+ if (currentLang !== null) {
148
+ currentChunk += '```\n'
149
+ }
150
+ chunks.push(currentChunk)
151
+
152
+ if (line.isClosingFence && currentLang !== null) {
153
+ currentChunk = ''
154
+ currentLang = null
155
+ continue
156
+ }
157
+
158
+ if (line.inCodeBlock || line.isOpeningFence) {
159
+ const lang = line.lang
160
+ currentChunk = '```' + lang + '\n'
161
+ if (!line.isOpeningFence) {
162
+ currentChunk += line.text
163
+ }
164
+ currentLang = lang
165
+ } else {
166
+ currentChunk = line.text
167
+ currentLang = null
168
+ }
169
+ } else {
170
+ // currentChunk is empty but line still exceeds - shouldn't happen after above check
171
+ currentChunk = line.text
172
+ if (line.inCodeBlock || line.isOpeningFence) {
173
+ currentLang = line.lang
174
+ }
175
+ }
176
+ } else {
177
+ currentChunk += line.text
178
+ if (line.inCodeBlock || line.isOpeningFence) {
179
+ currentLang = line.lang
180
+ } else if (line.isClosingFence) {
181
+ currentLang = null
182
+ }
183
+ }
184
+ }
185
+
186
+ if (currentChunk) {
187
+ chunks.push(currentChunk)
188
+ }
189
+
190
+ return chunks
191
+ }
192
+
193
+ export async function sendThreadMessage(
194
+ thread: ThreadChannel,
195
+ content: string,
196
+ options?: { flags?: number }
197
+ ): Promise<Message> {
198
+ const MAX_LENGTH = 2000
199
+
200
+ content = formatMarkdownTables(content)
201
+ content = escapeBackticksInCodeBlocks(content)
202
+
203
+ // If custom flags provided, send as single message (no chunking)
204
+ if (options?.flags !== undefined) {
205
+ return thread.send({ content, flags: options.flags })
206
+ }
207
+
208
+ const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH })
209
+
210
+ if (chunks.length > 1) {
211
+ discordLogger.log(
212
+ `MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`,
213
+ )
214
+ }
215
+
216
+ let firstMessage: Message | undefined
217
+ for (let i = 0; i < chunks.length; i++) {
218
+ const chunk = chunks[i]
219
+ if (!chunk) {
220
+ continue
221
+ }
222
+ const message = await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
223
+ if (i === 0) {
224
+ firstMessage = message
225
+ }
226
+ }
227
+
228
+ return firstMessage!
229
+ }
230
+
231
+ export async function resolveTextChannel(
232
+ channel: TextChannel | ThreadChannel | null | undefined,
233
+ ): Promise<TextChannel | null> {
234
+ if (!channel) {
235
+ return null
236
+ }
237
+
238
+ if (channel.type === ChannelType.GuildText) {
239
+ return channel as TextChannel
240
+ }
241
+
242
+ if (
243
+ channel.type === ChannelType.PublicThread ||
244
+ channel.type === ChannelType.PrivateThread ||
245
+ channel.type === ChannelType.AnnouncementThread
246
+ ) {
247
+ const parentId = channel.parentId
248
+ if (parentId) {
249
+ const parent = await channel.guild.channels.fetch(parentId)
250
+ if (parent?.type === ChannelType.GuildText) {
251
+ return parent as TextChannel
252
+ }
253
+ }
254
+ }
255
+
256
+ return null
257
+ }
258
+
259
+ export function escapeDiscordFormatting(text: string): string {
260
+ return text
261
+ .replace(/```/g, '\\`\\`\\`')
262
+ .replace(/````/g, '\\`\\`\\`\\`')
263
+ }
264
+
265
+ export function getKimakiMetadata(textChannel: TextChannel | null): {
266
+ projectDirectory?: string
267
+ channelAppId?: string
268
+ } {
269
+ if (!textChannel?.topic) {
270
+ return {}
271
+ }
272
+
273
+ const extracted = extractTagsArrays({
274
+ xml: textChannel.topic,
275
+ tags: ['kimaki.directory', 'kimaki.app'],
276
+ })
277
+
278
+ const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
279
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim()
280
+
281
+ return { projectDirectory, channelAppId }
282
+ }
@@ -0,0 +1,447 @@
1
+ import { test, expect } from 'vitest'
2
+ import { Lexer } from 'marked'
3
+ import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discord-utils.js'
4
+
5
+
6
+
7
+ test('escapes single backticks in code blocks', () => {
8
+ const input = '```js\nconst x = `hello`\n```'
9
+ const result = escapeBackticksInCodeBlocks(input)
10
+
11
+ expect(result).toMatchInlineSnapshot(`
12
+ "\`\`\`js
13
+ const x = \\\`hello\\\`
14
+ \`\`\`
15
+ "
16
+ `)
17
+ })
18
+
19
+ test('escapes backticks in code blocks with language', () => {
20
+ const input = '```typescript\nconst greeting = `Hello, ${name}!`\nconst inline = `test`\n```'
21
+ const result = escapeBackticksInCodeBlocks(input)
22
+
23
+ expect(result).toMatchInlineSnapshot(`
24
+ "\`\`\`typescript
25
+ const greeting = \\\`Hello, \${name}!\\\`
26
+ const inline = \\\`test\\\`
27
+ \`\`\`
28
+ "
29
+ `)
30
+ })
31
+
32
+ test('does not escape backticks outside code blocks', () => {
33
+ const input = 'This is `inline code` and this is a code block:\n```\nconst x = `template`\n```'
34
+ const result = escapeBackticksInCodeBlocks(input)
35
+
36
+ expect(result).toMatchInlineSnapshot(`
37
+ "This is \`inline code\` and this is a code block:
38
+ \`\`\`
39
+ const x = \\\`template\\\`
40
+ \`\`\`
41
+ "
42
+ `)
43
+ })
44
+
45
+ test('handles multiple code blocks', () => {
46
+ const input = `First block:
47
+ \`\`\`js
48
+ const a = \`test\`
49
+ \`\`\`
50
+
51
+ Some text with \`inline\` code
52
+
53
+ Second block:
54
+ \`\`\`python
55
+ name = f\`hello {world}\`
56
+ \`\`\``
57
+
58
+ const result = escapeBackticksInCodeBlocks(input)
59
+
60
+ expect(result).toMatchInlineSnapshot(`
61
+ "First block:
62
+ \`\`\`js
63
+ const a = \\\`test\\\`
64
+ \`\`\`
65
+
66
+
67
+ Some text with \`inline\` code
68
+
69
+ Second block:
70
+ \`\`\`python
71
+ name = f\\\`hello {world}\\\`
72
+ \`\`\`
73
+ "
74
+ `)
75
+ })
76
+
77
+ test('handles code blocks without language', () => {
78
+ const input = '```\nconst x = `value`\n```'
79
+ const result = escapeBackticksInCodeBlocks(input)
80
+
81
+ expect(result).toMatchInlineSnapshot(`
82
+ "\`\`\`
83
+ const x = \\\`value\\\`
84
+ \`\`\`
85
+ "
86
+ `)
87
+ })
88
+
89
+ test('handles nested backticks in code blocks', () => {
90
+ const input = '```js\nconst nested = `outer ${`inner`} text`\n```'
91
+ const result = escapeBackticksInCodeBlocks(input)
92
+
93
+ expect(result).toMatchInlineSnapshot(`
94
+ "\`\`\`js
95
+ const nested = \\\`outer \${\\\`inner\\\`} text\\\`
96
+ \`\`\`
97
+ "
98
+ `)
99
+ })
100
+
101
+ test('preserves markdown outside code blocks', () => {
102
+ const input = `# Heading
103
+
104
+ This is **bold** and *italic* text
105
+
106
+ \`\`\`js
107
+ const code = \`with template\`
108
+ \`\`\`
109
+
110
+ - List item 1
111
+ - List item 2`
112
+
113
+ const result = escapeBackticksInCodeBlocks(input)
114
+
115
+ expect(result).toMatchInlineSnapshot(`
116
+ "# Heading
117
+
118
+ This is **bold** and *italic* text
119
+
120
+ \`\`\`js
121
+ const code = \\\`with template\\\`
122
+ \`\`\`
123
+
124
+
125
+ - List item 1
126
+ - List item 2"
127
+ `)
128
+ })
129
+
130
+ test('does not escape code block delimiter backticks', () => {
131
+ const input = '```js\nconst x = `hello`\n```'
132
+ const result = escapeBackticksInCodeBlocks(input)
133
+
134
+ expect(result.startsWith('```')).toBe(true)
135
+ expect(result.endsWith('```\n')).toBe(true)
136
+ expect(result).toContain('\\`hello\\`')
137
+ expect(result).not.toContain('\\`\\`\\`js')
138
+ expect(result).not.toContain('\\`\\`\\`\n')
139
+
140
+ expect(result).toMatchInlineSnapshot(`
141
+ "\`\`\`js
142
+ const x = \\\`hello\\\`
143
+ \`\`\`
144
+ "
145
+ `)
146
+ })
147
+
148
+ test('splitMarkdownForDiscord returns single chunk for short content', () => {
149
+ const result = splitMarkdownForDiscord({
150
+ content: 'Hello world',
151
+ maxLength: 100,
152
+ })
153
+ expect(result).toMatchInlineSnapshot(`
154
+ [
155
+ "Hello world",
156
+ ]
157
+ `)
158
+ })
159
+
160
+ test('splitMarkdownForDiscord splits at line boundaries', () => {
161
+ const result = splitMarkdownForDiscord({
162
+ content: 'Line 1\nLine 2\nLine 3\nLine 4',
163
+ maxLength: 15,
164
+ })
165
+ expect(result).toMatchInlineSnapshot(`
166
+ [
167
+ "Line 1
168
+ Line 2
169
+ ",
170
+ "Line 3
171
+ Line 4",
172
+ ]
173
+ `)
174
+ })
175
+
176
+ test('splitMarkdownForDiscord preserves code blocks when not split', () => {
177
+ const result = splitMarkdownForDiscord({
178
+ content: '```js\nconst x = 1\n```',
179
+ maxLength: 100,
180
+ })
181
+ expect(result).toMatchInlineSnapshot(`
182
+ [
183
+ "\`\`\`js
184
+ const x = 1
185
+ \`\`\`",
186
+ ]
187
+ `)
188
+ })
189
+
190
+ test('splitMarkdownForDiscord adds closing and opening fences when splitting code block', () => {
191
+ const result = splitMarkdownForDiscord({
192
+ content: '```js\nline1\nline2\nline3\nline4\n```',
193
+ maxLength: 20,
194
+ })
195
+ expect(result).toMatchInlineSnapshot(`
196
+ [
197
+ "\`\`\`js
198
+ line1
199
+ line2
200
+ \`\`\`
201
+ ",
202
+ "\`\`\`js
203
+ line3
204
+ line4
205
+ \`\`\`
206
+ ",
207
+ ]
208
+ `)
209
+ })
210
+
211
+ test('splitMarkdownForDiscord handles code block with language', () => {
212
+ const result = splitMarkdownForDiscord({
213
+ content: '```typescript\nconst a = 1\nconst b = 2\n```',
214
+ maxLength: 30,
215
+ })
216
+ expect(result).toMatchInlineSnapshot(`
217
+ [
218
+ "\`\`\`typescript
219
+ const a = 1
220
+ \`\`\`
221
+ ",
222
+ "\`\`\`typescript
223
+ const b = 2
224
+ \`\`\`
225
+ ",
226
+ ]
227
+ `)
228
+ })
229
+
230
+ test('splitMarkdownForDiscord handles mixed content with code blocks', () => {
231
+ const result = splitMarkdownForDiscord({
232
+ content: 'Text before\n```js\ncode\n```\nText after',
233
+ maxLength: 25,
234
+ })
235
+ expect(result).toMatchInlineSnapshot(`
236
+ [
237
+ "Text before
238
+ \`\`\`js
239
+ code
240
+ \`\`\`
241
+ ",
242
+ "Text after",
243
+ ]
244
+ `)
245
+ })
246
+
247
+ test('splitMarkdownForDiscord handles code block without language', () => {
248
+ const result = splitMarkdownForDiscord({
249
+ content: '```\nline1\nline2\n```',
250
+ maxLength: 12,
251
+ })
252
+ expect(result).toMatchInlineSnapshot(`
253
+ [
254
+ "\`\`\`
255
+ line1
256
+ \`\`\`
257
+ ",
258
+ "\`\`\`
259
+ line2
260
+ \`\`\`
261
+ ",
262
+ ]
263
+ `)
264
+ })
265
+
266
+ test('splitMarkdownForDiscord handles multiple consecutive code blocks', () => {
267
+ const result = splitMarkdownForDiscord({
268
+ content: '```js\nfoo\n```\n```py\nbar\n```',
269
+ maxLength: 20,
270
+ })
271
+ expect(result).toMatchInlineSnapshot(`
272
+ [
273
+ "\`\`\`js
274
+ foo
275
+ \`\`\`
276
+ \`\`\`py
277
+ \`\`\`
278
+ ",
279
+ "\`\`\`py
280
+ bar
281
+ \`\`\`
282
+ ",
283
+ ]
284
+ `)
285
+ })
286
+
287
+ test('splitMarkdownForDiscord handles empty code block', () => {
288
+ const result = splitMarkdownForDiscord({
289
+ content: 'before\n```\n```\nafter',
290
+ maxLength: 50,
291
+ })
292
+ expect(result).toMatchInlineSnapshot(`
293
+ [
294
+ "before
295
+ \`\`\`
296
+ \`\`\`
297
+ after",
298
+ ]
299
+ `)
300
+ })
301
+
302
+ test('splitMarkdownForDiscord handles content exactly at maxLength', () => {
303
+ const result = splitMarkdownForDiscord({
304
+ content: '12345678901234567890',
305
+ maxLength: 20,
306
+ })
307
+ expect(result).toMatchInlineSnapshot(`
308
+ [
309
+ "12345678901234567890",
310
+ ]
311
+ `)
312
+ })
313
+
314
+ test('splitMarkdownForDiscord handles code block only', () => {
315
+ const result = splitMarkdownForDiscord({
316
+ content: '```ts\nconst x = 1\n```',
317
+ maxLength: 15,
318
+ })
319
+ expect(result).toMatchInlineSnapshot(`
320
+ [
321
+ "\`\`\`ts
322
+ \`\`\`
323
+ ",
324
+ "\`\`\`ts
325
+ const x = 1
326
+ \`\`\`
327
+ ",
328
+ ]
329
+ `)
330
+ })
331
+
332
+ test('splitMarkdownForDiscord handles code block at start with text after', () => {
333
+ const result = splitMarkdownForDiscord({
334
+ content: '```js\ncode\n```\nSome text after',
335
+ maxLength: 20,
336
+ })
337
+ expect(result).toMatchInlineSnapshot(`
338
+ [
339
+ "\`\`\`js
340
+ code
341
+ \`\`\`
342
+ ",
343
+ "Some text after",
344
+ ]
345
+ `)
346
+ })
347
+
348
+ test('splitMarkdownForDiscord handles text before code block at end', () => {
349
+ const result = splitMarkdownForDiscord({
350
+ content: 'Some text before\n```js\ncode\n```',
351
+ maxLength: 25,
352
+ })
353
+ expect(result).toMatchInlineSnapshot(`
354
+ [
355
+ "Some text before
356
+ \`\`\`js
357
+ \`\`\`
358
+ ",
359
+ "\`\`\`js
360
+ code
361
+ \`\`\`
362
+ ",
363
+ ]
364
+ `)
365
+ })
366
+
367
+ test('splitMarkdownForDiscord handles very long line inside code block', () => {
368
+ const result = splitMarkdownForDiscord({
369
+ content: '```js\nshort\nveryverylonglinethatexceedsmaxlength\nshort\n```',
370
+ maxLength: 25,
371
+ })
372
+ expect(result).toMatchInlineSnapshot(`
373
+ [
374
+ "\`\`\`js
375
+ short
376
+ \`\`\`
377
+ ",
378
+ "\`\`\`js
379
+ veryverylonglinethatexceedsmaxlength
380
+ \`\`\`
381
+ ",
382
+ "\`\`\`js
383
+ short
384
+ \`\`\`
385
+ ",
386
+ ]
387
+ `)
388
+ })
389
+
390
+ test('splitMarkdownForDiscord handles realistic long markdown with code block', () => {
391
+ const content = `Here is some explanation text before the code.
392
+
393
+ \`\`\`typescript
394
+ export function calculateTotal(items: Item[]): number {
395
+ let total = 0
396
+ for (const item of items) {
397
+ total += item.price * item.quantity
398
+ }
399
+ return total
400
+ }
401
+
402
+ export function formatCurrency(amount: number): string {
403
+ return new Intl.NumberFormat('en-US', {
404
+ style: 'currency',
405
+ currency: 'USD',
406
+ }).format(amount)
407
+ }
408
+ \`\`\`
409
+
410
+ And here is some text after the code block.`
411
+
412
+ const result = splitMarkdownForDiscord({
413
+ content,
414
+ maxLength: 200,
415
+ })
416
+ expect(result).toMatchInlineSnapshot(`
417
+ [
418
+ "Here is some explanation text before the code.
419
+
420
+ \`\`\`typescript
421
+ export function calculateTotal(items: Item[]): number {
422
+ let total = 0
423
+ for (const item of items) {
424
+ \`\`\`
425
+ ",
426
+ "\`\`\`typescript
427
+ total += item.price * item.quantity
428
+ }
429
+ return total
430
+ }
431
+
432
+ export function formatCurrency(amount: number): string {
433
+ return new Intl.NumberFormat('en-US', {
434
+ style: 'currency',
435
+ \`\`\`
436
+ ",
437
+ "\`\`\`typescript
438
+ currency: 'USD',
439
+ }).format(amount)
440
+ }
441
+ \`\`\`
442
+
443
+
444
+ And here is some text after the code block.",
445
+ ]
446
+ `)
447
+ })