kimaki 0.4.55 → 0.4.56

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.
Files changed (83) hide show
  1. package/dist/cli.js +150 -79
  2. package/dist/commands/create-new-project.js +1 -0
  3. package/dist/commands/diff.js +130 -0
  4. package/dist/commands/mention-mode.js +51 -0
  5. package/dist/commands/merge-worktree.js +96 -9
  6. package/dist/commands/model.js +147 -19
  7. package/dist/commands/permissions.js +31 -36
  8. package/dist/commands/queue.js +3 -1
  9. package/dist/commands/session.js +1 -0
  10. package/dist/commands/unset-model.js +149 -0
  11. package/dist/commands/user-command.js +2 -0
  12. package/dist/commands/verbosity.js +2 -1
  13. package/dist/commands/worktree.js +7 -3
  14. package/dist/config.js +10 -1
  15. package/dist/database.js +55 -33
  16. package/dist/discord-bot.js +109 -22
  17. package/dist/discord-utils.js +31 -0
  18. package/dist/discord-utils.test.js +37 -0
  19. package/dist/generated/internal/class.js +2 -2
  20. package/dist/generated/internal/prismaNamespace.js +15 -6
  21. package/dist/generated/internal/prismaNamespaceBrowser.js +15 -6
  22. package/dist/generated/models/channel_mention_mode.js +1 -0
  23. package/dist/generated/models/global_models.js +1 -0
  24. package/dist/interaction-handler.js +24 -6
  25. package/dist/logger.js +1 -0
  26. package/dist/message-formatting.js +30 -7
  27. package/dist/opencode-plugin.js +266 -52
  28. package/dist/opencode.js +36 -10
  29. package/dist/session-handler.js +84 -51
  30. package/dist/system-message.js +37 -15
  31. package/dist/unnest-code-blocks.test.js +26 -0
  32. package/dist/voice.js +7 -1
  33. package/dist/worktree-utils.js +74 -2
  34. package/package.json +10 -9
  35. package/src/__snapshots__/compact-session-context-no-system.md +30 -30
  36. package/src/__snapshots__/compact-session-context.md +41 -47
  37. package/src/__snapshots__/first-session-no-info.md +3991 -1174
  38. package/src/__snapshots__/first-session-with-info.md +3994 -1177
  39. package/src/__snapshots__/session-1.md +3991 -1174
  40. package/src/__snapshots__/session-2.md +4 -276
  41. package/src/__snapshots__/session-3.md +4182 -18879
  42. package/src/__snapshots__/session-with-tools.md +3991 -1174
  43. package/src/cli.ts +165 -98
  44. package/src/commands/create-new-project.ts +1 -0
  45. package/src/commands/diff.ts +148 -0
  46. package/src/commands/mention-mode.ts +72 -0
  47. package/src/commands/merge-worktree.ts +130 -9
  48. package/src/commands/model.ts +187 -25
  49. package/src/commands/permissions.ts +44 -42
  50. package/src/commands/queue.ts +3 -1
  51. package/src/commands/session.ts +1 -0
  52. package/src/commands/unset-model.ts +183 -0
  53. package/src/commands/user-command.ts +2 -0
  54. package/src/commands/verbosity.ts +2 -1
  55. package/src/commands/worktree.ts +10 -2
  56. package/src/config.ts +13 -1
  57. package/src/database.ts +61 -36
  58. package/src/discord-bot.ts +120 -24
  59. package/src/discord-utils.test.ts +39 -0
  60. package/src/discord-utils.ts +42 -0
  61. package/src/generated/browser.ts +10 -5
  62. package/src/generated/client.ts +10 -5
  63. package/src/generated/internal/class.ts +22 -12
  64. package/src/generated/internal/prismaNamespace.ts +174 -86
  65. package/src/generated/internal/prismaNamespaceBrowser.ts +23 -10
  66. package/src/generated/models/bot_tokens.ts +100 -0
  67. package/src/generated/models/channel_directories.ts +128 -0
  68. package/src/generated/models/channel_mention_mode.ts +1300 -0
  69. package/src/generated/models/global_models.ts +1256 -0
  70. package/src/generated/models/thread_sessions.ts +0 -100
  71. package/src/generated/models.ts +2 -1
  72. package/src/interaction-handler.ts +33 -6
  73. package/src/logger.ts +1 -0
  74. package/src/message-formatting.ts +36 -8
  75. package/src/opencode-plugin.ts +319 -0
  76. package/src/opencode.ts +43 -10
  77. package/src/schema.sql +15 -5
  78. package/src/session-handler.ts +99 -57
  79. package/src/system-message.ts +63 -14
  80. package/src/unnest-code-blocks.test.ts +27 -0
  81. package/src/voice.ts +7 -1
  82. package/src/worktree-utils.ts +84 -2
  83. package/src/generated/models/pending_auto_start.ts +0 -1192
