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.
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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.10",
5
+ "version": "0.4.13",
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')
@@ -168,11 +213,13 @@ async function setupVoiceHandling({
168
213
  guildId,
169
214
  channelId,
170
215
  appId,
216
+ discordClient,
171
217
  }: {
172
218
  connection: VoiceConnection
173
219
  guildId: string
174
220
  channelId: string
175
221
  appId: string
222
+ discordClient: Client
176
223
  }) {
177
224
  voiceLogger.log(
178
225
  `Setting up voice handling for guild ${guildId}, channel ${channelId}`,
@@ -285,8 +332,28 @@ async function setupVoiceHandling({
285
332
 
286
333
  genAiWorker.sendTextInput(text)
287
334
  },
288
- onError(error) {
335
+ async onError(error) {
289
336
  voiceLogger.error('GenAI worker error:', error)
337
+ const textChannelRow = getDatabase()
338
+ .prepare(
339
+ `SELECT cd2.channel_id FROM channel_directories cd1
340
+ JOIN channel_directories cd2 ON cd1.directory = cd2.directory
341
+ WHERE cd1.channel_id = ? AND cd1.channel_type = 'voice' AND cd2.channel_type = 'text'`,
342
+ )
343
+ .get(channelId) as { channel_id: string } | undefined
344
+
345
+ if (textChannelRow) {
346
+ try {
347
+ const textChannel = await discordClient.channels.fetch(
348
+ textChannelRow.channel_id,
349
+ )
350
+ if (textChannel?.isTextBased() && 'send' in textChannel) {
351
+ await textChannel.send(`⚠️ Voice session error: ${error}`)
352
+ }
353
+ } catch (e) {
354
+ voiceLogger.error('Failed to send error to text channel:', e)
355
+ }
356
+ }
290
357
  },
291
358
  })
292
359
 
@@ -629,69 +696,24 @@ async function sendThreadMessage(
629
696
 
630
697
  content = escapeBackticksInCodeBlocks(content)
631
698
 
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)
640
-
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 || ''
699
+ const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH })
647
700
 
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)
701
+ if (chunks.length > 1) {
702
+ discordLogger.log(
703
+ `MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`,
704
+ )
682
705
  }
683
706
 
684
- // Send all chunks
685
- discordLogger.log(
686
- `MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`,
687
- )
688
-
689
707
  let firstMessage: Message | undefined
690
708
  for (let i = 0; i < chunks.length; i++) {
691
709
  const chunk = chunks[i]
692
- if (!chunk) continue
710
+ if (!chunk) {
711
+ continue
712
+ }
693
713
  const message = await thread.send(chunk)
694
- if (i === 0) firstMessage = message
714
+ if (i === 0) {
715
+ firstMessage = message
716
+ }
695
717
  }
696
718
 
697
719
  return firstMessage!
@@ -852,6 +874,97 @@ export function escapeBackticksInCodeBlocks(markdown: string): string {
852
874
  return result
853
875
  }
854
876
 
877
+ type LineInfo = {
878
+ text: string
879
+ inCodeBlock: boolean
880
+ lang: string
881
+ isOpeningFence: boolean
882
+ isClosingFence: boolean
883
+ }
884
+
885
+ export function splitMarkdownForDiscord({
886
+ content,
887
+ maxLength,
888
+ }: {
889
+ content: string
890
+ maxLength: number
891
+ }): string[] {
892
+ if (content.length <= maxLength) {
893
+ return [content]
894
+ }
895
+
896
+ const lexer = new Lexer()
897
+ const tokens = lexer.lex(content)
898
+
899
+ const lines: LineInfo[] = []
900
+ for (const token of tokens) {
901
+ if (token.type === 'code') {
902
+ const lang = token.lang || ''
903
+ lines.push({ text: '```' + lang + '\n', inCodeBlock: false, lang, isOpeningFence: true, isClosingFence: false })
904
+ const codeLines = token.text.split('\n')
905
+ for (const codeLine of codeLines) {
906
+ lines.push({ text: codeLine + '\n', inCodeBlock: true, lang, isOpeningFence: false, isClosingFence: false })
907
+ }
908
+ lines.push({ text: '```\n', inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: true })
909
+ } else {
910
+ const rawLines = token.raw.split('\n')
911
+ for (let i = 0; i < rawLines.length; i++) {
912
+ const isLast = i === rawLines.length - 1
913
+ const text = isLast ? rawLines[i]! : rawLines[i]! + '\n'
914
+ if (text) {
915
+ lines.push({ text, inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: false })
916
+ }
917
+ }
918
+ }
919
+ }
920
+
921
+ const chunks: string[] = []
922
+ let currentChunk = ''
923
+ let currentLang: string | null = null
924
+
925
+ for (const line of lines) {
926
+ const wouldExceed = currentChunk.length + line.text.length > maxLength
927
+
928
+ if (wouldExceed && currentChunk) {
929
+ if (currentLang !== null) {
930
+ currentChunk += '```\n'
931
+ }
932
+ chunks.push(currentChunk)
933
+
934
+ if (line.isClosingFence && currentLang !== null) {
935
+ currentChunk = ''
936
+ currentLang = null
937
+ continue
938
+ }
939
+
940
+ if (line.inCodeBlock || line.isOpeningFence) {
941
+ const lang = line.lang
942
+ currentChunk = '```' + lang + '\n'
943
+ if (!line.isOpeningFence) {
944
+ currentChunk += line.text
945
+ }
946
+ currentLang = lang
947
+ } else {
948
+ currentChunk = line.text
949
+ currentLang = null
950
+ }
951
+ } else {
952
+ currentChunk += line.text
953
+ if (line.inCodeBlock || line.isOpeningFence) {
954
+ currentLang = line.lang
955
+ } else if (line.isClosingFence) {
956
+ currentLang = null
957
+ }
958
+ }
959
+ }
960
+
961
+ if (currentChunk) {
962
+ chunks.push(currentChunk)
963
+ }
964
+
965
+ return chunks
966
+ }
967
+
855
968
  /**
856
969
  * Escape Discord formatting characters to prevent breaking code blocks and inline code
857
970
  */
