kimaki 0.4.12 → 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.
@@ -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');
@@ -129,7 +146,7 @@ async function createUserAudioLogStream(guildId, channelId) {
129
146
  }
130
147
  }
131
148
  // Set up voice handling for a connection (called once per connection)
132
- async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
149
+ async function setupVoiceHandling({ connection, guildId, channelId, appId, discordClient, }) {
133
150
  voiceLogger.log(`Setting up voice handling for guild ${guildId}, channel ${channelId}`);
134
151
  // Check if this voice channel has an associated directory
135
152
  const channelDirRow = getDatabase()
@@ -227,8 +244,24 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
227
244
  : `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`;
228
245
  genAiWorker.sendTextInput(text);
229
246
  },
230
- onError(error) {
247
+ async onError(error) {
231
248
  voiceLogger.error('GenAI worker error:', error);
249
+ const textChannelRow = getDatabase()
250
+ .prepare(`SELECT cd2.channel_id FROM channel_directories cd1
251
+ JOIN channel_directories cd2 ON cd1.directory = cd2.directory
252
+ WHERE cd1.channel_id = ? AND cd1.channel_type = 'voice' AND cd2.channel_type = 'text'`)
253
+ .get(channelId);
254
+ if (textChannelRow) {
255
+ try {
256
+ const textChannel = await discordClient.channels.fetch(textChannelRow.channel_id);
257
+ if (textChannel?.isTextBased() && 'send' in textChannel) {
258
+ await textChannel.send(`⚠️ Voice session error: ${error}`);
259
+ }
260
+ }
261
+ catch (e) {
262
+ voiceLogger.error('Failed to send error to text channel:', e);
263
+ }
264
+ }
232
265
  },
233
266
  });
234
267
  // Stop any existing GenAI worker before storing new one
@@ -912,24 +945,27 @@ function getToolOutputToDisplay(part) {
912
945
  if (part.state.status === 'error') {
913
946
  return part.state.error || 'Unknown error';
914
947
  }
915
- if (part.tool === 'todowrite') {
916
- const todos = part.state.input?.todos || [];
917
- return todos
918
- .map((todo) => {
919
- let statusIcon = '▢';
920
- if (todo.status === 'in_progress') {
921
- statusIcon = '●';
922
- }
923
- if (todo.status === 'completed' || todo.status === 'cancelled') {
924
- statusIcon = '■';
925
- }
926
- return `\`${statusIcon}\` ${todo.content}`;
927
- })
928
- .filter(Boolean)
929
- .join('\n');
930
- }
931
948
  return '';
932
949
  }
