kimaki 0.4.4 → 0.4.7

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 = [
@@ -57,6 +70,18 @@ async function registerCommands(token, appId) {
57
70
  return option;
58
71
  })
59
72
  .toJSON(),
73
+ new SlashCommandBuilder()
74
+ .setName('accept')
75
+ .setDescription('Accept a pending permission request (this request only)')
76
+ .toJSON(),
77
+ new SlashCommandBuilder()
78
+ .setName('accept-always')
79
+ .setDescription('Accept and auto-approve future requests matching this pattern (e.g. "git *" approves all git commands)')
80
+ .toJSON(),
81
+ new SlashCommandBuilder()
82
+ .setName('reject')
83
+ .setDescription('Reject a pending permission request')
84
+ .toJSON(),
60
85
  ];
61
86
  const rest = new REST().setToken(token);
62
87
  try {
@@ -35,6 +35,8 @@ const abortControllers = new Map();
35
35
  const voiceConnections = new Map();
36
36
  // Map of directory to retry count for server restarts
37
37
  const serverRetryCount = new Map();
38
+ // Map of thread ID to pending permission (only one pending permission per thread)
39
+ const pendingPermissions = new Map();
38
40
  let db = null;
39
41
  function convertToMono16k(buffer) {
40
42
  // Parameters
@@ -595,6 +597,15 @@ async function processVoiceAttachment({ message, thread, projectDirectory, isNew
595
597
  await sendThreadMessage(thread, `📝 **Transcribed message:** ${escapeDiscordFormatting(transcription)}`);
596
598
  return transcription;
597
599
  }
600
+ function getImageAttachments(message) {
601
+ const imageAttachments = Array.from(message.attachments.values()).filter((attachment) => attachment.contentType?.startsWith('image/'));
602
+ return imageAttachments.map((attachment) => ({
603
+ type: 'file',
604
+ mime: attachment.contentType || 'image/png',
605
+ filename: attachment.name,
606
+ url: attachment.url,
607
+ }));
608
+ }
598
609
  export function escapeBackticksInCodeBlocks(markdown) {
599
610
  const lexer = new Lexer();
600
611
  const tokens = lexer.lex(markdown);
@@ -873,7 +884,7 @@ function formatPart(part) {
873
884
  const outputToDisplay = getToolOutputToDisplay(part);
874
885
  let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error';
875
886
  if (toolTitle) {
876
- toolTitle = `\`${escapeInlineCode(toolTitle)}\``;
887
+ toolTitle = `*${toolTitle}*`;
877
888
  }
878
889
  const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : '';
879
890
  const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
@@ -901,7 +912,7 @@ export async function createDiscordClient() {
901
912
  ],
902
913
  });
903
914
  }
904
- async function handleOpencodeSession(prompt, thread, projectDirectory, originalMessage) {
915
+ async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], }) {
905
916
  voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
906
917
  // Track session start time
907
918
  const sessionStartTime = Date.now();
@@ -1151,6 +1162,38 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1151
1162
  }
1152
1163
  break;
1153
1164
  }
1165
+ else if (event.type === 'permission.updated') {
1166
+ const permission = event.properties;
1167
+ if (permission.sessionID !== session.id) {
1168
+ voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
1169
+ continue;
1170
+ }
1171
+ sessionLogger.log(`Permission requested: type=${permission.type}, title=${permission.title}`);
1172
+ const patternStr = Array.isArray(permission.pattern)
1173
+ ? permission.pattern.join(', ')
1174
+ : permission.pattern || '';
1175
+ const permissionMessage = await sendThreadMessage(thread, `⚠️ **Permission Required**\n\n` +
1176
+ `**Type:** \`${permission.type}\`\n` +
1177
+ `**Action:** ${permission.title}\n` +
1178
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
1179
+ `\nUse \`/accept\` or \`/reject\` to respond.`);
1180
+ pendingPermissions.set(thread.id, {
1181
+ permission,
1182
+ messageId: permissionMessage.id,
1183
+ directory,
1184
+ });
1185
+ }
1186
+ else if (event.type === 'permission.replied') {
1187
+ const { permissionID, response, sessionID } = event.properties;
1188
+ if (sessionID !== session.id) {
1189
+ continue;
1190
+ }
1191
+ sessionLogger.log(`Permission ${permissionID} replied with: ${response}`);
1192
+ const pending = pendingPermissions.get(thread.id);
1193
+ if (pending && pending.permission.id === permissionID) {
1194
+ pendingPermissions.delete(thread.id);
1195
+ }
1196
+ }
1154
1197
  else if (event.type === 'file.edited') {
1155
1198
  sessionLogger.log(`File edited event received`);
1156
1199
  }
