kimaki 0.4.4 → 0.4.6

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/cli.js CHANGED
@@ -7,10 +7,23 @@ import { Events, ChannelType, REST, Routes, SlashCommandBuilder, } from 'discord
7
7
  import path from 'node:path';
8
8
  import fs from 'node:fs';
9
9
  import { createLogger } from './logger.js';
10
- import { spawnSync, execSync } from 'node:child_process';
10
+ import { spawn, spawnSync, execSync } from 'node:child_process';
11
11
  const cliLogger = createLogger('CLI');
12
12
  const cli = cac('kimaki');
13
13
  process.title = 'kimaki';
14
+ process.on('SIGUSR2', () => {
15
+ cliLogger.info('Received SIGUSR2, restarting process in 1000ms...');
16
+ setTimeout(() => {
17
+ cliLogger.info('Restarting...');
18
+ spawn(process.argv[0], [...process.execArgv, ...process.argv.slice(1)], {
19
+ stdio: 'inherit',
20
+ detached: true,
21
+ cwd: process.cwd(),
22
+ env: process.env,
23
+ }).unref();
24
+ process.exit(0);
25
+ }, 1000);
26
+ });
14
27
  const EXIT_NO_RESTART = 64;
15
28
  async function registerCommands(token, appId) {
16
29
  const commands = [
@@ -595,6 +595,15 @@ async function processVoiceAttachment({ message, thread, projectDirectory, isNew
595
595
  await sendThreadMessage(thread, `📝 **Transcribed message:** ${escapeDiscordFormatting(transcription)}`);
596
596
  return transcription;
597
597
  }
598
+ function getImageAttachments(message) {
599
+ const imageAttachments = Array.from(message.attachments.values()).filter((attachment) => attachment.contentType?.startsWith('image/'));
600
+ return imageAttachments.map((attachment) => ({
601
+ type: 'file',
602
+ mime: attachment.contentType || 'image/png',
603
+ filename: attachment.name,
604
+ url: attachment.url,
605
+ }));
606
+ }
598
607
  export function escapeBackticksInCodeBlocks(markdown) {
599
608
  const lexer = new Lexer();
600
609
  const tokens = lexer.lex(markdown);
@@ -873,7 +882,7 @@ function formatPart(part) {
873
882
  const outputToDisplay = getToolOutputToDisplay(part);
874
883
  let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error';
875
884
  if (toolTitle) {
876
- toolTitle = `\`${escapeInlineCode(toolTitle)}\``;
885
+ toolTitle = `*${toolTitle}*`;
877
886
  }
878
887
  const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : '';
879
888
  const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
@@ -901,7 +910,7 @@ export async function createDiscordClient() {
901
910
  ],
902
911
  });
903
912
  }
904
- async function handleOpencodeSession(prompt, thread, projectDirectory, originalMessage) {
913
+ async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], }) {
905
914
  voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
906
915
  // Track session start time
907
916
  const sessionStartTime = Date.now();
@@ -1211,12 +1220,17 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1211
1220
  };
1212
1221
  try {
1213
1222
  voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
1223
+ if (images.length > 0) {
1224
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
1225
+ }
1214
1226
  // Start the event handler
1215
1227
  const eventHandlerPromise = eventHandler();
1228
+ const parts = [{ type: 'text', text: prompt }, ...images];
1229
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
1216
1230
  const response = await getClient().session.prompt({
1217
1231
  path: { id: session.id },
1218
1232
  body: {
1219
- parts: [{ type: 'text', text: prompt }],
1233
+ parts,
1220
1234
  },
1221
1235
  signal: abortController.signal,
1222
1236
  });
@@ -1407,7 +1421,14 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1407
1421
  if (transcription) {
1408
1422
  messageContent = transcription;
1409
1423
  }
1410
- await handleOpencodeSession(messageContent, thread, projectDirectory, message);
1424
+ const images = getImageAttachments(message);
1425
+ await handleOpencodeSession({
1426
+ prompt: messageContent,
1427
+ thread,
1428
+ projectDirectory,
1429
+ originalMessage: message,
1430
+ images,
1431
+ });
1411
1432
  return;
1412
1433
  }
