kimaki 0.4.19 → 0.4.21

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.
@@ -43,6 +43,32 @@ The user cannot see bash tool outputs. If there is important information in bash
43
43
 
44
44
  Your current OpenCode session ID is: ${sessionId}
45
45
 
46
+ ## permissions
47
+
48
+ Only users with these Discord permissions can send messages to the bot:
49
+ - Server Owner
50
+ - Administrator permission
51
+ - Manage Server permission
52
+ - "Kimaki" role (case-insensitive)
53
+
54
+ ## changing the model
55
+
56
+ To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
57
+
58
+ \`\`\`json
59
+ {
60
+ "model": "anthropic/claude-sonnet-4-20250514"
61
+ }
62
+ \`\`\`
63
+
64
+ Examples:
65
+ - \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
66
+ - \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
67
+ - \`"openai/gpt-4o"\` - GPT-4o
68
+ - \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
69
+
70
+ Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
71
+
46
72
  ## uploading files to discord
47
73
 
48
74
  To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
@@ -585,7 +611,6 @@ async function processVoiceAttachment({ message, thread, projectDirectory, isNew
585
611
  if (!audioAttachment)
586
612
  return null;
587
613
  voiceLogger.log(`Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`);
588
- await message.react('⏳');
589
614
  await sendThreadMessage(thread, '🎤 Transcribing voice message...');
590
615
  const audioResponse = await fetch(audioAttachment.url);
591
616
  const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
@@ -940,30 +965,45 @@ function getToolSummaryText(part) {
940
965
  if (part.type !== 'tool')
941
966
  return '';
942
967
  if (part.tool === 'edit') {
968
+ const filePath = part.state.input?.filePath || '';
943
969
  const newString = part.state.input?.newString || '';
944
970
  const oldString = part.state.input?.oldString || '';
945
971
  const added = newString.split('\n').length;
946
972
  const removed = oldString.split('\n').length;
947
- return `(+${added}-${removed})`;
973
+ const fileName = filePath.split('/').pop() || '';
974
+ return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`;
948
975
  }