@@ -1211,12 +1254,17 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1211
1254
  };
1212
1255
  try {
1213
1256
  voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
1257
+ if (images.length > 0) {
1258
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
1259
+ }
1214
1260
  // Start the event handler
1215
1261
  const eventHandlerPromise = eventHandler();
1262
+ const parts = [{ type: 'text', text: prompt }, ...images];
1263
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
1216
1264
  const response = await getClient().session.prompt({
1217
1265
  path: { id: session.id },
1218
1266
  body: {
1219
- parts: [{ type: 'text', text: prompt }],
1267
+ parts,
1220
1268
  },
1221
1269
  signal: abortController.signal,
1222
1270
  });
@@ -1407,7 +1455,14 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1407
1455
  if (transcription) {
1408
1456
  messageContent = transcription;
1409
1457
  }
1410
- await handleOpencodeSession(messageContent, thread, projectDirectory, message);
1458
+ const images = getImageAttachments(message);
1459
+ await handleOpencodeSession({
1460
+ prompt: messageContent,
1461
+ thread,
1462
+ projectDirectory,
1463
+ originalMessage: message,
1464
+ images,
1465
+ });
1411
1466
  return;
1412
1467
  }
1413
1468
  // For text channels, start new sessions with kimaki.directory tag
@@ -1466,7 +1521,14 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1466
1521
  if (transcription) {
1467
1522
  messageContent = transcription;
1468
1523
  }
1469
- await handleOpencodeSession(messageContent, thread, projectDirectory, message);
1524
+ const images = getImageAttachments(message);
1525
+ await handleOpencodeSession({
1526
+ prompt: messageContent,
1527
+ thread,
1528
+ projectDirectory,
1529
+ originalMessage: message,
1530
+ images,
1531
+ });
1470
1532
  }
1471
1533
  else {
1472
1534
  discordLogger.log(`Channel type ${channel.type} is not supported`);
@@ -1721,7 +1783,11 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1721
1783
  });
1722
1784
  await command.editReply(`Created new session in ${thread.toString()}`);
1723
1785
  // Start the OpenCode session
1724
- await handleOpencodeSession(fullPrompt, thread, projectDirectory);
1786
+ await handleOpencodeSession({
1787
+ prompt: fullPrompt,
1788
+ thread,
1789
+ projectDirectory,
1790
+ });
1725
1791
  }
1726
1792
  catch (error) {
1727
1793
  voiceLogger.error('[SESSION] Error:', error);
@@ -1818,16 +1884,26 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1818
1884
  }
1819
1885
  else if (message.info.role === 'assistant') {
1820
1886
  // Render assistant parts
1887
+ const partsToRender = [];
1821
1888
  for (const part of message.parts) {
1822
1889
  const content = formatPart(part);
1823
1890
  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);
1891
+ partsToRender.push({ id: part.id, content });
1829
1892
  }
1830
1893
  }
1894
+ if (partsToRender.length > 0) {
1895
+ const combinedContent = partsToRender
1896
+ .map((p) => p.content)
1897
+ .join('\n\n');
1898
+ const discordMessage = await sendThreadMessage(thread, combinedContent);
1899
+ const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
1900
+ const transaction = getDatabase().transaction((parts) => {
1901
+ for (const part of parts) {
1902
+ stmt.run(part.id, discordMessage.id, thread.id);
1903
+ }
1904
+ });
1905
+ transaction(partsToRender);
1906
+ }
1831
1907
  }
