kimaki 0.4.9 → 0.4.12

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');
@@ -451,60 +495,20 @@ async function getOpenPort() {
451
495
  async function sendThreadMessage(thread, content) {
452
496
  const MAX_LENGTH = 2000;
453
497
  content = escapeBackticksInCodeBlocks(content);
454
- // Simple case: content fits in one message
455
- if (content.length <= MAX_LENGTH) {
456
- return await thread.send(content);
498
+ const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH });
499
+ if (chunks.length > 1) {
500
+ discordLogger.log(`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`);
457
501
  }
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
502
  let firstMessage;
501
503
  for (let i = 0; i < chunks.length; i++) {
502
504
  const chunk = chunks[i];
503
- if (!chunk)
505
+ if (!chunk) {
504
506
  continue;
507
+ }
505
508
  const message = await thread.send(chunk);
506
- if (i === 0)
509
+ if (i === 0) {
507
510
  firstMessage = message;
511
+ }
508
512
  }
509
513
  return firstMessage;
510
514
  }
@@ -621,6 +625,77 @@ export function escapeBackticksInCodeBlocks(markdown) {
621
625
  }
622
626
  return result;
623
627
  }
628
+ export function splitMarkdownForDiscord({ content, maxLength, }) {
629
+ if (content.length <= maxLength) {
630
+ return [content];
631
+ }
632
+ const lexer = new Lexer();
633
+ const tokens = lexer.lex(content);
634
+ const lines = [];
635
+ for (const token of tokens) {
636
+ if (token.type === 'code') {
637
+ const lang = token.lang || '';
638
+ lines.push({ text: '```' + lang + '\n', inCodeBlock: false, lang, isOpeningFence: true, isClosingFence: false });
639
+ const codeLines = token.text.split('\n');
640
+ for (const codeLine of codeLines) {
641
+ lines.push({ text: codeLine + '\n', inCodeBlock: true, lang, isOpeningFence: false, isClosingFence: false });
642
+ }
643
+ lines.push({ text: '```\n', inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: true });
644
+ }
645
+ else {
646
+ const rawLines = token.raw.split('\n');
647
+ for (let i = 0; i < rawLines.length; i++) {
648
+ const isLast = i === rawLines.length - 1;
649
+ const text = isLast ? rawLines[i] : rawLines[i] + '\n';
650
+ if (text) {
651
+ lines.push({ text, inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: false });
652
+ }
653
+ }
654
+ }
655
+ }
656
+ const chunks = [];
657
+ let currentChunk = '';
658
+ let currentLang = null;
659
+ for (const line of lines) {
660
+ const wouldExceed = currentChunk.length + line.text.length > maxLength;
661
+ if (wouldExceed && currentChunk) {
662
+ if (currentLang !== null) {
663
+ currentChunk += '```\n';
664
+ }
665
+ chunks.push(currentChunk);
666
+ if (line.isClosingFence && currentLang !== null) {
667
+ currentChunk = '';
668
+ currentLang = null;
669
+ continue;
670
+ }
671
+ if (line.inCodeBlock || line.isOpeningFence) {
672
+ const lang = line.lang;
673
+ currentChunk = '```' + lang + '\n';
674
+ if (!line.isOpeningFence) {
675
+ currentChunk += line.text;
676
+ }
677
+ currentLang = lang;
678
+ }
679
+ else {
680
+ currentChunk = line.text;
681
+ currentLang = null;
682
+ }
683
+ }
684
+ else {
685
+ currentChunk += line.text;
686
+ if (line.inCodeBlock || line.isOpeningFence) {
687
+ currentLang = line.lang;
688
+ }
689
+ else if (line.isClosingFence) {
690
+ currentLang = null;
691
+ }
692
+ }
693
+ }
694
+ if (currentChunk) {
695
+ chunks.push(currentChunk);
696
+ }
697
+ return chunks;
698
+ }
624
699
  /**
625
700
  * Escape Discord formatting characters to prevent breaking code blocks and inline code
626
701
  */
@@ -1265,6 +1340,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1265
1340
  path: { id: session.id },
1266
1341
  body: {
1267
1342
  parts,
1343
+ system: OPENCODE_SYSTEM_MESSAGE,
1268
1344
  },
1269
1345
  signal: abortController.signal,
1270
1346
  });
@@ -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
+ });
@@ -25,6 +25,9 @@ export const KimakiDiscordPlugin = async ({ client, $, directory, }) => {
25
25
  });
26
26
  return;
27
27
  }
