kimaki 0.10.2 → 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-commands/misc.js +76 -2
- package/dist/cli-runner.js +28 -10
- package/dist/cli.js +10 -0
- package/dist/commands/agent.js +116 -4
- package/dist/commands/gemini-apikey.js +24 -5
- 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/database.js +16 -0
- package/dist/discord-bot.js +10 -5
- package/dist/discord-command-registration.js +9 -46
- package/dist/discord-utils.js +43 -0
- package/dist/discord-utils.test.js +118 -2
- package/dist/errors.js +5 -0
- package/dist/external-opencode-sync.js +119 -54
- package/dist/interaction-handler.js +82 -1
- 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 +19 -4
- package/dist/store.js +2 -0
- package/dist/system-message.js +16 -0
- package/dist/system-message.test.js +16 -0
- package/dist/voice-handler.js +98 -79
- package/dist/voice.js +126 -1
- package/package.json +6 -6
- package/skills/goke/SKILL.md +39 -0
- package/skills/new-skill/SKILL.md +1 -0
- package/skills/npm-package/SKILL.md +57 -2
- package/skills/spiceflow/SKILL.md +2 -0
- package/skills/termcast/SKILL.md +32 -846
- package/skills/tuistory/SKILL.md +117 -17
- package/src/agent-model.e2e.test.ts +117 -0
- package/src/cli-commands/misc.ts +90 -2
- package/src/cli-runner.ts +28 -10
- package/src/cli.ts +23 -0
- package/src/commands/agent.ts +147 -4
- package/src/commands/gemini-apikey.ts +38 -6
- 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/database.ts +16 -0
- package/src/discord-bot.ts +11 -4
- package/src/discord-command-registration.ts +11 -71
- package/src/discord-utils.test.ts +144 -3
- package/src/discord-utils.ts +53 -0
- package/src/errors.ts +9 -0
- package/src/external-opencode-sync.ts +147 -64
- package/src/interaction-handler.ts +83 -1
- 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 +22 -3
- package/src/store.ts +17 -0
- package/src/system-message.test.ts +16 -0
- package/src/system-message.ts +16 -0
- package/src/voice-handler.ts +111 -94
- package/src/voice.ts +217 -0
|
@@ -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');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// File upload terminal
|
|
1
|
+
// File upload and TTS terminal commands for sharing local files into Discord threads.
|
|
2
2
|
import { goke } from 'goke';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { note } from '@clack/prompts';
|
|
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
11
11
|
import { spawn, execSync } from 'node:child_process';
|
|
12
12
|
import { createLogger, LogPrefix, initLogFile } from '../logger.js';
|
|
13
13
|
import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js';
|
|
14
|
-
import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory } from '../database.js';
|
|
14
|
+
import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, getAnyAudioApiKey } from '../database.js';
|
|
15
15
|
import { ShareMarkdown } from '../markdown.js';
|
|
16
16
|
import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js';
|
|
17
17
|
import { formatWorktreeName, formatAutoWorktreeName } from '../commands/new-worktree.js';
|
|
@@ -73,4 +73,78 @@ cli
|
|
|
73
73
|
process.exit(EXIT_NO_RESTART);
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
|
+
cli
|
|
77
|
+
.command('tts [text]', 'Generate speech audio from text. Reads from stdin if no text given.')
|
|
78
|
+
.option('-o, --output [path]', 'Output file path (default: speech.mp3)')
|
|
79
|
+
.option('-v, --voice [voice]', 'Voice ID, defaults to alloy (OpenAI) or Kore (Gemini)')
|
|
80
|
+
.option('-i, --instructions [text]', 'Style instructions, e.g. "Speak calmly" (OpenAI only)')
|
|
81
|
+
.option('--speed [speed]', 'Speed multiplier 0.25-4.0 (OpenAI only)')
|
|
82
|
+
.option('-p, --provider [provider]', 'openai or gemini (auto-detected from key)')
|
|
83
|
+
.option('-k, --api-key [key]', 'API key (falls back to stored key or env var)')
|
|
84
|
+
.action(async (text, options) => {
|
|
85
|
+
const { generateSpeech } = await import('../voice.js');
|
|
86
|
+
// Read from stdin if no positional text argument
|
|
87
|
+
if (!text) {
|
|
88
|
+
if (process.stdin.isTTY) {
|
|
89
|
+
cliLogger.error('No text provided. Pass text as argument or pipe via stdin.');
|
|
90
|
+
process.exit(EXIT_NO_RESTART);
|
|
91
|
+
}
|
|
92
|
+
const chunks = [];
|
|
93
|
+
for await (const chunk of process.stdin) {
|
|
94
|
+
chunks.push(chunk);
|
|
95
|
+
}
|
|
96
|
+
text = Buffer.concat(chunks).toString('utf-8').trim();
|
|
97
|
+
if (!text) {
|
|
98
|
+
cliLogger.error('Empty input from stdin.');
|
|
99
|
+
process.exit(EXIT_NO_RESTART);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
await initDatabase();
|
|
103
|
+
// Resolve API key: flag → DB → env
|
|
104
|
+
let apiKey = options.apiKey;
|
|
105
|
+
let provider = options.provider === 'openai' || options.provider === 'gemini'
|
|
106
|
+
? options.provider
|
|
107
|
+
: undefined;
|
|
108
|
+
if (!apiKey) {
|
|
109
|
+
const stored = await getAnyAudioApiKey();
|
|
110
|
+
if (stored) {
|
|
111
|
+
apiKey = stored.apiKey;
|
|
112
|
+
provider = provider || stored.provider;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!apiKey) {
|
|
116
|
+
if (process.env.OPENAI_API_KEY) {
|
|
117
|
+
apiKey = process.env.OPENAI_API_KEY;
|
|
118
|
+
provider = provider || 'openai';
|
|
119
|
+
}
|
|
120
|
+
else if (process.env.GEMINI_API_KEY) {
|
|
121
|
+
apiKey = process.env.GEMINI_API_KEY;
|
|
122
|
+
provider = provider || 'gemini';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!apiKey) {
|
|
126
|
+
cliLogger.error('No API key found. Use --api-key, set OPENAI_API_KEY/GEMINI_API_KEY, or run /transcription-key in Discord.');
|
|
127
|
+
process.exit(EXIT_NO_RESTART);
|
|
128
|
+
}
|
|
129
|
+
const speed = options.speed ? Number(options.speed) : 1.25;
|
|
130
|
+
cliLogger.log(`Generating speech with ${provider || 'auto-detected provider'}...`);
|
|
131
|
+
const result = await generateSpeech({
|
|
132
|
+
text,
|
|
133
|
+
voice: options.voice || undefined,
|
|
134
|
+
apiKey,
|
|
135
|
+
provider,
|
|
136
|
+
instructions: options.instructions || undefined,
|
|
137
|
+
speed,
|
|
138
|
+
});
|
|
139
|
+
if (result instanceof Error) {
|
|
140
|
+
cliLogger.error(`Speech generation failed: ${result.message}`);
|
|
141
|
+
process.exit(EXIT_NO_RESTART);
|
|
142
|
+
}
|
|
143
|
+
const ext = result.mediaType === 'audio/mp3' ? 'mp3' : 'wav';
|
|
144
|
+
const outputPath = options.output || `speech.${ext}`;
|
|
145
|
+
const resolvedOutput = path.resolve(outputPath);
|
|
146
|
+
await fs.promises.writeFile(resolvedOutput, result.audio);
|
|
147
|
+
cliLogger.log(`Audio saved to ${resolvedOutput} (${(result.audio.length / 1024).toFixed(1)} KB)`);
|
|
148
|
+
process.exit(0);
|
|
149
|
+
});
|
|
76
150
|
export default cli;
|
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
|
@@ -39,6 +39,8 @@ cli
|
|
|
39
39
|
.option('--mention-mode', 'Bot only responds when @mentioned (default for all channels)')
|
|
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
|
+
.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')
|
|
42
44
|
.option('--no-sentry', 'Disable Sentry error reporting')
|
|
43
45
|
.option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
|
|
44
46
|
.option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
|
|
@@ -134,6 +136,8 @@ cli
|
|
|
134
136
|
}),
|
|
135
137
|
...(options.mentionMode && { defaultMentionMode: true }),
|
|
136
138
|
...(options.noCritique && { critiqueEnabled: false }),
|
|
139
|
+
...(options.allowAllUsers && { allowAllUsers: true }),
|
|
140
|
+
...(options.disableSync && { syncEnabled: false }),
|
|
137
141
|
...(enabledSkills.length > 0 && { enabledSkills }),
|
|
138
142
|
...(disabledSkills.length > 0 && { disabledSkills }),
|
|
139
143
|
});
|
|
@@ -143,6 +147,9 @@ cli
|
|
|
143
147
|
if (disabledSkills.length > 0) {
|
|
144
148
|
cliLogger.log(`Skill blacklist enabled: [${disabledSkills.join(', ')}] will be hidden`);
|
|
145
149
|
}
|
|
150
|
+
if (options.allowAllUsers) {
|
|
151
|
+
cliLogger.log('Allow all users: any Discord member can start sessions (no-kimaki role still blocks)');
|
|
152
|
+
}
|
|
146
153
|
if (options.verbosity) {
|
|
147
154
|
cliLogger.log(`Default verbosity: ${options.verbosity}`);
|
|
148
155
|
}
|
|
@@ -152,6 +159,9 @@ cli
|
|
|
152
159
|
if (options.noCritique) {
|
|
153
160
|
cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
|
|
154
161
|
}
|
|
162
|
+
if (options.disableSync) {
|
|
163
|
+
cliLogger.log('Background sync disabled: external OpenCode sessions will not appear in Discord');
|
|
164
|
+
}
|
|
155
165
|
if (options.noSentry) {
|
|
156
166
|
process.env.KIMAKI_SENTRY_DISABLED = '1';
|
|
157
167
|
cliLogger.log('Sentry error reporting disabled (--no-sentry)');
|
package/dist/commands/agent.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
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 with that agent and the session keeps that agent afterwards.
|
|
5
|
+
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
|
|
4
6
|
import crypto from 'node:crypto';
|
|
5
7
|
import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, } from '../database.js';
|
|
6
8
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
7
|
-
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
9
|
+
import { resolveTextChannel, resolveWorkingDirectory, getKimakiMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
10
|
+
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
8
11
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
9
12
|
import { getCurrentModelInfo } from './model.js';
|
|
10
13
|
const agentLogger = createLogger(LogPrefix.AGENT);
|
|
@@ -159,7 +162,7 @@ export async function setAgentForContext({ context, agentName, }) {
|
|
|
159
162
|
}
|
|
160
163
|
}
|
|
161
164
|
export async function handleAgentCommand({ interaction, appId, }) {
|
|
162
|
-
await interaction.deferReply(
|
|
165
|
+
await interaction.deferReply();
|
|
163
166
|
const context = await resolveAgentCommandContext({ interaction, appId });
|
|
164
167
|
if (!context) {
|
|
165
168
|
return;
|
|
@@ -286,7 +289,13 @@ export async function handleAgentSelectMenu(interaction) {
|
|
|
286
289
|
*/
|
|
287
290
|
export async function handleQuickAgentCommand({ command, appId, }) {
|
|
288
291
|
const fallbackAgentName = command.commandName.replace(/-agent$/, '');
|
|
289
|
-
|
|
292
|
+
const prompt = command.options.getString('prompt') || undefined;
|
|
293
|
+
// Prompt mode: send the prompt with this agent immediately.
|
|
294
|
+
if (prompt) {
|
|
295
|
+
return handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt });
|
|
296
|
+
}
|
|
297
|
+
// No prompt: switch the persistent agent preference (original behavior).
|
|
298
|
+
await command.deferReply();
|
|
290
299
|
const context = await resolveAgentCommandContext({
|
|
291
300
|
interaction: command,
|
|
292
301
|
appId,
|
|
@@ -352,3 +361,106 @@ export async function handleQuickAgentCommand({ command, appId, }) {
|
|
|
352
361
|
});
|
|
353
362
|
}
|
|
354
363
|
}
|
|
364
|
+
/**
|
|
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.
|
|
369
|
+
*/
|
|
370
|
+
async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt, }) {
|
|
371
|
+
const channel = command.channel;
|
|
372
|
+
if (!channel) {
|
|
373
|
+
await command.reply({
|
|
374
|
+
content: 'This command can only be used in a channel',
|
|
375
|
+
flags: MessageFlags.Ephemeral,
|
|
376
|
+
});
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const resolvedAgentName = (await resolveQuickAgentNameFromInteraction({ command })) ||
|
|
380
|
+
fallbackAgentName;
|
|
381
|
+
const isThread = [
|
|
382
|
+
ChannelType.PublicThread,
|
|
383
|
+
ChannelType.PrivateThread,
|
|
384
|
+
ChannelType.AnnouncementThread,
|
|
385
|
+
].includes(channel.type);
|
|
386
|
+
const displayText = `${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`;
|
|
387
|
+
if (isThread) {
|
|
388
|
+
// In a thread: enqueue the prompt and switch the existing session to this agent.
|
|
389
|
+
const thread = channel;
|
|
390
|
+
const resolved = await resolveWorkingDirectory({ channel: thread });
|
|
391
|
+
if (!resolved) {
|
|
392
|
+
await command.reply({
|
|
393
|
+
content: 'Could not determine project directory for this channel',
|
|
394
|
+
flags: MessageFlags.Ephemeral,
|
|
395
|
+
});
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const runtime = getOrCreateRuntime({
|
|
399
|
+
threadId: thread.id,
|
|
400
|
+
thread,
|
|
401
|
+
projectDirectory: resolved.projectDirectory,
|
|
402
|
+
sdkDirectory: resolved.workingDirectory,
|
|
403
|
+
channelId: thread.parentId || thread.id,
|
|
404
|
+
appId,
|
|
405
|
+
});
|
|
406
|
+
// Visible reply showing the one-shot prompt (not ephemeral, so it appears in thread).
|
|
407
|
+
await command.reply({
|
|
408
|
+
content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
|
|
409
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
410
|
+
});
|
|
411
|
+
await runtime.enqueueIncoming({
|
|
412
|
+
prompt,
|
|
413
|
+
userId: command.user.id,
|
|
414
|
+
username: command.user.displayName,
|
|
415
|
+
agent: resolvedAgentName,
|
|
416
|
+
appId,
|
|
417
|
+
mode: 'opencode',
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
else if (channel.type === ChannelType.GuildText) {
|
|
421
|
+
// In a channel: create a new thread and enqueue with the requested agent.
|
|
422
|
+
const metadata = await getKimakiMetadata(channel);
|
|
423
|
+
const projectDirectory = metadata.projectDirectory;
|
|
424
|
+
if (!projectDirectory) {
|
|
425
|
+
await command.reply({
|
|
426
|
+
content: 'This channel is not configured with a project directory',
|
|
427
|
+
flags: MessageFlags.Ephemeral,
|
|
428
|
+
});
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
await command.deferReply();
|
|
432
|
+
const starterMessage = await channel.send({
|
|
433
|
+
content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
|
|
434
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
435
|
+
});
|
|
436
|
+
const thread = await starterMessage.startThread({
|
|
437
|
+
name: prompt.slice(0, 80),
|
|
438
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
439
|
+
reason: `${resolvedAgentName} agent prompt`,
|
|
440
|
+
});
|
|
441
|
+
await thread.members.add(command.user.id);
|
|
442
|
+
await command.editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`);
|
|
443
|
+
const runtime = getOrCreateRuntime({
|
|
444
|
+
threadId: thread.id,
|
|
445
|
+
thread,
|
|
446
|
+
projectDirectory,
|
|
447
|
+
sdkDirectory: projectDirectory,
|
|
448
|
+
channelId: channel.id,
|
|
449
|
+
appId,
|
|
450
|
+
});
|
|
451
|
+
await runtime.enqueueIncoming({
|
|
452
|
+
prompt,
|
|
453
|
+
userId: command.user.id,
|
|
454
|
+
username: command.user.displayName,
|
|
455
|
+
agent: resolvedAgentName,
|
|
456
|
+
appId,
|
|
457
|
+
mode: 'opencode',
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
await command.reply({
|
|
462
|
+
content: 'This command can only be used in text channels or threads',
|
|
463
|
+
flags: MessageFlags.Ephemeral,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Audio API key button, slash command, and modal handlers.
|
|
2
|
+
// Used for both transcription and speech generation — same OpenAI/Gemini keys.
|
|
2
3
|
// Auto-detects provider from key prefix: sk-* = OpenAI, otherwise Gemini.
|
|
3
|
-
import { ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags, } from 'discord.js';
|
|
4
|
+
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags, } from 'discord.js';
|
|
4
5
|
import { setGeminiApiKey, setOpenAIApiKey } from '../database.js';
|
|
6
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
5
7
|
function buildTranscriptionApiKeyModal(appId) {
|
|
6
8
|
const modal = new ModalBuilder()
|
|
7
9
|
.setCustomId(`transcription_apikey_modal:${appId}`)
|
|
8
|
-
.setTitle('
|
|
10
|
+
.setTitle('Audio API Key');
|
|
9
11
|
const apiKeyInput = new TextInputBuilder()
|
|
10
12
|
.setCustomId('apikey')
|
|
11
13
|
.setLabel('OpenAI or Gemini API Key')
|
|
@@ -16,6 +18,23 @@ function buildTranscriptionApiKeyModal(appId) {
|
|
|
16
18
|
modal.addComponents(actionRow);
|
|
17
19
|
return modal;
|
|
18
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Show a "Set API Key" button in a Discord thread.
|
|
23
|
+
* Reusable for both transcription and TTS — both use the same stored keys.
|
|
24
|
+
* The button opens a modal where the user can enter an OpenAI or Gemini key.
|
|
25
|
+
*/
|
|
26
|
+
export async function showApiKeyRequiredButton({ thread, appId, message, }) {
|
|
27
|
+
const button = new ButtonBuilder()
|
|
28
|
+
.setCustomId(`transcription_apikey:${appId}`)
|
|
29
|
+
.setLabel('Set API Key')
|
|
30
|
+
.setStyle(ButtonStyle.Primary);
|
|
31
|
+
const row = new ActionRowBuilder().addComponents(button);
|
|
32
|
+
await thread.send({
|
|
33
|
+
content: message || 'An API key (OpenAI or Gemini) is required. Set one to continue.',
|
|
34
|
+
components: [row],
|
|
35
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
19
38
|
export async function handleTranscriptionApiKeyButton(interaction) {
|
|
20
39
|
if (!interaction.customId.startsWith('transcription_apikey:'))
|
|
21
40
|
return;
|
|
@@ -58,13 +77,13 @@ export async function handleTranscriptionApiKeyModalSubmit(interaction) {
|
|
|
58
77
|
if (apiKey.startsWith('sk-')) {
|
|
59
78
|
await setOpenAIApiKey(appId, apiKey);
|
|
60
79
|
await interaction.editReply({
|
|
61
|
-
content: 'OpenAI API key saved. Voice
|
|
80
|
+
content: 'OpenAI API key saved. Voice transcription and speech generation are now enabled.',
|
|
62
81
|
});
|
|
63
82
|
}
|
|
64
83
|
else {
|
|
65
84
|
await setGeminiApiKey(appId, apiKey);
|
|
66
85
|
await interaction.editReply({
|
|
67
|
-
content: 'Gemini API key saved. Voice
|
|
86
|
+
content: 'Gemini API key saved. Voice transcription and speech generation are now enabled.',
|
|
68
87
|
});
|
|
69
88
|
}
|
|
70
89
|
}
|
|
@@ -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) {
|