1832
1908
  messageCount++;
1833
1909
  }
@@ -1885,6 +1961,115 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1885
1961
  await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
1886
1962
  }
1887
1963
  }
1964
+ else if (command.commandName === 'accept' ||
1965
+ command.commandName === 'accept-always') {
1966
+ const scope = command.commandName === 'accept-always' ? 'always' : 'once';
1967
+ const channel = command.channel;
1968
+ if (!channel) {
1969
+ await command.reply({
1970
+ content: 'This command can only be used in a channel',
1971
+ ephemeral: true,
1972
+ });
1973
+ return;
1974
+ }
1975
+ const isThread = [
1976
+ ChannelType.PublicThread,
1977
+ ChannelType.PrivateThread,
1978
+ ChannelType.AnnouncementThread,
1979
+ ].includes(channel.type);
1980
+ if (!isThread) {
1981
+ await command.reply({
1982
+ content: 'This command can only be used in a thread with an active session',
1983
+ ephemeral: true,
1984
+ });
1985
+ return;
1986
+ }
1987
+ const pending = pendingPermissions.get(channel.id);
1988
+ if (!pending) {
1989
+ await command.reply({
1990
+ content: 'No pending permission request in this thread',
1991
+ ephemeral: true,
1992
+ });
1993
+ return;
1994
+ }
1995
+ try {
1996
+ const getClient = await initializeOpencodeForDirectory(pending.directory);
1997
+ await getClient().postSessionIdPermissionsPermissionId({
1998
+ path: {
1999
+ id: pending.permission.sessionID,
2000
+ permissionID: pending.permission.id,
2001
+ },
2002
+ body: {
2003
+ response: scope,
2004
+ },
2005
+ });
2006
+ pendingPermissions.delete(channel.id);
2007
+ const msg = scope === 'always'
2008
+ ? `✅ Permission **accepted** (auto-approve similar requests)`
2009
+ : `✅ Permission **accepted**`;
2010
+ await command.reply(msg);
2011
+ sessionLogger.log(`Permission ${pending.permission.id} accepted with scope: ${scope}`);
2012
+ }
2013
+ catch (error) {
2014
+ voiceLogger.error('[ACCEPT] Error:', error);
2015
+ await command.reply({
2016
+ content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
2017
+ ephemeral: true,
2018
+ });
2019
+ }
2020
+ }
2021
+ else if (command.commandName === 'reject') {
2022
+ const channel = command.channel;
2023
+ if (!channel) {
2024
+ await command.reply({
2025
+ content: 'This command can only be used in a channel',
2026
+ ephemeral: true,
2027
+ });
2028
+ return;
2029
+ }
2030
+ const isThread = [
2031
+ ChannelType.PublicThread,
2032
+ ChannelType.PrivateThread,
2033
+ ChannelType.AnnouncementThread,
2034
+ ].includes(channel.type);
2035
+ if (!isThread) {
2036
+ await command.reply({
2037
+ content: 'This command can only be used in a thread with an active session',
2038
+ ephemeral: true,
2039
+ });
2040
+ return;
2041
+ }
2042
+ const pending = pendingPermissions.get(channel.id);
2043
+ if (!pending) {
2044
+ await command.reply({
2045
+ content: 'No pending permission request in this thread',
2046
+ ephemeral: true,
2047
+ });
2048
+ return;
2049
+ }
2050
+ try {
2051
+ const getClient = await initializeOpencodeForDirectory(pending.directory);
2052
+ await getClient().postSessionIdPermissionsPermissionId({
2053
+ path: {
2054
+ id: pending.permission.sessionID,
2055
+ permissionID: pending.permission.id,
2056
+ },
2057
+ body: {
2058
+ response: 'reject',
2059
+ },
2060
+ });
2061
+ pendingPermissions.delete(channel.id);
2062
+ await command.reply(`❌ Permission **rejected**`);
2063
+ sessionLogger.log(`Permission ${pending.permission.id} rejected`);
2064
+ }
2065
+ catch (error) {
2066
+ voiceLogger.error('[REJECT] Error:', error);
2067
+ await command.reply({
2068
+ content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
2069
+ ephemeral: true,
2070
+ });
2071
+ }
2072
+ }
1888
2073
  }