28
+ await client.tui.showToast({
29
+ body: { message: 'Creating Discord thread...', variant: 'info' },
30
+ });
28
31
  try {
29
32
  const result = await $ `npx -y kimaki send-to-discord ${sessionID} -d ${directory}`.text();
30
33
  const urlMatch = result.match(/https:\/\/discord\.com\/channels\/\S+/);
package/dist/tools.js CHANGED
@@ -8,7 +8,7 @@ const toolsLogger = createLogger('TOOLS');
8
8
  import { formatDistanceToNow } from 'date-fns';
9
9
  import { ShareMarkdown } from './markdown.js';
10
10
  import pc from 'picocolors';
11
- import { initializeOpencodeForDirectory } from './discordBot.js';
11
+ import { initializeOpencodeForDirectory, OPENCODE_SYSTEM_MESSAGE, } from './discordBot.js';
12
12
  export async function getTools({ onMessageCompleted, directory, }) {
13
13
  const getClient = await initializeOpencodeForDirectory(directory);
14
14
  const client = getClient();
@@ -48,6 +48,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
48
48
  body: {
49
49
  parts: [{ type: 'text', text: message }],
50
50
  model: sessionModel,
51
+ system: OPENCODE_SYSTEM_MESSAGE,
51
52
  },
52
53
  })
53
54
  .then(async (response) => {
@@ -114,7 +115,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
114
115
  path: { id: session.data.id },
115
116
  body: {
116
117
  parts: [{ type: 'text', text: message }],
117
- // model,
118
+ system: OPENCODE_SYSTEM_MESSAGE,
118
119
  },
119
120
  })
120
121
  .then(async (response) => {
package/dist/voice.js CHANGED
@@ -28,7 +28,17 @@ export async function transcribeAudio({ audio, prompt, language, temperature, ge
28
28
  throw new Error('Invalid audio format');
29
29
  }
30
30
  // Build the transcription prompt
31
- let transcriptionPrompt = `Please transcribe this audio file accurately. Here is some relevant information and filenames that may be present in the audio:\n<context>\n${prompt}\n</context>\n`;
31
+ let transcriptionPrompt = `Transcribe this audio accurately. The transcription will be sent to a coding agent (like Claude Code) to execute programming tasks.
32
+
33
+ Assume the speaker is using technical and programming terminology: file paths, function names, CLI commands, package names, API names, programming concepts, etc. Prioritize technical accuracy over literal transcription - if a word sounds like a common programming term, prefer that interpretation.
34
+
35
+ If the spoken message is unclear or ambiguous, rephrase it to better convey the intended meaning for a coding agent. The goal is effective communication of the user's programming intent, not a word-for-word transcription.
36
+
37
+ Here are relevant filenames and context that may appear in the audio:
38
+ <context>
39
+ ${prompt}
40
+ </context>
41
+ `;
32
42
  if (language) {
33
43
  transcriptionPrompt += `\nThe audio is in ${language}.`;
34
44
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.9",
5
+ "version": "0.4.12",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
package/src/discordBot.ts CHANGED
@@ -54,6 +54,51 @@ import { setGlobalDispatcher, Agent } from 'undici'
54
54
  // disables the automatic 5 minutes abort after no body
55
55
  setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }))
56
56
 
57
+ export const OPENCODE_SYSTEM_MESSAGE = `
58
+ The user is reading your messages from inside Discord, via kimaki.xyz
59
+
60
+ 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:
61
+
62
+ bunx critique web
63
+
64
+ you can also show latest commit changes using
65
+
66
+ bunx critique web HEAD~1
67
+
68
+ do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
69
+
70
+ ## markdown
71
+
72
+ discord does support basic markdown features like code blocks, code blocks languages, inline code, bold, italic, quotes, etc.
73
+
74
+ the max heading level is 3, so do not use ####
75
+
76
+ headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
77
+
78
+ ## tables
79
+
80
+ discord does NOT support markdown gfm tables.
81
+
82
+ so instead of using full markdown tables ALWAYS show code snippets with space aligned cells:
83
+
84
+ \`\`\`
85
+ Item Qty Price
86
+ ---------- --- -----
87
+ Apples 10 $5
88
+ Oranges 3 $2
89
+ \`\`\`
90
+
91
+ Using code blocks will make the content use monospaced font so that space will be aligned correctly
92
+
93
+ 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
94
+
95
+ code blocks for tables and diagrams MUST have Max length of 85 characters. otherwise the content will wrap
96
+
97
+ ## diagrams
98
+
99
+ you can create diagrams wrapping them in code blocks too.
100
+ `
101
+
57
102
  const discordLogger = createLogger('DISCORD')
58
103
  const voiceLogger = createLogger('VOICE')
59
104
  const opencodeLogger = createLogger('OPENCODE')
@@ -629,69 +674,24 @@ async function sendThreadMessage(
629
674
 
630
675
  content = escapeBackticksInCodeBlocks(content)
631
676
 
632
- // Simple case: content fits in one message
633
- if (content.length <= MAX_LENGTH) {
634
- return await thread.send(content)
635
- }
636
-
637
- // Use marked's lexer to tokenize markdown content
638
- const lexer = new Lexer()
639
- const tokens = lexer.lex(content)
677
+ const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH })
640
678
 