@@ -1121,30 +1234,31 @@ function getToolOutputToDisplay(part: Part): string {
1121
1234
  return part.state.error || 'Unknown error'
1122
1235
  }
1123
1236
 
1124
- if (part.tool === 'todowrite') {
1125
- const todos =
1126
- (part.state.input?.todos as {
1127
- content: string
1128
- status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
1129
- }[]) || []
1130
- return todos
1131
- .map((todo) => {
1132
- let statusIcon = '▢'
1133
- if (todo.status === 'in_progress') {
1134
- statusIcon = '●'
1135
- }
1136
- if (todo.status === 'completed' || todo.status === 'cancelled') {
1137
- statusIcon = '■'
1138
- }
1139
- return `\`${statusIcon}\` ${todo.content}`
1140
- })
1141
- .filter(Boolean)
1142
- .join('\n')
1143
- }
1144
-
1145
1237
  return ''
1146
1238
  }
1147
1239
 
1240
+ function formatTodoList(part: Part): string {
1241
+ if (part.type !== 'tool' || part.tool !== 'todowrite') return ''
1242
+ const todos =
1243
+ (part.state.input?.todos as {
1244
+ content: string
1245
+ status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
1246
+ }[]) || []
1247
+ if (todos.length === 0) return ''
1248
+ return todos
1249
+ .map((todo, i) => {
1250
+ const num = `${i + 1}.`
1251
+ if (todo.status === 'in_progress') {
1252
+ return `${num} **${todo.content}**`
1253
+ }
1254
+ if (todo.status === 'completed' || todo.status === 'cancelled') {
1255
+ return `${num} ~~${todo.content}~~`
1256
+ }
1257
+ return `${num} ${todo.content}`
1258
+ })
1259
+ .join('\n')
1260
+ }
1261
+
1148
1262
  function formatPart(part: Part): string {
1149
1263
  if (part.type === 'text') {
1150
1264
  return part.text || ''
@@ -1172,6 +1286,10 @@ function formatPart(part: Part): string {
1172
1286
  }
1173
1287
 
1174
1288
  if (part.type === 'tool') {
1289
+ if (part.tool === 'todowrite') {
1290
+ return formatTodoList(part)
1291
+ }
1292
+
1175
1293
  if (part.state.status !== 'completed' && part.state.status !== 'error') {
1176
1294
  return ''
1177
1295
  }
@@ -1179,9 +1297,20 @@ function formatPart(part: Part): string {
1179
1297
  const summaryText = getToolSummaryText(part)
1180
1298
  const outputToDisplay = getToolOutputToDisplay(part)
1181
1299
 
1182
- let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error'
1183
- if (toolTitle) {
1184
- toolTitle = `*${toolTitle}*`
1300
+ let toolTitle = ''
1301
+ if (part.state.status === 'error') {
1302
+ toolTitle = 'error'
1303
+ } else if (part.tool === 'bash') {
1304
+ const command = (part.state.input?.command as string) || ''
1305
+ const isSingleLine = !command.includes('\n')
1306
+ const hasBackticks = command.includes('`')
1307
+ if (isSingleLine && command.length <= 120 && !hasBackticks) {
1308
+ toolTitle = `\`${command}\``
1309
+ } else {
1310
+ toolTitle = part.state.title ? `*${part.state.title}*` : ''
1311
+ }
1312
+ } else if (part.state.title) {
1313
+ toolTitle = `*${part.state.title}*`
1185
1314
  }
1186
1315
 
1187
1316
  const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : ''
@@ -1687,6 +1816,7 @@ async function handleOpencodeSession({
1687
1816
  path: { id: session.id },
1688
1817
  body: {
1689
1818
  parts,
1819
+ system: OPENCODE_SYSTEM_MESSAGE,
1690
1820
  },
1691
1821
  signal: abortController.signal,
1692
1822
  })
@@ -2508,68 +2638,56 @@ export async function startDiscordBot({
2508
2638
  `📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`,
2509
2639
  )
2510
2640
 
2511
- // Render all existing messages
2512
- let messageCount = 0
2641
+ // Collect all assistant parts first, then only render the last 30
2642
+ const allAssistantParts: { id: string; content: string }[] = []
2513
2643
  for (const message of messages) {
2514
- if (message.info.role === 'user') {
2515
- // Render user messages
2516
- const userParts = message.parts.filter(
2517
- (p) => p.type === 'text' && !p.synthetic,
2518
- )
2519
- const userTexts = userParts
2520
- .map((p) => {
2521
- if (p.type === 'text') {
2522
- return p.text
2523
- }
2524
- return ''
2525
- })
2526
- .filter((t) => t.trim())
2527
-
2528
- const userText = userTexts.join('\n\n')
2529
- if (userText) {
2530
- // Escape backticks in user messages to prevent formatting issues
2531
- const escapedText = escapeDiscordFormatting(userText)
2532
- await sendThreadMessage(thread, `**User:**\n${escapedText}`)
2533
- }
2534
- } else if (message.info.role === 'assistant') {
2535
- // Render assistant parts
2536
- const partsToRender: { id: string; content: string }[] = []
2537
-
2644
+ if (message.info.role === 'assistant') {
2538
2645
  for (const part of message.parts) {
2539
2646
  const content = formatPart(part)
2540
2647
  if (content.trim()) {
2541
- partsToRender.push({ id: part.id, content })
2648
+ allAssistantParts.push({ id: part.id, content })
2542
2649
  }
2543
2650
  }
2651
+ }
2652
+ }
2544
2653
 
2545
- if (partsToRender.length > 0) {
2546
- const combinedContent = partsToRender
2547
- .map((p) => p.content)
2548
- .join('\n\n')
2654
+ const partsToRender = allAssistantParts.slice(-30)
2655
+ const skippedCount = allAssistantParts.length - partsToRender.length
2549
2656
 
2550
- const discordMessage = await sendThreadMessage(
2551
- thread,
2552
- combinedContent,
2553
- )
2657
+ if (skippedCount > 0) {
2658
+ await sendThreadMessage(
2659
+ thread,
2660
+ `*Skipped ${skippedCount} older assistant parts...*`,
2661
+ )
2662
+ }
2554
2663
 
2555
- const stmt = getDatabase().prepare(
2556
- 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
2557
- )
2664
+ if (partsToRender.length > 0) {
2665
+ const combinedContent = partsToRender
2666
+ .map((p) => p.content)
2667
+ .join('\n\n')
2558
2668
 
2559
- const transaction = getDatabase().transaction(
2560
- (parts: { id: string }[]) => {
2561
- for (const part of parts) {
2562
- stmt.run(part.id, discordMessage.id, thread.id)
2563
- }
2564
- },
2565
- )
2669
+ const discordMessage = await sendThreadMessage(
2670
+ thread,
2671
+ combinedContent,
2672
+ )
2566
2673
 
2567
- transaction(partsToRender)
2568
- }
2569
- }
2570
- messageCount++
2674
+ const stmt = getDatabase().prepare(
2675
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
2676
+ )
2677
+
2678
+ const transaction = getDatabase().transaction(
2679
+ (parts: { id: string }[]) => {
2680
+ for (const part of parts) {
2681
+ stmt.run(part.id, discordMessage.id, thread.id)
2682
+ }
2683
+ },
2684
+ )
2685
+
2686
+ transaction(partsToRender)
2571
2687
  }
2572
2688
 
2689
+ const messageCount = messages.length
2690
+
2573
2691
  await sendThreadMessage(
2574
2692
  thread,
2575
2693
  `✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`,
@@ -3009,6 +3127,7 @@ export async function startDiscordBot({
3009
3127
  guildId: newState.guild.id,
3010
3128
  channelId: voiceChannel.id,
3011
3129
  appId: currentAppId!,
3130
+ discordClient,
3012
3131
  })
3013
3132
 
3014
3133
  // Handle connection state changes