package/dist/cli.js CHANGED
@@ -6,7 +6,10 @@ import { cac } from '@xmorse/cac';
6
6
  import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, } from '@clack/prompts';
7
7
  import { deduplicateByKey, generateBotInstallUrl, abbreviatePath } from './utils.js';
8
8
  import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
9
- import { getBotToken, setBotToken, setGeminiApiKey, setChannelDirectory, findChannelsByDirectory, findChannelByAppId, getThreadSession, getThreadIdBySessionId, setPendingAutoStart, } from './database.js';
9
+ import { getBotToken, setBotToken, setChannelDirectory, findChannelsByDirectory, findChannelByAppId, getThreadSession, getThreadIdBySessionId, } from './database.js';
10
+ import { formatWorktreeName } from './commands/worktree.js';
11
+ import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
12
+ import yaml from 'js-yaml';
10
13
  import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
11
14
  import path from 'node:path';
12
15
  import fs from 'node:fs';
@@ -15,7 +18,7 @@ import { createLogger, LogPrefix } from './logger.js';
15
18
  import { uploadFilesToDiscord } from './discord-utils.js';
16
19
  import { spawn, spawnSync, execSync } from 'node:child_process';
17
20
  import http from 'node:http';
18
- import { setDataDir, getDataDir, getLockPort, setDefaultVerbosity } from './config.js';
21
+ import { setDataDir, getDataDir, getLockPort, setDefaultVerbosity, setDefaultMentionMode } from './config.js';
19
22
  import { sanitizeAgentName } from './commands/agent.js';
20
23
  const cliLogger = createLogger(LogPrefix.CLI);
21
24
  // Strip bracketed paste escape sequences from terminal input.
@@ -165,6 +168,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
165
168
  .setAutocomplete(true);
166
169
  return option;
167
170
  })
171
+ .setDMPermission(false)
168
172
  .toJSON(),
169
173
  new SlashCommandBuilder()
170
174
  .setName('new-session')
@@ -188,6 +192,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
188
192
  .setAutocomplete(true);
189
193
  return option;
190
194
  })
195
+ .setDMPermission(false)
191
196
  .toJSON(),
192
197
  new SlashCommandBuilder()
193
198
  .setName('new-worktree')
@@ -199,14 +204,22 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
199
204
  .setRequired(false);
200
205
  return option;
201
206
  })
207
+ .setDMPermission(false)
202
208
  .toJSON(),
203
209
  new SlashCommandBuilder()
204
210
  .setName('merge-worktree')
205
211
  .setDescription('Merge the worktree branch into the default branch')
212
+ .setDMPermission(false)
206
213
  .toJSON(),
207
214
  new SlashCommandBuilder()
208
215
  .setName('toggle-worktrees')
209
216
  .setDescription('Toggle automatic git worktree creation for new sessions in this channel')
217
+ .setDMPermission(false)
218
+ .toJSON(),
219
+ new SlashCommandBuilder()
220
+ .setName('toggle-mention-mode')
221
+ .setDescription('Toggle mention-only mode (bot only responds when @mentioned)')
222
+ .setDMPermission(false)
210
223
  .toJSON(),
211
224
  new SlashCommandBuilder()
212
225
  .setName('add-project')
@@ -219,6 +232,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
219
232
  .setAutocomplete(true);
220
233
  return option;
221
234
  })
235
+ .setDMPermission(false)
222
236
  .toJSON(),
223
237
  new SlashCommandBuilder()
224
238
  .setName('remove-project')
@@ -231,6 +245,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
231
245
  .setAutocomplete(true);
232
246
  return option;
233
247
  })
248
+ .setDMPermission(false)
234
249
  .toJSON(),
235
250
  new SlashCommandBuilder()
236
251
  .setName('create-new-project')
@@ -239,38 +254,57 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
239
254
  option.setName('name').setDescription('Name for the new project folder').setRequired(true);
240
255
  return option;
241
256
  })
257
+ .setDMPermission(false)
242
258
  .toJSON(),
243
259
  new SlashCommandBuilder()
244
260
  .setName('abort')