641
- const chunks: string[] = []
642
- let currentChunk = ''
643
-
644
- // Process each token and add to chunks
645
- for (const token of tokens) {
646
- const tokenText = token.raw || ''
647
-
648
- // If adding this token would exceed limit and we have content, flush current chunk
649
- if (currentChunk && currentChunk.length + tokenText.length > MAX_LENGTH) {
650
- chunks.push(currentChunk)
651
- currentChunk = ''
652
- }
653
-
654
- // If this single token is longer than MAX_LENGTH, split it
655
- if (tokenText.length > MAX_LENGTH) {
656
- if (currentChunk) {
657
- chunks.push(currentChunk)
658
- currentChunk = ''
659
- }
660
-
661
- let remainingText = tokenText
662
- while (remainingText.length > MAX_LENGTH) {
663
- // Try to split at a newline if possible
664
- let splitIndex = MAX_LENGTH
665
- const newlineIndex = remainingText.lastIndexOf('\n', MAX_LENGTH - 1)
666
- if (newlineIndex > MAX_LENGTH * 0.7) {
667
- splitIndex = newlineIndex + 1
668
- }
669
-
670
- chunks.push(remainingText.slice(0, splitIndex))
671
- remainingText = remainingText.slice(splitIndex)
672
- }
673
- currentChunk = remainingText
674
- } else {
675
- currentChunk += tokenText
676
- }
677
- }
678
-
679
- // Add any remaining content
680
- if (currentChunk) {
681
- chunks.push(currentChunk)
679
+ if (chunks.length > 1) {
680
+ discordLogger.log(
681
+ `MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`,
682
+ )
682
683
  }
683
684
 
684
- // Send all chunks
685
- discordLogger.log(
686
- `MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`,
687
- )
688
-
689
685
  let firstMessage: Message | undefined
690
686
  for (let i = 0; i < chunks.length; i++) {
691
687
  const chunk = chunks[i]
692
- if (!chunk) continue
688
+ if (!chunk) {
689
+ continue
690
+ }
693
691
  const message = await thread.send(chunk)
694
- if (i === 0) firstMessage = message
692
+ if (i === 0) {
693
+ firstMessage = message
694
+ }
695
695
  }
696
696
 
697
697
  return firstMessage!
@@ -852,6 +852,97 @@ export function escapeBackticksInCodeBlocks(markdown: string): string {
852
852
  return result
853
853
  }
854
854
 
855
+ type LineInfo = {
856
+ text: string
857
+ inCodeBlock: boolean
858
+ lang: string
859
+ isOpeningFence: boolean
860
+ isClosingFence: boolean
861
+ }
862
+
863
+ export function splitMarkdownForDiscord({
864
+ content,
865
+ maxLength,
866
+ }: {
867
+ content: string
868
+ maxLength: number
869
+ }): string[] {
870
+ if (content.length <= maxLength) {
871
+ return [content]
872
+ }
873
+
874
+ const lexer = new Lexer()
875
+ const tokens = lexer.lex(content)
876
+
877
+ const lines: LineInfo[] = []
878
+ for (const token of tokens) {
879
+ if (token.type === 'code') {
880
+ const lang = token.lang || ''
881
+ lines.push({ text: '```' + lang + '\n', inCodeBlock: false, lang, isOpeningFence: true, isClosingFence: false })
882
+ const codeLines = token.text.split('\n')
883
+ for (const codeLine of codeLines) {
884
+ lines.push({ text: codeLine + '\n', inCodeBlock: true, lang, isOpeningFence: false, isClosingFence: false })
885
+ }
886
+ lines.push({ text: '```\n', inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: true })
887
+ } else {
888
+ const rawLines = token.raw.split('\n')
889
+ for (let i = 0; i < rawLines.length; i++) {
890
+ const isLast = i === rawLines.length - 1
891
+ const text = isLast ? rawLines[i]! : rawLines[i]! + '\n'
892
+ if (text) {
893
+ lines.push({ text, inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: false })
894
+ }
895
+ }
896
+ }
897
+ }
898
+
899
+ const chunks: string[] = []
900
+ let currentChunk = ''
901
+ let currentLang: string | null = null
902
+
903
+ for (const line of lines) {
904
+ const wouldExceed = currentChunk.length + line.text.length > maxLength
905
+
906
+ if (wouldExceed && currentChunk) {
907
+ if (currentLang !== null) {
908
+ currentChunk += '```\n'
909
+ }
910
+ chunks.push(currentChunk)
911
+
912
+ if (line.isClosingFence && currentLang !== null) {
913
+ currentChunk = ''
914
+ currentLang = null
915
+ continue
916
+ }
917
+
918
+ if (line.inCodeBlock || line.isOpeningFence) {
919
+ const lang = line.lang
920
+ currentChunk = '```' + lang + '\n'
921
+ if (!line.isOpeningFence) {
922
+ currentChunk += line.text
923
+ }
924
+ currentLang = lang
925
+ } else {
926
+ currentChunk = line.text
927
+ currentLang = null
928
+ }
929
+ } else {
930
+ currentChunk += line.text
931
+ if (line.inCodeBlock || line.isOpeningFence) {
932
+ currentLang = line.lang
933
+ } else if (line.isClosingFence) {
934
+ currentLang = null
935
+ }
936
+ }
937
+ }
938
+
939
+ if (currentChunk) {
940
+ chunks.push(currentChunk)
941
+ }
942
+
943
+ return chunks
944
+ }
945
+
855
946
  /**
856
947
  * Escape Discord formatting characters to prevent breaking code blocks and inline code
857
948
  */
@@ -1687,6 +1778,7 @@ async function handleOpencodeSession({
1687
1778
  path: { id: session.id },
1688
1779
  body: {
1689
1780
  parts,
1781
+ system: OPENCODE_SYSTEM_MESSAGE,
1690
1782
  },
1691
1783
  signal: abortController.signal,
1692
1784
  })
@@ -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
 
5
5
 
6
6
 
@@ -144,3 +144,304 @@ const x = \\\`hello\\\`
144
144
  "
145
145
  `)