1413
1434
  // For text channels, start new sessions with kimaki.directory tag
@@ -1466,7 +1487,14 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1466
1487
  if (transcription) {
1467
1488
  messageContent = transcription;
1468
1489
  }
1469
- await handleOpencodeSession(messageContent, thread, projectDirectory, message);
1490
+ const images = getImageAttachments(message);
1491
+ await handleOpencodeSession({
1492
+ prompt: messageContent,
1493
+ thread,
1494
+ projectDirectory,
1495
+ originalMessage: message,
1496
+ images,
1497
+ });
1470
1498
  }
1471
1499
  else {
1472
1500
  discordLogger.log(`Channel type ${channel.type} is not supported`);
@@ -1721,7 +1749,11 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1721
1749
  });
1722
1750
  await command.editReply(`Created new session in ${thread.toString()}`);
1723
1751
  // Start the OpenCode session
1724
- await handleOpencodeSession(fullPrompt, thread, projectDirectory);
1752
+ await handleOpencodeSession({
1753
+ prompt: fullPrompt,
1754
+ thread,
1755
+ projectDirectory,
1756
+ });
1725
1757
  }
1726
1758
  catch (error) {
1727
1759
  voiceLogger.error('[SESSION] Error:', error);
@@ -1818,16 +1850,26 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1818
1850
  }
1819
1851
  else if (message.info.role === 'assistant') {
1820
1852
  // Render assistant parts
1853
+ const partsToRender = [];
1821
1854
  for (const part of message.parts) {
1822
1855
  const content = formatPart(part);
1823
1856
  if (content.trim()) {
1824
- const discordMessage = await sendThreadMessage(thread, content);
1825
- // Store part-message mapping in database
1826
- getDatabase()
1827
- .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
1828
- .run(part.id, discordMessage.id, thread.id);
1857
+ partsToRender.push({ id: part.id, content });
1829
1858
  }
1830
1859
  }
1860
+ if (partsToRender.length > 0) {
1861
+ const combinedContent = partsToRender
1862
+ .map((p) => p.content)
1863
+ .join('\n\n');
1864
+ const discordMessage = await sendThreadMessage(thread, combinedContent);
1865
+ const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
1866
+ const transaction = getDatabase().transaction((parts) => {
1867
+ for (const part of parts) {
1868
+ stmt.run(part.id, discordMessage.id, thread.id);
1869
+ }
1870
+ });
1871
+ transaction(partsToRender);
1872
+ }
1831
1873
  }
1832
1874
  messageCount++;
1833
1875
  }
package/dist/tools.js CHANGED
@@ -95,7 +95,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
95
95
  .optional()
96
96
  .describe('Optional model to use for this session'),
97
97
  }),
98
- execute: async ({ message, title, model }) => {
98
+ execute: async ({ message, title, }) => {
99
99
  if (!message.trim()) {
100
100
  throw new Error(`message must be a non empty string`);
101
101
  }
@@ -114,6 +114,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
114
114
  path: { id: session.data.id },
115
115
  body: {
116
116
  parts: [{ type: 'text', text: message }],
117
+ // model,
117
118
  },
118
119
  })
119
120
  .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.4",
5
+ "version": "0.4.6",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -24,7 +24,7 @@
24
24
  "@discordjs/opus": "^0.10.0",
25
25
  "@discordjs/voice": "^0.19.0",
26
26
  "@google/genai": "^1.16.0",
27
- "@opencode-ai/sdk": "^0.11.0",
27
+ "@opencode-ai/sdk": "^1.0.115",
28
28
  "@purinton/resampler": "^1.0.4",
29
29
  "@snazzah/davey": "^0.1.6",
30
30
  "ai": "^5.0.29",
@@ -45,7 +45,7 @@
45
45
  "zod": "^4.0.17"
46
46
  },