245
261
  .setDescription('Abort the current OpenCode request in this thread')
262
+ .setDMPermission(false)
246
263
  .toJSON(),
247
264
  new SlashCommandBuilder()
248
265
  .setName('compact')
249
266
  .setDescription('Compact the session context by summarizing conversation history')
267
+ .setDMPermission(false)
250
268
  .toJSON(),
251
269
  new SlashCommandBuilder()
252
270
  .setName('stop')
253
271
  .setDescription('Abort the current OpenCode request in this thread')
272
+ .setDMPermission(false)
254
273
  .toJSON(),
255
274
  new SlashCommandBuilder()
256
275
  .setName('share')
257
276
  .setDescription('Share the current session as a public URL')
277
+ .setDMPermission(false)
278
+ .toJSON(),
279
+ new SlashCommandBuilder()
280
+ .setName('diff')
281
+ .setDescription('Show git diff as a shareable URL')
282
+ .setDMPermission(false)
258
283
  .toJSON(),
259
284
  new SlashCommandBuilder()
260
285
  .setName('fork')
261
286
  .setDescription('Fork the session from a past user message')
287
+ .setDMPermission(false)
262
288
  .toJSON(),
263
289
  new SlashCommandBuilder()
264
290
  .setName('model')
265
291
  .setDescription('Set the preferred model for this channel or session')
292
+ .setDMPermission(false)
293
+ .toJSON(),
294
+ new SlashCommandBuilder()
295
+ .setName('unset-model-override')
296
+ .setDescription('Remove model override and use default instead')
297
+ .setDMPermission(false)
266
298
  .toJSON(),
267
299
  new SlashCommandBuilder()
268
300
  .setName('login')
269
301
  .setDescription('Authenticate with an AI provider (OAuth or API key). Use this instead of /connect')
302
+ .setDMPermission(false)
270
303
  .toJSON(),
271
304
  new SlashCommandBuilder()
272
305
  .setName('agent')
273
306
  .setDescription('Set the preferred agent for this channel or session')
307
+ .setDMPermission(false)
274
308
  .toJSON(),
275
309
  new SlashCommandBuilder()
276
310
  .setName('queue')
@@ -279,18 +313,22 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
279
313
  option.setName('message').setDescription('The message to queue').setRequired(true);
280
314
  return option;
281
315
  })
316
+ .setDMPermission(false)
282
317
  .toJSON(),
283
318
  new SlashCommandBuilder()
284
319
  .setName('clear-queue')
285
320
  .setDescription('Clear all queued messages in this thread')
321
+ .setDMPermission(false)
286
322
  .toJSON(),
287
323
  new SlashCommandBuilder()
288
324
  .setName('undo')
289
325
  .setDescription('Undo the last assistant message (revert file changes)')
326
+ .setDMPermission(false)
290
327
  .toJSON(),
291
328
  new SlashCommandBuilder()
292
329
  .setName('redo')
293
330
  .setDescription('Redo previously undone changes')
331
+ .setDMPermission(false)
294
332
  .toJSON(),
295
333
  new SlashCommandBuilder()
296
334
  .setName('verbosity')
@@ -303,10 +341,12 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
303
341
  .addChoices({ name: 'tools-and-text (default)', value: 'tools-and-text' }, { name: 'text-and-essential-tools', value: 'text-and-essential-tools' }, { name: 'text-only', value: 'text-only' });
304
342
  return option;
305
343
  })
344
+ .setDMPermission(false)
306
345
  .toJSON(),
307
346
  new SlashCommandBuilder()
308
347
  .setName('restart-opencode-server')
309
348
  .setDescription('Restart the opencode server for this channel only (fixes state/auth/plugins)')
349
+ .setDMPermission(false)
310
350
  .toJSON(),
311
351
  ];
312
352
  // Add user-defined commands with -cmd suffix
@@ -315,7 +355,8 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
315
355
  continue;
316
356
  }
317
357
  // Sanitize command name: oh-my-opencode uses MCP commands with colons, which Discord doesn't allow
318
- const sanitizedName = cmd.name.replace(/:/g, '-');
358
+ // Also convert to lowercase since Discord only allows lowercase in command names
359
+ const sanitizedName = cmd.name.toLowerCase().replace(/:/g, '-');
319
360
  const commandName = `${sanitizedName}-cmd`;
320
361
  const description = cmd.description || `Run /${cmd.name} command`;