146
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
+ })
@@ -1,4 +1,4 @@
1
1
  ---
2
- description: Send current session to Kimaki Discord
2
+ description: Create Discord thread for current session
3
3
  ---
4
- say "Session sent to discord!"
4
+ Creating Discord thread for this session...
@@ -39,6 +39,10 @@ export const KimakiDiscordPlugin: Plugin = async ({
39
39
  return
40
40
  }
41
41
 
42
+ await client.tui.showToast({
43
+ body: { message: 'Creating Discord thread...', variant: 'info' },
44
+ })
45
+
42
46
  try {
43
47
  const result =
44
48
  await $`npx -y kimaki send-to-discord ${sessionID} -d ${directory}`.text()
package/src/tools.ts CHANGED
@@ -15,7 +15,10 @@ import { formatDistanceToNow } from 'date-fns'
15
15
 
16
16
  import { ShareMarkdown } from './markdown.js'
17
17
  import pc from 'picocolors'
18
- import { initializeOpencodeForDirectory } from './discordBot.js'
18
+ import {
19
+ initializeOpencodeForDirectory,
20
+ OPENCODE_SYSTEM_MESSAGE,
21
+ } from './discordBot.js'
19
22
 
20
23
  export async function getTools({
21
24
  onMessageCompleted,
@@ -72,10 +75,10 @@ export async function getTools({
72
75
  getClient()
73
76
  .session.prompt({
74
77
  path: { id: sessionId },
75
-
76
78
  body: {
77
79
  parts: [{ type: 'text', text: message }],
78
80
  model: sessionModel,
81
+ system: OPENCODE_SYSTEM_MESSAGE,
79
82
  },
80
83
  })
81
84
  .then(async (response) => {
@@ -149,7 +152,7 @@ export async function getTools({
149
152
  path: { id: session.data.id },
150
153
  body: {
151
154
  parts: [{ type: 'text', text: message }],
152
- // model,
155
+ system: OPENCODE_SYSTEM_MESSAGE,
153
156
  },
154
157
  })
155
158
  .then(async (response) => {
package/src/voice.ts CHANGED
@@ -42,7 +42,17 @@ export async function transcribeAudio({
42
42
  }
43
43
 
44
44
  // Build the transcription prompt
45
- let transcriptionPrompt = `Please transcribe this audio file accurately. Here is some relevant information and filenames that may be present in the audio:\n<context>\n${prompt}\n</context>\n`
45
+ let transcriptionPrompt = `Transcribe this audio accurately. The transcription will be sent to a coding agent (like Claude Code) to execute programming tasks.
46
+
47
+ Assume the speaker is using technical and programming terminology: file paths, function names, CLI commands, package names, API names, programming concepts, etc. Prioritize technical accuracy over literal transcription - if a word sounds like a common programming term, prefer that interpretation.
48
+
49
+ If the spoken message is unclear or ambiguous, rephrase it to better convey the intended meaning for a coding agent. The goal is effective communication of the user's programming intent, not a word-for-word transcription.
50
+
51
+ Here are relevant filenames and context that may appear in the audio:
52
+ <context>
53
+ ${prompt}
54
+ </context>
55
+ `
46
56
  if (language) {
47
57
  transcriptionPrompt += `\nThe audio is in ${language}.`
48
58
  }