47
47
  "scripts": {
48
- "dev": "pnpm tsc && tsx --env-file .env src/cli.ts",
48
+ "dev": "tsx --env-file .env src/cli.ts",
49
49
  "dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
50
50
  "watch": "tsx scripts/watch-session.ts",
51
51
  "test:events": "tsx test-events.ts",
package/src/cli.ts CHANGED
@@ -37,13 +37,27 @@ import {
37
37
  import path from 'node:path'
38
38
  import fs from 'node:fs'
39
39
  import { createLogger } from './logger.js'
40
- import { spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
40
+ import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
41
41
 
42
42
  const cliLogger = createLogger('CLI')
43
43
  const cli = cac('kimaki')
44
44
 
45
45
  process.title = 'kimaki'
46
46
 
47
+ process.on('SIGUSR2', () => {
48
+ cliLogger.info('Received SIGUSR2, restarting process in 1000ms...')
49
+ setTimeout(() => {
50
+ cliLogger.info('Restarting...')
51
+ spawn(process.argv[0]!, [...process.execArgv, ...process.argv.slice(1)], {
52
+ stdio: 'inherit',
53
+ detached: true,
54
+ cwd: process.cwd(),
55
+ env: process.env,
56
+ }).unref()
57
+ process.exit(0)
58
+ }, 1000)
59
+ })
60
+
47
61
  const EXIT_NO_RESTART = 64
48
62
 
49
63
  type Project = {
package/src/discordBot.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  type OpencodeClient,
4
4
  type Part,
5
5
  type Config,
6
+ type FilePartInput,
6
7
  } from '@opencode-ai/sdk'
7
8
 
8
9
  import { createGenAIWorker, type GenAIWorker } from './genai-worker-wrapper.js'
@@ -813,6 +814,19 @@ async function processVoiceAttachment({
813
814
  return transcription
814
815
  }
815
816
 
817
+ function getImageAttachments(message: Message): FilePartInput[] {
818
+ const imageAttachments = Array.from(message.attachments.values()).filter(
819
+ (attachment) => attachment.contentType?.startsWith('image/'),
820
+ )
821
+
822
+ return imageAttachments.map((attachment) => ({
823
+ type: 'file' as const,
824
+ mime: attachment.contentType || 'image/png',
825
+ filename: attachment.name,
826
+ url: attachment.url,
827
+ }))
828
+ }
829
+
816
830
  export function escapeBackticksInCodeBlocks(markdown: string): string {
817
831
  const lexer = new Lexer()
818
832
  const tokens = lexer.lex(markdown)
@@ -1160,7 +1174,7 @@ function formatPart(part: Part): string {
1160
1174
 
1161
1175
  let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error'
1162
1176
  if (toolTitle) {
1163
- toolTitle = `\`${escapeInlineCode(toolTitle)}\``
1177
+ toolTitle = `*${toolTitle}*`
1164
1178
  }
1165
1179
 
1166
1180
  const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : ''
@@ -1193,12 +1207,19 @@ export async function createDiscordClient() {
1193
1207
  })
1194
1208
  }
1195
1209
 
1196
- async function handleOpencodeSession(
1197
- prompt: string,
1198
- thread: ThreadChannel,
1199
- projectDirectory?: string,
1200
- originalMessage?: Message,
1201
- ): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
1210
+ async function handleOpencodeSession({
1211
+ prompt,
1212
+ thread,
1213
+ projectDirectory,
1214
+ originalMessage,
1215
+ images = [],
1216
+ }: {
1217
+ prompt: string
1218
+ thread: ThreadChannel
1219
+ projectDirectory?: string
1220
+ originalMessage?: Message
1221
+ images?: FilePartInput[]
1222
+ }): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
1202
1223
  voiceLogger.log(
1203
1224
  `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
1204
1225
  )
@@ -1600,14 +1621,20 @@ async function handleOpencodeSession(
1600
1621
  voiceLogger.log(
1601
1622
  `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
1602
1623
  )
1624
+ if (images.length > 0) {
1625
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
1626
+ }
1603
1627
 
1604
1628
  // Start the event handler
1605
1629
  const eventHandlerPromise = eventHandler()
1606
1630
 
1631
+ const parts = [{ type: 'text' as const, text: prompt }, ...images]
1632
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
1633
+
1607
1634
  const response = await getClient().session.prompt({
1608
1635
  path: { id: session.id },
1609
1636
  body: {
1610
- parts: [{ type: 'text', text: prompt }],
1637
+ parts,
1611
1638
  },
1612
1639
  signal: abortController.signal,
1613
1640
  })
@@ -1874,12 +1901,14 @@ export async function startDiscordBot({
1874
1901
  messageContent = transcription
1875
1902
  }
1876
1903
 
1877
- await handleOpencodeSession(
1878
- messageContent,
1904
+ const images = getImageAttachments(message)
1905
+ await handleOpencodeSession({
1906
+ prompt: messageContent,
1879
1907
  thread,
1880
1908
  projectDirectory,
1881
- message,
1882
- )
1909
+ originalMessage: message,
1910
+ images,
1911
+ })
1883
1912
  return
1884
1913
  }
1885
1914
 
@@ -1967,12 +1996,14 @@ export async function startDiscordBot({
1967
1996
  messageContent = transcription
1968
1997
  }
1969
1998
 
1970
- await handleOpencodeSession(
1971
- messageContent,
1999
+ const images = getImageAttachments(message)
2000
+ await handleOpencodeSession({
2001
+ prompt: messageContent,
1972
2002
  thread,
1973
2003
  projectDirectory,
1974
- message,
1975
- )
2004
+ originalMessage: message,
2005
+ images,
2006
+ })
1976
2007
  } else {
1977
2008
  discordLogger.log(`Channel type ${channel.type} is not supported`)
1978
2009
  }
@@ -2307,7 +2338,11 @@ export async function startDiscordBot({
2307
2338
  )
2308
2339
 
2309
2340
  // Start the OpenCode session
2310
- await handleOpencodeSession(fullPrompt, thread, projectDirectory)
2341
+ await handleOpencodeSession({
2342
+ prompt: fullPrompt,
2343
+ thread,
2344
+ projectDirectory,
2345
+ })
2311
2346
  } catch (error) {
2312
2347
  voiceLogger.error('[SESSION] Error:', error)
2313
2348
  await command.editReply(
@@ -2446,22 +2481,39 @@ export async function startDiscordBot({
2446
2481
  }
2447
2482
  } else if (message.info.role === 'assistant') {
2448
2483
  // Render assistant parts
2484
+ const partsToRender: { id: string; content: string }[] = []
2485
+
2449
2486
  for (const part of message.parts) {
2450
2487
  const content = formatPart(part)
2451
2488
  if (content.trim()) {
2452
- const discordMessage = await sendThreadMessage(
2453
- thread,
2454
- content,
2455
- )
2456
-
2457
- // Store part-message mapping in database
2458
- getDatabase()
2459
- .prepare(
2460
- 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
2461
- )
2462
- .run(part.id, discordMessage.id, thread.id)
2489
+ partsToRender.push({ id: part.id, content })
2463
2490
  }
2464
2491
  }
2492
+
2493
+ if (partsToRender.length > 0) {
2494
+ const combinedContent = partsToRender
2495
+ .map((p) => p.content)
2496
+ .join('\n\n')
2497
+
2498
+ const discordMessage = await sendThreadMessage(
2499
+ thread,
2500
+ combinedContent,
2501
+ )
2502
+
2503
+ const stmt = getDatabase().prepare(
2504
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
2505
+ )
2506
+
2507
+ const transaction = getDatabase().transaction(
2508
+ (parts: { id: string }[]) => {
2509
+ for (const part of parts) {
2510
+ stmt.run(part.id, discordMessage.id, thread.id)
2511
+ }
2512
+ },
2513
+ )
2514
+
2515
+ transaction(partsToRender)
2516
+ }
2465
2517
  }
2466
2518
  messageCount++
2467
2519
  }
package/src/tools.ts CHANGED
@@ -127,7 +127,7 @@ export async function getTools({
127
127
  .optional()
128
128
  .describe('Optional model to use for this session'),
129
129
  }),
130
- execute: async ({ message, title, model }) => {
130
+ execute: async ({ message, title, }) => {
131
131
  if (!message.trim()) {
132
132
  throw new Error(`message must be a non empty string`)
133
133
  }
@@ -149,6 +149,7 @@ export async function getTools({
149
149
  path: { id: session.data.id },
150
150
  body: {
151
151
  parts: [{ type: 'text', text: message }],
152
+ // model,
152
153
  },
153
154
  })
154
155
  .then(async (response) => {