321
362
  commands.push(new SlashCommandBuilder()
@@ -328,6 +369,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
328
369
  .setRequired(false);
329
370
  return option;
330
371
  })
372
+ .setDMPermission(false)
331
373
  .toJSON());
332
374
  }
333
375
  // Add agent-specific quick commands like /plan-agent, /build-agent
@@ -340,6 +382,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
340
382
  commands.push(new SlashCommandBuilder()
341
383
  .setName(commandName.slice(0, 32)) // Discord limits to 32 chars
342
384
  .setDescription(description.slice(0, 100))
385
+ .setDMPermission(false)
343
386
  .toJSON());
344
387
  }
345
388
  const rest = new REST().setToken(token);
@@ -574,29 +617,7 @@ async function run({ restart, addChannels, useWorktrees, enableVoiceChannels })
574
617
  process.exit(0);
575
618
  }
576
619
  token = stripBracketedPaste(tokenInput);
577
- note(`You can get a Gemini api Key at https://aistudio.google.com/apikey`, `Gemini API Key`);
578
- const geminiApiKeyInput = await password({
579
- message: 'Enter your Gemini API Key for voice channels and audio transcription (optional, press Enter to skip):',
580
- validate(value) {
581
- const cleaned = stripBracketedPaste(value);
582
- if (cleaned && cleaned.length < 10) {
583
- return 'Invalid API key format';
584
- }
585
- return undefined;
586
- },
587
- });
588
- if (isCancel(geminiApiKeyInput)) {
589
- cancel('Setup cancelled');
590
- process.exit(0);
591
- }
592
- const geminiApiKey = stripBracketedPaste(geminiApiKeyInput) || null;
593
- // Store bot token early so setGeminiApiKey can satisfy FK constraint
594
620
  await setBotToken(appId, token);
595
- // Store API key in database
596
- if (geminiApiKey) {
597
- await setGeminiApiKey(appId, geminiApiKey);
598
- note('API key saved successfully', 'API Key Stored');
599
- }
600
621
  note(`Bot install URL:\n${generateBotInstallUrl({ clientId: appId })}\n\nYou MUST install the bot in your Discord server before continuing.`, 'Step 4: Install Bot to Server');