950
+ function formatTodoList(part) {
951
+ if (part.type !== 'tool' || part.tool !== 'todowrite')
952
+ return '';
953
+ const todos = part.state.input?.todos || [];
954
+ if (todos.length === 0)
955
+ return '';
956
+ return todos
957
+ .map((todo, i) => {
958
+ const num = `${i + 1}.`;
959
+ if (todo.status === 'in_progress') {
960
+ return `${num} **${todo.content}**`;
961
+ }
962
+ if (todo.status === 'completed' || todo.status === 'cancelled') {
963
+ return `${num} ~~${todo.content}~~`;
964
+ }
965
+ return `${num} ${todo.content}`;
966
+ })
967
+ .join('\n');
968
+ }
933
969
  function formatPart(part) {
934
970
  if (part.type === 'text') {
935
971
  return part.text || '';
@@ -952,14 +988,31 @@ function formatPart(part) {
952
988
  return `◼︎ snapshot ${part.snapshot}`;
953
989
  }
954
990
  if (part.type === 'tool') {
991
+ if (part.tool === 'todowrite') {
992
+ return formatTodoList(part);
993
+ }
955
994
  if (part.state.status !== 'completed' && part.state.status !== 'error') {
956
995
  return '';
957
996
  }
958
997
  const summaryText = getToolSummaryText(part);
959
998
  const outputToDisplay = getToolOutputToDisplay(part);
960
- let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error';
961
- if (toolTitle) {
962
- toolTitle = `*${toolTitle}*`;
999
+ let toolTitle = '';
1000
+ if (part.state.status === 'error') {
1001
+ toolTitle = 'error';
1002
+ }
1003
+ else if (part.tool === 'bash') {
1004
+ const command = part.state.input?.command || '';
1005
+ const isSingleLine = !command.includes('\n');
1006
+ const hasBackticks = command.includes('`');
1007
+ if (isSingleLine && command.length <= 120 && !hasBackticks) {
1008
+ toolTitle = `\`${command}\``;
1009
+ }
1010
+ else {
1011
+ toolTitle = part.state.title ? `*${part.state.title}*` : '';
1012
+ }
1013
+ }
1014
+ else if (part.state.title) {
1015
+ toolTitle = `*${part.state.title}*`;
963
1016
  }
964
1017
  const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : '';
965
1018
  const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
@@ -987,7 +1040,7 @@ export async function createDiscordClient() {
987
1040
  ],
988
1041
  });
989
1042
  }
990
- async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], }) {
1043
+ async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], parsedCommand, }) {
991
1044
  voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
992
1045
  // Track session start time
993
1046
  const sessionStartTime = Date.now();
@@ -1081,6 +1134,8 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1081
1134
  let currentParts = [];
1082
1135
  let stopTyping = null;
1083
1136
  let usedModel;
1137
+ let usedProviderID;
1138
+ let inputTokens = 0;
1084
1139
  const sendPartMessage = async (part) => {
1085
1140
  const content = formatPart(part) + '\n\n';
1086
1141
  if (!content.trim() || content.length === 0) {
@@ -1089,14 +1144,11 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1089
1144
  }
1090
1145
  // Skip if already sent
1091
1146
  if (partIdToMessage.has(part.id)) {
1092
- voiceLogger.log(`[SEND SKIP] Part ${part.id} already sent as message ${partIdToMessage.get(part.id)?.id}`);
1093
1147
  return;
1094
1148
  }
1095
1149
  try {
1096
- voiceLogger.log(`[SEND] Sending part ${part.id} (type: ${part.type}) to Discord, content length: ${content.length}`);
1097
1150
  const firstMessage = await sendThreadMessage(thread, content);
1098
1151
  partIdToMessage.set(part.id, firstMessage);
1099
- voiceLogger.log(`[SEND SUCCESS] Part ${part.id} sent as message ${firstMessage.id}`);
1100
1152
  // Store part-message mapping in database
1101
1153
  getDatabase()
1102
1154
  .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
@@ -1115,12 +1167,10 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1115
1167
  discordLogger.log(`Not starting typing, already aborted`);
1116
1168
  return () => { };
1117
1169
  }
1118
- discordLogger.log(`Starting typing for thread ${thread.id}`);
1119
1170
  // Clear any previous typing interval
1120
1171
  if (typingInterval) {
1121
1172
  clearInterval(typingInterval);
1122
1173
  typingInterval = null;
1123
- discordLogger.log(`Cleared previous typing interval`);
1124
1174
  }
1125
1175
  // Send initial typing
1126
1176
  thread.sendTyping().catch((e) => {
@@ -1148,39 +1198,34 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1148
1198
  if (typingInterval) {
1149
1199
  clearInterval(typingInterval);
1150
1200
  typingInterval = null;
1151
- discordLogger.log(`Stopped typing for thread ${thread.id}`);
1152
1201
  }
1153
1202
  };
1154
1203
  }
1155
1204
  try {
1156
1205
  let assistantMessageId;
1157
1206
  for await (const event of events) {
1158
- sessionLogger.log(`Received: ${event.type}`);
1159
1207
  if (event.type === 'message.updated') {
1160
1208
  const msg = event.properties.info;
1161
1209
  if (msg.sessionID !== session.id) {
1162
- voiceLogger.log(`[EVENT IGNORED] Message from different session (expected: ${session.id}, got: ${msg.sessionID})`);
1163
1210
  continue;
1164
1211
  }
1165
1212
  // Track assistant message ID
1166
1213
  if (msg.role === 'assistant') {
1167
1214
  assistantMessageId = msg.id;
1168
1215
  usedModel = msg.modelID;
1169
- voiceLogger.log(`[EVENT] Tracking assistant message ${assistantMessageId}`);
1170
- }
1171
- else {
1172
- sessionLogger.log(`Message role: ${msg.role}`);
1216
+ usedProviderID = msg.providerID;
1217
+ if (msg.tokens.input > 0) {
1218
+ inputTokens = msg.tokens.input;
1219
+ }
1173
1220
  }
1174
1221
  }
1175
1222
  else if (event.type === 'message.part.updated') {
1176
1223
  const part = event.properties.part;
1177
1224
  if (part.sessionID !== session.id) {
1178
- voiceLogger.log(`[EVENT IGNORED] Part from different session (expected: ${session.id}, got: ${part.sessionID})`);
1179
1225
  continue;
1180
1226
  }
1181
1227
  // Only process parts from assistant messages
1182
1228
  if (part.messageID !== assistantMessageId) {
1183
- voiceLogger.log(`[EVENT IGNORED] Part from non-assistant message (expected: ${assistantMessageId}, got: ${part.messageID})`);
1184
1229
  continue;
1185
1230
  }
1186
1231
  const existingIndex = currentParts.findIndex((p) => p.id === part.id);
@@ -1190,15 +1235,18 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1190
1235
  else {
1191
1236
  currentParts.push(part);
1192
1237
  }
1193
- voiceLogger.log(`[PART] Update: id=${part.id}, type=${part.type}, text=${'text' in part && typeof part.text === 'string' ? part.text.slice(0, 50) : ''}`);
1194
1238
  // Start typing on step-start
1195
1239
  if (part.type === 'step-start') {
1196
1240
  stopTyping = startTyping(thread);
1197
1241
  }
1198
1242
  // Check if this is a step-finish part
1199
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
+ }
1200
1249
  // Send all parts accumulated so far to Discord
1201
- voiceLogger.log(`[STEP-FINISH] Sending ${currentParts.length} parts to Discord`);
1202
1250
  for (const p of currentParts) {
1203
1251
  // Skip step-start and step-finish parts as they have no visual content
1204
1252
  if (p.type !== 'step-start' && p.type !== 'step-finish') {
@@ -1269,12 +1317,6 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1269
1317
  pendingPermissions.delete(thread.id);
1270
1318
  }
1271
1319
  }
1272
- else if (event.type === 'file.edited') {
1273
- sessionLogger.log(`File edited event received`);
1274
- }
1275
- else {
1276
- sessionLogger.log(`Unhandled event type: ${event.type}`);
1277
- }
1278
1320
  }
1279
1321
  }
1280
1322
  catch (e) {
@@ -1287,31 +1329,20 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1287
1329
  }
1288
1330
  finally {
1289
1331
  // Send any remaining parts that weren't sent
1290
- voiceLogger.log(`[CLEANUP] Checking ${currentParts.length} parts for unsent messages`);
1291
- let unsentCount = 0;
1292
1332
  for (const part of currentParts) {
1293
1333
  if (!partIdToMessage.has(part.id)) {
1294
- unsentCount++;
1295
- voiceLogger.log(`[CLEANUP] Sending unsent part: id=${part.id}, type=${part.type}`);
1296
1334
  try {
1297
1335
  await sendPartMessage(part);
1298
1336
  }
1299
1337
  catch (error) {
1300
- sessionLogger.log(`Failed to send part ${part.id} during cleanup:`, error);
1338
+ sessionLogger.error(`Failed to send part ${part.id}:`, error);
1301
1339
  }
1302
1340
  }
1303
1341
  }
1304
- if (unsentCount === 0) {
1305
- sessionLogger.log(`All parts were already sent`);
1306
- }
1307
- else {
1308
- sessionLogger.log(`Sent ${unsentCount} previously unsent parts`);
1309
- }
1310
1342
  // Stop typing when session ends
1311
1343
  if (stopTyping) {
1312
1344
  stopTyping();
1313
1345
  stopTyping = null;
1314
- sessionLogger.log(`Stopped typing for session`);
1315
1346
  }
1316
1347
  // Only send duration message if request was not aborted or was aborted with 'finished' reason
1317
1348
  if (!abortController.signal.aborted ||
@@ -1319,8 +1350,23 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1319
1350
  const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
1320
1351
  const attachCommand = port ? ` ⋅ ${session.id}` : '';
1321
1352
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
1322
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}_${attachCommand}${modelInfo}`);
1323
- 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}`);
1324
1370
  }
1325
1371
  else {
1326
1372
  sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
@@ -1328,22 +1374,36 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1328
1374
  }
1329
1375
  };
