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.
Files changed (69) hide show
  1. package/dist/agent-model.e2e.test.js +91 -1
  2. package/dist/cli-commands/misc.js +76 -2
  3. package/dist/cli-runner.js +28 -10
  4. package/dist/cli.js +10 -0
  5. package/dist/commands/agent.js +116 -4
  6. package/dist/commands/gemini-apikey.js +24 -5
  7. package/dist/commands/mention-mode.js +0 -1
  8. package/dist/commands/model-variant.js +2 -2
  9. package/dist/commands/model.js +47 -27
  10. package/dist/commands/unset-model.js +2 -2
  11. package/dist/commands/verbosity.js +0 -1
  12. package/dist/commands/worktree-settings.js +0 -1
  13. package/dist/database.js +16 -0
  14. package/dist/discord-bot.js +10 -5
  15. package/dist/discord-command-registration.js +9 -46
  16. package/dist/discord-utils.js +43 -0
  17. package/dist/discord-utils.test.js +118 -2
  18. package/dist/errors.js +5 -0
  19. package/dist/external-opencode-sync.js +119 -54
  20. package/dist/interaction-handler.js +82 -1
  21. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +1 -1
  22. package/dist/message-formatting.js +91 -0
  23. package/dist/message-formatting.test.js +206 -1
  24. package/dist/opencode.js +34 -158
  25. package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
  26. package/dist/session-handler/thread-session-runtime.js +19 -4
  27. package/dist/store.js +2 -0
  28. package/dist/system-message.js +16 -0
  29. package/dist/system-message.test.js +16 -0
  30. package/dist/voice-handler.js +98 -79
  31. package/dist/voice.js +126 -1
  32. package/package.json +6 -6
  33. package/skills/goke/SKILL.md +39 -0
  34. package/skills/new-skill/SKILL.md +1 -0
  35. package/skills/npm-package/SKILL.md +57 -2
  36. package/skills/spiceflow/SKILL.md +2 -0
  37. package/skills/termcast/SKILL.md +32 -846
  38. package/skills/tuistory/SKILL.md +117 -17
  39. package/src/agent-model.e2e.test.ts +117 -0
  40. package/src/cli-commands/misc.ts +90 -2
  41. package/src/cli-runner.ts +28 -10
  42. package/src/cli.ts +23 -0
  43. package/src/commands/agent.ts +147 -4
  44. package/src/commands/gemini-apikey.ts +38 -6
  45. package/src/commands/mention-mode.ts +0 -1
  46. package/src/commands/model-variant.ts +2 -2
  47. package/src/commands/model.ts +63 -37
  48. package/src/commands/unset-model.ts +1 -2
  49. package/src/commands/verbosity.ts +0 -1
  50. package/src/commands/worktree-settings.ts +0 -1
  51. package/src/database.ts +16 -0
  52. package/src/discord-bot.ts +11 -4
  53. package/src/discord-command-registration.ts +11 -71
  54. package/src/discord-utils.test.ts +144 -3
  55. package/src/discord-utils.ts +53 -0
  56. package/src/errors.ts +9 -0
  57. package/src/external-opencode-sync.ts +147 -64
  58. package/src/interaction-handler.ts +83 -1
  59. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +1 -1
  60. package/src/message-formatting.test.ts +247 -1
  61. package/src/message-formatting.ts +93 -1
  62. package/src/opencode.ts +36 -152
  63. package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
  64. package/src/session-handler/thread-session-runtime.ts +22 -3
  65. package/src/store.ts +17 -0
  66. package/src/system-message.test.ts +16 -0
  67. package/src/system-message.ts +16 -0
  68. package/src/voice-handler.ts +111 -94
  69. 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 command for sharing local files into Discord threads.
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;
@@ -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 is installed. OpenCode is downloaded on first run
942
- // to ~/.kimaki/bin/ (pinned version), so no install check is needed.
943
- await ensureCommandAvailable({
944
- name: 'bun',
945
- envPathKey: 'BUN_PATH',
946
- installUnix: 'curl -fsSL https://bun.sh/install | bash',
947
- installWindows: 'irm bun.sh/install.ps1 | iex',
948
- possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
949
- possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
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)');
@@ -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
- import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
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({ flags: MessageFlags.Ephemeral });
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
- await command.deferReply({ flags: MessageFlags.Ephemeral });
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
- // Transcription API key button, slash command, and modal handlers.
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('Transcription API Key');
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 messages will be transcribed with OpenAI.',
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 messages will be transcribed with Gemini.',
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({ flags: MessageFlags.Ephemeral });
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://github.com/remorses/kimaki/blob/main/docs/model-switching.md) in .opencode/agent/ for one-command model switching_';
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) {