601
622
  const installed = await text({
602
623
  message: 'Press Enter AFTER you have installed the bot in your server:',
@@ -845,6 +866,7 @@ cli
845
866
  .option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
846
867
  .option('--enable-voice-channels', 'Create voice channels for projects (disabled by default)')
847
868
  .option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text, text-and-essential-tools, or text-only)')
869
+ .option('--mention-mode', 'Bot only responds when @mentioned (default for all channels)')
848
870
  .action(async (options) => {
849
871
  try {
850
872
  // Set data directory early, before any database access
@@ -861,6 +883,10 @@ cli
861
883
  setDefaultVerbosity(options.verbosity);
862
884
  cliLogger.log(`Default verbosity: ${options.verbosity}`);
863
885
  }
886
+ if (options.mentionMode) {
887
+ setDefaultMentionMode(true);
888
+ cliLogger.log('Default mention mode: enabled (bot only responds when @mentioned)');
889
+ }
864
890
  if (options.installUrl) {
865
891
  await initDatabase();
866
892
  const existingBot = await getBotToken();
@@ -942,6 +968,10 @@ cli
942
968
  .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
943
969
  .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
944
970
  .option('--notify-only', 'Create notification thread without starting AI session')
971
+ .option('--worktree [name]', 'Create git worktree for session (name optional, derives from thread name)')
972
+ .option('-u, --user <username>', 'Discord username to add to thread')
973
+ .option('--agent <agent>', 'Agent to use for the session')
974
+ .option('--model <model>', 'Model to use (format: provider/model)')
945
975
  .action(async (options) => {
946
976
  try {
947
977
  let { channel: channelId, prompt, name, appId: optionAppId, notifyOnly } = options;
@@ -962,6 +992,10 @@ cli
962
992
  cliLogger.error('Prompt is required. Use --prompt <prompt>');
963
993
  process.exit(EXIT_NO_RESTART);
964
994
  }
995
+ if (options.worktree && notifyOnly) {
996
+ cliLogger.error('Cannot use --worktree with --notify-only');
997
+ process.exit(EXIT_NO_RESTART);
998
+ }
965
999
  // Get bot token from env var or database
966
1000
  const envToken = process.env.KIMAKI_BOT_TOKEN;
967
1001
  let botToken;
@@ -1069,7 +1103,15 @@ cli
1069
1103
  }
1070
1104
  }
1071
1105
  // Fall back to first guild the bot is in
1072
- const firstGuild = client.guilds.cache.first();
1106
+ let firstGuild = client.guilds.cache.first();
1107
+ if (!firstGuild) {
1108
+ // Cache might be empty, try fetching guilds from API
1109
+ const fetched = await client.guilds.fetch();
1110
+ const firstOAuth2Guild = fetched.first();
1111
+ if (firstOAuth2Guild) {
1112
+ firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
1113
+ }
1114
+ }
1073
1115
  if (!firstGuild) {
1074
1116
  throw new Error('No guild found. Add the bot to a server first.');
1075
1117
  }
@@ -1092,18 +1134,12 @@ cli
1092
1134
  }
1093
1135
  }
1094
1136
  cliLogger.log('Fetching channel info...');
1095
- // Get channel info to extract directory from topic
1096
- const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
1097
- headers: {
1098
- Authorization: `Bot ${botToken}`,
1099
- },
1100
- });
1101
- if (!channelResponse.ok) {
1102
- const error = await channelResponse.text();
1103
- cliLogger.log('Failed to fetch channel');
1104
- throw new Error(`Discord API error: ${channelResponse.status} - ${error}`);
1137
+ if (!channelId) {
1138
+ throw new Error('Channel ID not resolved');
1105
1139
  }
1106
- const channelData = (await channelResponse.json());
1140
+ const rest = new REST().setToken(botToken);
1141
+ // Get channel info to extract directory from topic
1142
+ const channelData = (await rest.get(Routes.channel(channelId)));
1107
1143
  const channelConfig = await getChannelDirectory(channelData.id);
1108
1144
  if (!channelConfig) {
1109
1145
  cliLogger.log('Channel not configured');
@@ -1116,17 +1152,56 @@ cli
1116
1152
  cliLogger.log('Channel belongs to different bot');
1117
1153
  throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`);
1118
1154
  }
1155
+ // Resolve username to user ID if provided
1156
+ const resolvedUser = await (async () => {
1157
+ if (!options.user) {
1158
+ return undefined;
1159
+ }
1160
+ cliLogger.log(`Searching for user "${options.user}" in guild...`);
1161
+ const searchResults = (await rest.get(Routes.guildMembersSearch(channelData.guild_id), {
1162
+ query: new URLSearchParams({ query: options.user, limit: '10' }),
1163
+ }));
1164
+ // Find exact match by display name, nickname, or username
1165
+ const exactMatch = searchResults.find((member) => {
1166
+ const displayName = member.nick || member.user.global_name || member.user.username;
1167
+ return (displayName.toLowerCase() === options.user.toLowerCase() ||
1168
+ member.user.username.toLowerCase() === options.user.toLowerCase());
1169
+ });
1170
+ const member = exactMatch || searchResults[0];
1171
+ if (!member) {
1172
+ throw new Error(`User "${options.user}" not found in guild`);
1173
+ }
1174
+ const username = member.nick || member.user.global_name || member.user.username;
1175
+ cliLogger.log(`Found user: ${username} (${member.user.id})`);
1176
+ return { id: member.user.id, username };
1177
+ })();
1119
1178
  cliLogger.log('Creating starter message...');
1120
1179
  // Discord has a 2000 character limit for messages.
1121
1180
  // If prompt exceeds this, send it as a file attachment instead.
1122
1181
  const DISCORD_MAX_LENGTH = 2000;
1123
1182
  let starterMessage;
1183
+ // Compute thread name and worktree name early (needed for embed)
1184
+ const baseThreadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt);
1185
+ const worktreeName = options.worktree
1186
+ ? formatWorktreeName(typeof options.worktree === 'string' ? options.worktree : baseThreadName)
1187
+ : undefined;
1188
+ const threadName = worktreeName
1189
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
1190
+ : baseThreadName;
1124
1191
  // Embed marker for auto-start sessions (unless --notify-only)
1125
- // Bot checks for this embed footer to know it should start a session
1126
- const AUTO_START_MARKER = 'kimaki:start';
1127
- const autoStartEmbed = notifyOnly
1192
+ // Bot parses this YAML to know it should start a session, optionally create a worktree, and set initial user
1193
+ const embedMarker = notifyOnly
1128
1194
  ? undefined
1129
- : [{ color: 0x2b2d31, footer: { text: AUTO_START_MARKER } }];
1195
+ : {
1196
+ start: true,
1197
+ ...(worktreeName && { worktree: worktreeName }),
1198
+ ...(resolvedUser && { username: resolvedUser.username, userId: resolvedUser.id }),
1199
+ ...(options.agent && { agent: options.agent }),
1200
+ ...(options.model && { model: options.model }),
1201
+ };
1202
+ const autoStartEmbed = embedMarker
1203
+ ? [{ color: 0x2b2d31, footer: { text: yaml.dump(embedMarker) } }]
1204
+ : undefined;
1130
1205
  if (prompt.length > DISCORD_MAX_LENGTH) {
1131
1206
  // Send as file attachment with a short summary
1132
1207
  const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
@@ -1139,7 +1214,8 @@ cli
1139
1214
  const tmpFile = path.join(tmpDir, `prompt-${Date.now()}.md`);
1140
1215
  fs.writeFileSync(tmpFile, prompt);
1141
1216
  try {
1142
- // Create message with file attachment
1217
+ // Using raw fetch for file uploads because discord.js REST client
1218
+ // doesn't handle FormData/multipart file attachments correctly
1143
1219
  const formData = new FormData();
1144
1220
  formData.append('payload_json', JSON.stringify({
1145
1221
  content: summaryContent,
@@ -1169,49 +1245,28 @@ cli
1169
1245
  }
1170
1246
  else {
1171
1247
  // Normal case: send prompt inline
1172
- const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
1173
- method: 'POST',
1174
- headers: {
1175
- Authorization: `Bot ${botToken}`,
1176
- 'Content-Type': 'application/json',
1177
- },
1178
- body: JSON.stringify({
1179
- content: prompt,
1180
- embeds: autoStartEmbed,
1181
- }),
1182
- });
1183
- if (!starterMessageResponse.ok) {
1184
- const error = await starterMessageResponse.text();
1185
- cliLogger.log('Failed to create message');
1186
- throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`);
1187
- }
1188
- starterMessage = (await starterMessageResponse.json());
1248
+ starterMessage = (await rest.post(Routes.channelMessages(channelId), {
1249
+ body: { content: prompt, embeds: autoStartEmbed },
1250
+ }));
1189
1251
  }
