kimaki 0.4.50 → 0.4.52

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.
@@ -40,34 +40,38 @@ export async function ensureKimakiAudioCategory(guild, botName) {
40
40
  type: ChannelType.GuildCategory,
41
41
  });
42
42
  }
43
- export async function createProjectChannels({ guild, projectDirectory, appId, botName, }) {
43
+ export async function createProjectChannels({ guild, projectDirectory, appId, botName, enableVoiceChannels = false, }) {
44
44
  const baseName = path.basename(projectDirectory);
45
45
  const channelName = `${baseName}`
46
46
  .toLowerCase()
47
47
  .replace(/[^a-z0-9-]/g, '-')
48
48
  .slice(0, 100);
49
49
  const kimakiCategory = await ensureKimakiCategory(guild, botName);
50
- const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName);
51
50
  const textChannel = await guild.channels.create({
52
51
  name: channelName,
53
52
  type: ChannelType.GuildText,
54
53
  parent: kimakiCategory,
55
54
  // Channel configuration is stored in SQLite, not in the topic
56
55
  });
57
- const voiceChannel = await guild.channels.create({
58
- name: channelName,
59
- type: ChannelType.GuildVoice,
60
- parent: kimakiAudioCategory,
61
- });
62
56
  getDatabase()
63
57
  .prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)')
64
58
  .run(textChannel.id, projectDirectory, 'text', appId);
65
- getDatabase()
66
- .prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)')
67
- .run(voiceChannel.id, projectDirectory, 'voice', appId);
59
+ let voiceChannelId = null;
60
+ if (enableVoiceChannels) {
61
+ const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName);
62
+ const voiceChannel = await guild.channels.create({
63
+ name: channelName,
64
+ type: ChannelType.GuildVoice,
65
+ parent: kimakiAudioCategory,
66
+ });
67
+ getDatabase()
68
+ .prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)')
69
+ .run(voiceChannel.id, projectDirectory, 'voice', appId);
70
+ voiceChannelId = voiceChannel.id;
71
+ }
68
72
  return {
69
73
  textChannelId: textChannel.id,
70
- voiceChannelId: voiceChannel.id,
74
+ voiceChannelId,
71
75
  channelName,
72
76
  };
73
77
  }
package/dist/cli.js CHANGED
@@ -213,11 +213,11 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
213
213
  .toJSON(),
214
214
  new SlashCommandBuilder()
215
215
  .setName('add-project')
