kimaki 0.4.13 → 0.4.14
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 +81 -12
- package/dist/discordBot.js +144 -55
- package/dist/tools.js +3 -3
- package/package.json +11 -12
- package/src/cli.ts +108 -11
- package/src/discordBot.ts +167 -90
- package/src/opencode-command-send-to-discord.md +12 -0
- package/src/opencode-command-upload-to-discord.md +22 -0
- package/src/tools.ts +3 -3
- package/src/opencode-command.md +0 -4
- package/src/opencode-plugin.ts +0 -75
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import { cac } from 'cac';
|
|
|
3
3
|
import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
|
|
4
4
|
import { deduplicateByKey, generateBotInstallUrl } from './utils.js';
|
|
5
5
|
import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discordBot.js';
|
|
6
|
-
import { Events, ChannelType, REST, Routes, SlashCommandBuilder, } from 'discord.js';
|
|
6
|
+
import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
@@ -78,12 +78,16 @@ async function registerCommands(token, appId) {
|
|
|
78
78
|
.toJSON(),
|
|
79
79
|
new SlashCommandBuilder()
|
|
80
80
|
.setName('accept-always')
|
|
81
|
-
.setDescription('Accept and auto-approve future requests matching this pattern
|
|
81
|
+
.setDescription('Accept and auto-approve future requests matching this pattern')
|
|
82
82
|
.toJSON(),
|
|
83
83
|
new SlashCommandBuilder()
|
|
84
84
|
.setName('reject')
|
|
85
85
|
.setDescription('Reject a pending permission request')
|
|
86
86
|
.toJSON(),
|
|
87
|
+
new SlashCommandBuilder()
|
|
88
|
+
.setName('abort')
|
|
89
|
+
.setDescription('Abort the current OpenCode request in this thread')
|
|
90
|
+
.toJSON(),
|
|
87
91
|
];
|
|
88
92
|
const rest = new REST().setToken(token);
|
|
89
93
|
try {
|
|
@@ -558,22 +562,87 @@ cli
|
|
|
558
562
|
}
|
|
559
563
|
});
|
|
560
564
|
cli
|
|
561
|
-
.command('
|
|
565
|
+
.command('upload-to-discord [...files]', 'Upload files to a Discord thread for a session')
|
|
566
|
+
.option('-s, --session <sessionId>', 'OpenCode session ID')
|
|
567
|
+
.action(async (files, options) => {
|
|
568
|
+
try {
|
|
569
|
+
const { session: sessionId } = options;
|
|
570
|
+
if (!sessionId) {
|
|
571
|
+
cliLogger.error('Session ID is required. Use --session <sessionId>');
|
|
572
|
+
process.exit(EXIT_NO_RESTART);
|
|
573
|
+
}
|
|
574
|
+
if (!files || files.length === 0) {
|
|
575
|
+
cliLogger.error('At least one file path is required');
|
|
576
|
+
process.exit(EXIT_NO_RESTART);
|
|
577
|
+
}
|
|
578
|
+
const resolvedFiles = files.map((f) => path.resolve(f));
|
|
579
|
+
for (const file of resolvedFiles) {
|
|
580
|
+
if (!fs.existsSync(file)) {
|
|
581
|
+
cliLogger.error(`File not found: ${file}`);
|
|
582
|
+
process.exit(EXIT_NO_RESTART);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const db = getDatabase();
|
|
586
|
+
const threadRow = db
|
|
587
|
+
.prepare('SELECT thread_id FROM thread_sessions WHERE session_id = ?')
|
|
588
|
+
.get(sessionId);
|
|
589
|
+
if (!threadRow) {
|
|
590
|
+
cliLogger.error(`No Discord thread found for session: ${sessionId}`);
|
|
591
|
+
cliLogger.error('Make sure the session has been sent to Discord first using /send-to-kimaki-discord');
|
|
592
|
+
process.exit(EXIT_NO_RESTART);
|
|
593
|
+
}
|
|
594
|
+
const botRow = db
|
|
595
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
596
|
+
.get();
|
|
597
|
+
if (!botRow) {
|
|
598
|
+
cliLogger.error('No bot credentials found. Run `kimaki` first to set up the bot.');
|
|
599
|
+
process.exit(EXIT_NO_RESTART);
|
|
600
|
+
}
|
|
601
|
+
const s = spinner();
|
|
602
|
+
s.start(`Uploading ${resolvedFiles.length} file(s)...`);
|
|
603
|
+
for (const file of resolvedFiles) {
|
|
604
|
+
const buffer = fs.readFileSync(file);
|
|
605
|
+
const formData = new FormData();
|
|
606
|
+
formData.append('payload_json', JSON.stringify({
|
|
607
|
+
attachments: [{ id: 0, filename: path.basename(file) }]
|
|
608
|
+
}));
|
|
609
|
+
formData.append('files[0]', new Blob([buffer]), path.basename(file));
|
|
610
|
+
const response = await fetch(`https://discord.com/api/v10/channels/${threadRow.thread_id}/messages`, {
|
|
611
|
+
method: 'POST',
|
|
612
|
+
headers: {
|
|
613
|
+
'Authorization': `Bot ${botRow.token}`,
|
|
614
|
+
},
|
|
615
|
+
body: formData,
|
|
616
|
+
});
|
|
617
|
+
if (!response.ok) {
|
|
618
|
+
const error = await response.text();
|
|
619
|
+
throw new Error(`Discord API error: ${response.status} - ${error}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
s.stop(`Uploaded ${resolvedFiles.length} file(s)!`);
|
|
623
|
+
note(`Files uploaded to Discord thread!\n\nFiles: ${resolvedFiles.map((f) => path.basename(f)).join(', ')}`, '✅ Success');
|
|
624
|
+
process.exit(0);
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
628
|
+
process.exit(EXIT_NO_RESTART);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
cli
|
|
632
|
+
.command('install-plugin', 'Install the OpenCode commands for kimaki Discord integration')
|
|
562
633
|
.action(async () => {
|
|
563
634
|
try {
|
|
564
635
|
const require = createRequire(import.meta.url);
|
|
565
|
-
const
|
|
566
|
-
const
|
|
636
|
+
const sendCommandSrc = require.resolve('./opencode-command-send-to-discord.md');
|
|
637
|
+
const uploadCommandSrc = require.resolve('./opencode-command-upload-to-discord.md');
|
|
567
638
|
const opencodeConfig = path.join(os.homedir(), '.config', 'opencode');
|
|
568
|
-
const pluginDir = path.join(opencodeConfig, 'plugin');
|
|
569
639
|
const commandDir = path.join(opencodeConfig, 'command');
|
|
570
|
-
fs.mkdirSync(pluginDir, { recursive: true });
|
|
571
640
|
fs.mkdirSync(commandDir, { recursive: true });
|
|
572
|
-
const
|
|
573
|
-
const
|
|
574
|
-
fs.copyFileSync(
|
|
575
|
-
fs.copyFileSync(
|
|
576
|
-
note(`
|
|
641
|
+
const sendCommandDest = path.join(commandDir, 'send-to-kimaki-discord.md');
|
|
642
|
+
const uploadCommandDest = path.join(commandDir, 'upload-to-discord.md');
|
|
643
|
+
fs.copyFileSync(sendCommandSrc, sendCommandDest);
|
|
644
|
+
fs.copyFileSync(uploadCommandSrc, uploadCommandDest);
|
|
645
|
+
note(`Commands installed:\n- ${sendCommandDest}\n- ${uploadCommandDest}\n\nUse /send-to-kimaki-discord to send session to Discord.\nUse /upload-to-discord to upload files to the thread.`, '✅ Installed');
|
|
577
646
|
process.exit(0);
|
|
578
647
|
}
|
|
579
648
|
catch (error) {
|
package/dist/discordBot.js
CHANGED
|
@@ -22,9 +22,25 @@ import { isAbortError } from './utils.js';
|
|
|
22
22
|
import { setGlobalDispatcher, Agent } from 'undici';
|
|
23
23
|
// disables the automatic 5 minutes abort after no body
|
|
24
24
|
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }));
|
|
25
|
-
|
|
25
|
+
function parseSlashCommand(text) {
|
|
26
|
+
const trimmed = text.trim();
|
|
27
|
+
if (!trimmed.startsWith('/')) {
|
|
28
|
+
return { isCommand: false };
|
|
29
|
+
}
|
|
30
|
+
const match = trimmed.match(/^\/(\S+)(?:\s+(.*))?$/);
|
|
31
|
+
if (!match) {
|
|
32
|
+
return { isCommand: false };
|
|
33
|
+
}
|
|
34
|
+
const command = match[1];
|
|
35
|
+
const args = match[2]?.trim() || '';
|
|
36
|
+
return { isCommand: true, command, arguments: args };
|
|
37
|
+
}
|
|
38
|
+
export function getOpencodeSystemMessage({ sessionId }) {
|
|
39
|
+
return `
|
|
26
40
|
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
27
41
|
|
|
42
|
+
Your current OpenCode session ID is: ${sessionId}
|
|
43
|
+
|
|
28
44
|
After each message, if you implemented changes, you can show the user a diff via an url running the command, to show the changes in working directory:
|
|
29
45
|
|
|
30
46
|
bunx critique web
|
|
@@ -66,6 +82,7 @@ code blocks for tables and diagrams MUST have Max length of 85 characters. other
|
|
|
66
82
|
|
|
67
83
|
you can create diagrams wrapping them in code blocks too.
|
|
68
84
|
`;
|
|
85
|
+
}
|
|
69
86
|
const discordLogger = createLogger('DISCORD');
|
|
70
87
|
const voiceLogger = createLogger('VOICE');
|
|
71
88
|
const opencodeLogger = createLogger('OPENCODE');
|
|
@@ -1023,7 +1040,7 @@ export async function createDiscordClient() {
|
|
|
1023
1040
|
],
|
|
1024
1041
|
});
|
|
1025
1042
|
}
|
|
1026
|
-
async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], }) {
|
|
1043
|
+
async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], parsedCommand, }) {
|
|
1027
1044
|
voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
|
|
1028
1045
|
// Track session start time
|
|
1029
1046
|
const sessionStartTime = Date.now();
|
|
@@ -1117,6 +1134,8 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1117
1134
|
let currentParts = [];
|
|
1118
1135
|
let stopTyping = null;
|
|
1119
1136
|
let usedModel;
|
|
1137
|
+
let usedProviderID;
|
|
1138
|
+
let inputTokens = 0;
|
|
1120
1139
|
const sendPartMessage = async (part) => {
|
|
1121
1140
|
const content = formatPart(part) + '\n\n';
|
|
1122
1141
|
if (!content.trim() || content.length === 0) {
|
|
@@ -1125,14 +1144,11 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1125
1144
|
}
|
|
1126
1145
|
// Skip if already sent
|
|
1127
1146
|
if (partIdToMessage.has(part.id)) {
|
|
1128
|
-
voiceLogger.log(`[SEND SKIP] Part ${part.id} already sent as message ${partIdToMessage.get(part.id)?.id}`);
|
|
1129
1147
|
return;
|
|
1130
1148
|
}
|
|
1131
1149
|
try {
|
|
1132
|
-
voiceLogger.log(`[SEND] Sending part ${part.id} (type: ${part.type}) to Discord, content length: ${content.length}`);
|
|
1133
1150
|
const firstMessage = await sendThreadMessage(thread, content);
|
|
1134
1151
|
partIdToMessage.set(part.id, firstMessage);
|
|
1135
|
-
voiceLogger.log(`[SEND SUCCESS] Part ${part.id} sent as message ${firstMessage.id}`);
|
|
1136
1152
|
// Store part-message mapping in database
|
|
1137
1153
|
getDatabase()
|
|
1138
1154
|
.prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
|
|
@@ -1151,12 +1167,10 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1151
1167
|
discordLogger.log(`Not starting typing, already aborted`);
|
|
1152
1168
|
return () => { };
|
|
1153
1169
|
}
|
|
1154
|
-
discordLogger.log(`Starting typing for thread ${thread.id}`);
|
|
1155
1170
|
// Clear any previous typing interval
|
|
1156
1171
|
if (typingInterval) {
|
|
1157
1172
|
clearInterval(typingInterval);
|
|
1158
1173
|
typingInterval = null;
|
|
1159
|
-
discordLogger.log(`Cleared previous typing interval`);
|
|
1160
1174
|
}
|
|
1161
1175
|
// Send initial typing
|
|
1162
1176
|
thread.sendTyping().catch((e) => {
|
|
@@ -1184,39 +1198,34 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1184
1198
|
if (typingInterval) {
|
|
1185
1199
|
clearInterval(typingInterval);
|
|
1186
1200
|
typingInterval = null;
|
|
1187
|
-
discordLogger.log(`Stopped typing for thread ${thread.id}`);
|
|
1188
1201
|
}
|
|
1189
1202
|
};
|
|
1190
1203
|
}
|
|
1191
1204
|
try {
|
|
1192
1205
|
let assistantMessageId;
|
|
1193
1206
|
for await (const event of events) {
|
|
1194
|
-
sessionLogger.log(`Received: ${event.type}`);
|
|
1195
1207
|
if (event.type === 'message.updated') {
|
|
1196
1208
|
const msg = event.properties.info;
|
|
1197
1209
|
if (msg.sessionID !== session.id) {
|
|
1198
|
-
voiceLogger.log(`[EVENT IGNORED] Message from different session (expected: ${session.id}, got: ${msg.sessionID})`);
|
|
1199
1210
|
continue;
|
|
1200
1211
|
}
|
|
1201
1212
|
// Track assistant message ID
|
|
1202
1213
|
if (msg.role === 'assistant') {
|
|
1203
1214
|
assistantMessageId = msg.id;
|
|
1204
1215
|
usedModel = msg.modelID;
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1216
|
+
usedProviderID = msg.providerID;
|
|
1217
|
+
if (msg.tokens.input > 0) {
|
|
1218
|
+
inputTokens = msg.tokens.input;
|
|
1219
|
+
}
|
|
1209
1220
|
}
|
|
1210
1221
|
}
|
|
1211
1222
|
else if (event.type === 'message.part.updated') {
|
|
1212
1223
|
const part = event.properties.part;
|
|
1213
1224
|
if (part.sessionID !== session.id) {
|
|
1214
|
-
voiceLogger.log(`[EVENT IGNORED] Part from different session (expected: ${session.id}, got: ${part.sessionID})`);
|
|
1215
1225
|
continue;
|
|
1216
1226
|
}
|
|
1217
1227
|
// Only process parts from assistant messages
|
|
1218
1228
|
if (part.messageID !== assistantMessageId) {
|
|
1219
|
-
voiceLogger.log(`[EVENT IGNORED] Part from non-assistant message (expected: ${assistantMessageId}, got: ${part.messageID})`);
|
|
1220
1229
|
continue;
|
|
1221
1230
|
}
|
|
1222
1231
|
const existingIndex = currentParts.findIndex((p) => p.id === part.id);
|
|
@@ -1226,15 +1235,18 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1226
1235
|
else {
|
|
1227
1236
|
currentParts.push(part);
|
|
1228
1237
|
}
|
|
1229
|
-
voiceLogger.log(`[PART] Update: id=${part.id}, type=${part.type}, text=${'text' in part && typeof part.text === 'string' ? part.text.slice(0, 50) : ''}`);
|
|
1230
1238
|
// Start typing on step-start
|
|
1231
1239
|
if (part.type === 'step-start') {
|
|
1232
1240
|
stopTyping = startTyping(thread);
|
|
1233
1241
|
}
|
|
1234
1242
|
// Check if this is a step-finish part
|
|
1235
1243
|
if (part.type === 'step-finish') {
|
|
1244
|
+
// Track tokens from step-finish part
|
|
1245
|
+
if (part.tokens?.input && part.tokens.input > 0) {
|
|
1246
|
+
inputTokens = part.tokens.input;
|
|
1247
|
+
voiceLogger.log(`[STEP-FINISH] Captured tokens: ${inputTokens}`);
|
|
1248
|
+
}
|
|
1236
1249
|
// Send all parts accumulated so far to Discord
|
|
1237
|
-
voiceLogger.log(`[STEP-FINISH] Sending ${currentParts.length} parts to Discord`);
|
|
1238
1250
|
for (const p of currentParts) {
|
|
1239
1251
|
// Skip step-start and step-finish parts as they have no visual content
|
|
1240
1252
|
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
@@ -1305,12 +1317,6 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1305
1317
|
pendingPermissions.delete(thread.id);
|
|
1306
1318
|
}
|
|
1307
1319
|
}
|
|
1308
|
-
else if (event.type === 'file.edited') {
|
|
1309
|
-
sessionLogger.log(`File edited event received`);
|
|
1310
|
-
}
|
|
1311
|
-
else {
|
|
1312
|
-
sessionLogger.log(`Unhandled event type: ${event.type}`);
|
|
1313
|
-
}
|
|
1314
1320
|
}
|
|
1315
1321
|
}
|
|
1316
1322
|
catch (e) {
|
|
@@ -1323,31 +1329,20 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1323
1329
|
}
|
|
1324
1330
|
finally {
|
|
1325
1331
|
// Send any remaining parts that weren't sent
|
|
1326
|
-
voiceLogger.log(`[CLEANUP] Checking ${currentParts.length} parts for unsent messages`);
|
|
1327
|
-
let unsentCount = 0;
|
|
1328
1332
|
for (const part of currentParts) {
|
|
1329
1333
|
if (!partIdToMessage.has(part.id)) {
|
|
1330
|
-
unsentCount++;
|
|
1331
|
-
voiceLogger.log(`[CLEANUP] Sending unsent part: id=${part.id}, type=${part.type}`);
|
|
1332
1334
|
try {
|
|
1333
1335
|
await sendPartMessage(part);
|
|
1334
1336
|
}
|
|
1335
1337
|
catch (error) {
|
|
1336
|
-
sessionLogger.
|
|
1338
|
+
sessionLogger.error(`Failed to send part ${part.id}:`, error);
|
|
1337
1339
|
}
|
|
1338
1340
|
}
|
|
1339
1341
|
}
|
|
1340
|
-
if (unsentCount === 0) {
|
|
1341
|
-
sessionLogger.log(`All parts were already sent`);
|
|
1342
|
-
}
|
|
1343
|
-
else {
|
|
1344
|
-
sessionLogger.log(`Sent ${unsentCount} previously unsent parts`);
|
|
1345
|
-
}
|
|
1346
1342
|
// Stop typing when session ends
|
|
1347
1343
|
if (stopTyping) {
|
|
1348
1344
|
stopTyping();
|
|
1349
1345
|
stopTyping = null;
|
|
1350
|
-
sessionLogger.log(`Stopped typing for session`);
|
|
1351
1346
|
}
|
|
1352
1347
|
// Only send duration message if request was not aborted or was aborted with 'finished' reason
|
|
1353
1348
|
if (!abortController.signal.aborted ||
|
|
@@ -1355,8 +1350,23 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1355
1350
|
const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
|
|
1356
1351
|
const attachCommand = port ? ` ⋅ ${session.id}` : '';
|
|
1357
1352
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
|
|
1358
|
-
|
|
1359
|
-
|
|
1353
|
+
let contextInfo = '';
|
|
1354
|
+
if (inputTokens > 0 && usedProviderID && usedModel) {
|
|
1355
|
+
try {
|
|
1356
|
+
const providersResponse = await getClient().provider.list({ query: { directory } });
|
|
1357
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
|
|
1358
|
+
const model = provider?.models?.[usedModel];
|
|
1359
|
+
if (model?.limit?.context) {
|
|
1360
|
+
const percentage = Math.round((inputTokens / model.limit.context) * 100);
|
|
1361
|
+
contextInfo = ` ⋅ ${percentage}%`;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
catch (e) {
|
|
1365
|
+
sessionLogger.error('Failed to fetch provider info for context percentage:', e);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`);
|
|
1369
|
+
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${inputTokens}`);
|
|
1360
1370
|
}
|
|
1361
1371
|
else {
|
|
1362
1372
|
sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
|
|
@@ -1364,22 +1374,36 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1364
1374
|
}
|
|
1365
1375
|
};
|
|
1366
1376
|
try {
|
|
1367
|
-
voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
|
1368
|
-
if (images.length > 0) {
|
|
1369
|
-
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
|
|
1370
|
-
}
|
|
1371
1377
|
// Start the event handler
|
|
1372
1378
|
const eventHandlerPromise = eventHandler();
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1379
|
+
let response;
|
|
1380
|
+
if (parsedCommand?.isCommand) {
|
|
1381
|
+
sessionLogger.log(`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`);
|
|
1382
|
+
response = await getClient().session.command({
|
|
1383
|
+
path: { id: session.id },
|
|
1384
|
+
body: {
|
|
1385
|
+
command: parsedCommand.command,
|
|
1386
|
+
arguments: parsedCommand.arguments,
|
|
1387
|
+
},
|
|
1388
|
+
signal: abortController.signal,
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
else {
|
|
1392
|
+
voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
|
1393
|
+
if (images.length > 0) {
|
|
1394
|
+
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
|
|
1395
|
+
}
|
|
1396
|
+
const parts = [{ type: 'text', text: prompt }, ...images];
|
|
1397
|
+
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
|
|
1398
|
+
response = await getClient().session.prompt({
|
|
1399
|
+
path: { id: session.id },
|
|
1400
|
+
body: {
|
|
1401
|
+
parts,
|
|
1402
|
+
system: getOpencodeSystemMessage({ sessionId: session.id }),
|
|
1403
|
+
},
|
|
1404
|
+
signal: abortController.signal,
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1383
1407
|
abortController.abort('finished');
|
|
1384
1408
|
sessionLogger.log(`Successfully sent prompt, got response`);
|
|
1385
1409
|
// Update reaction to success
|
|
@@ -1493,7 +1517,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1493
1517
|
discordClient.on(Events.MessageCreate, async (message) => {
|
|
1494
1518
|
try {
|
|
1495
1519
|
if (message.author?.bot) {
|
|
1496
|
-
voiceLogger.log(`[IGNORED] Bot message from ${message.author.tag} in channel ${message.channelId}`);
|
|
1497
1520
|
return;
|
|
1498
1521
|
}
|
|
1499
1522
|
if (message.partial) {
|
|
@@ -1511,10 +1534,8 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1511
1534
|
const isOwner = message.member.id === message.guild.ownerId;
|
|
1512
1535
|
const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator);
|
|
1513
1536
|
if (!isOwner && !isAdmin) {
|
|
1514
|
-
voiceLogger.log(`[IGNORED] Non-authoritative user ${message.author.tag} (ID: ${message.author.id}) - not owner or admin`);
|
|
1515
1537
|
return;
|
|
1516
1538
|
}
|
|
1517
|
-
voiceLogger.log(`[AUTHORIZED] Message from ${message.author.tag} (Owner: ${isOwner}, Admin: ${isAdmin})`);
|
|
1518
1539
|
}
|
|
1519
1540
|
const channel = message.channel;
|
|
1520
1541
|
const isThread = [
|
|
@@ -1568,12 +1589,14 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1568
1589
|
messageContent = transcription;
|
|
1569
1590
|
}
|
|
1570
1591
|
const images = getImageAttachments(message);
|
|
1592
|
+
const parsedCommand = parseSlashCommand(messageContent);
|
|
1571
1593
|
await handleOpencodeSession({
|
|
1572
1594
|
prompt: messageContent,
|
|
1573
1595
|
thread,
|
|
1574
1596
|
projectDirectory,
|
|
1575
1597
|
originalMessage: message,
|
|
1576
1598
|
images,
|
|
1599
|
+
parsedCommand,
|
|
1577
1600
|
});
|
|
1578
1601
|
return;
|
|
1579
1602
|
}
|
|
@@ -1634,12 +1657,14 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1634
1657
|
messageContent = transcription;
|
|
1635
1658
|
}
|
|
1636
1659
|
const images = getImageAttachments(message);
|
|
1660
|
+
const parsedCommand = parseSlashCommand(messageContent);
|
|
1637
1661
|
await handleOpencodeSession({
|
|
1638
1662
|
prompt: messageContent,
|
|
1639
1663
|
thread,
|
|
1640
1664
|
projectDirectory,
|
|
1641
1665
|
originalMessage: message,
|
|
1642
1666
|
images,
|
|
1667
|
+
parsedCommand,
|
|
1643
1668
|
});
|
|
1644
1669
|
}
|
|
1645
1670
|
else {
|
|
@@ -1895,10 +1920,12 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1895
1920
|
});
|
|
1896
1921
|
await command.editReply(`Created new session in ${thread.toString()}`);
|
|
1897
1922
|
// Start the OpenCode session
|
|
1923
|
+
const parsedCommand = parseSlashCommand(fullPrompt);
|
|
1898
1924
|
await handleOpencodeSession({
|
|
1899
1925
|
prompt: fullPrompt,
|
|
1900
1926
|
thread,
|
|
1901
1927
|
projectDirectory,
|
|
1928
|
+
parsedCommand,
|
|
1902
1929
|
});
|
|
1903
1930
|
}
|
|
1904
1931
|
catch (error) {
|
|
@@ -2167,6 +2194,68 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2167
2194
|
});
|
|
2168
2195
|
}
|
|
2169
2196
|
}
|
|
2197
|
+
else if (command.commandName === 'abort') {
|
|
2198
|
+
const channel = command.channel;
|
|
2199
|
+
if (!channel) {
|
|
2200
|
+
await command.reply({
|
|
2201
|
+
content: 'This command can only be used in a channel',
|
|
2202
|
+
ephemeral: true,
|
|
2203
|
+
});
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
const isThread = [
|
|
2207
|
+
ChannelType.PublicThread,
|
|
2208
|
+
ChannelType.PrivateThread,
|
|
2209
|
+
ChannelType.AnnouncementThread,
|
|
2210
|
+
].includes(channel.type);
|
|
2211
|
+
if (!isThread) {
|
|
2212
|
+
await command.reply({
|
|
2213
|
+
content: 'This command can only be used in a thread with an active session',
|
|
2214
|
+
ephemeral: true,
|
|
2215
|
+
});
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
const textChannel = resolveTextChannel(channel);
|
|
2219
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel);
|
|
2220
|
+
if (!directory) {
|
|
2221
|
+
await command.reply({
|
|
2222
|
+
content: 'Could not determine project directory for this channel',
|
|
2223
|
+
ephemeral: true,
|
|
2224
|
+
});
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
const row = getDatabase()
|
|
2228
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
2229
|
+
.get(channel.id);
|
|
2230
|
+
if (!row?.session_id) {
|
|
2231
|
+
await command.reply({
|
|
2232
|
+
content: 'No active session in this thread',
|
|
2233
|
+
ephemeral: true,
|
|
2234
|
+
});
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
const sessionId = row.session_id;
|
|
2238
|
+
try {
|
|
2239
|
+
const existingController = abortControllers.get(sessionId);
|
|
2240
|
+
if (existingController) {
|
|
2241
|
+
existingController.abort(new Error('User requested abort'));
|
|
2242
|
+
abortControllers.delete(sessionId);
|
|
2243
|
+
}
|
|
2244
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
2245
|
+
await getClient().session.abort({
|
|
2246
|
+
path: { id: sessionId },
|
|
2247
|
+
});
|
|
2248
|
+
await command.reply(`🛑 Request **aborted**`);
|
|
2249
|
+
sessionLogger.log(`Session ${sessionId} aborted by user`);
|
|
2250
|
+
}
|
|
2251
|
+
catch (error) {
|
|
2252
|
+
voiceLogger.error('[ABORT] Error:', error);
|
|
2253
|
+
await command.reply({
|
|
2254
|
+
content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2255
|
+
ephemeral: true,
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2170
2259
|
}
|
|
2171
2260
|
}
|
|
2172
2261
|
catch (error) {
|
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,
|
|
11
|
+
import { initializeOpencodeForDirectory, getOpencodeSystemMessage, } from './discordBot.js';
|
|
12
12
|
export async function getTools({ onMessageCompleted, directory, }) {
|
|
13
13
|
const getClient = await initializeOpencodeForDirectory(directory);
|
|
14
14
|
const client = getClient();
|
|
@@ -48,7 +48,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
48
48
|
body: {
|
|
49
49
|
parts: [{ type: 'text', text: message }],
|
|
50
50
|
model: sessionModel,
|
|
51
|
-
system:
|
|
51
|
+
system: getOpencodeSystemMessage({ sessionId }),
|
|
52
52
|
},
|
|
53
53
|
})
|
|
54
54
|
.then(async (response) => {
|
|
@@ -115,7 +115,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
115
115
|
path: { id: session.data.id },
|
|
116
116
|
body: {
|
|
117
117
|
parts: [{ type: 'text', text: message }],
|
|
118
|
-
system:
|
|
118
|
+
system: getOpencodeSystemMessage({ sessionId: session.data.id }),
|
|
119
119
|
},
|
|
120
120
|
})
|
|
121
121
|
.then(async (response) => {
|
package/package.json
CHANGED
|
@@ -2,17 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
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
|
-
},
|
|
5
|
+
"version": "0.4.14",
|
|
16
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
17
7
|
"bin": "bin.js",
|
|
18
8
|
"files": [
|
|
@@ -54,5 +44,14 @@
|
|
|
54
44
|
"string-dedent": "^3.0.2",
|
|
55
45
|
"undici": "^7.16.0",
|
|
56
46
|
"zod": "^4.0.17"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"dev": "tsx --env-file .env src/cli.ts",
|
|
50
|
+
"dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
|
|
51
|
+
"watch": "tsx scripts/watch-session.ts",
|
|
52
|
+
"test:events": "tsx test-events.ts",
|
|
53
|
+
"pcm-to-mp3": "bun scripts/pcm-to-mp3",
|
|
54
|
+
"test:send": "tsx send-test-message.ts",
|
|
55
|
+
"register-commands": "tsx scripts/register-commands.ts"
|
|
57
56
|
}
|
|
58
|
-
}
|
|
57
|
+
}
|