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 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 (e.g. "git *" approves all git commands)')
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('install-plugin', 'Install the OpenCode plugin for /send-to-kimaki-discord 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 pluginSrc = require.resolve('./opencode-plugin.ts');
566
- const commandSrc = require.resolve('./opencode-command.md');
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 pluginDest = path.join(pluginDir, 'send-to-kimaki-discord.ts');
573
- const commandDest = path.join(commandDir, 'send-to-kimaki-discord.md');
574
- fs.copyFileSync(pluginSrc, pluginDest);
575
- fs.copyFileSync(commandSrc, commandDest);
576
- note(`Plugin: ${pluginDest}\nCommand: ${commandDest}\n\nUse /send-to-kimaki-discord in OpenCode to send the current session to Discord.`, '✅ Installed');
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) {
@@ -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
- export const OPENCODE_SYSTEM_MESSAGE = `
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
- voiceLogger.log(`[EVENT] Tracking assistant message ${assistantMessageId}`);
1206
- }
1207
- else {
1208
- sessionLogger.log(`Message role: ${msg.role}`);
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.log(`Failed to send part ${part.id} during cleanup:`, error);
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
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}_${attachCommand}${modelInfo}`);
1359
- sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}`);
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
- const parts = [{ type: 'text', text: prompt }, ...images];
1374
- sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
1375
- const response = await getClient().session.prompt({
1376
- path: { id: session.id },
1377
- body: {
1378
- parts,
1379
- system: OPENCODE_SYSTEM_MESSAGE,
1380
- },
1381
- signal: abortController.signal,
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, OPENCODE_SYSTEM_MESSAGE, } from './discordBot.js';
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: OPENCODE_SYSTEM_MESSAGE,
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: OPENCODE_SYSTEM_MESSAGE,
118
+ system: getOpencodeSystemMessage({ sessionId: session.data.id }),
119
119
  },
120
120
  })
121
121
  .then(async (response) => {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.13",
5
+ "version": "0.4.15",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
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 (e.g. "git *" approves all git commands)')
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('install-plugin', 'Install the OpenCode plugin for /send-to-kimaki-discord 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 pluginSrc = require.resolve('./opencode-plugin.ts')
840
- const commandSrc = require.resolve('./opencode-command.md')
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 pluginDest = path.join(pluginDir, 'send-to-kimaki-discord.ts')
850
- const commandDest = path.join(commandDir, 'send-to-kimaki-discord.md')
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(pluginSrc, pluginDest)
853
- fs.copyFileSync(commandSrc, commandDest)
949
+ fs.copyFileSync(sendCommandSrc, sendCommandDest)
950
+ fs.copyFileSync(uploadCommandSrc, uploadCommandDest)
854
951
 
855
952
  note(
856
- `Plugin: ${pluginDest}\nCommand: ${commandDest}\n\nUse /send-to-kimaki-discord in OpenCode to send the current session to Discord.`,
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
- export const OPENCODE_SYSTEM_MESSAGE = `
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
- voiceLogger.log(
1598
- `[EVENT] Tracking assistant message ${assistantMessageId}`,
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
- voiceLogger.log(
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.log(
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
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}_${attachCommand}${modelInfo}`)
1792
- sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}`)
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
- const parts = [{ type: 'text' as const, text: prompt }, ...images]
1813
- sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
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
- const response = await getClient().session.prompt({
1816
- path: { id: session.id },
1817
- body: {
1818
- parts,
1819
- system: OPENCODE_SYSTEM_MESSAGE,
1820
- },
1821
- signal: abortController.signal,
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
- OPENCODE_SYSTEM_MESSAGE,
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: OPENCODE_SYSTEM_MESSAGE,
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: OPENCODE_SYSTEM_MESSAGE,
155
+ system: getOpencodeSystemMessage({ sessionId: session.data.id }),
156
156
  },
157
157
  })
158
158
  .then(async (response) => {
@@ -1,4 +0,0 @@
1
- ---
2
- description: Create Discord thread for current session
3
- ---
4
- Creating Discord thread for this session...
@@ -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
- }