1330
1376
  try {
1331
- voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
1332
- if (images.length > 0) {
1333
- sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
1334
- }
1335
1377
  // Start the event handler
1336
1378
  const eventHandlerPromise = eventHandler();
1337
- const parts = [{ type: 'text', text: prompt }, ...images];
1338
- sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
1339
- const response = await getClient().session.prompt({
1340
- path: { id: session.id },
1341
- body: {
1342
- parts,
1343
- system: OPENCODE_SYSTEM_MESSAGE,
1344
- },
1345
- signal: abortController.signal,
1346
- });
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
+ }
1347
1407
  abortController.abort('finished');
1348
1408
  sessionLogger.log(`Successfully sent prompt, got response`);
1349
1409
  // Update reaction to success
@@ -1457,7 +1517,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1457
1517
  discordClient.on(Events.MessageCreate, async (message) => {
1458
1518
  try {
1459
1519
  if (message.author?.bot) {
1460
- voiceLogger.log(`[IGNORED] Bot message from ${message.author.tag} in channel ${message.channelId}`);
1461
1520
  return;
1462
1521
  }
1463
1522
  if (message.partial) {
@@ -1475,10 +1534,8 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1475
1534
  const isOwner = message.member.id === message.guild.ownerId;
1476
1535
  const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator);
1477
1536
  if (!isOwner && !isAdmin) {
1478
- voiceLogger.log(`[IGNORED] Non-authoritative user ${message.author.tag} (ID: ${message.author.id}) - not owner or admin`);
1479
1537
  return;
1480
1538
  }
1481
- voiceLogger.log(`[AUTHORIZED] Message from ${message.author.tag} (Owner: ${isOwner}, Admin: ${isAdmin})`);
1482
1539
  }
1483
1540
  const channel = message.channel;
1484
1541
  const isThread = [
@@ -1532,12 +1589,14 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1532
1589
  messageContent = transcription;
1533
1590
  }
1534
1591
  const images = getImageAttachments(message);
1592
+ const parsedCommand = parseSlashCommand(messageContent);
1535
1593
  await handleOpencodeSession({
1536
1594
  prompt: messageContent,
1537
1595
  thread,
1538
1596
  projectDirectory,
1539
1597
  originalMessage: message,
1540
1598
  images,
1599
+ parsedCommand,
1541
1600
  });
1542
1601
  return;
1543
1602
  }
@@ -1598,12 +1657,14 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1598
1657
  messageContent = transcription;
1599
1658
  }