949
976
  if (part.tool === 'write') {
977
+ const filePath = part.state.input?.filePath || '';
950
978
  const content = part.state.input?.content || '';
951
979
  const lines = content.split('\n').length;
952
- return `(${lines} line${lines === 1 ? '' : 's'})`;
980
+ const fileName = filePath.split('/').pop() || '';
981
+ return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`;
953
982
  }
954
983
  if (part.tool === 'webfetch') {
955
984
  const url = part.state.input?.url || '';
956
985
  const urlWithoutProtocol = url.replace(/^https?:\/\//, '');
957
- return urlWithoutProtocol ? `(${urlWithoutProtocol})` : '';
986
+ return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : '';
958
987
  }
959
- if (part.tool === 'bash' ||
960
- part.tool === 'read' ||
961
- part.tool === 'list' ||
962
- part.tool === 'glob' ||
963
- part.tool === 'grep' ||
964
- part.tool === 'task' ||
965
- part.tool === 'todoread' ||
966
- part.tool === 'todowrite') {
988
+ if (part.tool === 'read') {
989
+ const filePath = part.state.input?.filePath || '';
990
+ const fileName = filePath.split('/').pop() || '';
991
+ return fileName ? `*${fileName}*` : '';
992
+ }
993
+ if (part.tool === 'list') {
994
+ const path = part.state.input?.path || '';
995
+ const dirName = path.split('/').pop() || path;
996
+ return dirName ? `*${dirName}*` : '';
997
+ }
998
+ if (part.tool === 'glob') {
999
+ const pattern = part.state.input?.pattern || '';
1000
+ return pattern ? `*${pattern}*` : '';
1001
+ }
1002
+ if (part.tool === 'grep') {
1003
+ const pattern = part.state.input?.pattern || '';
1004
+ return pattern ? `*${pattern}*` : '';
1005
+ }
1006
+ if (part.tool === 'bash' || part.tool === 'task' || part.tool === 'todoread' || part.tool === 'todowrite') {
967
1007
  return '';
968
1008
  }
969
1009
  if (!part.state.input)
@@ -1036,17 +1076,21 @@ function formatPart(part) {
1036
1076
  }
1037
1077
  else if (part.tool === 'bash') {
1038
1078
  const command = part.state.input?.command || '';
1079
+ const description = part.state.input?.description || '';
1039
1080
  const isSingleLine = !command.includes('\n');
1040
1081
  const hasBackticks = command.includes('`');
1041
- if (isSingleLine && command.length <= 120 && !hasBackticks) {
1082
+ if (isSingleLine && !hasBackticks && command.length <= 50) {
1042
1083
  toolTitle = `\`${command}\``;
1043
1084
  }
1044
- else {
1045
- toolTitle = stateTitle ? `*${stateTitle}*` : '';
1085
+ else if (description) {
1086
+ toolTitle = `_${description}_`;
1087
+ }
1088
+ else if (stateTitle) {
1089
+ toolTitle = `_${stateTitle}_`;
1046
1090
  }
1047
1091
  }
1048
1092
  else if (stateTitle) {
1049
- toolTitle = `*${stateTitle}*`;
1093
+ toolTitle = `_${stateTitle}_`;
1050
1094
  }
1051
1095
  const icon = part.state.status === 'error' ? '⨯' : '◼︎';
1052
1096
  return `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
@@ -1074,16 +1118,6 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1074
1118
  voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
1075
1119
  // Track session start time
1076
1120
  const sessionStartTime = Date.now();
1077
- // Add processing reaction to original message
1078
- if (originalMessage) {
1079
- try {
1080
- await originalMessage.react('⏳');
1081
- discordLogger.log(`Added processing reaction to message`);
1082
- }
1083
- catch (e) {
1084
- discordLogger.log(`Could not add processing reaction:`, e);
1085
- }
1086
- }
1087
1121
  // Use default directory if not specified
1088
1122
  const directory = projectDirectory || process.cwd();
1089
1123
  sessionLogger.log(`Using directory: ${directory}`);
@@ -1134,39 +1168,70 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1134
1168
  voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
1135
1169
  existingController.abort(new Error('New request started'));
1136
1170
  }
1137
- if (abortControllers.has(session.id)) {
1138
- abortControllers.get(session.id)?.abort(new Error('new reply'));
1139
- }
1140
1171
  const abortController = new AbortController();
1141
- // Store this controller for this session
1142
1172
  abortControllers.set(session.id, abortController);
1173
+ if (existingController) {
1174
+ await new Promise((resolve) => { setTimeout(resolve, 200); });
1175
+ if (abortController.signal.aborted) {
1176
+ sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`);
1177
+ return;
1178
+ }
1179
+ }
1180
+ if (abortController.signal.aborted) {
1181
+ sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`);
1182
+ return;
1183
+ }
1143
1184
  const eventsResult = await getClient().event.subscribe({
1144
1185
  signal: abortController.signal,
1145
1186
  });
1187
+ if (abortController.signal.aborted) {
1188
+ sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
1189
+ return;
1190
+ }
1146
1191
  const events = eventsResult.stream;
1147
1192
  sessionLogger.log(`Subscribed to OpenCode events`);
1148
- // Load existing part-message mappings from database
1149
- const partIdToMessage = new Map();
1150
- const existingParts = getDatabase()
1151
- .prepare('SELECT part_id, message_id FROM part_messages WHERE thread_id = ?')
1152
- .all(thread.id);
1153
- // Pre-populate map with existing messages
1154
- for (const row of existingParts) {
1155
- try {
1156
- const message = await thread.messages.fetch(row.message_id);
1157
- if (message) {
1158
- partIdToMessage.set(row.part_id, message);
1159
- }
1160
- }
1161
- catch (error) {
1162
- voiceLogger.log(`Could not fetch message ${row.message_id} for part ${row.part_id}`);
1163
- }
1164
- }
1193
+ const sentPartIds = new Set(getDatabase()
1194
+ .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
1195
+ .all(thread.id)
1196
+ .map((row) => row.part_id));
1165
1197
  let currentParts = [];
1166
1198
  let stopTyping = null;
1167
1199
  let usedModel;
1168
1200
  let usedProviderID;
1169
1201
  let tokensUsedInSession = 0;
1202
+ let typingInterval = null;
1203
+ function startTyping() {
1204
+ if (abortController.signal.aborted) {
1205
+ discordLogger.log(`Not starting typing, already aborted`);
1206
+ return () => { };
1207
+ }
1208
+ if (typingInterval) {
1209
+ clearInterval(typingInterval);
1210
+ typingInterval = null;
1211
+ }
1212
+ thread.sendTyping().catch((e) => {
1213
+ discordLogger.log(`Failed to send initial typing: ${e}`);
1214
+ });
1215
+ typingInterval = setInterval(() => {
1216
+ thread.sendTyping().catch((e) => {
1217
+ discordLogger.log(`Failed to send periodic typing: ${e}`);
1218
+ });
1219
+ }, 8000);
1220
+ if (!abortController.signal.aborted) {
1221
+ abortController.signal.addEventListener('abort', () => {
1222
+ if (typingInterval) {
1223
+ clearInterval(typingInterval);
1224
+ typingInterval = null;
1225
+ }
1226
+ }, { once: true });
1227
+ }
1228
+ return () => {
1229
+ if (typingInterval) {
1230
+ clearInterval(typingInterval);
1231
+ typingInterval = null;
1232
+ }
1233
+ };
1234
+ }
1170
1235
  const sendPartMessage = async (part) => {
1171
1236
  const content = formatPart(part) + '\n\n';
1172
1237
  if (!content.trim() || content.length === 0) {
@@ -1174,12 +1239,12 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1174
1239
  return;
1175
1240
  }
1176
1241
  // Skip if already sent
1177
- if (partIdToMessage.has(part.id)) {
1242
+ if (sentPartIds.has(part.id)) {
1178
1243
  return;
1179
1244
  }
1180
1245
  try {
1181
1246
  const firstMessage = await sendThreadMessage(thread, content);
1182
- partIdToMessage.set(part.id, firstMessage);
1247
+ sentPartIds.add(part.id);
1183
1248
  // Store part-message mapping in database
1184
1249
  getDatabase()
1185
1250
  .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
@@ -1190,48 +1255,6 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1190
1255
  }
1191
1256
  };
1192
1257
  const eventHandler = async () => {
1193
- // Local typing function for this session
1194
- // Outer-scoped interval for typing notifications. Only one at a time.
1195
- let typingInterval = null;
1196
- function startTyping(thread) {
1197
- if (abortController.signal.aborted) {
1198
- discordLogger.log(`Not starting typing, already aborted`);
1199
- return () => { };
1200
- }
1201
- // Clear any previous typing interval
1202
- if (typingInterval) {
1203
- clearInterval(typingInterval);
1204
- typingInterval = null;
1205
- }
1206
- // Send initial typing
1207
- thread.sendTyping().catch((e) => {
1208
- discordLogger.log(`Failed to send initial typing: ${e}`);
1209
- });
1210
- // Set up interval to send typing every 8 seconds
1211
- typingInterval = setInterval(() => {
1212
- thread.sendTyping().catch((e) => {
1213
- discordLogger.log(`Failed to send periodic typing: ${e}`);
1214
- });
1215
- }, 8000);
1216
- // Only add listener if not already aborted
1217
- if (!abortController.signal.aborted) {
1218
- abortController.signal.addEventListener('abort', () => {
1219
- if (typingInterval) {
1220
- clearInterval(typingInterval);
1221
- typingInterval = null;
1222
- }
1223
- }, {
1224
- once: true,
1225
- });
1226
- }
1227
- // Return stop function
1228
- return () => {
1229
- if (typingInterval) {
1230
- clearInterval(typingInterval);
1231
- typingInterval = null;
1232
- }
1233
- };
1234
- }
1235
1258
  try {
1236
1259
  let assistantMessageId;
1237
1260
  for await (const event of events) {
@@ -1269,12 +1292,16 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1269
1292
  }
1270
1293
  // Start typing on step-start
1271
1294
  if (part.type === 'step-start') {
1272
- stopTyping = startTyping(thread);
1295
+ stopTyping = startTyping();
1273
1296
  }
1274
1297
  // Send tool parts immediately when they start running
1275
1298
  if (part.type === 'tool' && part.state.status === 'running') {
1276
1299
  await sendPartMessage(part);
1277
1300
  }
1301
+ // Send reasoning parts immediately (shows "◼︎ thinking" indicator early)
1302
+ if (part.type === 'reasoning') {
1303
+ await sendPartMessage(part);
1304
+ }
1278
1305
  // Check if this is a step-finish part
1279
1306
  if (part.type === 'step-finish') {
1280
1307
  // Send all parts accumulated so far to Discord
@@ -1288,7 +1315,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1288
1315
  setTimeout(() => {
1289
1316
  if (abortController.signal.aborted)
1290
1317
  return;
1291
- stopTyping = startTyping(thread);
1318
+ stopTyping = startTyping();
1292
1319
  }, 300);
1293
1320
  }
1294
1321
  }
@@ -1361,7 +1388,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1361
1388
  finally {
1362
1389
  // Send any remaining parts that weren't sent
1363
1390
  for (const part of currentParts) {
1364
- if (!partIdToMessage.has(part.id)) {
1391
+ if (!sentPartIds.has(part.id)) {
1365
1392
  try {
1366
1393
  await sendPartMessage(part);
1367
1394
  }
@@ -1403,8 +1430,12 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1403
1430
  }
1404
1431
  };
1405
1432
  try {
1406
- // Start the event handler
1407
1433
  const eventHandlerPromise = eventHandler();
1434
+ if (abortController.signal.aborted) {
1435
+ sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
1436
+ return;
1437
+ }
1438
+ stopTyping = startTyping();
1408
1439
  let response;
1409
1440
  if (parsedCommand?.isCommand) {
1410
1441
  sessionLogger.log(`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`);
@@ -1571,11 +1602,13 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1571
1602
  return;
1572
1603
  }
1573
1604
  }
1574
- // Check if user is authoritative (server owner or has admin permissions)
1605
+ // Check if user is authoritative (server owner, admin, manage server, or has Kimaki role)
1575
1606
  if (message.guild && message.member) {
1576
1607
  const isOwner = message.member.id === message.guild.ownerId;
1577
1608
  const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator);
1578
- if (!isOwner && !isAdmin) {
1609
+ const canManageServer = message.member.permissions.has(PermissionsBitField.Flags.ManageGuild);
1610
+ const hasKimakiRole = message.member.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki');
1611
+ if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
1579
1612
  return;
1580
1613
  }
1581
1614
  }
@@ -2488,12 +2521,13 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2488
2521
  const member = newState.member || oldState.member;
2489
2522
  if (!member)
2490
2523
  return;
2491
- // Check if user is admin or server owner
2524
+ // Check if user is admin, server owner, can manage server, or has Kimaki role
2492
2525
  const guild = newState.guild || oldState.guild;
2493
2526
  const isOwner = member.id === guild.ownerId;
2494
2527
  const isAdmin = member.permissions.has(PermissionsBitField.Flags.Administrator);
2495
- if (!isOwner && !isAdmin) {
2496
- // Not an admin user, ignore
2528
+ const canManageServer = member.permissions.has(PermissionsBitField.Flags.ManageGuild);
2529
+ const hasKimakiRole = member.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki');
2530
+ if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
2497
2531
  return;
2498
2532
  }
2499
2533
  // Handle admin leaving voice channel
@@ -2512,7 +2546,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2512
2546
  if (m.id === member.id || m.user.bot)
2513
2547
  return false;
2514
2548
  return (m.id === guild.ownerId ||
2515
- m.permissions.has(PermissionsBitField.Flags.Administrator));
2549
+ m.permissions.has(PermissionsBitField.Flags.Administrator) ||
2550
+ m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
2551
+ m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki'));
2516
2552
  });
2517
2553
  if (!hasOtherAdmins) {
2518
2554
  voiceLogger.log(`No other admins in channel, bot leaving voice channel in guild: ${guild.name}`);
@@ -2542,7 +2578,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2542
2578
  if (m.id === member.id || m.user.bot)
2543
2579
  return false;
2544
2580
  return (m.id === guild.ownerId ||
2545
- m.permissions.has(PermissionsBitField.Flags.Administrator));
2581
+ m.permissions.has(PermissionsBitField.Flags.Administrator) ||
2582
+ m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
2583
+ m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki'));
2546
2584
  });
2547
2585
  if (!hasOtherAdmins) {
2548
2586
  voiceLogger.log(`Following admin to new channel: ${newState.channel?.name}`);
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.19",
5
+ "version": "0.4.21",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
@@ -21,7 +21,7 @@
21
21
  "bin.js"
22
22
  ],
23
23
  "devDependencies": {
24
- "@opencode-ai/plugin": "^1.0.169",
24
+ "@opencode-ai/plugin": "^1.0.193",
25
25
  "@types/better-sqlite3": "^7.6.13",
26
26
  "@types/bun": "latest",
27
27
  "@types/js-yaml": "^4.0.9",
@@ -35,7 +35,7 @@
35
35
  "@discordjs/opus": "^0.10.0",
36
36
  "@discordjs/voice": "^0.19.0",
37
37
  "@google/genai": "^1.34.0",
38
- "@opencode-ai/sdk": "^1.0.169",
38
+ "@opencode-ai/sdk": "^1.0.193",
39
39
  "@purinton/resampler": "^1.0.4",
40
40
  "@snazzah/davey": "^0.1.6",
41
41
  "ai": "^5.0.114",
package/src/discordBot.ts CHANGED
@@ -61,7 +61,6 @@ type ParsedCommand = {
61
61
  } | {
62
62
  isCommand: false
63
63
  }
64
-
65
64
  function parseSlashCommand(text: string): ParsedCommand {
66
65
  const trimmed = text.trim()
67
66
  if (!trimmed.startsWith('/')) {
@@ -84,6 +83,32 @@ The user cannot see bash tool outputs. If there is important information in bash
84
83
 
85
84
  Your current OpenCode session ID is: ${sessionId}
86
85
 
86
+ ## permissions
87
+
88
+ Only users with these Discord permissions can send messages to the bot:
89
+ - Server Owner
90
+ - Administrator permission
91
+ - Manage Server permission
92
+ - "Kimaki" role (case-insensitive)
93
+
94
+ ## changing the model
95
+
96
+ To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
97
+
98
+ \`\`\`json
99
+ {
100
+ "model": "anthropic/claude-sonnet-4-20250514"
101
+ }
102
+ \`\`\`
103
+
104
+ Examples:
105
+ - \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
106
+ - \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
107
+ - \`"openai/gpt-4o"\` - GPT-4o
108
+ - \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
109
+
110
+ Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
111
+
87
112
  ## uploading files to discord
88
113
 
89
114
  To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
@@ -805,7 +830,6 @@ async function processVoiceAttachment({
805
830
  `Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`,
806
831
  )
807
832
 
808
- await message.react('⏳')
809
833
  await sendThreadMessage(thread, '🎤 Transcribing voice message...')
810
834
 
811
835
  const audioResponse = await fetch(audioAttachment.url)
@@ -1259,35 +1283,52 @@ function getToolSummaryText(part: Part): string {
1259
1283
  if (part.type !== 'tool') return ''
1260
1284
 
1261
1285
  if (part.tool === 'edit') {
1286
+ const filePath = (part.state.input?.filePath as string) || ''
1262
1287
  const newString = (part.state.input?.newString as string) || ''
1263
1288
  const oldString = (part.state.input?.oldString as string) || ''
1264
1289
  const added = newString.split('\n').length
1265
1290
  const removed = oldString.split('\n').length
1266
- return `(+${added}-${removed})`
1291
+ const fileName = filePath.split('/').pop() || ''
1292
+ return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`
1267
1293
  }
