kimaki 0.4.13 → 0.4.15
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 +1 -1
- 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
package/src/cli.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
REST,
|
|
34
34
|
Routes,
|
|
35
35
|
SlashCommandBuilder,
|
|
36
|
+
AttachmentBuilder,
|
|
36
37
|
} from 'discord.js'
|
|
37
38
|
import path from 'node:path'
|
|
38
39
|
import fs from 'node:fs'
|
|
@@ -134,12 +135,16 @@ async function registerCommands(token: string, appId: string) {
|
|
|
134
135
|
.toJSON(),
|
|
135
136
|
new SlashCommandBuilder()
|
|
136
137
|
.setName('accept-always')
|
|
137
|
-
.setDescription('Accept and auto-approve future requests matching this pattern
|
|
138
|
+
.setDescription('Accept and auto-approve future requests matching this pattern')
|
|
138
139
|
.toJSON(),
|
|
139
140
|
new SlashCommandBuilder()
|
|
140
141
|
.setName('reject')
|
|
141
142
|
.setDescription('Reject a pending permission request')
|
|
142
143
|
.toJSON(),
|
|
144
|
+
new SlashCommandBuilder()
|
|
145
|
+
.setName('abort')
|
|
146
|
+
.setDescription('Abort the current OpenCode request in this thread')
|
|
147
|
+
.toJSON(),
|
|
143
148
|
]
|
|
144
149
|
|
|
145
150
|
const rest = new REST().setToken(token)
|
|
@@ -832,28 +837,120 @@ cli
|
|
|
832
837
|
})
|
|
833
838
|
|
|
834
839
|
cli
|
|
835
|
-
.command('
|
|
840
|
+
.command('upload-to-discord [...files]', 'Upload files to a Discord thread for a session')
|
|
841
|
+
.option('-s, --session <sessionId>', 'OpenCode session ID')
|
|
842
|
+
.action(async (files: string[], options: { session?: string }) => {
|
|
843
|
+
try {
|
|
844
|
+
const { session: sessionId } = options
|
|
845
|
+
|
|
846
|
+
if (!sessionId) {
|
|
847
|
+
cliLogger.error('Session ID is required. Use --session <sessionId>')
|
|
848
|
+
process.exit(EXIT_NO_RESTART)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (!files || files.length === 0) {
|
|
852
|
+
cliLogger.error('At least one file path is required')
|
|
853
|
+
process.exit(EXIT_NO_RESTART)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const resolvedFiles = files.map((f) => path.resolve(f))
|
|
857
|
+
for (const file of resolvedFiles) {
|
|
858
|
+
if (!fs.existsSync(file)) {
|
|
859
|
+
cliLogger.error(`File not found: ${file}`)
|
|
860
|
+
process.exit(EXIT_NO_RESTART)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const db = getDatabase()
|
|
865
|
+
|
|
866
|
+
const threadRow = db
|
|
867
|
+
.prepare('SELECT thread_id FROM thread_sessions WHERE session_id = ?')
|
|
868
|
+
.get(sessionId) as { thread_id: string } | undefined
|
|
869
|
+
|
|
870
|
+
if (!threadRow) {
|
|
871
|
+
cliLogger.error(`No Discord thread found for session: ${sessionId}`)
|
|
872
|
+
cliLogger.error('Make sure the session has been sent to Discord first using /send-to-kimaki-discord')
|
|
873
|
+
process.exit(EXIT_NO_RESTART)
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const botRow = db
|
|
877
|
+
.prepare(
|
|
878
|
+
'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
|
|
879
|
+
)
|
|
880
|
+
.get() as { app_id: string; token: string } | undefined
|
|
881
|
+
|
|
882
|
+
if (!botRow) {
|
|
883
|
+
cliLogger.error('No bot credentials found. Run `kimaki` first to set up the bot.')
|
|
884
|
+
process.exit(EXIT_NO_RESTART)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const s = spinner()
|
|
888
|
+
s.start(`Uploading ${resolvedFiles.length} file(s)...`)
|
|
889
|
+
|
|
890
|
+
for (const file of resolvedFiles) {
|
|
891
|
+
const buffer = fs.readFileSync(file)
|
|
892
|
+
|
|
893
|
+
const formData = new FormData()
|
|
894
|
+
formData.append('payload_json', JSON.stringify({
|
|
895
|
+
attachments: [{ id: 0, filename: path.basename(file) }]
|
|
896
|
+
}))
|
|
897
|
+
formData.append('files[0]', new Blob([buffer]), path.basename(file))
|
|
898
|
+
|
|
899
|
+
const response = await fetch(
|
|
900
|
+
`https://discord.com/api/v10/channels/${threadRow.thread_id}/messages`,
|
|
901
|
+
{
|
|
902
|
+
method: 'POST',
|
|
903
|
+
headers: {
|
|
904
|
+
'Authorization': `Bot ${botRow.token}`,
|
|
905
|
+
},
|
|
906
|
+
body: formData,
|
|
907
|
+
}
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
if (!response.ok) {
|
|
911
|
+
const error = await response.text()
|
|
912
|
+
throw new Error(`Discord API error: ${response.status} - ${error}`)
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
s.stop(`Uploaded ${resolvedFiles.length} file(s)!`)
|
|
917
|
+
|
|
918
|
+
note(
|
|
919
|
+
`Files uploaded to Discord thread!\n\nFiles: ${resolvedFiles.map((f) => path.basename(f)).join(', ')}`,
|
|
920
|
+
'✅ Success',
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
process.exit(0)
|
|
924
|
+
} catch (error) {
|
|
925
|
+
cliLogger.error(
|
|
926
|
+
'Error:',
|
|
927
|
+
error instanceof Error ? error.message : String(error),
|
|
928
|
+
)
|
|
929
|
+
process.exit(EXIT_NO_RESTART)
|
|
930
|
+
}
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
cli
|
|
934
|
+
.command('install-plugin', 'Install the OpenCode commands for kimaki Discord integration')
|
|
836
935
|
.action(async () => {
|
|
837
936
|
try {
|
|
838
937
|
const require = createRequire(import.meta.url)
|
|
839
|
-
const
|
|
840
|
-
const
|
|
938
|
+
const sendCommandSrc = require.resolve('./opencode-command-send-to-discord.md')
|
|
939
|
+
const uploadCommandSrc = require.resolve('./opencode-command-upload-to-discord.md')
|
|
841
940
|
|
|
842
941
|
const opencodeConfig = path.join(os.homedir(), '.config', 'opencode')
|
|
843
|
-
const pluginDir = path.join(opencodeConfig, 'plugin')
|
|
844
942
|
const commandDir = path.join(opencodeConfig, 'command')
|
|
845
943
|
|
|
846
|
-
fs.mkdirSync(pluginDir, { recursive: true })
|
|
847
944
|
fs.mkdirSync(commandDir, { recursive: true })
|
|
848
945
|
|
|
849
|
-
const
|
|
850
|
-
const
|
|
946
|
+
const sendCommandDest = path.join(commandDir, 'send-to-kimaki-discord.md')
|
|
947
|
+
const uploadCommandDest = path.join(commandDir, 'upload-to-discord.md')
|
|
851
948
|
|
|
852
|
-
fs.copyFileSync(
|
|
853
|
-
fs.copyFileSync(
|
|
949
|
+
fs.copyFileSync(sendCommandSrc, sendCommandDest)
|
|
950
|
+
fs.copyFileSync(uploadCommandSrc, uploadCommandDest)
|
|
854
951
|
|
|
855
952
|
note(
|
|
856
|
-
`
|
|
953
|
+
`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.`,
|
|
857
954
|
'✅ Installed',
|
|
858
955
|
)
|
|
859
956
|
|
package/src/discordBot.ts
CHANGED
|
@@ -54,9 +54,34 @@ import { setGlobalDispatcher, Agent } from 'undici'
|
|
|
54
54
|
// disables the automatic 5 minutes abort after no body
|
|
55
55
|
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }))
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
type ParsedCommand = {
|
|
58
|
+
isCommand: true
|
|
59
|
+
command: string
|
|
60
|
+
arguments: string
|
|
61
|
+
} | {
|
|
62
|
+
isCommand: false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseSlashCommand(text: string): ParsedCommand {
|
|
66
|
+
const trimmed = text.trim()
|
|
67
|
+
if (!trimmed.startsWith('/')) {
|
|
68
|
+
return { isCommand: false }
|
|
69
|
+
}
|
|
70
|
+
const match = trimmed.match(/^\/(\S+)(?:\s+(.*))?$/)
|
|
71
|
+
if (!match) {
|
|
72
|
+
return { isCommand: false }
|
|
73
|
+
}
|
|
74
|
+
const command = match[1]!
|
|
75
|
+
const args = match[2]?.trim() || ''
|
|
76
|
+
return { isCommand: true, command, arguments: args }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getOpencodeSystemMessage({ sessionId }: { sessionId: string }) {
|
|
80
|
+
return `
|
|
58
81
|
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
59
82
|
|
|
83
|
+
Your current OpenCode session ID is: ${sessionId}
|
|
84
|
+
|
|
60
85
|
After each message, if you implemented changes, you can show the user a diff via an url running the command, to show the changes in working directory:
|
|
61
86
|
|
|
62
87
|
bunx critique web
|
|
@@ -98,6 +123,7 @@ code blocks for tables and diagrams MUST have Max length of 85 characters. other
|
|
|
98
123
|
|
|
99
124
|
you can create diagrams wrapping them in code blocks too.
|
|
100
125
|
`
|
|
126
|
+
}
|
|
101
127
|
|
|
102
128
|
const discordLogger = createLogger('DISCORD')
|
|
103
129
|
const voiceLogger = createLogger('VOICE')
|
|
@@ -1349,12 +1375,14 @@ async function handleOpencodeSession({
|
|
|
1349
1375
|
projectDirectory,
|
|
1350
1376
|
originalMessage,
|
|
1351
1377
|
images = [],
|
|
1378
|
+
parsedCommand,
|
|
1352
1379
|
}: {
|
|
1353
1380
|
prompt: string
|
|
1354
1381
|
thread: ThreadChannel
|
|
1355
1382
|
projectDirectory?: string
|
|
1356
1383
|
originalMessage?: Message
|
|
1357
1384
|
images?: FilePartInput[]
|
|
1385
|
+
parsedCommand?: ParsedCommand
|
|
1358
1386
|
}): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
|
|
1359
1387
|
voiceLogger.log(
|
|
1360
1388
|
`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
|
|
@@ -1477,6 +1505,8 @@ async function handleOpencodeSession({
|
|
|
1477
1505
|
let currentParts: Part[] = []
|
|
1478
1506
|
let stopTyping: (() => void) | null = null
|
|
1479
1507
|
let usedModel: string | undefined
|
|
1508
|
+
let usedProviderID: string | undefined
|
|
1509
|
+
let inputTokens = 0
|
|
1480
1510
|
|
|
1481
1511
|
const sendPartMessage = async (part: Part) => {
|
|
1482
1512
|
const content = formatPart(part) + '\n\n'
|
|
@@ -1487,22 +1517,12 @@ async function handleOpencodeSession({
|
|
|
1487
1517
|
|
|
1488
1518
|
// Skip if already sent
|
|
1489
1519
|
if (partIdToMessage.has(part.id)) {
|
|
1490
|
-
voiceLogger.log(
|
|
1491
|
-
`[SEND SKIP] Part ${part.id} already sent as message ${partIdToMessage.get(part.id)?.id}`,
|
|
1492
|
-
)
|
|
1493
1520
|
return
|
|
1494
1521
|
}
|
|
1495
1522
|
|
|
1496
1523
|
try {
|
|
1497
|
-
voiceLogger.log(
|
|
1498
|
-
`[SEND] Sending part ${part.id} (type: ${part.type}) to Discord, content length: ${content.length}`,
|
|
1499
|
-
)
|
|
1500
|
-
|
|
1501
1524
|
const firstMessage = await sendThreadMessage(thread, content)
|
|
1502
1525
|
partIdToMessage.set(part.id, firstMessage)
|
|
1503
|
-
voiceLogger.log(
|
|
1504
|
-
`[SEND SUCCESS] Part ${part.id} sent as message ${firstMessage.id}`,
|
|
1505
|
-
)
|
|
1506
1526
|
|
|
1507
1527
|
// Store part-message mapping in database
|
|
1508
1528
|
getDatabase()
|
|
@@ -1525,13 +1545,10 @@ async function handleOpencodeSession({
|
|
|
1525
1545
|
discordLogger.log(`Not starting typing, already aborted`)
|
|
1526
1546
|
return () => {}
|
|
1527
1547
|
}
|
|
1528
|
-
discordLogger.log(`Starting typing for thread ${thread.id}`)
|
|
1529
|
-
|
|
1530
1548
|
// Clear any previous typing interval
|
|
1531
1549
|
if (typingInterval) {
|
|
1532
1550
|
clearInterval(typingInterval)
|
|
1533
1551
|
typingInterval = null
|
|
1534
|
-
discordLogger.log(`Cleared previous typing interval`)
|
|
1535
1552
|
}
|
|
1536
1553
|
|
|
1537
1554
|
// Send initial typing
|
|
@@ -1567,7 +1584,6 @@ async function handleOpencodeSession({
|
|
|
1567
1584
|
if (typingInterval) {
|
|
1568
1585
|
clearInterval(typingInterval)
|
|
1569
1586
|
typingInterval = null
|
|
1570
|
-
discordLogger.log(`Stopped typing for thread ${thread.id}`)
|
|
1571
1587
|
}
|
|
1572
1588
|
}
|
|
1573
1589
|
}
|
|
@@ -1576,45 +1592,31 @@ async function handleOpencodeSession({
|
|
|
1576
1592
|
let assistantMessageId: string | undefined
|
|
1577
1593
|
|
|
1578
1594
|
for await (const event of events) {
|
|
1579
|
-
sessionLogger.log(`Received: ${event.type}`)
|
|
1580
1595
|
if (event.type === 'message.updated') {
|
|
1581
1596
|
const msg = event.properties.info
|
|
1582
1597
|
|
|
1583
1598
|
if (msg.sessionID !== session.id) {
|
|
1584
|
-
voiceLogger.log(
|
|
1585
|
-
`[EVENT IGNORED] Message from different session (expected: ${session.id}, got: ${msg.sessionID})`,
|
|
1586
|
-
)
|
|
1587
1599
|
continue
|
|
1588
1600
|
}
|
|
1589
1601
|
|
|
1590
1602
|
// Track assistant message ID
|
|
1591
1603
|
if (msg.role === 'assistant') {
|
|
1592
1604
|
assistantMessageId = msg.id
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
1605
|
usedModel = msg.modelID
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
} else {
|
|
1601
|
-
sessionLogger.log(`Message role: ${msg.role}`)
|
|
1606
|
+
usedProviderID = msg.providerID
|
|
1607
|
+
if (msg.tokens.input > 0) {
|
|
1608
|
+
inputTokens = msg.tokens.input
|
|
1609
|
+
}
|
|
1602
1610
|
}
|
|
1603
1611
|
} else if (event.type === 'message.part.updated') {
|
|
1604
1612
|
const part = event.properties.part
|
|
1605
1613
|
|
|
1606
1614
|
if (part.sessionID !== session.id) {
|
|
1607
|
-
voiceLogger.log(
|
|
1608
|
-
`[EVENT IGNORED] Part from different session (expected: ${session.id}, got: ${part.sessionID})`,
|
|
1609
|
-
)
|
|
1610
1615
|
continue
|
|
1611
1616
|
}
|
|
1612
1617
|
|
|
1613
1618
|
// Only process parts from assistant messages
|
|
1614
1619
|
if (part.messageID !== assistantMessageId) {
|
|
1615
|
-
voiceLogger.log(
|
|
1616
|
-
`[EVENT IGNORED] Part from non-assistant message (expected: ${assistantMessageId}, got: ${part.messageID})`,
|
|
1617
|
-
)
|
|
1618
1620
|
continue
|
|
1619
1621
|
}
|
|
1620
1622
|
|
|
@@ -1627,9 +1629,7 @@ async function handleOpencodeSession({
|
|
|
1627
1629
|
currentParts.push(part)
|
|
1628
1630
|
}
|
|
1629
1631
|
|
|
1630
|
-
|
|
1631
|
-
`[PART] Update: id=${part.id}, type=${part.type}, text=${'text' in part && typeof part.text === 'string' ? part.text.slice(0, 50) : ''}`,
|
|
1632
|
-
)
|
|
1632
|
+
|
|
1633
1633
|
|
|
1634
1634
|
// Start typing on step-start
|
|
1635
1635
|
if (part.type === 'step-start') {
|
|
@@ -1638,10 +1638,12 @@ async function handleOpencodeSession({
|
|
|
1638
1638
|
|
|
1639
1639
|
// Check if this is a step-finish part
|
|
1640
1640
|
if (part.type === 'step-finish') {
|
|
1641
|
+
// Track tokens from step-finish part
|
|
1642
|
+
if (part.tokens?.input && part.tokens.input > 0) {
|
|
1643
|
+
inputTokens = part.tokens.input
|
|
1644
|
+
voiceLogger.log(`[STEP-FINISH] Captured tokens: ${inputTokens}`)
|
|
1645
|
+
}
|
|
1641
1646
|
// Send all parts accumulated so far to Discord
|
|
1642
|
-
voiceLogger.log(
|
|
1643
|
-
`[STEP-FINISH] Sending ${currentParts.length} parts to Discord`,
|
|
1644
|
-
)
|
|
1645
1647
|
for (const p of currentParts) {
|
|
1646
1648
|
// Skip step-start and step-finish parts as they have no visual content
|
|
1647
1649
|
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
@@ -1728,10 +1730,6 @@ async function handleOpencodeSession({
|
|
|
1728
1730
|
if (pending && pending.permission.id === permissionID) {
|
|
1729
1731
|
pendingPermissions.delete(thread.id)
|
|
1730
1732
|
}
|
|
1731
|
-
} else if (event.type === 'file.edited') {
|
|
1732
|
-
sessionLogger.log(`File edited event received`)
|
|
1733
|
-
} else {
|
|
1734
|
-
sessionLogger.log(`Unhandled event type: ${event.type}`)
|
|
1735
1733
|
}
|
|
1736
1734
|
}
|
|
1737
1735
|
} catch (e) {
|
|
@@ -1745,37 +1743,20 @@ async function handleOpencodeSession({
|
|
|
1745
1743
|
throw e
|
|
1746
1744
|
} finally {
|
|
1747
1745
|
// Send any remaining parts that weren't sent
|
|
1748
|
-
voiceLogger.log(
|
|
1749
|
-
`[CLEANUP] Checking ${currentParts.length} parts for unsent messages`,
|
|
1750
|
-
)
|
|
1751
|
-
let unsentCount = 0
|
|
1752
1746
|
for (const part of currentParts) {
|
|
1753
1747
|
if (!partIdToMessage.has(part.id)) {
|
|
1754
|
-
unsentCount++
|
|
1755
|
-
voiceLogger.log(
|
|
1756
|
-
`[CLEANUP] Sending unsent part: id=${part.id}, type=${part.type}`,
|
|
1757
|
-
)
|
|
1758
1748
|
try {
|
|
1759
1749
|
await sendPartMessage(part)
|
|
1760
1750
|
} catch (error) {
|
|
1761
|
-
sessionLogger.
|
|
1762
|
-
`Failed to send part ${part.id} during cleanup:`,
|
|
1763
|
-
error,
|
|
1764
|
-
)
|
|
1751
|
+
sessionLogger.error(`Failed to send part ${part.id}:`, error)
|
|
1765
1752
|
}
|
|
1766
1753
|
}
|
|
1767
1754
|
}
|
|
1768
|
-
if (unsentCount === 0) {
|
|
1769
|
-
sessionLogger.log(`All parts were already sent`)
|
|
1770
|
-
} else {
|
|
1771
|
-
sessionLogger.log(`Sent ${unsentCount} previously unsent parts`)
|
|
1772
|
-
}
|
|
1773
1755
|
|
|
1774
1756
|
// Stop typing when session ends
|
|
1775
1757
|
if (stopTyping) {
|
|
1776
1758
|
stopTyping()
|
|
1777
1759
|
stopTyping = null
|
|
1778
|
-
sessionLogger.log(`Stopped typing for session`)
|
|
1779
1760
|
}
|
|
1780
1761
|
|
|
1781
1762
|
// Only send duration message if request was not aborted or was aborted with 'finished' reason
|
|
@@ -1788,8 +1769,22 @@ async function handleOpencodeSession({
|
|
|
1788
1769
|
)
|
|
1789
1770
|
const attachCommand = port ? ` ⋅ ${session.id}` : ''
|
|
1790
1771
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
|
|
1791
|
-
|
|
1792
|
-
|
|
1772
|
+
let contextInfo = ''
|
|
1773
|
+
if (inputTokens > 0 && usedProviderID && usedModel) {
|
|
1774
|
+
try {
|
|
1775
|
+
const providersResponse = await getClient().provider.list({ query: { directory } })
|
|
1776
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
|
|
1777
|
+
const model = provider?.models?.[usedModel]
|
|
1778
|
+
if (model?.limit?.context) {
|
|
1779
|
+
const percentage = Math.round((inputTokens / model.limit.context) * 100)
|
|
1780
|
+
contextInfo = ` ⋅ ${percentage}%`
|
|
1781
|
+
}
|
|
1782
|
+
} catch (e) {
|
|
1783
|
+
sessionLogger.error('Failed to fetch provider info for context percentage:', e)
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`)
|
|
1787
|
+
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${inputTokens}`)
|
|
1793
1788
|
} else {
|
|
1794
1789
|
sessionLogger.log(
|
|
1795
1790
|
`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
|
|
@@ -1799,27 +1794,42 @@ async function handleOpencodeSession({
|
|
|
1799
1794
|
}
|
|
1800
1795
|
|
|
1801
1796
|
try {
|
|
1802
|
-
voiceLogger.log(
|
|
1803
|
-
`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
|
|
1804
|
-
)
|
|
1805
|
-
if (images.length > 0) {
|
|
1806
|
-
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
1797
|
// Start the event handler
|
|
1810
1798
|
const eventHandlerPromise = eventHandler()
|
|
1811
1799
|
|
|
1812
|
-
|
|
1813
|
-
|
|
1800
|
+
let response: { data?: unknown }
|
|
1801
|
+
if (parsedCommand?.isCommand) {
|
|
1802
|
+
sessionLogger.log(
|
|
1803
|
+
`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`,
|
|
1804
|
+
)
|
|
1805
|
+
response = await getClient().session.command({
|
|
1806
|
+
path: { id: session.id },
|
|
1807
|
+
body: {
|
|
1808
|
+
command: parsedCommand.command,
|
|
1809
|
+
arguments: parsedCommand.arguments,
|
|
1810
|
+
},
|
|
1811
|
+
signal: abortController.signal,
|
|
1812
|
+
})
|
|
1813
|
+
} else {
|
|
1814
|
+
voiceLogger.log(
|
|
1815
|
+
`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
|
|
1816
|
+
)
|
|
1817
|
+
if (images.length > 0) {
|
|
1818
|
+
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
|
|
1819
|
+
}
|
|
1814
1820
|
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1821
|
+
const parts = [{ type: 'text' as const, text: prompt }, ...images]
|
|
1822
|
+
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
|
|
1823
|
+
|
|
1824
|
+
response = await getClient().session.prompt({
|
|
1825
|
+
path: { id: session.id },
|
|
1826
|
+
body: {
|
|
1827
|
+
parts,
|
|
1828
|
+
system: getOpencodeSystemMessage({ sessionId: session.id }),
|
|
1829
|
+
},
|
|
1830
|
+
signal: abortController.signal,
|
|
1831
|
+
})
|
|
1832
|
+
}
|
|
1823
1833
|
abortController.abort('finished')
|
|
1824
1834
|
|
|
1825
1835
|
sessionLogger.log(`Successfully sent prompt, got response`)
|
|
@@ -1976,9 +1986,6 @@ export async function startDiscordBot({
|
|
|
1976
1986
|
discordClient.on(Events.MessageCreate, async (message: Message) => {
|
|
1977
1987
|
try {
|
|
1978
1988
|
if (message.author?.bot) {
|
|
1979
|
-
voiceLogger.log(
|
|
1980
|
-
`[IGNORED] Bot message from ${message.author.tag} in channel ${message.channelId}`,
|
|
1981
|
-
)
|
|
1982
1989
|
return
|
|
1983
1990
|
}
|
|
1984
1991
|
if (message.partial) {
|
|
@@ -2002,15 +2009,8 @@ export async function startDiscordBot({
|
|
|
2002
2009
|
)
|
|
2003
2010
|
|
|
2004
2011
|
if (!isOwner && !isAdmin) {
|
|
2005
|
-
voiceLogger.log(
|
|
2006
|
-
`[IGNORED] Non-authoritative user ${message.author.tag} (ID: ${message.author.id}) - not owner or admin`,
|
|
2007
|
-
)
|
|
2008
2012
|
return
|
|
2009
2013
|
}
|
|
2010
|
-
|
|
2011
|
-
voiceLogger.log(
|
|
2012
|
-
`[AUTHORIZED] Message from ${message.author.tag} (Owner: ${isOwner}, Admin: ${isAdmin})`,
|
|
2013
|
-
)
|
|
2014
2014
|
}
|
|
2015
2015
|
|
|
2016
2016
|
const channel = message.channel
|
|
@@ -2084,12 +2084,14 @@ export async function startDiscordBot({
|
|
|
2084
2084
|
}
|
|
2085
2085
|
|
|
2086
2086
|
const images = getImageAttachments(message)
|
|
2087
|
+
const parsedCommand = parseSlashCommand(messageContent)
|
|
2087
2088
|
await handleOpencodeSession({
|
|
2088
2089
|
prompt: messageContent,
|
|
2089
2090
|
thread,
|
|
2090
2091
|
projectDirectory,
|
|
2091
2092
|
originalMessage: message,
|
|
2092
2093
|
images,
|
|
2094
|
+
parsedCommand,
|
|
2093
2095
|
})
|
|
2094
2096
|
return
|
|
2095
2097
|
}
|
|
@@ -2179,12 +2181,14 @@ export async function startDiscordBot({
|
|
|
2179
2181
|
}
|
|
2180
2182
|
|
|
2181
2183
|
const images = getImageAttachments(message)
|
|
2184
|
+
const parsedCommand = parseSlashCommand(messageContent)
|
|
2182
2185
|
await handleOpencodeSession({
|
|
2183
2186
|
prompt: messageContent,
|
|
2184
2187
|
thread,
|
|
2185
2188
|
projectDirectory,
|
|
2186
2189
|
originalMessage: message,
|
|
2187
2190
|
images,
|
|
2191
|
+
parsedCommand,
|
|
2188
2192
|
})
|
|
2189
2193
|
} else {
|
|
2190
2194
|
discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
@@ -2520,10 +2524,12 @@ export async function startDiscordBot({
|
|
|
2520
2524
|
)
|
|
2521
2525
|
|
|
2522
2526
|
// Start the OpenCode session
|
|
2527
|
+
const parsedCommand = parseSlashCommand(fullPrompt)
|
|
2523
2528
|
await handleOpencodeSession({
|
|
2524
2529
|
prompt: fullPrompt,
|
|
2525
2530
|
thread,
|
|
2526
2531
|
projectDirectory,
|
|
2532
|
+
parsedCommand,
|
|
2527
2533
|
})
|
|
2528
2534
|
} catch (error) {
|
|
2529
2535
|
voiceLogger.error('[SESSION] Error:', error)
|
|
@@ -2891,6 +2897,77 @@ export async function startDiscordBot({
|
|
|
2891
2897
|
ephemeral: true,
|
|
2892
2898
|
})
|
|
2893
2899
|
}
|
|
2900
|
+
} else if (command.commandName === 'abort') {
|
|
2901
|
+
const channel = command.channel
|
|
2902
|
+
|
|
2903
|
+
if (!channel) {
|
|
2904
|
+
await command.reply({
|
|
2905
|
+
content: 'This command can only be used in a channel',
|
|
2906
|
+
ephemeral: true,
|
|
2907
|
+
})
|
|
2908
|
+
return
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
const isThread = [
|
|
2912
|
+
ChannelType.PublicThread,
|
|
2913
|
+
ChannelType.PrivateThread,
|
|
2914
|
+
ChannelType.AnnouncementThread,
|
|
2915
|
+
].includes(channel.type)
|
|
2916
|
+
|
|
2917
|
+
if (!isThread) {
|
|
2918
|
+
await command.reply({
|
|
2919
|
+
content: 'This command can only be used in a thread with an active session',
|
|
2920
|
+
ephemeral: true,
|
|
2921
|
+
})
|
|
2922
|
+
return
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
const textChannel = resolveTextChannel(channel as ThreadChannel)
|
|
2926
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel)
|
|
2927
|
+
|
|
2928
|
+
if (!directory) {
|
|
2929
|
+
await command.reply({
|
|
2930
|
+
content: 'Could not determine project directory for this channel',
|
|
2931
|
+
ephemeral: true,
|
|
2932
|
+
})
|
|
2933
|
+
return
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
const row = getDatabase()
|
|
2937
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
2938
|
+
.get(channel.id) as { session_id: string } | undefined
|
|
2939
|
+
|
|
2940
|
+
if (!row?.session_id) {
|
|
2941
|
+
await command.reply({
|
|
2942
|
+
content: 'No active session in this thread',
|
|
2943
|
+
ephemeral: true,
|
|
2944
|
+
})
|
|
2945
|
+
return
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
const sessionId = row.session_id
|
|
2949
|
+
|
|
2950
|
+
try {
|
|
2951
|
+
const existingController = abortControllers.get(sessionId)
|
|
2952
|
+
if (existingController) {
|
|
2953
|
+
existingController.abort(new Error('User requested abort'))
|
|
2954
|
+
abortControllers.delete(sessionId)
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
2958
|
+
await getClient().session.abort({
|
|
2959
|
+
path: { id: sessionId },
|
|
2960
|
+
})
|
|
2961
|
+
|
|
2962
|
+
await command.reply(`🛑 Request **aborted**`)
|
|
2963
|
+
sessionLogger.log(`Session ${sessionId} aborted by user`)
|
|
2964
|
+
} catch (error) {
|
|
2965
|
+
voiceLogger.error('[ABORT] Error:', error)
|
|
2966
|
+
await command.reply({
|
|
2967
|
+
content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2968
|
+
ephemeral: true,
|
|
2969
|
+
})
|
|
2970
|
+
}
|
|
2894
2971
|
}
|
|
2895
2972
|
}
|
|
2896
2973
|
} catch (error) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Send current session to Discord
|
|
3
|
+
---
|
|
4
|
+
Run the following command to send this session to Discord:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx -y kimaki send-to-discord <sessionId>
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Replace `<sessionId>` with your current OpenCode session ID (available in the system prompt).
|
|
11
|
+
|
|
12
|
+
The command will create a Discord thread with your session history and return the Discord URL.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Upload files to Discord thread
|
|
3
|
+
---
|
|
4
|
+
Upload files to the current Discord thread by running:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx -y kimaki upload-to-discord --session <sessionId> <file1> [file2] [file3] ...
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Replace `<sessionId>` with your current OpenCode session ID (available in the system prompt).
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# Upload a single file
|
|
16
|
+
npx -y kimaki upload-to-discord --session ses_abc123 ./screenshot.png
|
|
17
|
+
|
|
18
|
+
# Upload multiple files
|
|
19
|
+
npx -y kimaki upload-to-discord --session ses_abc123 ./image1.png ./image2.jpg ./document.pdf
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The session must have been sent to Discord first using `/send-to-kimaki-discord`.
|
package/src/tools.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { ShareMarkdown } from './markdown.js'
|
|
|
17
17
|
import pc from 'picocolors'
|
|
18
18
|
import {
|
|
19
19
|
initializeOpencodeForDirectory,
|
|
20
|
-
|
|
20
|
+
getOpencodeSystemMessage,
|
|
21
21
|
} from './discordBot.js'
|
|
22
22
|
|
|
23
23
|
export async function getTools({
|
|
@@ -78,7 +78,7 @@ export async function getTools({
|
|
|
78
78
|
body: {
|
|
79
79
|
parts: [{ type: 'text', text: message }],
|
|
80
80
|
model: sessionModel,
|
|
81
|
-
system:
|
|
81
|
+
system: getOpencodeSystemMessage({ sessionId }),
|
|
82
82
|
},
|
|
83
83
|
})
|
|
84
84
|
.then(async (response) => {
|
|
@@ -152,7 +152,7 @@ export async function getTools({
|
|
|
152
152
|
path: { id: session.data.id },
|
|
153
153
|
body: {
|
|
154
154
|
parts: [{ type: 'text', text: message }],
|
|
155
|
-
system:
|
|
155
|
+
system: getOpencodeSystemMessage({ sessionId: session.data.id }),
|
|
156
156
|
},
|
|
157
157
|
})
|
|
158
158
|
.then(async (response) => {
|
package/src/opencode-command.md
DELETED
package/src/opencode-plugin.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Kimaki Discord Plugin for OpenCode
|
|
3
|
-
*
|
|
4
|
-
* Adds /send-to-kimaki-discord command that sends the current session to Discord.
|
|
5
|
-
*
|
|
6
|
-
* Installation:
|
|
7
|
-
* kimaki install-plugin
|
|
8
|
-
*
|
|
9
|
-
* Use in OpenCode TUI:
|
|
10
|
-
* /send-to-kimaki-discord
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type { Plugin } from '@opencode-ai/plugin'
|
|
14
|
-
|
|
15
|
-
export const KimakiDiscordPlugin: Plugin = async ({
|
|
16
|
-
client,
|
|
17
|
-
$,
|
|
18
|
-
directory,
|
|
19
|
-
}) => {
|
|
20
|
-
return {
|
|
21
|
-
event: async ({ event }) => {
|
|
22
|
-
if (event.type !== 'command.executed') {
|
|
23
|
-
return
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const { name, sessionID } = event.properties as {
|
|
27
|
-
name: string
|
|
28
|
-
sessionID: string
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (name !== 'send-to-kimaki-discord') {
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (!sessionID) {
|
|
36
|
-
await client.tui.showToast({
|
|
37
|
-
body: { message: 'No session ID available', variant: 'error' },
|
|
38
|
-
})
|
|
39
|
-
return
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
await client.tui.showToast({
|
|
43
|
-
body: { message: 'Creating Discord thread...', variant: 'info' },
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
const result =
|
|
48
|
-
await $`npx -y kimaki send-to-discord ${sessionID} -d ${directory}`.text()
|
|
49
|
-
|
|
50
|
-
const urlMatch = result.match(/https:\/\/discord\.com\/channels\/\S+/)
|
|
51
|
-
const url = urlMatch ? urlMatch[0] : null
|
|
52
|
-
|
|
53
|
-
await client.tui.showToast({
|
|
54
|
-
body: {
|
|
55
|
-
message: url ? `Sent to Discord: ${url}` : 'Session sent to Discord',
|
|
56
|
-
variant: 'success',
|
|
57
|
-
},
|
|
58
|
-
})
|
|
59
|
-
} catch (error: any) {
|
|
60
|
-
const message =
|
|
61
|
-
error.stderr?.toString().trim() ||
|
|
62
|
-
error.stdout?.toString().trim() ||
|
|
63
|
-
error.message ||
|
|
64
|
-
String(error)
|
|
65
|
-
|
|
66
|
-
await client.tui.showToast({
|
|
67
|
-
body: {
|
|
68
|
-
message: `Failed: ${message.slice(0, 100)}`,
|
|
69
|
-
variant: 'error',
|
|
70
|
-
},
|
|
71
|
-
})
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
}
|
|
75
|
-
}
|