kimaki 0.4.13 → 0.4.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js 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,17 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.13",
6
- "scripts": {
7
- "dev": "tsx --env-file .env src/cli.ts",
8
- "prepublishOnly": "pnpm tsc",
9
- "dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
10
- "watch": "tsx scripts/watch-session.ts",
11
- "test:events": "tsx test-events.ts",
12
- "pcm-to-mp3": "bun scripts/pcm-to-mp3",
13
- "test:send": "tsx send-test-message.ts",
14
- "register-commands": "tsx scripts/register-commands.ts"
15
- },
5
+ "version": "0.4.14",
16
6
  "repository": "https://github.com/remorses/kimaki",
17
7
  "bin": "bin.js",
18
8
  "files": [
@@ -54,5 +44,14 @@
54
44
  "string-dedent": "^3.0.2",
55
45
  "undici": "^7.16.0",
56
46
  "zod": "^4.0.17"
47
+ },
48
+ "scripts": {
49
+ "dev": "tsx --env-file .env src/cli.ts",
50
+ "dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
51
+ "watch": "tsx scripts/watch-session.ts",
52
+ "test:events": "tsx test-events.ts",
53
+ "pcm-to-mp3": "bun scripts/pcm-to-mp3",
54
+ "test:send": "tsx send-test-message.ts",
55
+ "register-commands": "tsx scripts/register-commands.ts"
57
56
  }
58
- }
57
+ }