1600
1659
  const images = getImageAttachments(message);
1660
+ const parsedCommand = parseSlashCommand(messageContent);
1601
1661
  await handleOpencodeSession({
1602
1662
  prompt: messageContent,
1603
1663
  thread,
1604
1664
  projectDirectory,
1605
1665
  originalMessage: message,
1606
1666
  images,
1667
+ parsedCommand,
1607
1668
  });
1608
1669
  }
1609
1670
  else {
@@ -1859,10 +1920,12 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1859
1920
  });
1860
1921
  await command.editReply(`Created new session in ${thread.toString()}`);
1861
1922
  // Start the OpenCode session
1923
+ const parsedCommand = parseSlashCommand(fullPrompt);
1862
1924
  await handleOpencodeSession({
1863
1925
  prompt: fullPrompt,
1864
1926
  thread,
1865
1927
  projectDirectory,
1928
+ parsedCommand,
1866
1929
  });
1867
1930
  }
1868
1931
  catch (error) {
@@ -1937,52 +2000,37 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1937
2000
  await command.editReply(`Resumed session "${sessionTitle}" in ${thread.toString()}`);
1938
2001
  // Send initial message to thread
1939
2002
  await sendThreadMessage(thread, `📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`);
1940
- // Render all existing messages
1941
- let messageCount = 0;
2003
+ // Collect all assistant parts first, then only render the last 30
2004
+ const allAssistantParts = [];
1942
2005
  for (const message of messages) {
1943
- if (message.info.role === 'user') {
1944
- // Render user messages
1945
- const userParts = message.parts.filter((p) => p.type === 'text' && !p.synthetic);
1946
- const userTexts = userParts
1947
- .map((p) => {
1948
- if (p.type === 'text') {
1949
- return p.text;
1950
- }
1951
- return '';
1952
- })
1953
- .filter((t) => t.trim());
1954
- const userText = userTexts.join('\n\n');
1955
- if (userText) {
1956
- // Escape backticks in user messages to prevent formatting issues
1957
- const escapedText = escapeDiscordFormatting(userText);
1958
- await sendThreadMessage(thread, `**User:**\n${escapedText}`);
1959
- }
1960
- }
1961
- else if (message.info.role === 'assistant') {
1962
- // Render assistant parts
1963
- const partsToRender = [];
2006
+ if (message.info.role === 'assistant') {
1964
2007
  for (const part of message.parts) {
1965
2008
  const content = formatPart(part);
1966
2009
  if (content.trim()) {
1967
- partsToRender.push({ id: part.id, content });
2010
+ allAssistantParts.push({ id: part.id, content });
1968
2011
  }
1969
2012
  }
1970
- if (partsToRender.length > 0) {
1971
- const combinedContent = partsToRender
1972
- .map((p) => p.content)
1973
- .join('\n\n');
1974
- const discordMessage = await sendThreadMessage(thread, combinedContent);
1975
- const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
1976
- const transaction = getDatabase().transaction((parts) => {
1977
- for (const part of parts) {
1978
- stmt.run(part.id, discordMessage.id, thread.id);
1979
- }
1980
- });
1981
- transaction(partsToRender);
1982
- }
1983
2013
  }
1984
- messageCount++;
1985
2014
  }
2015
+ const partsToRender = allAssistantParts.slice(-30);
2016
+ const skippedCount = allAssistantParts.length - partsToRender.length;
2017
+ if (skippedCount > 0) {
2018
+ await sendThreadMessage(thread, `*Skipped ${skippedCount} older assistant parts...*`);
2019
+ }
2020
+ if (partsToRender.length > 0) {
2021
+ const combinedContent = partsToRender
2022
+ .map((p) => p.content)
2023
+ .join('\n\n');
2024
+ const discordMessage = await sendThreadMessage(thread, combinedContent);
2025
+ const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
2026
+ const transaction = getDatabase().transaction((parts) => {
2027
+ for (const part of parts) {
2028
+ stmt.run(part.id, discordMessage.id, thread.id);
2029
+ }
2030
+ });
2031
+ transaction(partsToRender);
2032
+ }
2033
+ const messageCount = messages.length;
1986
2034
  await sendThreadMessage(thread, `✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`);
1987
2035
  }
1988
2036
  catch (error) {
@@ -2146,6 +2194,68 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2146
2194
  });
2147
2195
  }
2148
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
+ }
2149
2259
  }
2150
2260
  }
2151
2261
  catch (error) {
@@ -2321,6 +2431,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2321
2431
  guildId: newState.guild.id,
2322
2432
  channelId: voiceChannel.id,
2323
2433
  appId: currentAppId,
2434
+ discordClient,
2324
2435
  });
2325
2436
  // Handle connection state changes
2326
2437
  connection.on(VoiceConnectionStatus.Disconnected, async () => {
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.12",
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
+ }