1190
1252
  cliLogger.log('Creating thread...');
1191
- // Create thread from the message
1192
- const threadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt);
1193
- const threadResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${starterMessage.id}/threads`, {
1194
- method: 'POST',
1195
- headers: {
1196
- Authorization: `Bot ${botToken}`,
1197
- 'Content-Type': 'application/json',
1198
- },
1199
- body: JSON.stringify({
1253
+ const threadData = (await rest.post(Routes.threads(channelId, starterMessage.id), {
1254
+ body: {
1200
1255
  name: threadName.slice(0, 100),
1201
1256
  auto_archive_duration: 1440, // 1 day
1202
- }),
1203
- });
1204
- if (!threadResponse.ok) {
1205
- const error = await threadResponse.text();
1206
- cliLogger.log('Failed to create thread');
1207
- throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
1208
- }
1209
- const threadData = (await threadResponse.json());
1257
+ },
1258
+ }));
1210
1259
  cliLogger.log('Thread created!');
1260
+ // Add user to thread if specified
1261
+ if (resolvedUser) {
1262
+ cliLogger.log(`Adding user ${resolvedUser.username} to thread...`);
1263
+ await rest.put(Routes.threadMembers(threadData.id, resolvedUser.id));
1264
+ }
1211
1265
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
1266
+ const worktreeNote = worktreeName ? `\nWorktree: ${worktreeName} (will be created by bot)` : '';
1212
1267
  const successMessage = notifyOnly
1213
1268
  ? `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
1214
- : `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`;
1269
+ : `Thread: ${threadData.name}\nDirectory: ${projectDirectory}${worktreeNote}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`;
1215
1270
  note(successMessage, '✅ Thread Created');
1216
1271
  cliLogger.log(threadUrl);
1217
1272
  process.exit(0);
@@ -1311,7 +1366,15 @@ cli
1311
1366
  }
1312
1367
  catch (error) {
1313
1368
  cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.message : String(error));
1314
- const firstGuild = client.guilds.cache.first();
1369
+ let firstGuild = client.guilds.cache.first();
1370
+ if (!firstGuild) {
1371
+ // Cache might be empty, try fetching guilds from API
1372
+ const fetched = await client.guilds.fetch();
1373
+ const firstOAuth2Guild = fetched.first();
1374
+ if (firstOAuth2Guild) {
1375
+ firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
1376
+ }
1377
+ }
1315
1378
  if (!firstGuild) {
1316
1379
  cliLogger.log('No guild found');
1317
1380
  cliLogger.error('No guild found. Add the bot to a server first.');
@@ -1322,7 +1385,15 @@ cli
1322
1385
  }