1889
2074
  }
1890
2075
  catch (error) {
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,17 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.4",
5
+ "version": "0.4.7",
6
+ "scripts": {
7
+ "dev": "tsx --env-file .env src/cli.ts",
8
+ "prepublishOnly": "pnpm tsc",
9
+ "dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
10
+ "watch": "tsx scripts/watch-session.ts",
11
+ "test:events": "tsx test-events.ts",
12
+ "pcm-to-mp3": "bun scripts/pcm-to-mp3",
13
+ "test:send": "tsx send-test-message.ts",
14
+ "register-commands": "tsx scripts/register-commands.ts"
15
+ },
6
16
  "repository": "https://github.com/remorses/kimaki",
7
17
  "bin": "bin.js",
8
18
  "files": [
@@ -24,7 +34,7 @@
24
34
  "@discordjs/opus": "^0.10.0",
25
35
  "@discordjs/voice": "^0.19.0",
26
36
  "@google/genai": "^1.16.0",
27
- "@opencode-ai/sdk": "^0.11.0",
37
+ "@opencode-ai/sdk": "^1.0.115",
28
38
  "@purinton/resampler": "^1.0.4",
29
39
  "@snazzah/davey": "^0.1.6",
30
40
  "ai": "^5.0.29",
@@ -43,14 +53,5 @@
43
53
  "string-dedent": "^3.0.2",
44
54
  "undici": "^7.16.0",
45
55
  "zod": "^4.0.17"
46
- },
47
- "scripts": {
48
- "dev": "pnpm tsc && tsx --env-file .env src/cli.ts",
49
- "dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
50
- "watch": "tsx scripts/watch-session.ts",
51
- "test:events": "tsx test-events.ts",
52
- "pcm-to-mp3": "bun scripts/pcm-to-mp3",
53
- "test:send": "tsx send-test-message.ts",
54
- "register-commands": "tsx scripts/register-commands.ts"
55
56
  }
56
- }
57
+ }
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 = {
@@ -112,6 +126,18 @@ async function registerCommands(token: string, appId: string) {
112
126
  return option
113
127
  })
114
128
  .toJSON(),
129
+ new SlashCommandBuilder()
130
+ .setName('accept')
131
+ .setDescription('Accept a pending permission request (this request only)')
132
+ .toJSON(),
133
+ new SlashCommandBuilder()
134
+ .setName('accept-always')
135
+ .setDescription('Accept and auto-approve future requests matching this pattern (e.g. "git *" approves all git commands)')
136
+ .toJSON(),
137
+ new SlashCommandBuilder()
138
+ .setName('reject')
139
+ .setDescription('Reject a pending permission request')
140
+ .toJSON(),
115
141
  ]
116
142
 
117
143
  const rest = new REST().setToken(token)
package/src/discordBot.ts CHANGED
@@ -3,6 +3,8 @@ import {
3
3
  type OpencodeClient,
4
4
  type Part,
5
5
  type Config,
6
+ type FilePartInput,
7
+ type Permission,
6
8
  } from '@opencode-ai/sdk'
7
9
 
8
10
  import { createGenAIWorker, type GenAIWorker } from './genai-worker-wrapper.js'
@@ -89,6 +91,12 @@ const voiceConnections = new Map<
89
91
  // Map of directory to retry count for server restarts
90
92
  const serverRetryCount = new Map<string, number>()
91
93
 
94
+ // Map of thread ID to pending permission (only one pending permission per thread)
95
+ const pendingPermissions = new Map<
96
+ string,
97
+ { permission: Permission; messageId: string; directory: string }
98
+ >()
99
+
92
100
  let db: Database.Database | null = null
93
101
 
