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.
- package/dist/cli.js +150 -79
- package/dist/commands/create-new-project.js +1 -0
- package/dist/commands/diff.js +130 -0
- package/dist/commands/mention-mode.js +51 -0
- package/dist/commands/merge-worktree.js +96 -9
- package/dist/commands/model.js +147 -19
- package/dist/commands/permissions.js +31 -36
- package/dist/commands/queue.js +3 -1
- package/dist/commands/session.js +1 -0
- package/dist/commands/unset-model.js +149 -0
- package/dist/commands/user-command.js +2 -0
- package/dist/commands/verbosity.js +2 -1
- package/dist/commands/worktree.js +7 -3
- package/dist/config.js +10 -1
- package/dist/database.js +55 -33
- package/dist/discord-bot.js +109 -22
- package/dist/discord-utils.js +31 -0
- package/dist/discord-utils.test.js +37 -0
- package/dist/generated/internal/class.js +2 -2
- package/dist/generated/internal/prismaNamespace.js +15 -6
- package/dist/generated/internal/prismaNamespaceBrowser.js +15 -6
- package/dist/generated/models/channel_mention_mode.js +1 -0
- package/dist/generated/models/global_models.js +1 -0
- package/dist/interaction-handler.js +24 -6
- package/dist/logger.js +1 -0
- package/dist/message-formatting.js +30 -7
- package/dist/opencode-plugin.js +266 -52
- package/dist/opencode.js +36 -10
- package/dist/session-handler.js +84 -51
- package/dist/system-message.js +37 -15
- package/dist/unnest-code-blocks.test.js +26 -0
- package/dist/voice.js +7 -1
- package/dist/worktree-utils.js +74 -2
- package/package.json +10 -9
- package/src/__snapshots__/compact-session-context-no-system.md +30 -30
- package/src/__snapshots__/compact-session-context.md +41 -47
- package/src/__snapshots__/first-session-no-info.md +3991 -1174
- package/src/__snapshots__/first-session-with-info.md +3994 -1177
- package/src/__snapshots__/session-1.md +3991 -1174
- package/src/__snapshots__/session-2.md +4 -276
- package/src/__snapshots__/session-3.md +4182 -18879
- package/src/__snapshots__/session-with-tools.md +3991 -1174
- package/src/cli.ts +165 -98
- package/src/commands/create-new-project.ts +1 -0
- package/src/commands/diff.ts +148 -0
- package/src/commands/mention-mode.ts +72 -0
- package/src/commands/merge-worktree.ts +130 -9
- package/src/commands/model.ts +187 -25
- package/src/commands/permissions.ts +44 -42
- package/src/commands/queue.ts +3 -1
- package/src/commands/session.ts +1 -0
- package/src/commands/unset-model.ts +183 -0
- package/src/commands/user-command.ts +2 -0
- package/src/commands/verbosity.ts +2 -1
- package/src/commands/worktree.ts +10 -2
- package/src/config.ts +13 -1
- package/src/database.ts +61 -36
- package/src/discord-bot.ts +120 -24
- package/src/discord-utils.test.ts +39 -0
- package/src/discord-utils.ts +42 -0
- package/src/generated/browser.ts +10 -5
- package/src/generated/client.ts +10 -5
- package/src/generated/internal/class.ts +22 -12
- package/src/generated/internal/prismaNamespace.ts +174 -86
- package/src/generated/internal/prismaNamespaceBrowser.ts +23 -10
- package/src/generated/models/bot_tokens.ts +100 -0
- package/src/generated/models/channel_directories.ts +128 -0
- package/src/generated/models/channel_mention_mode.ts +1300 -0
- package/src/generated/models/global_models.ts +1256 -0
- package/src/generated/models/thread_sessions.ts +0 -100
- package/src/generated/models.ts +2 -1
- package/src/interaction-handler.ts +33 -6
- package/src/logger.ts +1 -0
- package/src/message-formatting.ts +36 -8
- package/src/opencode-plugin.ts +319 -0
- package/src/opencode.ts +43 -10
- package/src/schema.sql +15 -5
- package/src/session-handler.ts +99 -57
- package/src/system-message.ts +63 -14
- package/src/unnest-code-blocks.test.ts +27 -0
- package/src/voice.ts +7 -1
- package/src/worktree-utils.ts +84 -2
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1096
|
-
|
|
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
|
|
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
|
|
1126
|
-
const
|
|
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
|
-
:
|
|
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
|
-
//
|
|
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
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
-
|
|
1192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.');
|
|
@@ -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
|
+
}
|