216
- .setDescription('Create Discord channels for a new OpenCode project')
216
+ .setDescription('Create Discord channels for a project. Use `npx kimaki add-project` for unlisted projects')
217
217
  .addStringOption((option) => {
218
218
  option
219
219
  .setName('project')
220
- .setDescription('Select an OpenCode project')
220
+ .setDescription('Select a project. Use `npx kimaki add-project` if not listed')
221
221
  .setRequired(true)
222
222
  .setAutocomplete(true);
223
223
  return option;
@@ -428,7 +428,7 @@ async function backgroundInit({ currentDir, token, appId, }) {
428
428
  cliLogger.error('Background init failed:', error instanceof Error ? error.message : String(error));
429
429
  }
430
430
  }
431
- async function run({ restart, addChannels, useWorktrees }) {
431
+ async function run({ restart, addChannels, useWorktrees, enableVoiceChannels }) {
432
432
  startCaffeinate();
433
433
  const forceSetup = Boolean(restart);
434
434
  intro('šŸ¤– Discord Bot Setup');
@@ -777,6 +777,7 @@ async function run({ restart, addChannels, useWorktrees }) {
777
777
  projectDirectory: project.worktree,
778
778
  appId,
779
779
  botName: discordClient.user?.username,
780
+ enableVoiceChannels,
780
781
  });
781
782
  createdChannels.push({
782
783
  name: channelName,
@@ -823,6 +824,7 @@ cli
823
824
  .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
824
825
  .option('--install-url', 'Print the bot install URL and exit')
825
826
  .option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
827
+ .option('--enable-voice-channels', 'Create voice channels for projects (disabled by default)')
826
828
  .option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text, text-and-essential-tools, or text-only)')
827
829
  .action(async (options) => {
828
830
  try {
@@ -859,6 +861,7 @@ cli
859
861
  addChannels: options.addChannels,
860
862
  dataDir: options.dataDir,
861
863
  useWorktrees: options.useWorktrees,
864
+ enableVoiceChannels: options.enableVoiceChannels,
862
865
  });
863
866
  }
864
867
  catch (error) {
@@ -1217,7 +1220,7 @@ cli
1217
1220
  }
1218
1221
  });
1219
1222
  cli
1220
- .command('add-project [directory]', 'Create Discord channels for a project directory')
1223
+ .command('add-project [directory]', 'Create Discord channels for a project directory (e.g. ./folder)')
1221
1224
  .option('-g, --guild <guildId>', 'Discord guild/server ID (auto-detects if bot is in only one server)')
1222
1225
  .option('-a, --app-id <appId>', 'Bot application ID (reads from database if available)')
1223
1226
  .action(async (directory, options) => {
@@ -52,7 +52,8 @@ export async function handleAddProjectCommand({ command, appId }) {
52
52
  appId,
53
53
  botName: command.client.user?.username,
54
54
  });
55
- await command.editReply(`āœ… Created channels for project:\nšŸ“ Text: <#${textChannelId}>\nšŸ”Š Voice: <#${voiceChannelId}>\nšŸ“ Directory: \`${directory}\``);
55
+ const voiceInfo = voiceChannelId ? `\nšŸ”Š Voice: <#${voiceChannelId}>` : '';
56
+ await command.editReply(`āœ… Created channels for project:\nšŸ“ Text: <#${textChannelId}>${voiceInfo}\nšŸ“ Directory: \`${directory}\``);
56
57
  logger.log(`Created channels for project ${channelName} at ${directory}`);
57
58
  }
58
59
  catch (error) {
@@ -83,7 +83,8 @@ export async function handleCreateNewProjectCommand({ command, appId, }) {
83
83
  }
84
84
  const { textChannelId, voiceChannelId, channelName, projectDirectory, sanitizedName } = result;
85
85
  const textChannel = (await guild.channels.fetch(textChannelId));
86
- await command.editReply(`āœ… Created new project **${sanitizedName}**\nšŸ“ Directory: \`${projectDirectory}\`\nšŸ“ Text: <#${textChannelId}>\nšŸ”Š Voice: <#${voiceChannelId}>\n_Starting session..._`);
86
+ const voiceInfo = voiceChannelId ? `\nšŸ”Š Voice: <#${voiceChannelId}>` : '';
87
+ await command.editReply(`āœ… Created new project **${sanitizedName}**\nšŸ“ Directory: \`${projectDirectory}\`\nšŸ“ Text: <#${textChannelId}>${voiceInfo}\n_Starting session..._`);
87
88
  const starterMessage = await textChannel.send({
88
89
  content: `šŸš€ **New project initialized**\nšŸ“ \`${projectDirectory}\``,
89
90
  flags: SILENT_MESSAGE_FLAGS,
@@ -31,7 +31,7 @@ setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections:
31
31
  const discordLogger = createLogger(LogPrefix.DISCORD);
32
32
  const voiceLogger = createLogger(LogPrefix.VOICE);
33
33
  function prefixWithDiscordUser({ username, prompt }) {
34
- return `<discord-user name="${username}" />\n${prompt}`;
34
+ return `${prompt}\n<discord-user name="${username}" />`;
35
35
  }
36
36
  export async function createDiscordClient() {
37
37
  return new Client({
@@ -117,6 +117,17 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
117
117
  }
118
118
  }
119
119
  if (message.guild && message.member) {
120
+ // Check for "no-kimaki" role first - blocks user regardless of other permissions.
121
+ // This implements the "four-eyes principle": even owners must remove this role
122
+ // to use the bot, adding friction to prevent accidental usage.
123
+ const hasNoKimakiRole = message.member.roles.cache.some((role) => role.name.toLowerCase() === 'no-kimaki');
124
+ if (hasNoKimakiRole) {
125
+ await message.reply({
126
+ content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
127
+ flags: SILENT_MESSAGE_FLAGS,
128
+ });
129
+ return;
130
+ }
120
131
  const isOwner = message.member.id === message.guild.ownerId;
121
132
  const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator);
122
133
  const canManageServer = message.member.permissions.has(PermissionsBitField.Flags.ManageGuild);
@@ -171,6 +171,7 @@ export async function getFileAttachments(message) {
171
171
  mime,
172
172
  filename: attachment.name,
173
173
  url: dataUrl,
174
+ sourceUrl: attachment.url,
174
175
  };
175
176
  }));
176
177
  return results.filter((r) => r !== null);
@@ -2,6 +2,9 @@
2
2
  // Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
3
3
  // Handles streaming events, permissions, abort signals, and message queuing.
4
4
  import prettyMilliseconds from 'pretty-ms';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { xdgState } from 'xdg-basedir';
5
8
  import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent, getThreadWorktree, getChannelVerbosity, } from './database.js';
6
9
  import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2, } from './opencode.js';
