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 +26 -1
- package/dist/discordBot.js +196 -11
- package/dist/tools.js +2 -1
- package/package.json +13 -12
- package/src/cli.ts +27 -1
- package/src/discordBot.ts +254 -28
- package/src/tools.ts +2 -1
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 {
|
package/dist/discordBot.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
|
1198
|
-
thread
|
|
1199
|
-
projectDirectory
|
|
1200
|
-
originalMessage
|
|
1201
|
-
|
|
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
|
|
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
|
-
|
|
1878
|
-
|
|
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
|
-
|
|
1971
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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) => {
|