1268
1294
 
1269
1295
  if (part.tool === 'write') {
1296
+ const filePath = (part.state.input?.filePath as string) || ''
1270
1297
  const content = (part.state.input?.content as string) || ''
1271
1298
  const lines = content.split('\n').length
1272
- return `(${lines} line${lines === 1 ? '' : 's'})`
1299
+ const fileName = filePath.split('/').pop() || ''
1300
+ return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`
1273
1301
  }
1274
1302
 
1275
1303
  if (part.tool === 'webfetch') {
1276
1304
  const url = (part.state.input?.url as string) || ''
1277
1305
  const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
1278
- return urlWithoutProtocol ? `(${urlWithoutProtocol})` : ''
1306
+ return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : ''
1279
1307
  }
1280
1308
 
1281
- if (
1282
- part.tool === 'bash' ||
1283
- part.tool === 'read' ||
1284
- part.tool === 'list' ||
1285
- part.tool === 'glob' ||
1286
- part.tool === 'grep' ||
1287
- part.tool === 'task' ||
1288
- part.tool === 'todoread' ||
1289
- part.tool === 'todowrite'
1290
- ) {
1309
+ if (part.tool === 'read') {
1310
+ const filePath = (part.state.input?.filePath as string) || ''
1311
+ const fileName = filePath.split('/').pop() || ''
1312
+ return fileName ? `*${fileName}*` : ''
1313
+ }
1314
+
1315
+ if (part.tool === 'list') {
1316
+ const path = (part.state.input?.path as string) || ''
1317
+ const dirName = path.split('/').pop() || path
1318
+ return dirName ? `*${dirName}*` : ''
1319
+ }
1320
+
1321
+ if (part.tool === 'glob') {
1322
+ const pattern = (part.state.input?.pattern as string) || ''
1323
+ return pattern ? `*${pattern}*` : ''
1324
+ }
1325
+
1326
+ if (part.tool === 'grep') {
1327
+ const pattern = (part.state.input?.pattern as string) || ''
1328
+ return pattern ? `*${pattern}*` : ''
1329
+ }
1330
+
1331
+ if (part.tool === 'bash' || part.tool === 'task' || part.tool === 'todoread' || part.tool === 'todowrite') {
1291
1332
  return ''
1292
1333
  }
1293
1334
 
@@ -1372,15 +1413,18 @@ function formatPart(part: Part): string {
1372
1413
  toolTitle = part.state.error || 'error'
1373
1414
  } else if (part.tool === 'bash') {
1374
1415
  const command = (part.state.input?.command as string) || ''
1416
+ const description = (part.state.input?.description as string) || ''
1375
1417
  const isSingleLine = !command.includes('\n')
1376
1418
  const hasBackticks = command.includes('`')
1377
- if (isSingleLine && command.length <= 120 && !hasBackticks) {
1419
+ if (isSingleLine && !hasBackticks && command.length <= 50) {
1378
1420
  toolTitle = `\`${command}\``
1379
- } else {
1380
- toolTitle = stateTitle ? `*${stateTitle}*` : ''
1421
+ } else if (description) {
1422
+ toolTitle = `_${description}_`
1423
+ } else if (stateTitle) {
1424
+ toolTitle = `_${stateTitle}_`
1381
1425
  }
1382
1426
  } else if (stateTitle) {
1383
- toolTitle = `*${stateTitle}*`
1427
+ toolTitle = `_${stateTitle}_`
1384
1428
  }
1385
1429
 
1386
1430
  const icon = part.state.status === 'error' ? '⨯' : '◼︎'
@@ -1430,16 +1474,6 @@ async function handleOpencodeSession({
1430
1474
  // Track session start time
1431
1475
  const sessionStartTime = Date.now()
1432
1476
 
1433
- // Add processing reaction to original message
1434
- if (originalMessage) {
1435
- try {
1436
- await originalMessage.react('⏳')
1437
- discordLogger.log(`Added processing reaction to message`)
1438
- } catch (e) {
1439
- discordLogger.log(`Could not add processing reaction:`, e)
1440
- }
1441
- }
1442
-
1443
1477
  // Use default directory if not specified
1444
1478
  const directory = projectDirectory || process.cwd()
1445
1479
  sessionLogger.log(`Using directory: ${directory}`)
@@ -1507,40 +1541,40 @@ async function handleOpencodeSession({
1507
1541
  existingController.abort(new Error('New request started'))
1508
1542
  }
1509
1543
 
1510
- if (abortControllers.has(session.id)) {
1511
- abortControllers.get(session.id)?.abort(new Error('new reply'))
1512
- }
1513
1544
  const abortController = new AbortController()
1514
- // Store this controller for this session
1515
1545
  abortControllers.set(session.id, abortController)
1516
1546
 
1547
+ if (existingController) {
1548
+ await new Promise((resolve) => { setTimeout(resolve, 200) })
1549
+ if (abortController.signal.aborted) {
1550
+ sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`)
1551
+ return
1552
+ }
1553
+ }
1554
+
1555
+ if (abortController.signal.aborted) {
1556
+ sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`)
1557
+ return
1558
+ }
1559
+
1517
1560
  const eventsResult = await getClient().event.subscribe({
1518
1561
  signal: abortController.signal,
1519
1562
  })
1563
+
1564
+ if (abortController.signal.aborted) {
1565
+ sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`)
1566
+ return
1567
+ }
1568
+
1520
1569
  const events = eventsResult.stream
1521
1570
  sessionLogger.log(`Subscribed to OpenCode events`)
1522
1571
 
1523
- // Load existing part-message mappings from database
1524
- const partIdToMessage = new Map<string, Message>()
1525
- const existingParts = getDatabase()
1526
- .prepare(
1527
- 'SELECT part_id, message_id FROM part_messages WHERE thread_id = ?',
1528
- )
1529
- .all(thread.id) as { part_id: string; message_id: string }[]
1530
-
1531
- // Pre-populate map with existing messages
1532
- for (const row of existingParts) {
1533
- try {
1534
- const message = await thread.messages.fetch(row.message_id)
1535
- if (message) {
1536
- partIdToMessage.set(row.part_id, message)
1537
- }
1538
- } catch (error) {
1539
- voiceLogger.log(
1540
- `Could not fetch message ${row.message_id} for part ${row.part_id}`,
1541
- )
1542
- }
1543
- }
1572
+ const sentPartIds = new Set<string>(
1573
+ (getDatabase()
1574
+ .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
1575
+ .all(thread.id) as { part_id: string }[])
1576
+ .map((row) => row.part_id)
1577
+ )
1544
1578
 
1545
1579
  let currentParts: Part[] = []
1546
1580
  let stopTyping: (() => void) | null = null
@@ -1548,6 +1582,49 @@ async function handleOpencodeSession({
1548
1582
  let usedProviderID: string | undefined
1549
1583
  let tokensUsedInSession = 0
1550
1584
 
1585
+ let typingInterval: NodeJS.Timeout | null = null
1586
+
1587
+ function startTyping(): () => void {
1588
+ if (abortController.signal.aborted) {
1589
+ discordLogger.log(`Not starting typing, already aborted`)
1590
+ return () => {}
1591
+ }
1592
+ if (typingInterval) {
1593
+ clearInterval(typingInterval)
1594
+ typingInterval = null
1595
+ }
1596
+
1597
+ thread.sendTyping().catch((e) => {
1598
+ discordLogger.log(`Failed to send initial typing: ${e}`)
1599
+ })
1600
+
1601
+ typingInterval = setInterval(() => {
1602
+ thread.sendTyping().catch((e) => {
1603
+ discordLogger.log(`Failed to send periodic typing: ${e}`)
1604
+ })
1605
+ }, 8000)
1606
+
1607
+ if (!abortController.signal.aborted) {
1608
+ abortController.signal.addEventListener(
1609
+ 'abort',
1610
+ () => {
1611
+ if (typingInterval) {
1612
+ clearInterval(typingInterval)
1613
+ typingInterval = null
1614
+ }
1615
+ },
1616
+ { once: true },
1617
+ )
1618
+ }
1619
+
1620
+ return () => {
1621
+ if (typingInterval) {
1622
+ clearInterval(typingInterval)
1623
+ typingInterval = null
1624
+ }
1625
+ }
1626
+ }
1627
+
1551
1628
  const sendPartMessage = async (part: Part) => {
1552
1629
  const content = formatPart(part) + '\n\n'
1553
1630
  if (!content.trim() || content.length === 0) {
@@ -1556,13 +1633,13 @@ async function handleOpencodeSession({
1556
1633
  }
1557
1634
 
1558
1635
  // Skip if already sent
1559
- if (partIdToMessage.has(part.id)) {
1636
+ if (sentPartIds.has(part.id)) {
1560
1637
  return
1561
1638
  }
1562
1639
 
1563
1640
  try {
1564
1641
  const firstMessage = await sendThreadMessage(thread, content)
1565
- partIdToMessage.set(part.id, firstMessage)
1642
+ sentPartIds.add(part.id)
1566
1643
 
1567
1644
  // Store part-message mapping in database
1568
1645
  getDatabase()
@@ -1576,58 +1653,6 @@ async function handleOpencodeSession({
1576
1653
  }
1577
1654
 
1578
1655
  const eventHandler = async () => {
1579
- // Local typing function for this session
1580
- // Outer-scoped interval for typing notifications. Only one at a time.
1581
- let typingInterval: NodeJS.Timeout | null = null
1582
-
1583
- function startTyping(thread: ThreadChannel): () => void {
1584
- if (abortController.signal.aborted) {
1585
- discordLogger.log(`Not starting typing, already aborted`)
1586
- return () => {}
1587
- }
1588
- // Clear any previous typing interval
1589
- if (typingInterval) {
1590
- clearInterval(typingInterval)
1591
- typingInterval = null
1592
- }
1593
-
1594
- // Send initial typing
1595
- thread.sendTyping().catch((e) => {
1596
- discordLogger.log(`Failed to send initial typing: ${e}`)
1597
- })
1598
-
1599
- // Set up interval to send typing every 8 seconds
1600
- typingInterval = setInterval(() => {
1601
- thread.sendTyping().catch((e) => {
1602
- discordLogger.log(`Failed to send periodic typing: ${e}`)
1603
- })
1604
- }, 8000)
1605
-
1606
- // Only add listener if not already aborted
1607
- if (!abortController.signal.aborted) {
1608
- abortController.signal.addEventListener(
1609
- 'abort',
1610
- () => {
1611
- if (typingInterval) {
1612
- clearInterval(typingInterval)
1613
- typingInterval = null
1614
- }
1615
- },
1616
- {
1617
- once: true,
1618
- },
1619
- )
1620
- }
1621
-
1622
- // Return stop function
1623
- return () => {
1624
- if (typingInterval) {
1625
- clearInterval(typingInterval)
1626
- typingInterval = null
1627
- }
1628
- }
1629
- }
1630
-
1631
1656
  try {
1632
1657
  let assistantMessageId: string | undefined
1633
1658
 
@@ -1678,7 +1703,7 @@ async function handleOpencodeSession({
1678
1703
 
1679
1704
  // Start typing on step-start
1680
1705
  if (part.type === 'step-start') {
1681
- stopTyping = startTyping(thread)
1706
+ stopTyping = startTyping()
1682
1707
  }
1683
1708
 
1684
1709
  // Send tool parts immediately when they start running
@@ -1686,6 +1711,11 @@ async function handleOpencodeSession({
1686
1711
  await sendPartMessage(part)
1687
1712
  }
1688
1713
 
1714
+ // Send reasoning parts immediately (shows "◼︎ thinking" indicator early)
1715
+ if (part.type === 'reasoning') {
1716
+ await sendPartMessage(part)
1717
+ }
1718
+
1689
1719
  // Check if this is a step-finish part
1690
1720
  if (part.type === 'step-finish') {
1691
1721
 
@@ -1699,7 +1729,7 @@ async function handleOpencodeSession({
1699
1729
  // start typing in a moment, so that if the session finished, because step-finish is at the end of the message, we do not show typing status
1700
1730
  setTimeout(() => {
1701
1731
  if (abortController.signal.aborted) return
1702
- stopTyping = startTyping(thread)
1732
+ stopTyping = startTyping()
1703
1733
  }, 300)
1704
1734
  }
1705
1735
  } else if (event.type === 'session.error') {
@@ -1790,7 +1820,7 @@ async function handleOpencodeSession({
1790
1820
  } finally {
1791
1821
  // Send any remaining parts that weren't sent
1792
1822
  for (const part of currentParts) {
1793
- if (!partIdToMessage.has(part.id)) {
1823
+ if (!sentPartIds.has(part.id)) {
1794
1824
  try {
1795
1825
  await sendPartMessage(part)
1796
1826
  } catch (error) {
@@ -1841,9 +1871,15 @@ async function handleOpencodeSession({
1841
1871
  }
1842
1872
 
1843
1873
  try {
1844
- // Start the event handler
1845
1874
  const eventHandlerPromise = eventHandler()
1846
1875
 
1876
+ if (abortController.signal.aborted) {
1877
+ sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
1878
+ return
1879
+ }
1880
+
1881
+ stopTyping = startTyping()
1882
+
1847
1883
  let response: { data?: unknown; error?: unknown; response: Response }
1848
1884
  if (parsedCommand?.isCommand) {
1849
1885
  sessionLogger.log(
@@ -2063,14 +2099,20 @@ export async function startDiscordBot({
2063
2099
  }
2064
2100
  }
2065
2101
 
2066
- // Check if user is authoritative (server owner or has admin permissions)
2102
+ // Check if user is authoritative (server owner, admin, manage server, or has Kimaki role)
2067
2103
  if (message.guild && message.member) {
2068
2104
  const isOwner = message.member.id === message.guild.ownerId
2069
2105
  const isAdmin = message.member.permissions.has(
2070
2106
  PermissionsBitField.Flags.Administrator,
2071
2107
  )
2108
+ const canManageServer = message.member.permissions.has(
2109
+ PermissionsBitField.Flags.ManageGuild,
2110
+ )
2111
+ const hasKimakiRole = message.member.roles.cache.some(
2112
+ (role) => role.name.toLowerCase() === 'kimaki',
2113
+ )
2072
2114
 
2073
- if (!isOwner && !isAdmin) {
2115
+ if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
2074
2116
  return
2075
2117
  }
2076
2118
  }
@@ -3259,15 +3301,20 @@ export async function startDiscordBot({
3259
3301
  const member = newState.member || oldState.member
3260
3302
  if (!member) return
3261
3303
 
3262
- // Check if user is admin or server owner
3304
+ // Check if user is admin, server owner, can manage server, or has Kimaki role
3263
3305
  const guild = newState.guild || oldState.guild
3264
3306
  const isOwner = member.id === guild.ownerId
3265
3307
  const isAdmin = member.permissions.has(
3266
3308
  PermissionsBitField.Flags.Administrator,
3267
3309
  )
3310
+ const canManageServer = member.permissions.has(
3311
+ PermissionsBitField.Flags.ManageGuild,
3312
+ )
3313
+ const hasKimakiRole = member.roles.cache.some(
3314
+ (role) => role.name.toLowerCase() === 'kimaki',
3315
+ )
3268
3316
 
3269
- if (!isOwner && !isAdmin) {
3270
- // Not an admin user, ignore
3317
+ if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
3271
3318
  return
3272
3319
  }
3273
3320
 
@@ -3293,7 +3340,9 @@ export async function startDiscordBot({
3293
3340
  if (m.id === member.id || m.user.bot) return false
3294
3341
  return (
3295
3342
  m.id === guild.ownerId ||
3296
- m.permissions.has(PermissionsBitField.Flags.Administrator)
3343
+ m.permissions.has(PermissionsBitField.Flags.Administrator) ||
3344
+ m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
3345
+ m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki')
3297
3346
  )
3298
3347
  })
3299
3348
 
@@ -3338,7 +3387,9 @@ export async function startDiscordBot({
3338
3387
  if (m.id === member.id || m.user.bot) return false
3339
3388
  return (
3340
3389
  m.id === guild.ownerId ||
3341
- m.permissions.has(PermissionsBitField.Flags.Administrator)
3390
+ m.permissions.has(PermissionsBitField.Flags.Administrator) ||
3391
+ m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
3392
+ m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki')
3342
3393
  )
3343
3394
  })
3344
3395