94
102
  function convertToMono16k(buffer: Buffer): Buffer {
@@ -813,6 +821,19 @@ async function processVoiceAttachment({
813
821
  return transcription
814
822
  }
815
823
 
824
+ function getImageAttachments(message: Message): FilePartInput[] {
825
+ const imageAttachments = Array.from(message.attachments.values()).filter(
826
+ (attachment) => attachment.contentType?.startsWith('image/'),
827
+ )
828
+
829
+ return imageAttachments.map((attachment) => ({
830
+ type: 'file' as const,
831
+ mime: attachment.contentType || 'image/png',
832
+ filename: attachment.name,
833
+ url: attachment.url,
834
+ }))
835
+ }
836
+
816
837
  export function escapeBackticksInCodeBlocks(markdown: string): string {
817
838
  const lexer = new Lexer()
818
839
  const tokens = lexer.lex(markdown)
@@ -1160,7 +1181,7 @@ function formatPart(part: Part): string {
1160
1181
 
1161
1182
  let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error'
1162
1183
  if (toolTitle) {
1163
- toolTitle = `\`${escapeInlineCode(toolTitle)}\``
1184
+ toolTitle = `*${toolTitle}*`
1164
1185
  }
1165
1186
 
1166
1187
  const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : ''
@@ -1193,12 +1214,19 @@ export async function createDiscordClient() {
1193
1214
  })
1194
1215
  }
1195
1216
 
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> {
1217
+ async function handleOpencodeSession({
1218
+ prompt,
1219
+ thread,
1220
+ projectDirectory,
1221
+ originalMessage,
1222
+ images = [],
1223
+ }: {
1224
+ prompt: string
1225
+ thread: ThreadChannel
1226
+ projectDirectory?: string
1227
+ originalMessage?: Message
1228
+ images?: FilePartInput[]
1229
+ }): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
1202
1230
  voiceLogger.log(
1203
1231
  `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
1204
1232
  )
@@ -1526,6 +1554,51 @@ async function handleOpencodeSession(
1526
1554
  )
1527
1555
  }
1528
1556
  break
1557
+ } else if (event.type === 'permission.updated') {
1558
+ const permission = event.properties
1559
+ if (permission.sessionID !== session.id) {
1560
+ voiceLogger.log(
1561
+ `[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
1562
+ )
1563
+ continue
1564
+ }
1565
+
1566
+ sessionLogger.log(
1567
+ `Permission requested: type=${permission.type}, title=${permission.title}`,
1568
+ )
1569
+
1570
+ const patternStr = Array.isArray(permission.pattern)
1571
+ ? permission.pattern.join(', ')
1572
+ : permission.pattern || ''
1573
+
1574
+ const permissionMessage = await sendThreadMessage(
1575
+ thread,
1576
+ `⚠️ **Permission Required**\n\n` +
1577
+ `**Type:** \`${permission.type}\`\n` +
1578
+ `**Action:** ${permission.title}\n` +
1579
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
1580
+ `\nUse \`/accept\` or \`/reject\` to respond.`,
1581
+ )
1582
+
1583
+ pendingPermissions.set(thread.id, {
1584
+ permission,
1585
+ messageId: permissionMessage.id,
1586
+ directory,
1587
+ })
1588
+ } else if (event.type === 'permission.replied') {
1589
+ const { permissionID, response, sessionID } = event.properties
1590
+ if (sessionID !== session.id) {
1591
+ continue
1592
+ }
1593
+
1594
+ sessionLogger.log(
1595
+ `Permission ${permissionID} replied with: ${response}`,
1596
+ )
1597
+
1598
+ const pending = pendingPermissions.get(thread.id)
1599
+ if (pending && pending.permission.id === permissionID) {
1600
+ pendingPermissions.delete(thread.id)
1601
+ }
1529
1602
  } else if (event.type === 'file.edited') {
1530
1603
  sessionLogger.log(`File edited event received`)
1531
1604
  } else {
@@ -1600,14 +1673,20 @@ async function handleOpencodeSession(
1600
1673
  voiceLogger.log(
1601
1674
  `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
1602
1675
  )
1676
+ if (images.length > 0) {
1677
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
1678
+ }
1603
1679
 
1604
1680
  // Start the event handler
1605
1681
  const eventHandlerPromise = eventHandler()
1606
1682
 
1683
+ const parts = [{ type: 'text' as const, text: prompt }, ...images]
1684
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
1685
+
1607
1686
  const response = await getClient().session.prompt({
1608
1687
  path: { id: session.id },
1609
1688
  body: {
1610
- parts: [{ type: 'text', text: prompt }],
1689
+ parts,
1611
1690
  },
1612
1691
  signal: abortController.signal,
1613
1692
  })
@@ -1874,12 +1953,14 @@ export async function startDiscordBot({
1874
1953
  messageContent = transcription
1875
1954
  }
1876
1955
 
1877
- await handleOpencodeSession(
1878
- messageContent,
1956
+ const images = getImageAttachments(message)
1957
+ await handleOpencodeSession({
1958
+ prompt: messageContent,
1879
1959
  thread,
1880
1960
  projectDirectory,
1881
- message,
1882
- )
1961
+ originalMessage: message,
1962
+ images,
1963
+ })
1883
1964
  return
1884
1965
  }
1885
1966
 
@@ -1967,12 +2048,14 @@ export async function startDiscordBot({
1967
2048
  messageContent = transcription
1968
2049
  }
1969
2050
 
1970
- await handleOpencodeSession(
1971
- messageContent,
2051
+ const images = getImageAttachments(message)
2052
+ await handleOpencodeSession({
2053
+ prompt: messageContent,
1972
2054
  thread,
1973
2055
  projectDirectory,
1974
- message,
1975
- )
2056
+ originalMessage: message,
2057
+ images,
2058
+ })
1976
2059
  } else {
1977
2060
  discordLogger.log(`Channel type ${channel.type} is not supported`)
1978
2061
  }
@@ -2307,7 +2390,11 @@ export async function startDiscordBot({
2307
2390
  )
2308
2391
 
2309
2392
  // Start the OpenCode session
2310
- await handleOpencodeSession(fullPrompt, thread, projectDirectory)
2393
+ await handleOpencodeSession({
2394
+ prompt: fullPrompt,
2395
+ thread,
2396
+ projectDirectory,
2397
+ })
2311
2398
  } catch (error) {
2312
2399
  voiceLogger.error('[SESSION] Error:', error)
2313
2400
  await command.editReply(
@@ -2446,22 +2533,39 @@ export async function startDiscordBot({
2446
2533
  }
2447
2534
  } else if (message.info.role === 'assistant') {
2448
2535
  // Render assistant parts
2536
+ const partsToRender: { id: string; content: string }[] = []
2537
+
2449
2538
  for (const part of message.parts) {
2450
2539
  const content = formatPart(part)
2451
2540
  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)
2541
+ partsToRender.push({ id: part.id, content })
2463
2542
  }
2464
2543
  }
2544
+
2545
+ if (partsToRender.length > 0) {
2546
+ const combinedContent = partsToRender
2547
+ .map((p) => p.content)
2548
+ .join('\n\n')
2549
+
2550
+ const discordMessage = await sendThreadMessage(
2551
+ thread,
2552
+ combinedContent,
2553
+ )
2554
+
2555
+ const stmt = getDatabase().prepare(
2556
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
2557
+ )
2558
+
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
+ )
2566
+
2567
+ transaction(partsToRender)
2568
+ }
2465
2569
  }
2466
2570
  messageCount++
2467
2571
  }
@@ -2547,6 +2651,128 @@ export async function startDiscordBot({
2547
2651
  `Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
2548
2652
  )
2549
2653
  }
2654
+ } else if (
2655
+ command.commandName === 'accept' ||
2656
+ command.commandName === 'accept-always'
2657
+ ) {
2658
+ const scope = command.commandName === 'accept-always' ? 'always' : 'once'
2659
+ const channel = command.channel
2660
+
2661
+ if (!channel) {
2662
+ await command.reply({
2663
+ content: 'This command can only be used in a channel',
2664
+ ephemeral: true,
2665
+ })
2666
+ return
2667
+ }
2668
+
2669
+ const isThread = [
2670
+ ChannelType.PublicThread,
2671
+ ChannelType.PrivateThread,
2672
+ ChannelType.AnnouncementThread,
2673
+ ].includes(channel.type)
2674
+
2675
+ if (!isThread) {
2676
+ await command.reply({
2677
+ content: 'This command can only be used in a thread with an active session',
2678
+ ephemeral: true,
2679
+ })
2680
+ return
2681
+ }
2682
+
2683
+ const pending = pendingPermissions.get(channel.id)
2684
+ if (!pending) {
2685
+ await command.reply({
2686
+ content: 'No pending permission request in this thread',
2687
+ ephemeral: true,
2688
+ })
2689
+ return
2690
+ }
2691
+
2692
+ try {
2693
+ const getClient = await initializeOpencodeForDirectory(pending.directory)
2694
+ await getClient().postSessionIdPermissionsPermissionId({
2695
+ path: {
2696
+ id: pending.permission.sessionID,
2697
+ permissionID: pending.permission.id,
2698
+ },
2699
+ body: {
2700
+ response: scope,
2701
+ },
2702
+ })
2703
+
2704
+ pendingPermissions.delete(channel.id)
2705
+ const msg =
2706
+ scope === 'always'
2707
+ ? `✅ Permission **accepted** (auto-approve similar requests)`
2708
+ : `✅ Permission **accepted**`
2709
+ await command.reply(msg)
2710
+ sessionLogger.log(
2711
+ `Permission ${pending.permission.id} accepted with scope: ${scope}`,
2712
+ )
2713
+ } catch (error) {
2714
+ voiceLogger.error('[ACCEPT] Error:', error)
2715
+ await command.reply({
2716
+ content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
2717
+ ephemeral: true,
2718
+ })
2719
+ }
2720
+ } else if (command.commandName === 'reject') {
2721
+ const channel = command.channel
2722
+
2723
+ if (!channel) {
2724
+ await command.reply({
2725
+ content: 'This command can only be used in a channel',
2726
+ ephemeral: true,
2727
+ })
2728
+ return
2729
+ }
2730
+
2731
+ const isThread = [
2732
+ ChannelType.PublicThread,
2733
+ ChannelType.PrivateThread,
2734
+ ChannelType.AnnouncementThread,
2735
+ ].includes(channel.type)
2736
+
2737
+ if (!isThread) {
2738
+ await command.reply({
2739
+ content: 'This command can only be used in a thread with an active session',
2740
+ ephemeral: true,
2741
+ })
2742
+ return
2743
+ }
2744
+
2745
+ const pending = pendingPermissions.get(channel.id)
2746
+ if (!pending) {
2747
+ await command.reply({
2748
+ content: 'No pending permission request in this thread',
2749
+ ephemeral: true,
2750
+ })
2751
+ return
2752
+ }
2753
+
2754
+ try {
2755
+ const getClient = await initializeOpencodeForDirectory(pending.directory)
2756
+ await getClient().postSessionIdPermissionsPermissionId({
2757
+ path: {
2758
+ id: pending.permission.sessionID,
2759
+ permissionID: pending.permission.id,
2760
+ },
2761
+ body: {
2762
+ response: 'reject',
2763
+ },
2764
+ })
2765
+
2766
+ pendingPermissions.delete(channel.id)
2767
+ await command.reply(`❌ Permission **rejected**`)
2768
+ sessionLogger.log(`Permission ${pending.permission.id} rejected`)
2769
+ } catch (error) {
2770
+ voiceLogger.error('[REJECT] Error:', error)
2771
+ await command.reply({
2772
+ content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
2773
+ ephemeral: true,
2774
+ })
2775
+ }
2550
2776
  }
2551
2777
  }
2552
2778
  } catch (error) {
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) => {