7
10
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js';
@@ -57,6 +60,114 @@ export function getQueueLength(threadId) {
57
60
  export function clearQueue(threadId) {
58
61
  messageQueue.delete(threadId);
59
62
  }
63
+ /**
64
+ * Read user's recent models from OpenCode TUI's state file.
65
+ * Uses same path as OpenCode: path.join(xdgState, "opencode", "model.json")
66
+ * Returns all recent models so we can iterate until finding a valid one.
67
+ * See: opensrc/repos/github.com/sst/opencode/packages/opencode/src/global/index.ts
68
+ */
69
+ function getRecentModelsFromTuiState() {
70
+ if (!xdgState) {
71
+ return [];
72
+ }
73
+ // Same path as OpenCode TUI: path.join(Global.Path.state, "model.json")
74
+ const modelJsonPath = path.join(xdgState, 'opencode', 'model.json');
75
+ const result = errore.tryFn(() => {
76
+ const content = fs.readFileSync(modelJsonPath, 'utf-8');
77
+ const data = JSON.parse(content);
78
+ return data.recent ?? [];
79
+ });
80
+ if (result instanceof Error) {
81
+ // File doesn't exist or is invalid - this is normal for fresh installs
82
+ return [];
83
+ }
84
+ return result;
85
+ }
86
+ /**
87
+ * Parse a model string in format "provider/model" into providerID and modelID.
88
+ */
89
+ function parseModelString(model) {
90
+ const [providerID, ...modelParts] = model.split('/');
91
+ const modelID = modelParts.join('/');
92
+ if (!providerID || !modelID) {
93
+ return undefined;
94
+ }
95
+ return { providerID, modelID };
96
+ }
97
+ /**
98
+ * Validate that a model is available (provider connected + model exists).
99
+ */
100
+ function isModelValid(model, connected, providers) {
101
+ const isConnected = connected.includes(model.providerID);
102
+ const provider = providers.find((p) => p.id === model.providerID);
103
+ const modelExists = provider?.models && model.modelID in provider.models;
104
+ return isConnected && !!modelExists;
105
+ }
106
+ /**
107
+ * Get the default model from OpenCode when no user preference is set.
108
+ * Priority (matches OpenCode TUI behavior):
109
+ * 1. OpenCode config.model setting
110
+ * 2. User's recent models from TUI state (~/.local/state/opencode/model.json)
111
+ * 3. First connected provider's default model from API
112
+ */
113
+ async function getDefaultModel({ getClient, directory, }) {
114
+ if (getClient instanceof Error) {
115
+ return undefined;
116
+ }
117
+ // Fetch connected providers to validate any model we return
118
+ const providersResponse = await errore.tryAsync(() => {
119
+ return getClient().provider.list({ query: { directory } });
120
+ });
121
+ if (providersResponse instanceof Error) {
122
+ sessionLogger.log(`[MODEL] Failed to fetch providers for default model:`, providersResponse.message);
123
+ return undefined;
124
+ }
125
+ if (!providersResponse.data) {
126
+ return undefined;
127
+ }
128
+ const { connected, default: defaults, all: providers } = providersResponse.data;
129
+ if (connected.length === 0) {
130
+ sessionLogger.log(`[MODEL] No connected providers found`);
131
+ return undefined;
132
+ }
133
+ // 1. Check OpenCode config.model setting (highest priority after user preference)
134
+ const configResponse = await errore.tryAsync(() => {
135
+ return getClient().config.get({ query: { directory } });
136
+ });
137
+ if (!(configResponse instanceof Error) && configResponse.data?.model) {
138
+ const configModel = parseModelString(configResponse.data.model);
139
+ if (configModel && isModelValid(configModel, connected, providers)) {
140
+ sessionLogger.log(`[MODEL] Using config model: ${configModel.providerID}/${configModel.modelID}`);
141
+ return configModel;
142
+ }
143
+ if (configModel) {
144
+ sessionLogger.log(`[MODEL] Config model ${configResponse.data.model} not available, checking recent`);
145
+ }
146
+ }
147
+ // 2. Try to use user's recent models from TUI state (iterate until finding valid one)
148
+ const recentModels = getRecentModelsFromTuiState();
149
+ for (const recentModel of recentModels) {
150
+ if (isModelValid(recentModel, connected, providers)) {
151
+ sessionLogger.log(`[MODEL] Using recent TUI model: ${recentModel.providerID}/${recentModel.modelID}`);
152
+ return recentModel;
153
+ }
154
+ }
155
+ if (recentModels.length > 0) {
156
+ sessionLogger.log(`[MODEL] No valid recent TUI models found`);
157
+ }
158
+ // 3. Fall back to first connected provider's default model
159
+ const firstConnected = connected[0];
160
+ if (!firstConnected) {
161
+ return undefined;
162
+ }
163
+ const defaultModelId = defaults[firstConnected];
164
+ if (!defaultModelId) {
165
+ sessionLogger.log(`[MODEL] No default model for provider ${firstConnected}`);
166
+ return undefined;
167
+ }
168
+ sessionLogger.log(`[MODEL] Using provider default: ${firstConnected}/${defaultModelId}`);
169
+ return { providerID: firstConnected, modelID: defaultModelId };
170
+ }
60
171
  /**
61
172
  * Abort a running session and retry with the last user message.
62
173
  * Used when model preference changes mid-request.
@@ -774,7 +885,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
774
885
  sessionLogger.log(`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`);
775
886
  await sendThreadMessage(thread, `Ā» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
776
887
  setImmediate(() => {
777
- const prefixedPrompt = `<discord-user name="${nextMessage.username}" />\n${nextMessage.prompt}`;
888
+ const prefixedPrompt = `${nextMessage.prompt}\n<discord-user name="${nextMessage.username}" />`;
778
889
  void errore
779
890
  .tryAsync(async () => {
780
891
  return handleOpencodeSession({
@@ -950,7 +1061,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
950
1061
  // Send the queued message as a new prompt (recursive call)
951
1062
  // Use setImmediate to avoid blocking and allow this finally to complete
952
1063
  setImmediate(() => {
953
- const prefixedPrompt = `<discord-user name="${nextMessage.username}" />\n${nextMessage.prompt}`;
1064
+ const prefixedPrompt = `${nextMessage.prompt}\n<discord-user name="${nextMessage.username}" />`;
954
1065
  handleOpencodeSession({
955
1066
  prompt: prefixedPrompt,
956
1067
  thread,
@@ -991,11 +1102,13 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
991
1102
  sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({
992
1103
  mime: img.mime,
993
1104
  filename: img.filename,
994
- urlPreview: img.url.slice(0, 50) + '...',
1105
+ sourceUrl: img.sourceUrl,
995
1106
  })));
996
- // Just list filenames, not the full base64 URLs (images are passed as separate parts)
997
- const imageList = images.map((img) => `- ${img.filename}`).join('\n');
998
- return `${prompt}\n\n**attached images:**\n${imageList}`;
1107
+ // List source URLs and clarify these images are already in context (not paths to read)
1108
+ const imageList = images
1109
+ .map((img) => `- ${img.sourceUrl || img.filename}`)
1110
+ .join('\n');
1111
+ return `${prompt}\n\n**The following images are already included in this message as inline content (do not use Read tool on these):**\n${imageList}`;
999
1112
  })();
1000
1113
  const parts = [{ type: 'text', text: promptWithImagePaths }, ...images];
1001
1114
  sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
@@ -1004,22 +1117,44 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
1004
1117
  sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`);
1005
1118
  }
1006
1119
  const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
1007
- const modelParam = (() => {
1008
- if (agentPreference) {
1009
- sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`);
1010
- return undefined;
1120
+ const modelParam = await (async () => {
1121
+ // Use explicit user preference if set (takes priority over agent model)
1122
+ if (modelPreference) {
1123
+ const [providerID, ...modelParts] = modelPreference.split('/');
1124
+ const modelID = modelParts.join('/');
1125
+ if (providerID && modelID) {
1126
+ sessionLogger.log(`[MODEL] Using preference: ${modelPreference}`);
1127
+ return { providerID, modelID };
1128
+ }
1011
1129
  }
1012
- if (!modelPreference) {
1013
- return undefined;
1130
+ // If agent is set, check if agent has a model configured
1131
+ if (agentPreference) {
1132
+ const agentsResponse = await errore.tryAsync(() => {
1133
+ return getClient().app.agents({ query: { directory: sdkDirectory } });
1134
+ });
1135
+ if (!(agentsResponse instanceof Error) && agentsResponse.data) {
1136
+ const agent = agentsResponse.data.find((a) => a.name === agentPreference);
1137
+ if (agent?.model) {
1138
+ sessionLogger.log(`[MODEL] Using agent model: ${agent.model.providerID}/${agent.model.modelID}`);
1139
+ return agent.model;
1140
+ }
1141
+ sessionLogger.log(`[MODEL] Agent "${agentPreference}" has no model configured, using default`);
1142
+ }
1014
1143
  }
1015
- const [providerID, ...modelParts] = modelPreference.split('/');
1016
- const modelID = modelParts.join('/');
1017
- if (!providerID || !modelID) {
1018
- return undefined;
1144
+ // Fetch default model from OpenCode (like TUI does)
1145
+ const defaultModel = await getDefaultModel({ getClient, directory: sdkDirectory });
1146
+ if (defaultModel) {
1147
+ sessionLogger.log(`[MODEL] Using default: ${defaultModel.providerID}/${defaultModel.modelID}`);
1148
+ return defaultModel;
1019
1149
  }
1020
- sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
1021
- return { providerID, modelID };
1150
+ // No model available - this will likely cause an error from OpenCode
1151
+ sessionLogger.log(`[MODEL] No model available (no preference, no default)`);
1152
+ return undefined;
1022
1153
  })();
1154
+ // Fail early if no model available
1155
+ if (!modelParam) {
1156
+ throw new Error('No AI provider connected. Configure a provider in OpenCode with `/connect` command.');
1157
+ }
1023
1158
  // Build worktree info for system message (worktreeInfo was fetched at the start)
1024
1159
  const worktree = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
1025
1160
  ? {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.50",
5
+ "version": "0.4.52",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -41,6 +41,7 @@
41
41
  "ripgrep-js": "^3.0.0",
42
42
  "string-dedent": "^3.0.2",
43
43
  "undici": "^7.16.0",
44
+ "xdg-basedir": "^5.1.0",
44
45
  "zod": "^4.2.1",
45
46
  "errore": "^0.10.0"
46
47
  },
@@ -63,12 +63,14 @@ export async function createProjectChannels({
63
63
  projectDirectory,
64
64
  appId,
65
65
  botName,
66
+ enableVoiceChannels = false,
66
67
  }: {
67
68
  guild: Guild
68
69
  projectDirectory: string
69
70
  appId: string
70
71
  botName?: string
71
- }): Promise<{ textChannelId: string; voiceChannelId: string; channelName: string }> {
72
+ enableVoiceChannels?: boolean
73
+ }): Promise<{ textChannelId: string; voiceChannelId: string | null; channelName: string }> {
72
74
  const baseName = path.basename(projectDirectory)
73
75
  const channelName = `${baseName}`
74
76
  .toLowerCase()
@@ -76,7 +78,6 @@ export async function createProjectChannels({
76
78
  .slice(0, 100)
77
79
 
78
80
  const kimakiCategory = await ensureKimakiCategory(guild, botName)
79
- const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName)
80
81
 
81
82
  const textChannel = await guild.channels.create({
82
83
  name: channelName,
@@ -85,27 +86,35 @@ export async function createProjectChannels({
85
86
  // Channel configuration is stored in SQLite, not in the topic
86
87
  })
87
88
 
88
- const voiceChannel = await guild.channels.create({
89
- name: channelName,
90
- type: ChannelType.GuildVoice,
91
- parent: kimakiAudioCategory,
92
- })
93
-
94
89
  getDatabase()
95
90
  .prepare(
96
91
  'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
97
92
  )
98
93
  .run(textChannel.id, projectDirectory, 'text', appId)
99
94
 
100
- getDatabase()
101
- .prepare(
102
- 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
103
- )
104
- .run(voiceChannel.id, projectDirectory, 'voice', appId)
95
+ let voiceChannelId: string | null = null
96
+
97
+ if (enableVoiceChannels) {
98
+ const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName)
99
+
100
+ const voiceChannel = await guild.channels.create({
101
+ name: channelName,
102
+ type: ChannelType.GuildVoice,
103
+ parent: kimakiAudioCategory,
104
+ })
105
+
106
+ getDatabase()
107
+ .prepare(
108
+ 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
109
+ )
110
+ .run(voiceChannel.id, projectDirectory, 'voice', appId)
111
+
112
+ voiceChannelId = voiceChannel.id
113
+ }
105
114
 
106
115
  return {
107
116
  textChannelId: textChannel.id,
108
- voiceChannelId: voiceChannel.id,
117
+ voiceChannelId,
109
118
  channelName,
110
119
  }
111
120
  }
package/src/cli.ts CHANGED
@@ -209,6 +209,7 @@ type CliOptions = {
209
209
  addChannels?: boolean
210
210
  dataDir?: string
211
211
  useWorktrees?: boolean
212
+ enableVoiceChannels?: boolean
212
213
  }
213
214
 
214
215
  // Commands to skip when registering user commands (reserved names)
@@ -298,11 +299,11 @@ async function registerCommands({
298
299
  .toJSON(),
299
300
  new SlashCommandBuilder()
300
301
  .setName('add-project')
301
- .setDescription('Create Discord channels for a new OpenCode project')
302
+ .setDescription('Create Discord channels for a project. Use `npx kimaki add-project` for unlisted projects')
302
303
  .addStringOption((option) => {
303
304
  option
304
305
  .setName('project')
305
- .setDescription('Select an OpenCode project')
306
+ .setDescription('Select a project. Use `npx kimaki add-project` if not listed')
306
307
  .setRequired(true)
307
308
  .setAutocomplete(true)
308
309
 
@@ -595,7 +596,7 @@ async function backgroundInit({
595
596
  }
596
597
  }
597
598
 
598
- async function run({ restart, addChannels, useWorktrees }: CliOptions) {
599
+ async function run({ restart, addChannels, useWorktrees, enableVoiceChannels }: CliOptions) {
599
600
  startCaffeinate()
600
601
 
601
602
  const forceSetup = Boolean(restart)
@@ -1057,6 +1058,7 @@ async function run({ restart, addChannels, useWorktrees }: CliOptions) {
1057
1058
  projectDirectory: project.worktree,
1058
1059
  appId,
1059
1060
  botName: discordClient.user?.username,
1061
+ enableVoiceChannels,
1060
1062
  })
1061
1063
 
1062
1064
  createdChannels.push({
@@ -1123,6 +1125,7 @@ cli
1123
1125
  .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
1124
1126
  .option('--install-url', 'Print the bot install URL and exit')
1125
1127
  .option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
1128
+ .option('--enable-voice-channels', 'Create voice channels for projects (disabled by default)')
1126
1129
  .option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text, text-and-essential-tools, or text-only)')
1127
1130
  .action(
1128
1131
  async (options: {
@@ -1131,6 +1134,7 @@ cli
1131
1134
  dataDir?: string
1132
1135
  installUrl?: boolean
1133
1136
  useWorktrees?: boolean
1137
+ enableVoiceChannels?: boolean
1134
1138
  verbosity?: string
1135
1139
  }) => {
1136
1140
  try {
@@ -1172,6 +1176,7 @@ cli
1172
1176
  addChannels: options.addChannels,
1173
1177
  dataDir: options.dataDir,
1174
1178
  useWorktrees: options.useWorktrees,
1179
+ enableVoiceChannels: options.enableVoiceChannels,
1175
1180
  })
1176
1181
  } catch (error) {
1177
1182
  cliLogger.error('Unhandled error:', error instanceof Error ? error.message : String(error))
@@ -1639,7 +1644,7 @@ cli
1639
1644
  })
1640
1645
 
1641
1646
  cli
1642
- .command('add-project [directory]', 'Create Discord channels for a project directory')
1647
+ .command('add-project [directory]', 'Create Discord channels for a project directory (e.g. ./folder)')
1643
1648
  .option('-g, --guild <guildId>', 'Discord guild/server ID (auto-detects if bot is in only one server)')
1644
1649
  .option('-a, --app-id <appId>', 'Bot application ID (reads from database if available)')
1645
1650
  .action(
@@ -72,8 +72,9 @@ export async function handleAddProjectCommand({ command, appId }: CommandContext
72
72
  botName: command.client.user?.username,
73
73
  })
74
74
 
75
+ const voiceInfo = voiceChannelId ? `\nšŸ”Š Voice: <#${voiceChannelId}>` : ''
75
76
  await command.editReply(
76
- `āœ… Created channels for project:\nšŸ“ Text: <#${textChannelId}>\nšŸ”Š Voice: <#${voiceChannelId}>\nšŸ“ Directory: \`${directory}\``,
77
+ `āœ… Created channels for project:\nšŸ“ Text: <#${textChannelId}>${voiceInfo}\nšŸ“ Directory: \`${directory}\``,
77
78
  )
78
79
 
79
80
  logger.log(`Created channels for project ${channelName} at ${directory}`)
@@ -31,7 +31,7 @@ export async function createNewProject({
31
31
  botName?: string
32
32
  }): Promise<{
33
33
  textChannelId: string
34
- voiceChannelId: string
34
+ voiceChannelId: string | null
35
35
  channelName: string
36
36
  projectDirectory: string
37
37
  sanitizedName: string
@@ -124,8 +124,9 @@ export async function handleCreateNewProjectCommand({
124
124
  const { textChannelId, voiceChannelId, channelName, projectDirectory, sanitizedName } = result
125
125
  const textChannel = (await guild.channels.fetch(textChannelId)) as TextChannel
126
126
 
127
+ const voiceInfo = voiceChannelId ? `\nšŸ”Š Voice: <#${voiceChannelId}>` : ''
127
128
  await command.editReply(
128
- `āœ… Created new project **${sanitizedName}**\nšŸ“ Directory: \`${projectDirectory}\`\nšŸ“ Text: <#${textChannelId}>\nšŸ”Š Voice: <#${voiceChannelId}>\n_Starting session..._`,
129
+ `āœ… Created new project **${sanitizedName}**\nšŸ“ Directory: \`${projectDirectory}\`\nšŸ“ Text: <#${textChannelId}>${voiceInfo}\n_Starting session..._`,
129
130
  )
130
131
 
131
132
  const starterMessage = await textChannel.send({
@@ -78,7 +78,7 @@ const discordLogger = createLogger(LogPrefix.DISCORD)
78
78
  const voiceLogger = createLogger(LogPrefix.VOICE)
79
79
 
80
80
  function prefixWithDiscordUser({ username, prompt }: { username: string; prompt: string }): string {
81
- return `<discord-user name="${username}" />\n${prompt}`
81
+ return `${prompt}\n<discord-user name="${username}" />`
82
82
  }
83
83
 
84
84
  type StartOptions = {
@@ -193,6 +193,20 @@ export async function startDiscordBot({
193
193
  }
194
194
 
195
195
  if (message.guild && message.member) {
196
+ // Check for "no-kimaki" role first - blocks user regardless of other permissions.
197
+ // This implements the "four-eyes principle": even owners must remove this role
198
+ // to use the bot, adding friction to prevent accidental usage.
199
+ const hasNoKimakiRole = message.member.roles.cache.some(
200
+ (role) => role.name.toLowerCase() === 'no-kimaki',
201
+ )
202
+ if (hasNoKimakiRole) {
203
+ await message.reply({
204
+ content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
205
+ flags: SILENT_MESSAGE_FLAGS,
206
+ })
207
+ return
208
+ }
209
+
196
210
  const isOwner = message.member.id === message.guild.ownerId
197
211
  const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator)
198
212
  const canManageServer = message.member.permissions.has(
@@ -5,6 +5,11 @@
5
5
  import type { Part } from '@opencode-ai/sdk/v2'
6
6
  import type { FilePartInput } from '@opencode-ai/sdk'
7
7
  import type { Message } from 'discord.js'
8
+
9
+ // Extended FilePartInput with original Discord URL for reference in prompts
10
+ export type DiscordFileAttachment = FilePartInput & {
11
+ sourceUrl?: string
12
+ }
8
13
  import * as errore from 'errore'
9
14
  import { createLogger, LogPrefix } from './logger.js'
10
15
  import { FetchError } from './errors.js'
@@ -179,7 +184,7 @@ export async function getTextAttachments(message: Message): Promise<string> {
179
184
  return textContents.join('\n\n')
180
185
  }
181
186
 
182
- export async function getFileAttachments(message: Message): Promise<FilePartInput[]> {
187
+ export async function getFileAttachments(message: Message): Promise<DiscordFileAttachment[]> {
183
188
  const fileAttachments = Array.from(message.attachments.values()).filter((attachment) => {
184
189
  const contentType = attachment.contentType || ''
185
190
  return contentType.startsWith('image/') || contentType === 'application/pdf'
@@ -220,11 +225,12 @@ export async function getFileAttachments(message: Message): Promise<FilePartInpu
220
225
  mime,
221
226
  filename: attachment.name,
222
227
  url: dataUrl,
228
+ sourceUrl: attachment.url,
223
229
  }
224
230
  }),
225
231
  )
226
232
 
227
- return results.filter((r) => r !== null) as FilePartInput[]
233
+ return results.filter((r) => r !== null) as DiscordFileAttachment[]
228
234
  }
229
235
 
230
236
  export function getToolSummaryText(part: Part): string {
@@ -3,9 +3,12 @@
3
3
  // Handles streaming events, permissions, abort signals, and message queuing.
4
4
 
5
5
  import type { Part, PermissionRequest, QuestionRequest } from '@opencode-ai/sdk/v2'
6
- import type { FilePartInput } from '@opencode-ai/sdk'
6
+ import type { DiscordFileAttachment } from './message-formatting.js'
7
7
  import type { Message, ThreadChannel } from 'discord.js'
8
8
  import prettyMilliseconds from 'pretty-ms'
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import { xdgState } from 'xdg-basedir'
9
12
  import {
10
13
  getDatabase,
11
14
  getSessionModel,
@@ -96,7 +99,7 @@ export type QueuedMessage = {
96
99
  userId: string
97
100
  username: string
98
101
  queuedAt: number
99
- images?: FilePartInput[]
102
+ images?: DiscordFileAttachment[]
100
103
  }
101
104
 
102
105
  // Queue of messages waiting to be sent after current response finishes
@@ -126,6 +129,143 @@ export function clearQueue(threadId: string): void {
126
129
  messageQueue.delete(threadId)
127
130
  }
128
131
 
132
+ /**
133
+ * Read user's recent models from OpenCode TUI's state file.
134
+ * Uses same path as OpenCode: path.join(xdgState, "opencode", "model.json")
135
+ * Returns all recent models so we can iterate until finding a valid one.
136
+ * See: opensrc/repos/github.com/sst/opencode/packages/opencode/src/global/index.ts
137
+ */
138
+ function getRecentModelsFromTuiState(): Array<{ providerID: string; modelID: string }> {
139
+ if (!xdgState) {
140
+ return []
141
+ }
142
+ // Same path as OpenCode TUI: path.join(Global.Path.state, "model.json")
143
+ const modelJsonPath = path.join(xdgState, 'opencode', 'model.json')
144
+
145
+ const result = errore.tryFn(() => {
146
+ const content = fs.readFileSync(modelJsonPath, 'utf-8')
147
+ const data = JSON.parse(content) as {
148
+ recent?: Array<{ providerID: string; modelID: string }>
149
+ }
150
+ return data.recent ?? []
151
+ })
152
+
153
+ if (result instanceof Error) {
154
+ // File doesn't exist or is invalid - this is normal for fresh installs
155
+ return []
156
+ }
157
+
158
+ return result
159
+ }
160
+
161
+ /**
162
+ * Parse a model string in format "provider/model" into providerID and modelID.
163
+ */
164
+ function parseModelString(model: string): { providerID: string; modelID: string } | undefined {
165
+ const [providerID, ...modelParts] = model.split('/')
166
+ const modelID = modelParts.join('/')
167
+ if (!providerID || !modelID) {
168
+ return undefined
169
+ }
170
+ return { providerID, modelID }
171
+ }
172
+
173
+ /**
174
+ * Validate that a model is available (provider connected + model exists).
175
+ */
176
+ function isModelValid(
177
+ model: { providerID: string; modelID: string },
178
+ connected: string[],
179
+ providers: Array<{ id: string; models?: Record<string, unknown> }>,
180
+ ): boolean {
181
+ const isConnected = connected.includes(model.providerID)
182
+ const provider = providers.find((p) => p.id === model.providerID)
183
+ const modelExists = provider?.models && model.modelID in provider.models
184
+ return isConnected && !!modelExists
185
+ }
186
+
187
+ /**
188
+ * Get the default model from OpenCode when no user preference is set.
189
+ * Priority (matches OpenCode TUI behavior):
190
+ * 1. OpenCode config.model setting
191
+ * 2. User's recent models from TUI state (~/.local/state/opencode/model.json)
192
+ * 3. First connected provider's default model from API
193
+ */
194
+ async function getDefaultModel({
195
+ getClient,
196
+ directory,
197
+ }: {
198
+ getClient: Awaited<ReturnType<typeof initializeOpencodeForDirectory>>
199
+ directory: string
200
+ }): Promise<{ providerID: string; modelID: string } | undefined> {
201
+ if (getClient instanceof Error) {
202
+ return undefined
203
+ }
204
+
205
+ // Fetch connected providers to validate any model we return
206
+ const providersResponse = await errore.tryAsync(() => {
207
+ return getClient().provider.list({ query: { directory } })
208
+ })
209
+ if (providersResponse instanceof Error) {
210
+ sessionLogger.log(`[MODEL] Failed to fetch providers for default model:`, providersResponse.message)
211
+ return undefined
212
+ }
213
+ if (!providersResponse.data) {
214
+ return undefined
215
+ }
216
+
217
+ const { connected, default: defaults, all: providers } = providersResponse.data
218
+ if (connected.length === 0) {
219
+ sessionLogger.log(`[MODEL] No connected providers found`)
220
+ return undefined
221
+ }
222
+
223
+ // 1. Check OpenCode config.model setting (highest priority after user preference)
224
+ const configResponse = await errore.tryAsync(() => {
225
+ return getClient().config.get({ query: { directory } })
226
+ })
227
+ if (!(configResponse instanceof Error) && configResponse.data?.model) {
228
+ const configModel = parseModelString(configResponse.data.model)
229
+ if (configModel && isModelValid(configModel, connected, providers)) {
230
+ sessionLogger.log(`[MODEL] Using config model: ${configModel.providerID}/${configModel.modelID}`)
231
+ return configModel
232
+ }
233
+ if (configModel) {
234
+ sessionLogger.log(
235
+ `[MODEL] Config model ${configResponse.data.model} not available, checking recent`,
236
+ )
237
+ }
238
+ }
239
+
240
+ // 2. Try to use user's recent models from TUI state (iterate until finding valid one)
241
+ const recentModels = getRecentModelsFromTuiState()
242
+ for (const recentModel of recentModels) {
243
+ if (isModelValid(recentModel, connected, providers)) {
244
+ sessionLogger.log(
245
+ `[MODEL] Using recent TUI model: ${recentModel.providerID}/${recentModel.modelID}`,
246
+ )
247
+ return recentModel
248
+ }
249
+ }
250
+ if (recentModels.length > 0) {
251
+ sessionLogger.log(`[MODEL] No valid recent TUI models found`)
252
+ }
253
+
254
+ // 3. Fall back to first connected provider's default model
255
+ const firstConnected = connected[0]
256
+ if (!firstConnected) {
257
+ return undefined
258
+ }
259
+ const defaultModelId = defaults[firstConnected]
260
+ if (!defaultModelId) {
261
+ sessionLogger.log(`[MODEL] No default model for provider ${firstConnected}`)
262
+ return undefined
263
+ }
264
+
265
+ sessionLogger.log(`[MODEL] Using provider default: ${firstConnected}/${defaultModelId}`)
266
+ return { providerID: firstConnected, modelID: defaultModelId }
267
+ }
268
+
129
269
  /**
130
270
  * Abort a running session and retry with the last user message.
131
271
  * Used when model preference changes mid-request.
@@ -189,7 +329,7 @@ export async function abortAndRetrySession({
189
329
  | { type: 'text'; text: string }
190
330
  | undefined
191
331
  const prompt = textPart?.text || ''
192
- const images = lastUserMessage.parts.filter((p) => p.type === 'file') as FilePartInput[]
332
+ const images = lastUserMessage.parts.filter((p) => p.type === 'file') as DiscordFileAttachment[]
193
333
 
194
334
  sessionLogger.log(`[ABORT+RETRY] Re-triggering session ${sessionId} with new model`)
195
335
 
@@ -233,7 +373,7 @@ export async function handleOpencodeSession({
233
373
  thread: ThreadChannel
234
374
  projectDirectory?: string
235
375
  originalMessage?: Message
236
- images?: FilePartInput[]
376
+ images?: DiscordFileAttachment[]
237
377
  channelId?: string
238
378
  /** If set, uses session.command API instead of session.prompt */
239
379
  command?: { name: string; arguments: string }
@@ -1059,7 +1199,7 @@ export async function handleOpencodeSession({
1059
1199
  )
1060
1200
 
1061
1201
  setImmediate(() => {
1062
- const prefixedPrompt = `<discord-user name="${nextMessage.username}" />\n${nextMessage.prompt}`
1202
+ const prefixedPrompt = `${nextMessage.prompt}\n<discord-user name="${nextMessage.username}" />`
1063
1203
  void errore
1064
1204
  .tryAsync(async () => {
1065
1205
  return handleOpencodeSession({
@@ -1272,7 +1412,7 @@ export async function handleOpencodeSession({
1272
1412
  // Send the queued message as a new prompt (recursive call)
1273
1413
  // Use setImmediate to avoid blocking and allow this finally to complete
1274
1414
  setImmediate(() => {
1275
- const prefixedPrompt = `<discord-user name="${nextMessage.username}" />\n${nextMessage.prompt}`
1415
+ const prefixedPrompt = `${nextMessage.prompt}\n<discord-user name="${nextMessage.username}" />`
1276
1416
  handleOpencodeSession({
1277
1417
  prompt: prefixedPrompt,
1278
1418
  thread,
@@ -1323,12 +1463,14 @@ export async function handleOpencodeSession({
1323
1463
  images.map((img) => ({
1324
1464
  mime: img.mime,
1325
1465
  filename: img.filename,
1326
- urlPreview: img.url.slice(0, 50) + '...',
1466
+ sourceUrl: img.sourceUrl,
1327
1467
  })),
1328
1468
  )
1329
- // Just list filenames, not the full base64 URLs (images are passed as separate parts)
1330
- const imageList = images.map((img) => `- ${img.filename}`).join('\n')
1331
- return `${prompt}\n\n**attached images:**\n${imageList}`
1469
+ // List source URLs and clarify these images are already in context (not paths to read)
1470
+ const imageList = images
1471
+ .map((img) => `- ${img.sourceUrl || img.filename}`)
1472
+ .join('\n')
1473
+ return `${prompt}\n\n**The following images are already included in this message as inline content (do not use Read tool on these):**\n${imageList}`
1332
1474
  })()
1333
1475
 
1334
1476
  const parts = [{ type: 'text' as const, text: promptWithImagePaths }, ...images]
@@ -1342,23 +1484,53 @@ export async function handleOpencodeSession({
1342
1484
 
1343
1485
  const modelPreference =
1344
1486
  getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
1345
- const modelParam = (() => {
1346
- if (agentPreference) {
1347
- sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`)
1348
- return undefined
1487
+ const modelParam = await (async () => {
1488
+ // Use explicit user preference if set (takes priority over agent model)
1489
+ if (modelPreference) {
1490
+ const [providerID, ...modelParts] = modelPreference.split('/')
1491
+ const modelID = modelParts.join('/')
1492
+ if (providerID && modelID) {
1493
+ sessionLogger.log(`[MODEL] Using preference: ${modelPreference}`)
1494
+ return { providerID, modelID }
1495
+ }
1349
1496
  }
1350
- if (!modelPreference) {
1351
- return undefined
1497
+
1498
+ // If agent is set, check if agent has a model configured
1499
+ if (agentPreference) {
1500
+ const agentsResponse = await errore.tryAsync(() => {
1501
+ return getClient().app.agents({ query: { directory: sdkDirectory } })
1502
+ })
1503
+ if (!(agentsResponse instanceof Error) && agentsResponse.data) {
1504
+ const agent = agentsResponse.data.find((a) => a.name === agentPreference)
1505
+ if (agent?.model) {
1506
+ sessionLogger.log(
1507
+ `[MODEL] Using agent model: ${agent.model.providerID}/${agent.model.modelID}`,
1508
+ )
1509
+ return agent.model
1510
+ }
1511
+ sessionLogger.log(`[MODEL] Agent "${agentPreference}" has no model configured, using default`)
1512
+ }
1352
1513
  }
1353
- const [providerID, ...modelParts] = modelPreference.split('/')
1354
- const modelID = modelParts.join('/')
1355
- if (!providerID || !modelID) {
1356
- return undefined
1514
+
1515
+ // Fetch default model from OpenCode (like TUI does)
1516
+ const defaultModel = await getDefaultModel({ getClient, directory: sdkDirectory })
1517
+ if (defaultModel) {
1518
+ sessionLogger.log(`[MODEL] Using default: ${defaultModel.providerID}/${defaultModel.modelID}`)
1519
+ return defaultModel
1357
1520
  }
1358
- sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`)
1359
- return { providerID, modelID }
1521
+
1522
+ // No model available - this will likely cause an error from OpenCode
1523
+ sessionLogger.log(`[MODEL] No model available (no preference, no default)`)
1524
+ return undefined
1360
1525
  })()
1361
1526
 
1527
+ // Fail early if no model available
1528
+ if (!modelParam) {
1529
+ throw new Error(
1530
+ 'No AI provider connected. Configure a provider in OpenCode with `/connect` command.',
1531
+ )
1532
+ }
1533
+
1362
1534
  // Build worktree info for system message (worktreeInfo was fetched at the start)
1363
1535
  const worktree: WorktreeInfo | undefined =
1364
1536
  worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory