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.
- package/dist/channel-management.js +15 -11
- package/dist/cli.js +7 -4
- package/dist/commands/add-project.js +2 -1
- package/dist/commands/create-new-project.js +2 -1
- package/dist/discord-bot.js +12 -1
- package/dist/message-formatting.js +1 -0
- package/dist/session-handler.js +153 -18
- package/package.json +2 -1
- package/src/channel-management.ts +23 -14
- package/src/cli.ts +9 -4
- package/src/commands/add-project.ts +2 -1
- package/src/commands/create-new-project.ts +3 -2
- package/src/discord-bot.ts +15 -1
- package/src/message-formatting.ts +8 -2
- package/src/session-handler.ts +194 -22
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/discord-bot.js
CHANGED
|
@@ -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
|
|
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);
|
package/dist/session-handler.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
1105
|
+
sourceUrl: img.sourceUrl,
|
|
995
1106
|
})));
|
|
996
|
-
//
|
|
997
|
-
const imageList = images
|
|
998
|
-
|
|
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 (
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
|
1013
|
-
|
|
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
|
-
|
|
1016
|
-
const
|
|
1017
|
-
if (
|
|
1018
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
104
|
-
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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}
|
|
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({
|
package/src/discord-bot.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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
|
|
233
|
+
return results.filter((r) => r !== null) as DiscordFileAttachment[]
|
|
228
234
|
}
|
|
229
235
|
|
|
230
236
|
export function getToolSummaryText(part: Part): string {
|
package/src/session-handler.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
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
|
|
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?:
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1466
|
+
sourceUrl: img.sourceUrl,
|
|
1327
1467
|
})),
|
|
1328
1468
|
)
|
|
1329
|
-
//
|
|
1330
|
-
const imageList = images
|
|
1331
|
-
|
|
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 (
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
-
|
|
1351
|
-
|
|
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
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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
|
-
|
|
1359
|
-
|
|
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
|