kimaki 0.11.0 → 0.12.0
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/agent-model.e2e.test.js +91 -1
- package/dist/cli-runner.js +28 -10
- package/dist/cli.js +5 -0
- package/dist/commands/agent.js +14 -16
- package/dist/commands/mention-mode.js +0 -1
- package/dist/commands/model-variant.js +2 -2
- package/dist/commands/model.js +47 -27
- package/dist/commands/unset-model.js +2 -2
- package/dist/commands/verbosity.js +0 -1
- package/dist/commands/worktree-settings.js +0 -1
- package/dist/discord-bot.js +10 -5
- package/dist/discord-command-registration.js +1 -1
- package/dist/discord-utils.js +14 -0
- package/dist/discord-utils.test.js +51 -1
- package/dist/external-opencode-sync.js +119 -54
- package/dist/interaction-handler.js +4 -0
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +1 -1
- package/dist/message-formatting.js +91 -0
- package/dist/message-formatting.test.js +206 -1
- package/dist/opencode.js +34 -158
- package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
- package/dist/session-handler/thread-session-runtime.js +11 -3
- package/dist/store.js +1 -0
- package/dist/system-message.test.js +16 -0
- package/dist/voice-handler.js +91 -68
- package/package.json +6 -6
- package/skills/npm-package/SKILL.md +12 -2
- package/skills/termcast/SKILL.md +32 -846
- package/skills/tuistory/SKILL.md +71 -0
- package/src/agent-model.e2e.test.ts +117 -0
- package/src/cli-runner.ts +28 -10
- package/src/cli.ts +11 -0
- package/src/commands/agent.ts +14 -17
- package/src/commands/mention-mode.ts +0 -1
- package/src/commands/model-variant.ts +2 -2
- package/src/commands/model.ts +63 -37
- package/src/commands/unset-model.ts +1 -2
- package/src/commands/verbosity.ts +0 -1
- package/src/commands/worktree-settings.ts +0 -1
- package/src/discord-bot.ts +11 -4
- package/src/discord-command-registration.ts +1 -1
- package/src/discord-utils.test.ts +63 -2
- package/src/discord-utils.ts +19 -0
- package/src/external-opencode-sync.ts +147 -64
- package/src/interaction-handler.ts +5 -0
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +1 -1
- package/src/message-formatting.test.ts +247 -1
- package/src/message-formatting.ts +93 -1
- package/src/opencode.ts +36 -152
- package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
- package/src/session-handler/thread-session-runtime.ts +11 -2
- package/src/store.ts +9 -0
- package/src/system-message.test.ts +16 -0
- package/src/voice-handler.ts +106 -78
|
@@ -19,7 +19,7 @@ import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provid
|
|
|
19
19
|
import { setDataDir } from './config.js';
|
|
20
20
|
import { store } from './store.js';
|
|
21
21
|
import { startDiscordBot } from './discord-bot.js';
|
|
22
|
-
import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, } from './database.js';
|
|
22
|
+
import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, getThreadSession, getSessionAgent, getChannelAgent, } from './database.js';
|
|
23
23
|
import { getDb } from './db.js';
|
|
24
24
|
import * as orm from 'drizzle-orm';
|
|
25
25
|
import * as schema from './schema.js';
|
|
@@ -258,6 +258,10 @@ describe('agent model resolution', () => {
|
|
|
258
258
|
description: `Switch to ${agentName} agent`,
|
|
259
259
|
}))
|
|
260
260
|
.setDMPermission(false)
|
|
261
|
+
.addStringOption((opt) => opt
|
|
262
|
+
.setName('prompt')
|
|
263
|
+
.setDescription('Send a prompt with this agent')
|
|
264
|
+
.setRequired(false))
|
|
261
265
|
.toJSON();
|
|
262
266
|
});
|
|
263
267
|
const rest = new REST({ version: '10', api: discord.restUrl }).setToken(discord.botToken);
|
|
@@ -681,6 +685,92 @@ describe('agent model resolution', () => {
|
|
|
681
685
|
expect(secondFooter.content).toContain(DEFAULT_MODEL);
|
|
682
686
|
expect(secondFooter.content).not.toContain(AGENT_MODEL);
|
|
683
687
|
}, 20_000);
|
|
688
|
+
test('/plan-agent with prompt starts a session with the plan agent', async () => {
|
|
689
|
+
await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
|
|
690
|
+
const prompt = 'Reply with exactly: inline-plan-agent-msg';
|
|
691
|
+
const { id: interactionId } = await discord
|
|
692
|
+
.channel(TEXT_CHANNEL_ID)
|
|
693
|
+
.user(TEST_USER_ID)
|
|
694
|
+
.runSlashCommand({
|
|
695
|
+
name: 'plan-agent',
|
|
696
|
+
options: [{ name: 'prompt', type: 3, value: prompt }],
|
|
697
|
+
});
|
|
698
|
+
await discord
|
|
699
|
+
.channel(TEXT_CHANNEL_ID)
|
|
700
|
+
.waitForInteractionAck({ interactionId, timeout: 4_000 });
|
|
701
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
702
|
+
timeout: 4_000,
|
|
703
|
+
predicate: (t) => {
|
|
704
|
+
return t.name === prompt;
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
await waitForFooterMessage({
|
|
708
|
+
discord,
|
|
709
|
+
threadId: thread.id,
|
|
710
|
+
timeout: 4_000,
|
|
711
|
+
afterMessageIncludes: 'ok',
|
|
712
|
+
afterAuthorId: discord.botUserId,
|
|
713
|
+
});
|
|
714
|
+
const sessionId = await getThreadSession(thread.id);
|
|
715
|
+
expect(sessionId).toBeDefined();
|
|
716
|
+
expect(sessionId ? await getSessionAgent(sessionId) : undefined).toBe('plan');
|
|
717
|
+
expect(await getChannelAgent(TEXT_CHANNEL_ID)).toBe('test-agent');
|
|
718
|
+
expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
|
|
719
|
+
"--- from: assistant (TestBot)
|
|
720
|
+
» **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-msg
|
|
721
|
+
*using deterministic-provider/plan-model-v2 ⋅ plan*
|
|
722
|
+
⬥ ok
|
|
723
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
|
|
724
|
+
`);
|
|
725
|
+
}, 20_000);
|
|
726
|
+
test('/plan-agent with prompt in an existing thread changes the session agent', async () => {
|
|
727
|
+
await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
|
|
728
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
729
|
+
content: 'Reply with exactly: inline-existing-first-msg',
|
|
730
|
+
});
|
|
731
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
732
|
+
timeout: 4_000,
|
|
733
|
+
predicate: (t) => {
|
|
734
|
+
return t.name === 'Reply with exactly: inline-existing-first-msg';
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
await waitForFooterMessage({
|
|
738
|
+
discord,
|
|
739
|
+
threadId: thread.id,
|
|
740
|
+
timeout: 4_000,
|
|
741
|
+
afterMessageIncludes: 'ok',
|
|
742
|
+
afterAuthorId: discord.botUserId,
|
|
743
|
+
});
|
|
744
|
+
const prompt = 'Reply with exactly: inline-existing-plan-msg';
|
|
745
|
+
const th = discord.thread(thread.id);
|
|
746
|
+
const { id: interactionId } = await th.user(TEST_USER_ID).runSlashCommand({
|
|
747
|
+
name: 'plan-agent',
|
|
748
|
+
options: [{ name: 'prompt', type: 3, value: prompt }],
|
|
749
|
+
});
|
|
750
|
+
await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
|
|
751
|
+
await waitForFooterMessage({
|
|
752
|
+
discord,
|
|
753
|
+
threadId: thread.id,
|
|
754
|
+
timeout: 4_000,
|
|
755
|
+
afterMessageIncludes: 'inline-existing-plan-msg',
|
|
756
|
+
afterAuthorId: discord.botUserId,
|
|
757
|
+
});
|
|
758
|
+
const sessionId = await getThreadSession(thread.id);
|
|
759
|
+
expect(sessionId).toBeDefined();
|
|
760
|
+
expect(sessionId ? await getSessionAgent(sessionId) : undefined).toBe('plan');
|
|
761
|
+
expect(await getChannelAgent(TEXT_CHANNEL_ID)).toBe('test-agent');
|
|
762
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
763
|
+
"--- from: user (agent-model-tester)
|
|
764
|
+
Reply with exactly: inline-existing-first-msg
|
|
765
|
+
--- from: assistant (TestBot)
|
|
766
|
+
*using deterministic-provider/agent-model-v2 ⋅ test-agent*
|
|
767
|
+
⬥ ok
|
|
768
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
|
|
769
|
+
» **agent-model-tester** (plan): Reply with exactly: inline-existing-plan-msg
|
|
770
|
+
⬥ ok
|
|
771
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
|
|
772
|
+
`);
|
|
773
|
+
}, 20_000);
|
|
684
774
|
test('/plan-agent inside a thread switches the model for that thread', async () => {
|
|
685
775
|
// 1. Start with test-agent on the channel
|
|
686
776
|
await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
|
package/dist/cli-runner.js
CHANGED
|
@@ -938,16 +938,34 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
|
|
|
938
938
|
startCaffeinate();
|
|
939
939
|
const forceRestartOnboarding = Boolean(restartOnboarding);
|
|
940
940
|
const forceGateway = Boolean(gateway);
|
|
941
|
-
// Step 0: Ensure bun
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
941
|
+
// Step 0: Ensure opencode and bun are installed
|
|
942
|
+
await Promise.all([
|
|
943
|
+
ensureCommandAvailable({
|
|
944
|
+
name: 'opencode',
|
|
945
|
+
envPathKey: 'OPENCODE_PATH',
|
|
946
|
+
installUnix: 'curl -fsSL https://opencode.ai/install | bash',
|
|
947
|
+
installWindows: 'irm https://opencode.ai/install.ps1 | iex',
|
|
948
|
+
possiblePathsUnix: [
|
|
949
|
+
'~/.local/bin/opencode',
|
|
950
|
+
'~/.opencode/bin/opencode',
|
|
951
|
+
'/usr/local/bin/opencode',
|
|
952
|
+
'/opt/opencode/bin/opencode',
|
|
953
|
+
],
|
|
954
|
+
possiblePathsWindows: [
|
|
955
|
+
'~\\.local\\bin\\opencode.exe',
|
|
956
|
+
'~\\AppData\\Local\\opencode\\opencode.exe',
|
|
957
|
+
'~\\.opencode\\bin\\opencode.exe',
|
|
958
|
+
],
|
|
959
|
+
}),
|
|
960
|
+
ensureCommandAvailable({
|
|
961
|
+
name: 'bun',
|
|
962
|
+
envPathKey: 'BUN_PATH',
|
|
963
|
+
installUnix: 'curl -fsSL https://bun.sh/install | bash',
|
|
964
|
+
installWindows: 'irm bun.sh/install.ps1 | iex',
|
|
965
|
+
possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
|
|
966
|
+
possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
|
|
967
|
+
}),
|
|
968
|
+
]);
|
|
951
969
|
void backgroundUpgradeKimaki();
|
|
952
970
|
// Start in-process Hrana server before database init. Required for the bot
|
|
953
971
|
// process because it serves as both the DB server and the single-instance
|
package/dist/cli.js
CHANGED
|
@@ -40,6 +40,7 @@ cli
|
|
|
40
40
|
.option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
|
|
41
41
|
.option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
|
|
42
42
|
.option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
|
|
43
|
+
.option('--disable-sync', 'Disable background sync of external OpenCode sessions into Discord')
|
|
43
44
|
.option('--no-sentry', 'Disable Sentry error reporting')
|
|
44
45
|
.option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
|
|
45
46
|
.option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
|
|
@@ -136,6 +137,7 @@ cli
|
|
|
136
137
|
...(options.mentionMode && { defaultMentionMode: true }),
|
|
137
138
|
...(options.noCritique && { critiqueEnabled: false }),
|
|
138
139
|
...(options.allowAllUsers && { allowAllUsers: true }),
|
|
140
|
+
...(options.disableSync && { syncEnabled: false }),
|
|
139
141
|
...(enabledSkills.length > 0 && { enabledSkills }),
|
|
140
142
|
...(disabledSkills.length > 0 && { disabledSkills }),
|
|
141
143
|
});
|
|
@@ -157,6 +159,9 @@ cli
|
|
|
157
159
|
if (options.noCritique) {
|
|
158
160
|
cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
|
|
159
161
|
}
|
|
162
|
+
if (options.disableSync) {
|
|
163
|
+
cliLogger.log('Background sync disabled: external OpenCode sessions will not appear in Discord');
|
|
164
|
+
}
|
|
160
165
|
if (options.noSentry) {
|
|
161
166
|
process.env.KIMAKI_SENTRY_DISABLED = '1';
|
|
162
167
|
cliLogger.log('Sentry error reporting disabled (--no-sentry)');
|
package/dist/commands/agent.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// /agent command - Set the preferred agent for this channel or session.
|
|
2
2
|
// Also provides quick agent commands like /plan-agent, /build-agent that switch instantly.
|
|
3
3
|
// When a prompt is provided to a quick agent command (e.g. /plan-agent "fix the bug"),
|
|
4
|
-
// the prompt is sent
|
|
4
|
+
// the prompt is sent with that agent and the session keeps that agent afterwards.
|
|
5
5
|
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
7
|
import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, } from '../database.js';
|
|
@@ -162,7 +162,7 @@ export async function setAgentForContext({ context, agentName, }) {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
export async function handleAgentCommand({ interaction, appId, }) {
|
|
165
|
-
await interaction.deferReply(
|
|
165
|
+
await interaction.deferReply();
|
|
166
166
|
const context = await resolveAgentCommandContext({ interaction, appId });
|
|
167
167
|
if (!context) {
|
|
168
168
|
return;
|
|
@@ -290,13 +290,12 @@ export async function handleAgentSelectMenu(interaction) {
|
|
|
290
290
|
export async function handleQuickAgentCommand({ command, appId, }) {
|
|
291
291
|
const fallbackAgentName = command.commandName.replace(/-agent$/, '');
|
|
292
292
|
const prompt = command.options.getString('prompt') || undefined;
|
|
293
|
-
//
|
|
294
|
-
// without changing the persistent agent preference.
|
|
293
|
+
// Prompt mode: send the prompt with this agent immediately.
|
|
295
294
|
if (prompt) {
|
|
296
295
|
return handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt });
|
|
297
296
|
}
|
|
298
297
|
// No prompt: switch the persistent agent preference (original behavior).
|
|
299
|
-
await command.deferReply(
|
|
298
|
+
await command.deferReply();
|
|
300
299
|
const context = await resolveAgentCommandContext({
|
|
301
300
|
interaction: command,
|
|
302
301
|
appId,
|
|
@@ -363,10 +362,10 @@ export async function handleQuickAgentCommand({ command, appId, }) {
|
|
|
363
362
|
}
|
|
364
363
|
}
|
|
365
364
|
/**
|
|
366
|
-
* Handle
|
|
367
|
-
* In a thread: enqueue the prompt
|
|
368
|
-
* In a channel: create a new thread
|
|
369
|
-
*
|
|
365
|
+
* Handle prompt mode: send a prompt with the requested agent.
|
|
366
|
+
* In a thread: enqueue the prompt on the existing session and switch that session.
|
|
367
|
+
* In a channel: create a new thread whose session starts with the requested agent.
|
|
368
|
+
* Channel-level preferences are not changed.
|
|
370
369
|
*/
|
|
371
370
|
async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt, }) {
|
|
372
371
|
const channel = command.channel;
|
|
@@ -386,7 +385,7 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
|
|
|
386
385
|
].includes(channel.type);
|
|
387
386
|
const displayText = `${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`;
|
|
388
387
|
if (isThread) {
|
|
389
|
-
// In a thread: enqueue the prompt
|
|
388
|
+
// In a thread: enqueue the prompt and switch the existing session to this agent.
|
|
390
389
|
const thread = channel;
|
|
391
390
|
const resolved = await resolveWorkingDirectory({ channel: thread });
|
|
392
391
|
if (!resolved) {
|
|
@@ -419,9 +418,8 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
|
|
|
419
418
|
});
|
|
420
419
|
}
|
|
421
420
|
else if (channel.type === ChannelType.GuildText) {
|
|
422
|
-
// In a channel: create a new thread and enqueue with the agent
|
|
423
|
-
const
|
|
424
|
-
const metadata = await getKimakiMetadata(textChannel);
|
|
421
|
+
// In a channel: create a new thread and enqueue with the requested agent.
|
|
422
|
+
const metadata = await getKimakiMetadata(channel);
|
|
425
423
|
const projectDirectory = metadata.projectDirectory;
|
|
426
424
|
if (!projectDirectory) {
|
|
427
425
|
await command.reply({
|
|
@@ -431,14 +429,14 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
|
|
|
431
429
|
return;
|
|
432
430
|
}
|
|
433
431
|
await command.deferReply();
|
|
434
|
-
const starterMessage = await
|
|
432
|
+
const starterMessage = await channel.send({
|
|
435
433
|
content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
|
|
436
434
|
flags: SILENT_MESSAGE_FLAGS,
|
|
437
435
|
});
|
|
438
436
|
const thread = await starterMessage.startThread({
|
|
439
437
|
name: prompt.slice(0, 80),
|
|
440
438
|
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
441
|
-
reason:
|
|
439
|
+
reason: `${resolvedAgentName} agent prompt`,
|
|
442
440
|
});
|
|
443
441
|
await thread.members.add(command.user.id);
|
|
444
442
|
await command.editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`);
|
|
@@ -447,7 +445,7 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
|
|
|
447
445
|
thread,
|
|
448
446
|
projectDirectory,
|
|
449
447
|
sdkDirectory: projectDirectory,
|
|
450
|
-
channelId:
|
|
448
|
+
channelId: channel.id,
|
|
451
449
|
appId,
|
|
452
450
|
});
|
|
453
451
|
await runtime.enqueueIncoming({
|
|
@@ -38,6 +38,5 @@ export async function handleToggleMentionModeCommand({ command, }) {
|
|
|
38
38
|
content: nextEnabled
|
|
39
39
|
? `Mention mode **enabled** for this channel.\nThe bot will only start new sessions when @mentioned.\nMessages in existing threads are not affected.`
|
|
40
40
|
: `Mention mode **disabled** for this channel.\nThe bot will respond to all messages in **#${channel.name}**.`,
|
|
41
|
-
flags: MessageFlags.Ephemeral,
|
|
42
41
|
});
|
|
43
42
|
}
|
|
@@ -40,7 +40,7 @@ function formatSourceLabel(info) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
export async function handleModelVariantCommand({ interaction, appId, }) {
|
|
43
|
-
await interaction.deferReply(
|
|
43
|
+
await interaction.deferReply();
|
|
44
44
|
const channel = interaction.channel;
|
|
45
45
|
if (!channel) {
|
|
46
46
|
await interaction.editReply({
|
|
@@ -292,7 +292,7 @@ export async function handleVariantScopeSelectMenu(interaction) {
|
|
|
292
292
|
async function applyVariant({ interaction, context, variant, scope, contextHash, }) {
|
|
293
293
|
const modelId = context.modelId;
|
|
294
294
|
const variantSuffix = variant ? ` (${variant})` : '';
|
|
295
|
-
const agentTip = '\n_Tip: create [agent .md files](https://
|
|
295
|
+
const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/model-switching) in .opencode/agent/ for one-command model switching_';
|
|
296
296
|
try {
|
|
297
297
|
if (scope === 'session') {
|
|
298
298
|
if (!context.sessionId) {
|
package/dist/commands/model.js
CHANGED
|
@@ -11,6 +11,20 @@ import { createLogger, LogPrefix } from '../logger.js';
|
|
|
11
11
|
import * as errore from 'errore';
|
|
12
12
|
import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
|
|
13
13
|
const modelLogger = createLogger(LogPrefix.MODEL);
|
|
14
|
+
function buildSafeSelectOption({ label, value, description, }) {
|
|
15
|
+
const trimmedLabel = label?.trim();
|
|
16
|
+
const trimmedValue = value?.trim();
|
|
17
|
+
const safeLabel = (trimmedLabel || trimmedValue || 'Unknown').slice(0, 100);
|
|
18
|
+
const safeValue = trimmedValue || trimmedLabel || '';
|
|
19
|
+
if (!safeLabel || !safeValue) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
label: safeLabel,
|
|
24
|
+
value: safeValue,
|
|
25
|
+
description: description?.slice(0, 100),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
14
28
|
// Store context by hash to avoid customId length limits (Discord max: 100 chars).
|
|
15
29
|
// Entries are TTL'd to prevent unbounded growth when users open /model and never
|
|
16
30
|
// interact with the select menu.
|
|
@@ -172,7 +186,7 @@ export async function getCurrentModelInfo({ sessionId, channelId, appId, agentPr
|
|
|
172
186
|
export async function handleModelCommand({ interaction, appId, }) {
|
|
173
187
|
modelLogger.log('[MODEL] handleModelCommand called');
|
|
174
188
|
// Defer reply immediately to avoid 3-second timeout
|
|
175
|
-
await interaction.deferReply(
|
|
189
|
+
await interaction.deferReply();
|
|
176
190
|
modelLogger.log('[MODEL] Deferred reply');
|
|
177
191
|
const channel = interaction.channel;
|
|
178
192
|
if (!channel) {
|
|
@@ -307,15 +321,16 @@ export async function handleModelCommand({ interaction, appId, }) {
|
|
|
307
321
|
const contextHash = crypto.randomBytes(8).toString('hex');
|
|
308
322
|
setModelContext(contextHash, context);
|
|
309
323
|
const allProviderOptions = [...availableProviders]
|
|
310
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
324
|
+
.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''))
|
|
311
325
|
.map((provider) => {
|
|
312
326
|
const modelCount = Object.keys(provider.models || {}).length;
|
|
313
|
-
return {
|
|
314
|
-
label: provider.name
|
|
327
|
+
return buildSafeSelectOption({
|
|
328
|
+
label: provider.name,
|
|
315
329
|
value: provider.id,
|
|
316
|
-
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available
|
|
317
|
-
};
|
|
318
|
-
})
|
|
330
|
+
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`,
|
|
331
|
+
});
|
|
332
|
+
})
|
|
333
|
+
.filter((option) => !!option);
|
|
319
334
|
const { options } = buildPaginatedOptions({
|
|
320
335
|
allOptions: allProviderOptions,
|
|
321
336
|
page: 0,
|
|
@@ -383,15 +398,16 @@ export async function handleProviderSelectMenu(interaction) {
|
|
|
383
398
|
const { all: allProviders, connected } = providersResponse.data;
|
|
384
399
|
const availableProviders = allProviders.filter((p) => connected.includes(p.id));
|
|
385
400
|
const allProviderOptions = [...availableProviders]
|
|
386
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
401
|
+
.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''))
|
|
387
402
|
.map((p) => {
|
|
388
403
|
const modelCount = Object.keys(p.models || {}).length;
|
|
389
|
-
return {
|
|
390
|
-
label: p.name
|
|
404
|
+
return buildSafeSelectOption({
|
|
405
|
+
label: p.name,
|
|
391
406
|
value: p.id,
|
|
392
|
-
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available
|
|
393
|
-
};
|
|
394
|
-
})
|
|
407
|
+
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`,
|
|
408
|
+
});
|
|
409
|
+
})
|
|
410
|
+
.filter((option) => !!option);
|
|
395
411
|
const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: providerNavPage });
|
|
396
412
|
const selectMenu = new StringSelectMenuBuilder()
|
|
397
413
|
.setCustomId(`model_provider:${contextHash}`)
|
|
@@ -434,10 +450,11 @@ export async function handleProviderSelectMenu(interaction) {
|
|
|
434
450
|
const models = Object.entries(provider.models || {})
|
|
435
451
|
.map(([modelId, model]) => ({
|
|
436
452
|
id: modelId,
|
|
437
|
-
name: model.name,
|
|
453
|
+
name: model.name || modelId,
|
|
438
454
|
releaseDate: model.release_date,
|
|
439
455
|
}))
|
|
440
|
-
.
|
|
456
|
+
.filter((model) => model.id && model.name)
|
|
457
|
+
.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''));
|
|
441
458
|
if (models.length === 0) {
|
|
442
459
|
await interaction.editReply({
|
|
443
460
|
content: `No models available for ${provider.name}`,
|
|
@@ -447,19 +464,21 @@ export async function handleProviderSelectMenu(interaction) {
|
|
|
447
464
|
}
|
|
448
465
|
// Update context with provider info and reuse the same hash
|
|
449
466
|
context.providerId = selectedProviderId;
|
|
450
|
-
context.providerName = provider.name;
|
|
467
|
+
context.providerName = provider.name?.trim() || provider.id;
|
|
451
468
|
context.modelPage = 0;
|
|
452
469
|
setModelContext(contextHash, context);
|
|
453
|
-
const allModelOptions = models
|
|
470
|
+
const allModelOptions = models
|
|
471
|
+
.map((model) => {
|
|
454
472
|
const dateStr = model.releaseDate
|
|
455
473
|
? new Date(model.releaseDate).toLocaleDateString()
|
|
456
474
|
: 'Unknown date';
|
|
457
|
-
return {
|
|
458
|
-
label: model.name
|
|
475
|
+
return buildSafeSelectOption({
|
|
476
|
+
label: model.name,
|
|
459
477
|
value: model.id,
|
|
460
|
-
description: dateStr
|
|
461
|
-
};
|
|
462
|
-
})
|
|
478
|
+
description: dateStr,
|
|
479
|
+
});
|
|
480
|
+
})
|
|
481
|
+
.filter((option) => !!option);
|
|
463
482
|
const { options } = buildPaginatedOptions({
|
|
464
483
|
allOptions: allModelOptions,
|
|
465
484
|
page: 0,
|
|
@@ -527,13 +546,14 @@ export async function handleModelSelectMenu(interaction) {
|
|
|
527
546
|
return;
|
|
528
547
|
}
|
|
529
548
|
const allModelOptions = Object.entries(provider.models || {})
|
|
530
|
-
.map(([modelId, model]) => ({
|
|
531
|
-
label: model.name
|
|
549
|
+
.map(([modelId, model]) => buildSafeSelectOption({
|
|
550
|
+
label: model.name || modelId,
|
|
532
551
|
value: modelId,
|
|
533
|
-
description:
|
|
552
|
+
description: model.release_date
|
|
534
553
|
? new Date(model.release_date).toLocaleDateString()
|
|
535
|
-
: 'Unknown date'
|
|
554
|
+
: 'Unknown date',
|
|
536
555
|
}))
|
|
556
|
+
.filter((option) => !!option)
|
|
537
557
|
.sort((a, b) => a.label.localeCompare(b.label));
|
|
538
558
|
const { options } = buildPaginatedOptions({ allOptions: allModelOptions, page: modelNavPage });
|
|
539
559
|
const selectMenu = new StringSelectMenuBuilder()
|
|
@@ -708,7 +728,7 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
708
728
|
const modelDisplay = modelId.split('/')[1] || modelId;
|
|
709
729
|
const variant = context.selectedVariant ?? null;
|
|
710
730
|
const variantSuffix = variant ? ` (${variant})` : '';
|
|
711
|
-
const agentTip = '\n_Tip: create [agent .md files](https://
|
|
731
|
+
const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/model-switching) in .opencode/agent/ for one-command model switching_';
|
|
712
732
|
try {
|
|
713
733
|
if (selectedScope === 'session') {
|
|
714
734
|
if (!context.sessionId) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// /unset-model-override command - Remove model overrides and use default instead.
|
|
2
|
-
import { ChatInputCommandInteraction, ChannelType,
|
|
2
|
+
import { ChatInputCommandInteraction, ChannelType, } from 'discord.js';
|
|
3
3
|
import { getChannelModel, getSessionModel, getThreadSession, clearSessionModel, } from '../database.js';
|
|
4
4
|
import { getDb } from '../db.js';
|
|
5
5
|
import * as orm from 'drizzle-orm';
|
|
@@ -35,7 +35,7 @@ function formatModelSource(type, agentName) {
|
|
|
35
35
|
*/
|
|
36
36
|
export async function handleUnsetModelCommand({ interaction, appId, }) {
|
|
37
37
|
unsetModelLogger.log('[UNSET-MODEL] handleUnsetModelCommand called');
|
|
38
|
-
await interaction.deferReply(
|
|
38
|
+
await interaction.deferReply();
|
|
39
39
|
const channel = interaction.channel;
|
|
40
40
|
if (!channel) {
|
|
41
41
|
await interaction.editReply({
|
|
@@ -37,6 +37,5 @@ export async function handleToggleWorktreesCommand({ command, }) {
|
|
|
37
37
|
content: nextEnabled
|
|
38
38
|
? `Worktrees **enabled** for this channel.\n\nNew sessions started from messages in **#${channel.name}** will now automatically create git worktrees.\n\nNew setting for **#${channel.name}**: **enabled**.`
|
|
39
39
|
: `Worktrees **disabled** for this channel.\n\nNew sessions started from messages in **#${channel.name}** will use the main project directory.\n\nNew setting for **#${channel.name}**: **disabled**.`,
|
|
40
|
-
flags: MessageFlags.Ephemeral,
|
|
41
40
|
});
|
|
42
41
|
}
|
package/dist/discord-bot.js
CHANGED
|
@@ -6,7 +6,7 @@ import { stopOpencodeServer, } from './opencode.js';
|
|
|
6
6
|
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
|
|
7
7
|
import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
|
|
8
8
|
import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
9
|
-
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, } from './discord-utils.js';
|
|
9
|
+
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
|
|
10
10
|
import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
|
|
11
11
|
import YAML from 'yaml';
|
|
12
12
|
import { getTextAttachments, resolveMentions, } from './message-formatting.js';
|
|
@@ -304,7 +304,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
304
304
|
// still need Kimaki permission so multi-agent orchestration stays opt-in.
|
|
305
305
|
const isInjectedSelfBotMessage = isCliInjectedPrompt && message.author?.id === discordClient.user?.id;
|
|
306
306
|
if (message.author?.bot && !isInjectedSelfBotMessage) {
|
|
307
|
-
|
|
307
|
+
const member = await resolveGuildMessageMember(message);
|
|
308
|
+
if (!hasKimakiBotPermission(member, message.guild)) {
|
|
308
309
|
return;
|
|
309
310
|
}
|
|
310
311
|
}
|
|
@@ -343,15 +344,19 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
343
344
|
}
|
|
344
345
|
}
|
|
345
346
|
}
|
|
346
|
-
if (!isCliInjectedPrompt && message.guild
|
|
347
|
-
|
|
347
|
+
if (!isCliInjectedPrompt && message.guild) {
|
|
348
|
+
const member = await resolveGuildMessageMember(message);
|
|
349
|
+
if (!member) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (hasNoKimakiRole(member)) {
|
|
348
353
|
await message.reply({
|
|
349
354
|
content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
|
|
350
355
|
flags: SILENT_MESSAGE_FLAGS,
|
|
351
356
|
});
|
|
352
357
|
return;
|
|
353
358
|
}
|
|
354
|
-
if (!hasKimakiBotPermission(message.
|
|
359
|
+
if (!hasKimakiBotPermission(member, message.guild)) {
|
|
355
360
|
await message.reply({
|
|
356
361
|
content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
357
362
|
flags: SILENT_MESSAGE_FLAGS,
|
|
@@ -395,7 +395,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
395
395
|
.setDMPermission(false)
|
|
396
396
|
.addStringOption((opt) => opt
|
|
397
397
|
.setName('prompt')
|
|
398
|
-
.setDescription('Send a
|
|
398
|
+
.setDescription('Send a prompt with this agent')
|
|
399
399
|
.setRequired(false))
|
|
400
400
|
.toJSON());
|
|
401
401
|
}
|
package/dist/discord-utils.js
CHANGED
|
@@ -72,6 +72,20 @@ export function hasKimakiAdminPermission(member, guild) {
|
|
|
72
72
|
const hasKimakiRole = hasRoleByName(member, 'kimaki', guild);
|
|
73
73
|
return isOwner || isAdmin || canManageServer || hasKimakiRole;
|
|
74
74
|
}
|
|
75
|
+
export async function resolveGuildMessageMember(message) {
|
|
76
|
+
if (!message.guild)
|
|
77
|
+
return null;
|
|
78
|
+
if (message.member)
|
|
79
|
+
return message.member;
|
|
80
|
+
const fetchedMember = await message.guild.members
|
|
81
|
+
.fetch(message.author.id)
|
|
82
|
+
.catch((e) => new Error('Failed to fetch guild member', { cause: e }));
|
|
83
|
+
if (fetchedMember instanceof Error) {
|
|
84
|
+
discordLogger.warn(`[PERMISSION] Denying message ${message.id}: ${fetchedMember.message}`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return fetchedMember;
|
|
88
|
+
}
|
|
75
89
|
function hasRoleByName(member, roleName, guild) {
|
|
76
90
|
const target = roleName.toLowerCase();
|
|
77
91
|
if (member instanceof GuildMember) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PermissionsBitField } from 'discord.js';
|
|
2
2
|
import { afterEach, describe, expect, test } from 'vitest';
|
|
3
|
-
import { hasKimakiAdminPermission, hasKimakiBotPermission, splitMarkdownForDiscord } from './discord-utils.js';
|
|
3
|
+
import { hasKimakiAdminPermission, hasKimakiBotPermission, resolveGuildMessageMember, splitMarkdownForDiscord, } from './discord-utils.js';
|
|
4
4
|
import { store } from './store.js';
|
|
5
5
|
describe('splitMarkdownForDiscord', () => {
|
|
6
6
|
test('never returns chunks over the max length with code fences', () => {
|
|
@@ -198,3 +198,53 @@ describe('hasKimakiAdminPermission', () => {
|
|
|
198
198
|
expect(hasKimakiAdminPermission(member, guild)).toBe(true);
|
|
199
199
|
});
|
|
200
200
|
});
|
|
201
|
+
describe('resolveGuildMessageMember', () => {
|
|
202
|
+
test('uses hydrated message member without fetching', async () => {
|
|
203
|
+
const member = { id: 'member-id' };
|
|
204
|
+
const message = {
|
|
205
|
+
guild: {
|
|
206
|
+
members: {
|
|
207
|
+
fetch() {
|
|
208
|
+
throw new Error('should not fetch');
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
member,
|
|
213
|
+
author: { id: 'member-id' },
|
|
214
|
+
id: 'message-id',
|
|
215
|
+
};
|
|
216
|
+
await expect(resolveGuildMessageMember(message)).resolves.toBe(member);
|
|
217
|
+
});
|
|
218
|
+
test('fetches missing guild message member', async () => {
|
|
219
|
+
const member = { id: 'member-id' };
|
|
220
|
+
const message = {
|
|
221
|
+
guild: {
|
|
222
|
+
members: {
|
|
223
|
+
fetch(id) {
|
|
224
|
+
expect(id).toBe('member-id');
|
|
225
|
+
return Promise.resolve(member);
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
member: null,
|
|
230
|
+
author: { id: 'member-id' },
|
|
231
|
+
id: 'message-id',
|
|
232
|
+
};
|
|
233
|
+
await expect(resolveGuildMessageMember(message)).resolves.toBe(member);
|
|
234
|
+
});
|
|
235
|
+
test('denies when missing guild message member cannot be fetched', async () => {
|
|
236
|
+
const message = {
|
|
237
|
+
guild: {
|
|
238
|
+
members: {
|
|
239
|
+
fetch() {
|
|
240
|
+
return Promise.reject(new Error('missing member'));
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
member: null,
|
|
245
|
+
author: { id: 'member-id' },
|
|
246
|
+
id: 'message-id',
|
|
247
|
+
};
|
|
248
|
+
await expect(resolveGuildMessageMember(message)).resolves.toBe(null);
|
|
249
|
+
});
|
|
250
|
+
});
|