1323
1386
  }
1324
1387
  else {
1325
- const firstGuild = client.guilds.cache.first();
1388
+ let firstGuild = client.guilds.cache.first();
1389
+ if (!firstGuild) {
1390
+ // Cache might be empty, try fetching guilds from API
1391
+ const fetched = await client.guilds.fetch();
1392
+ const firstOAuth2Guild = fetched.first();
1393
+ if (firstOAuth2Guild) {
1394
+ firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
1395
+ }
1396
+ }
1326
1397
  if (!firstGuild) {
1327
1398
  cliLogger.log('No guild found');
1328
1399
  cliLogger.error('No guild found. Add the bot to a server first.');
@@ -101,6 +101,7 @@ export async function handleCreateNewProjectCommand({ command, appId, }) {
101
101
  thread,
102
102
  projectDirectory,
103
103
  channelId: textChannel.id,
104
+ appId,
104
105
  });
105
106
  logger.log(`Created new project ${channelName} at ${projectDirectory}`);
106
107
  }
@@ -0,0 +1,130 @@
1
+ // /diff command - Show git diff as a shareable URL.
2
+ import { ChannelType, EmbedBuilder } from 'discord.js';
3
+ import { exec } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import path from 'node:path';
6
+ import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
7
+ import { createLogger, LogPrefix } from '../logger.js';
8
+ const execAsync = promisify(exec);
9
+ const logger = createLogger(LogPrefix.DIFF);
10
+ export async function handleDiffCommand({ command }) {
11
+ const channel = command.channel;
12
+ if (!channel) {
13
+ await command.reply({
14
+ content: 'This command can only be used in a channel',
15
+ ephemeral: true,
16
+ flags: SILENT_MESSAGE_FLAGS,
17
+ });
18
+ return;
19
+ }
20
+ const isThread = [
21
+ ChannelType.PublicThread,
22
+ ChannelType.PrivateThread,
23
+ ChannelType.AnnouncementThread,
24
+ ].includes(channel.type);
25
+ const isTextChannel = channel.type === ChannelType.GuildText;
26
+ if (!isThread && !isTextChannel) {
27
+ await command.reply({
28
+ content: 'This command can only be used in a text channel or thread',
29
+ ephemeral: true,
30
+ flags: SILENT_MESSAGE_FLAGS,
31
+ });
32
+ return;
33
+ }
34
+ const textChannel = isThread
35
+ ? await resolveTextChannel(channel)
36
+ : channel;
37
+ const { projectDirectory: directory } = await getKimakiMetadata(textChannel);
38
+ if (!directory) {
39
+ await command.reply({
40
+ content: 'Could not determine project directory for this channel',
41
+ ephemeral: true,
42
+ flags: SILENT_MESSAGE_FLAGS,
43
+ });
44
+ return;
45
+ }
46
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
47
+ try {
48
+ const projectName = path.basename(directory);
49
+ const title = `${projectName}: Discord /diff`;
50
+ const { stdout, stderr } = await execAsync(`bunx critique --web "${title}" --json`, {
51
+ cwd: directory,
52
+ timeout: 30000,
53
+ });
54
+ // critique --json outputs JSON on the last line: {"url":"...","id":"..."} or {"error":"..."}
55
+ const output = stdout || stderr;
56
+ const lines = output.trim().split('\n');
57
+ const jsonLine = lines[lines.length - 1];
58
+ if (!jsonLine) {
59
+ await command.editReply({
60
+ content: 'No changes to show',
61
+ });
62
+ return;
63
+ }
64
+ let result;
65
+ try {
66
+ result = JSON.parse(jsonLine);
67
+ }
68
+ catch {
69
+ // Fallback: try to find URL in output
70
+ const urlMatch = output.match(/https?:\/\/critique\.work\/[^\s]+/);
71
+ if (urlMatch) {
72
+ await command.editReply({
73
+ content: `[diff](${urlMatch[0]})`,
74
+ });
75
+ logger.log(`Diff shared: ${urlMatch[0]}`);
76
+ return;
77
+ }
78
+ await command.editReply({
79
+ content: 'No changes to show',
80
+ });
81
+ return;
82
+ }
83
+ if (result.error || !result.url || !result.id) {
84
+ await command.editReply({
85
+ content: result.error || 'No changes to show',
86
+ });
87
+ return;
88
+ }
89
+ const imageUrl = `https://critique.work/og/${result.id}.png`;
90
+ const embed = new EmbedBuilder().setTitle(title).setURL(result.url).setImage(imageUrl);
91
+ await command.editReply({
92
+ embeds: [embed],
93
+ });
94
+ logger.log(`Diff shared: ${result.url}`);
95
+ }
96
+ catch (error) {
97
+ logger.error('[DIFF] Error:', error);
98
+ // exec error includes stdout/stderr - try to parse JSON from it
99
+ const execError = error;
100
+ const output = execError.stdout || execError.stderr || '';
101
+ // Check if critique output JSON even on error
102
+ const lines = output.trim().split('\n');
103
+ const jsonLine = lines[lines.length - 1];
104
+ if (jsonLine) {
105
+ try {
106
+ const result = JSON.parse(jsonLine);
107
+ if (result.error) {
108
+ await command.editReply({
109
+ content: result.error,
110
+ });
111
+ return;
112
+ }
113
+ }
114
+ catch {
115
+ // not JSON, continue to generic error
116
+ }
117
+ }
118
+ // Check for common errors
119
+ const message = execError.message || 'Unknown error';
120
+ if (message.includes('command not found') || message.includes('ENOENT')) {
121
+ await command.editReply({
122
+ content: 'bunx/critique not available',
123
+ });
124
+ return;
125
+ }
126
+ await command.editReply({
127
+ content: `Failed to generate diff: ${message.slice(0, 200)}`,
128
+ });
129
+ }
130
+ }
@@ -0,0 +1,51 @@
1
+ // /toggle-mention-mode command.
2
+ // Toggles mention-only mode for a channel.
3
+ // When enabled, bot only responds to messages that @mention it.
4
+ // Messages in threads are not affected - they always work without mentions.
5
+ import { ChatInputCommandInteraction, ChannelType } from 'discord.js';
6
+ import { getChannelMentionMode, setChannelMentionMode } from '../database.js';
7
+ import { getKimakiMetadata } from '../discord-utils.js';
8
+ import { createLogger, LogPrefix } from '../logger.js';
9
+ const mentionModeLogger = createLogger(LogPrefix.CLI);
10
+ /**
11
+ * Handle the /toggle-mention-mode slash command.
12
+ * Toggles whether the bot only responds when @mentioned in this channel.
13
+ */
14
+ export async function handleToggleMentionModeCommand({ command, appId, }) {
15
+ mentionModeLogger.log('[TOGGLE_MENTION_MODE] Command called');
16
+ const channel = command.channel;
17
+ if (!channel || channel.type !== ChannelType.GuildText) {
18
+ await command.reply({
19
+ content: 'This command can only be used in text channels (not threads).',
20
+ ephemeral: true,
21
+ });
22
+ return;
23
+ }
24
+ const textChannel = channel;
25
+ const metadata = await getKimakiMetadata(textChannel);
26
+ if (metadata.channelAppId && metadata.channelAppId !== appId) {
27
+ await command.reply({
28
+ content: 'This channel is configured for a different bot.',
29
+ ephemeral: true,
30
+ });
31
+ return;
32
+ }
33
+ if (!metadata.projectDirectory) {
34
+ await command.reply({
35
+ content: 'This channel is not configured with a project directory.\nUse `/add-project` to set up this channel.',
36
+ ephemeral: true,
37
+ });
38
+ return;
39
+ }
40
+ const wasEnabled = await getChannelMentionMode(textChannel.id);
41
+ const nextEnabled = !wasEnabled;
42
+ await setChannelMentionMode(textChannel.id, nextEnabled);
43
+ const nextLabel = nextEnabled ? 'enabled' : 'disabled';
44
+ mentionModeLogger.log(`[TOGGLE_MENTION_MODE] ${nextLabel.toUpperCase()} for channel ${textChannel.id}`);
45
+ await command.reply({
46
+ content: nextEnabled
47
+ ? `Mention mode **enabled** for this channel.\nThe bot will only start new sessions when @mentioned.\nMessages in existing threads are not affected.`
48
+ : `Mention mode **disabled** for this channel.\nThe bot will respond to all messages in **#${textChannel.name}**.`,
49
+ ephemeral: true,
50
+ });
51
+ }