kimaki 0.4.37 โ 0.4.38
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 +6 -2
- package/dist/cli.js +34 -14
- package/dist/commands/agent.js +101 -19
- package/dist/database.js +15 -0
- package/dist/discord-bot.js +17 -23
- package/dist/interaction-handler.js +6 -1
- package/dist/session-handler.js +36 -6
- package/package.json +1 -1
- package/src/channel-management.ts +6 -2
- package/src/cli.ts +57 -18
- package/src/commands/agent.ts +147 -24
- package/src/database.ts +17 -0
- package/src/discord-bot.ts +21 -27
- package/src/interaction-handler.ts +7 -1
- package/src/session-handler.ts +47 -9
|
@@ -6,7 +6,9 @@ import path from 'node:path';
|
|
|
6
6
|
import { getDatabase } from './database.js';
|
|
7
7
|
import { extractTagsArrays } from './xml.js';
|
|
8
8
|
export async function ensureKimakiCategory(guild, botName) {
|
|
9
|
-
|
|
9
|
+
// Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
|
|
10
|
+
const isKimakiBot = botName?.toLowerCase() === 'kimaki';
|
|
11
|
+
const categoryName = botName && !isKimakiBot ? `Kimaki ${botName}` : 'Kimaki';
|
|
10
12
|
const existingCategory = guild.channels.cache.find((channel) => {
|
|
11
13
|
if (channel.type !== ChannelType.GuildCategory) {
|
|
12
14
|
return false;
|
|
@@ -22,7 +24,9 @@ export async function ensureKimakiCategory(guild, botName) {
|
|
|
22
24
|
});
|
|
23
25
|
}
|
|
24
26
|
export async function ensureKimakiAudioCategory(guild, botName) {
|
|
25
|
-
|
|
27
|
+
// Skip appending bot name if it's already "kimaki" to avoid "Kimaki Audio kimaki"
|
|
28
|
+
const isKimakiBot = botName?.toLowerCase() === 'kimaki';
|
|
29
|
+
const categoryName = botName && !isKimakiBot ? `Kimaki Audio ${botName}` : 'Kimaki Audio';
|
|
26
30
|
const existingCategory = guild.channels.cache.find((channel) => {
|
|
27
31
|
if (channel.type !== ChannelType.GuildCategory) {
|
|
28
32
|
return false;
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
|
14
14
|
import http from 'node:http';
|
|
15
15
|
import { setDataDir, getDataDir, getLockPort } from './config.js';
|
|
16
16
|
import { extractTagsArrays } from './xml.js';
|
|
17
|
+
import { sanitizeAgentName } from './commands/agent.js';
|
|
17
18
|
const cliLogger = createLogger('CLI');
|
|
18
19
|
const cli = cac('kimaki');
|
|
19
20
|
process.title = 'kimaki';
|
|
@@ -119,7 +120,7 @@ async function startLockServer() {
|
|
|
119
120
|
const EXIT_NO_RESTART = 64;
|
|
120
121
|
// Commands to skip when registering user commands (reserved names)
|
|
121
122
|
const SKIP_USER_COMMANDS = ['init'];
|
|
122
|
-
async function registerCommands(token, appId, userCommands = []) {
|
|
123
|
+
async function registerCommands({ token, appId, userCommands = [], agents = [], }) {
|
|
123
124
|
const commands = [
|
|
124
125
|
new SlashCommandBuilder()
|
|
125
126
|
.setName('resume')
|
|
@@ -254,6 +255,18 @@ async function registerCommands(token, appId, userCommands = []) {
|
|
|
254
255
|
})
|
|
255
256
|
.toJSON());
|
|
256
257
|
}
|
|
258
|
+
// Add agent-specific quick commands like /plan-agent, /build-agent
|
|
259
|
+
// Filter to primary/all mode agents (same as /agent command shows)
|
|
260
|
+
const primaryAgents = agents.filter((a) => a.mode === 'primary' || a.mode === 'all');
|
|
261
|
+
for (const agent of primaryAgents) {
|
|
262
|
+
const sanitizedName = sanitizeAgentName(agent.name);
|
|
263
|
+
const commandName = `${sanitizedName}-agent`;
|
|
264
|
+
const description = agent.description || `Switch to ${agent.name} agent`;
|
|
265
|
+
commands.push(new SlashCommandBuilder()
|
|
266
|
+
.setName(commandName.slice(0, 32)) // Discord limits to 32 chars
|
|
267
|
+
.setDescription(description.slice(0, 100))
|
|
268
|
+
.toJSON());
|
|
269
|
+
}
|
|
257
270
|
const rest = new REST().setToken(token);
|
|
258
271
|
try {
|
|
259
272
|
const data = (await rest.put(Routes.applicationCommands(appId), {
|
|
@@ -506,8 +519,8 @@ async function run({ restart, addChannels }) {
|
|
|
506
519
|
const getClient = await opencodePromise;
|
|
507
520
|
s.stop('OpenCode server ready!');
|
|
508
521
|
s.start('Fetching OpenCode data...');
|
|
509
|
-
// Fetch projects and
|
|
510
|
-
const [projects, allUserCommands] = await Promise.all([
|
|
522
|
+
// Fetch projects, commands, and agents in parallel
|
|
523
|
+
const [projects, allUserCommands, allAgents] = await Promise.all([
|
|
511
524
|
getClient()
|
|
512
525
|
.project.list({})
|
|
513
526
|
.then((r) => r.data || [])
|
|
@@ -521,6 +534,10 @@ async function run({ restart, addChannels }) {
|
|
|
521
534
|
.command.list({ query: { directory: currentDir } })
|
|
522
535
|
.then((r) => r.data || [])
|
|
523
536
|
.catch(() => []),
|
|
537
|
+
getClient()
|
|
538
|
+
.app.agents({ query: { directory: currentDir } })
|
|
539
|
+
.then((r) => r.data || [])
|
|
540
|
+
.catch(() => []),
|
|
524
541
|
]);
|
|
525
542
|
s.stop(`Found ${projects.length} OpenCode project(s)`);
|
|
526
543
|
const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
|
|
@@ -611,7 +628,7 @@ async function run({ restart, addChannels }) {
|
|
|
611
628
|
note(`Found ${registrableCommands.length} user-defined command(s):\n${commandList}`, 'OpenCode Commands');
|
|
612
629
|
}
|
|
613
630
|
cliLogger.log('Registering slash commands asynchronously...');
|
|
614
|
-
void registerCommands(token, appId, allUserCommands)
|
|
631
|
+
void registerCommands({ token, appId, userCommands: allUserCommands, agents: allAgents })
|
|
615
632
|
.then(() => {
|
|
616
633
|
cliLogger.log('Slash commands registered!');
|
|
617
634
|
})
|
|
@@ -746,12 +763,6 @@ cli
|
|
|
746
763
|
process.exit(EXIT_NO_RESTART);
|
|
747
764
|
}
|
|
748
765
|
});
|
|
749
|
-
// Magic prefix used to identify bot-initiated sessions.
|
|
750
|
-
// The running bot will recognize this prefix and start a session.
|
|
751
|
-
const BOT_SESSION_PREFIX = '๐ค **Bot-initiated session**';
|
|
752
|
-
// Notify-only prefix - bot won't start a session, just creates thread for notifications.
|
|
753
|
-
// Reply to the thread to start a session with the notification as context.
|
|
754
|
-
const BOT_NOTIFY_PREFIX = '๐ข **Notification**';
|
|
755
766
|
cli
|
|
756
767
|
.command('send', 'Send a message to Discord channel, creating a thread. Use --notify-only to skip AI session.')
|
|
757
768
|
.alias('start-session') // backwards compatibility
|
|
@@ -953,9 +964,7 @@ cli
|
|
|
953
964
|
throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`);
|
|
954
965
|
}
|
|
955
966
|
s.message('Creating starter message...');
|
|
956
|
-
// Create starter message with
|
|
957
|
-
// BOT_SESSION_PREFIX triggers AI session, BOT_NOTIFY_PREFIX is notification-only
|
|
958
|
-
const messagePrefix = notifyOnly ? BOT_NOTIFY_PREFIX : BOT_SESSION_PREFIX;
|
|
967
|
+
// Create starter message with just the prompt (no prefix)
|
|
959
968
|
const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
960
969
|
method: 'POST',
|
|
961
970
|
headers: {
|
|
@@ -963,7 +972,7 @@ cli
|
|
|
963
972
|
'Content-Type': 'application/json',
|
|
964
973
|
},
|
|
965
974
|
body: JSON.stringify({
|
|
966
|
-
content:
|
|
975
|
+
content: prompt,
|
|
967
976
|
}),
|
|
968
977
|
});
|
|
969
978
|
if (!starterMessageResponse.ok) {
|
|
@@ -992,6 +1001,17 @@ cli
|
|
|
992
1001
|
throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
|
|
993
1002
|
}
|
|
994
1003
|
const threadData = (await threadResponse.json());
|
|
1004
|
+
// Mark thread for auto-start if not notify-only
|
|
1005
|
+
// This is optional - only works if local database exists (for local bot auto-start)
|
|
1006
|
+
if (!notifyOnly) {
|
|
1007
|
+
try {
|
|
1008
|
+
const db = getDatabase();
|
|
1009
|
+
db.prepare('INSERT OR REPLACE INTO pending_auto_start (thread_id) VALUES (?)').run(threadData.id);
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
// Database not available (e.g., CI environment) - skip auto-start marking
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
995
1015
|
s.stop('Thread created!');
|
|
996
1016
|
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
|
|
997
1017
|
const successMessage = notifyOnly
|
package/dist/commands/agent.js
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
// /agent command - Set the preferred agent for this channel or session.
|
|
2
|
+
// Also provides quick agent commands like /plan-agent, /build-agent that switch instantly.
|
|
2
3
|
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, } from 'discord.js';
|
|
3
4
|
import crypto from 'node:crypto';
|
|
4
|
-
import { getDatabase, setChannelAgent, setSessionAgent, runModelMigrations } from '../database.js';
|
|
5
|
+
import { getDatabase, setChannelAgent, setSessionAgent, clearSessionModel, runModelMigrations } from '../database.js';
|
|
5
6
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
6
7
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
7
8
|
import { createLogger } from '../logger.js';
|
|
8
9
|
const agentLogger = createLogger('AGENT');
|
|
9
10
|
const pendingAgentContexts = new Map();
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Sanitize an agent name to be a valid Discord command name component.
|
|
13
|
+
* Lowercase, alphanumeric and hyphens only.
|
|
14
|
+
*/
|
|
15
|
+
export function sanitizeAgentName(name) {
|
|
16
|
+
return name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the context for an agent command (directory, channel, session).
|
|
20
|
+
* Returns null if the command cannot be executed in this context.
|
|
21
|
+
*/
|
|
22
|
+
export async function resolveAgentCommandContext({ interaction, appId, }) {
|
|
13
23
|
const channel = interaction.channel;
|
|
14
24
|
if (!channel) {
|
|
15
25
|
await interaction.editReply({ content: 'This command can only be used in a channel' });
|
|
16
|
-
return;
|
|
26
|
+
return null;
|
|
17
27
|
}
|
|
18
28
|
const isThread = [
|
|
19
29
|
ChannelType.PublicThread,
|
|
@@ -47,22 +57,53 @@ export async function handleAgentCommand({ interaction, appId, }) {
|
|
|
47
57
|
await interaction.editReply({
|
|
48
58
|
content: 'This command can only be used in text channels or threads',
|
|
49
59
|
});
|
|
50
|
-
return;
|
|
60
|
+
return null;
|
|
51
61
|
}
|
|
52
62
|
if (channelAppId && channelAppId !== appId) {
|
|
53
63
|
await interaction.editReply({ content: 'This channel is not configured for this bot' });
|
|
54
|
-
return;
|
|
64
|
+
return null;
|
|
55
65
|
}
|
|
56
66
|
if (!projectDirectory) {
|
|
57
67
|
await interaction.editReply({
|
|
58
68
|
content: 'This channel is not configured with a project directory',
|
|
59
69
|
});
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
dir: projectDirectory,
|
|
74
|
+
channelId: targetChannelId,
|
|
75
|
+
sessionId,
|
|
76
|
+
isThread,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Set the agent preference for a context (session or channel).
|
|
81
|
+
* When switching agents for a session, also clears the session model preference
|
|
82
|
+
* so the new agent's model takes effect.
|
|
83
|
+
*/
|
|
84
|
+
export function setAgentForContext({ context, agentName, }) {
|
|
85
|
+
if (context.isThread && context.sessionId) {
|
|
86
|
+
setSessionAgent(context.sessionId, agentName);
|
|
87
|
+
// Clear session model so the new agent's model takes effect
|
|
88
|
+
clearSessionModel(context.sessionId);
|
|
89
|
+
agentLogger.log(`Set agent ${agentName} for session ${context.sessionId} (cleared model preference)`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
setChannelAgent(context.channelId, agentName);
|
|
93
|
+
agentLogger.log(`Set agent ${agentName} for channel ${context.channelId}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function handleAgentCommand({ interaction, appId, }) {
|
|
97
|
+
await interaction.deferReply({ ephemeral: true });
|
|
98
|
+
runModelMigrations();
|
|
99
|
+
const context = await resolveAgentCommandContext({ interaction, appId });
|
|
100
|
+
if (!context) {
|
|
60
101
|
return;
|
|
61
102
|
}
|
|
62
103
|
try {
|
|
63
|
-
const getClient = await initializeOpencodeForDirectory(
|
|
104
|
+
const getClient = await initializeOpencodeForDirectory(context.dir);
|
|
64
105
|
const agentsResponse = await getClient().app.agents({
|
|
65
|
-
query: { directory:
|
|
106
|
+
query: { directory: context.dir },
|
|
66
107
|
});
|
|
67
108
|
if (!agentsResponse.data || agentsResponse.data.length === 0) {
|
|
68
109
|
await interaction.editReply({ content: 'No agents available' });
|
|
@@ -76,12 +117,7 @@ export async function handleAgentCommand({ interaction, appId, }) {
|
|
|
76
117
|
return;
|
|
77
118
|
}
|
|
78
119
|
const contextHash = crypto.randomBytes(8).toString('hex');
|
|
79
|
-
pendingAgentContexts.set(contextHash,
|
|
80
|
-
dir: projectDirectory,
|
|
81
|
-
channelId: targetChannelId,
|
|
82
|
-
sessionId,
|
|
83
|
-
isThread,
|
|
84
|
-
});
|
|
120
|
+
pendingAgentContexts.set(contextHash, context);
|
|
85
121
|
const options = agents.map((agent) => ({
|
|
86
122
|
label: agent.name.slice(0, 100),
|
|
87
123
|
value: agent.name,
|
|
@@ -128,17 +164,14 @@ export async function handleAgentSelectMenu(interaction) {
|
|
|
128
164
|
return;
|
|
129
165
|
}
|
|
130
166
|
try {
|
|
167
|
+
setAgentForContext({ context, agentName: selectedAgent });
|
|
131
168
|
if (context.isThread && context.sessionId) {
|
|
132
|
-
setSessionAgent(context.sessionId, selectedAgent);
|
|
133
|
-
agentLogger.log(`Set agent ${selectedAgent} for session ${context.sessionId}`);
|
|
134
169
|
await interaction.editReply({
|
|
135
170
|
content: `Agent preference set for this session: **${selectedAgent}**`,
|
|
136
171
|
components: [],
|
|
137
172
|
});
|
|
138
173
|
}
|
|
139
174
|
else {
|
|
140
|
-
setChannelAgent(context.channelId, selectedAgent);
|
|
141
|
-
agentLogger.log(`Set agent ${selectedAgent} for channel ${context.channelId}`);
|
|
142
175
|
await interaction.editReply({
|
|
143
176
|
content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
|
|
144
177
|
components: [],
|
|
@@ -154,3 +187,52 @@ export async function handleAgentSelectMenu(interaction) {
|
|
|
154
187
|
});
|
|
155
188
|
}
|
|
156
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Handle quick agent commands like /plan-agent, /build-agent.
|
|
192
|
+
* These instantly switch to the specified agent without showing a dropdown.
|
|
193
|
+
*/
|
|
194
|
+
export async function handleQuickAgentCommand({ command, appId, }) {
|
|
195
|
+
await command.deferReply({ ephemeral: true });
|
|
196
|
+
runModelMigrations();
|
|
197
|
+
// Extract agent name from command: "plan-agent" โ "plan"
|
|
198
|
+
const sanitizedAgentName = command.commandName.replace(/-agent$/, '');
|
|
199
|
+
const context = await resolveAgentCommandContext({ interaction: command, appId });
|
|
200
|
+
if (!context) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const getClient = await initializeOpencodeForDirectory(context.dir);
|
|
205
|
+
const agentsResponse = await getClient().app.agents({
|
|
206
|
+
query: { directory: context.dir },
|
|
207
|
+
});
|
|
208
|
+
if (!agentsResponse.data || agentsResponse.data.length === 0) {
|
|
209
|
+
await command.editReply({ content: 'No agents available in this project' });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// Find the agent matching the sanitized command name
|
|
213
|
+
const matchingAgent = agentsResponse.data.find((a) => sanitizeAgentName(a.name) === sanitizedAgentName);
|
|
214
|
+
if (!matchingAgent) {
|
|
215
|
+
await command.editReply({
|
|
216
|
+
content: `Agent not found. Available agents: ${agentsResponse.data.map((a) => a.name).join(', ')}`,
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
setAgentForContext({ context, agentName: matchingAgent.name });
|
|
221
|
+
if (context.isThread && context.sessionId) {
|
|
222
|
+
await command.editReply({
|
|
223
|
+
content: `Switched to **${matchingAgent.name}** agent for this session`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
await command.editReply({
|
|
228
|
+
content: `Switched to **${matchingAgent.name}** agent for this channel\n\nAll new sessions will use this agent.`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
agentLogger.error('Error in quick agent command:', error);
|
|
234
|
+
await command.editReply({
|
|
235
|
+
content: `Failed to switch agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
package/dist/database.js
CHANGED
|
@@ -57,6 +57,13 @@ export function getDatabase() {
|
|
|
57
57
|
catch {
|
|
58
58
|
// Column already exists, ignore
|
|
59
59
|
}
|
|
60
|
+
// Table for threads that should auto-start a session (created by CLI without --notify-only)
|
|
61
|
+
db.exec(`
|
|
62
|
+
CREATE TABLE IF NOT EXISTS pending_auto_start (
|
|
63
|
+
thread_id TEXT PRIMARY KEY,
|
|
64
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
65
|
+
)
|
|
66
|
+
`);
|
|
60
67
|
db.exec(`
|
|
61
68
|
CREATE TABLE IF NOT EXISTS bot_api_keys (
|
|
62
69
|
app_id TEXT PRIMARY KEY,
|
|
@@ -147,6 +154,14 @@ export function setSessionModel(sessionId, modelId) {
|
|
|
147
154
|
const db = getDatabase();
|
|
148
155
|
db.prepare(`INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`).run(sessionId, modelId);
|
|
149
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Clear the model preference for a session.
|
|
159
|
+
* Used when switching agents so the agent's model takes effect.
|
|
160
|
+
*/
|
|
161
|
+
export function clearSessionModel(sessionId) {
|
|
162
|
+
const db = getDatabase();
|
|
163
|
+
db.prepare('DELETE FROM session_models WHERE session_id = ?').run(sessionId);
|
|
164
|
+
}
|
|
150
165
|
/**
|
|
151
166
|
* Get the agent preference for a channel.
|
|
152
167
|
*/
|
package/dist/discord-bot.js
CHANGED
|
@@ -152,17 +152,11 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
152
152
|
discordLogger.log(`Cannot start session: no project directory for thread ${thread.id}`);
|
|
153
153
|
return;
|
|
154
154
|
}
|
|
155
|
-
// Include starter message
|
|
155
|
+
// Include starter message as context for the session
|
|
156
156
|
let prompt = message.content;
|
|
157
157
|
const starterMessage = await thread.fetchStarterMessage().catch(() => null);
|
|
158
|
-
if (starterMessage?.content) {
|
|
159
|
-
|
|
160
|
-
const notificationContent = starterMessage.content
|
|
161
|
-
.replace(/^๐ข \*\*Notification\*\*\n?/, '')
|
|
162
|
-
.trim();
|
|
163
|
-
if (notificationContent) {
|
|
164
|
-
prompt = `Context from notification:\n${notificationContent}\n\nUser request:\n${message.content}`;
|
|
165
|
-
}
|
|
158
|
+
if (starterMessage?.content && starterMessage.content !== message.content) {
|
|
159
|
+
prompt = `Context from thread:\n${starterMessage.content}\n\nUser request:\n${message.content}`;
|
|
166
160
|
}
|
|
167
161
|
await handleOpencodeSession({
|
|
168
162
|
prompt,
|
|
@@ -316,35 +310,35 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
316
310
|
}
|
|
317
311
|
}
|
|
318
312
|
});
|
|
319
|
-
//
|
|
320
|
-
const BOT_SESSION_PREFIX = '๐ค **Bot-initiated session**';
|
|
321
|
-
// Handle bot-initiated threads created by `kimaki send`
|
|
313
|
+
// Handle bot-initiated threads created by `kimaki send` (without --notify-only)
|
|
322
314
|
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
323
315
|
try {
|
|
324
316
|
if (!newlyCreated) {
|
|
325
317
|
return;
|
|
326
318
|
}
|
|
319
|
+
// Check if this thread is marked for auto-start in the database
|
|
320
|
+
const db = getDatabase();
|
|
321
|
+
const pendingRow = db
|
|
322
|
+
.prepare('SELECT thread_id FROM pending_auto_start WHERE thread_id = ?')
|
|
323
|
+
.get(thread.id);
|
|
324
|
+
if (!pendingRow) {
|
|
325
|
+
return; // Not a CLI-initiated auto-start thread
|
|
326
|
+
}
|
|
327
|
+
// Remove from pending table
|
|
328
|
+
db.prepare('DELETE FROM pending_auto_start WHERE thread_id = ?').run(thread.id);
|
|
329
|
+
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
|
|
327
330
|
// Only handle threads in text channels
|
|
328
331
|
const parent = thread.parent;
|
|
329
332
|
if (!parent || parent.type !== ChannelType.GuildText) {
|
|
330
333
|
return;
|
|
331
334
|
}
|
|
332
|
-
// Get the starter message
|
|
335
|
+
// Get the starter message for the prompt
|
|
333
336
|
const starterMessage = await thread.fetchStarterMessage().catch(() => null);
|
|
334
337
|
if (!starterMessage) {
|
|
335
338
|
discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`);
|
|
336
339
|
return;
|
|
337
340
|
}
|
|
338
|
-
|
|
339
|
-
if (starterMessage.author.id !== discordClient.user?.id) {
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
|
|
346
|
-
// Extract the prompt (everything after the prefix)
|
|
347
|
-
const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim();
|
|
341
|
+
const prompt = starterMessage.content.trim();
|
|
348
342
|
if (!prompt) {
|
|
349
343
|
discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
|
|
350
344
|
return;
|
|
@@ -12,7 +12,7 @@ import { handleAbortCommand } from './commands/abort.js';
|
|
|
12
12
|
import { handleShareCommand } from './commands/share.js';
|
|
13
13
|
import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js';
|
|
14
14
|
import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, } from './commands/model.js';
|
|
15
|
-
import { handleAgentCommand, handleAgentSelectMenu } from './commands/agent.js';
|
|
15
|
+
import { handleAgentCommand, handleAgentSelectMenu, handleQuickAgentCommand } from './commands/agent.js';
|
|
16
16
|
import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
|
|
17
17
|
import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js';
|
|
18
18
|
import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js';
|
|
@@ -94,6 +94,11 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
94
94
|
await handleRedoCommand({ command: interaction, appId });
|
|
95
95
|
return;
|
|
96
96
|
}
|
|
97
|
+
// Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
|
|
98
|
+
if (interaction.commandName.endsWith('-agent') && interaction.commandName !== 'agent') {
|
|
99
|
+
await handleQuickAgentCommand({ command: interaction, appId });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
97
102
|
// Handle user-defined commands (ending with -cmd suffix)
|
|
98
103
|
if (interaction.commandName.endsWith('-cmd')) {
|
|
99
104
|
await handleUserCommand({ command: interaction, appId });
|
package/dist/session-handler.js
CHANGED
|
@@ -473,6 +473,30 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
473
473
|
requestId: questionRequest.id,
|
|
474
474
|
input: { questions: questionRequest.questions },
|
|
475
475
|
});
|
|
476
|
+
// Process queued messages if any - queued message will cancel the pending question
|
|
477
|
+
const queue = messageQueue.get(thread.id);
|
|
478
|
+
if (queue && queue.length > 0) {
|
|
479
|
+
const nextMessage = queue.shift();
|
|
480
|
+
if (queue.length === 0) {
|
|
481
|
+
messageQueue.delete(thread.id);
|
|
482
|
+
}
|
|
483
|
+
sessionLogger.log(`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`);
|
|
484
|
+
await sendThreadMessage(thread, `ยป **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
|
|
485
|
+
// handleOpencodeSession will call cancelPendingQuestion, which cancels the dropdown
|
|
486
|
+
setImmediate(() => {
|
|
487
|
+
handleOpencodeSession({
|
|
488
|
+
prompt: nextMessage.prompt,
|
|
489
|
+
thread,
|
|
490
|
+
projectDirectory: directory,
|
|
491
|
+
images: nextMessage.images,
|
|
492
|
+
channelId,
|
|
493
|
+
}).catch(async (e) => {
|
|
494
|
+
sessionLogger.error(`[QUEUE] Failed to process queued message:`, e);
|
|
495
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
496
|
+
await sendThreadMessage(thread, `โ Queued message failed: ${errorMsg.slice(0, 200)}`);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
}
|
|
476
500
|
}
|
|
477
501
|
else if (event.type === 'session.idle') {
|
|
478
502
|
// Session is done processing - abort to signal completion
|
|
@@ -581,9 +605,20 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
581
605
|
})();
|
|
582
606
|
const parts = [{ type: 'text', text: promptWithImagePaths }, ...images];
|
|
583
607
|
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
|
|
608
|
+
// Get agent preference: session-level overrides channel-level
|
|
609
|
+
const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined);
|
|
610
|
+
if (agentPreference) {
|
|
611
|
+
sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`);
|
|
612
|
+
}
|
|
584
613
|
// Get model preference: session-level overrides channel-level
|
|
614
|
+
// BUT: if an agent is set, don't pass model param so the agent's model takes effect
|
|
585
615
|
const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
|
|
586
616
|
const modelParam = (() => {
|
|
617
|
+
// When an agent is set, let the agent's model config take effect
|
|
618
|
+
if (agentPreference) {
|
|
619
|
+
sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`);
|
|
620
|
+
return undefined;
|
|
621
|
+
}
|
|
587
622
|
if (!modelPreference) {
|
|
588
623
|
return undefined;
|
|
589
624
|
}
|
|
@@ -595,11 +630,6 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
595
630
|
sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
|
|
596
631
|
return { providerID, modelID };
|
|
597
632
|
})();
|
|
598
|
-
// Get agent preference: session-level overrides channel-level
|
|
599
|
-
const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined);
|
|
600
|
-
if (agentPreference) {
|
|
601
|
-
sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`);
|
|
602
|
-
}
|
|
603
633
|
// Use session.command API for slash commands, session.prompt for regular messages
|
|
604
634
|
const response = command
|
|
605
635
|
? await getClient().session.command({
|
|
@@ -650,8 +680,8 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
650
680
|
return { sessionID: session.id, result: response.data, port };
|
|
651
681
|
}
|
|
652
682
|
catch (error) {
|
|
653
|
-
sessionLogger.error(`ERROR: Failed to send prompt:`, error);
|
|
654
683
|
if (!isAbortError(error, abortController.signal)) {
|
|
684
|
+
sessionLogger.error(`ERROR: Failed to send prompt:`, error);
|
|
655
685
|
abortController.abort('error');
|
|
656
686
|
if (originalMessage) {
|
|
657
687
|
try {
|
package/package.json
CHANGED
|
@@ -11,7 +11,9 @@ export async function ensureKimakiCategory(
|
|
|
11
11
|
guild: Guild,
|
|
12
12
|
botName?: string,
|
|
13
13
|
): Promise<CategoryChannel> {
|
|
14
|
-
|
|
14
|
+
// Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
|
|
15
|
+
const isKimakiBot = botName?.toLowerCase() === 'kimaki'
|
|
16
|
+
const categoryName = botName && !isKimakiBot ? `Kimaki ${botName}` : 'Kimaki'
|
|
15
17
|
|
|
16
18
|
const existingCategory = guild.channels.cache.find((channel): channel is CategoryChannel => {
|
|
17
19
|
if (channel.type !== ChannelType.GuildCategory) {
|
|
@@ -35,7 +37,9 @@ export async function ensureKimakiAudioCategory(
|
|
|
35
37
|
guild: Guild,
|
|
36
38
|
botName?: string,
|
|
37
39
|
): Promise<CategoryChannel> {
|
|
38
|
-
|
|
40
|
+
// Skip appending bot name if it's already "kimaki" to avoid "Kimaki Audio kimaki"
|
|
41
|
+
const isKimakiBot = botName?.toLowerCase() === 'kimaki'
|
|
42
|
+
const categoryName = botName && !isKimakiBot ? `Kimaki Audio ${botName}` : 'Kimaki Audio'
|
|
39
43
|
|
|
40
44
|
const existingCategory = guild.channels.cache.find((channel): channel is CategoryChannel => {
|
|
41
45
|
if (channel.type !== ChannelType.GuildCategory) {
|
package/src/cli.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_pro
|
|
|
46
46
|
import http from 'node:http'
|
|
47
47
|
import { setDataDir, getDataDir, getLockPort } from './config.js'
|
|
48
48
|
import { extractTagsArrays } from './xml.js'
|
|
49
|
+
import { sanitizeAgentName } from './commands/agent.js'
|
|
49
50
|
|
|
50
51
|
const cliLogger = createLogger('CLI')
|
|
51
52
|
const cli = cac('kimaki')
|
|
@@ -176,11 +177,23 @@ type CliOptions = {
|
|
|
176
177
|
// Commands to skip when registering user commands (reserved names)
|
|
177
178
|
const SKIP_USER_COMMANDS = ['init']
|
|
178
179
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
180
|
+
type AgentInfo = {
|
|
181
|
+
name: string
|
|
182
|
+
description?: string
|
|
183
|
+
mode: string
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function registerCommands({
|
|
187
|
+
token,
|
|
188
|
+
appId,
|
|
189
|
+
userCommands = [],
|
|
190
|
+
agents = [],
|
|
191
|
+
}: {
|
|
192
|
+
token: string
|
|
193
|
+
appId: string
|
|
194
|
+
userCommands?: OpencodeCommand[]
|
|
195
|
+
agents?: AgentInfo[]
|
|
196
|
+
}) {
|
|
184
197
|
const commands = [
|
|
185
198
|
new SlashCommandBuilder()
|
|
186
199
|
.setName('resume')
|
|
@@ -329,6 +342,22 @@ async function registerCommands(
|
|
|
329
342
|
)
|
|
330
343
|
}
|
|
331
344
|
|
|
345
|
+
// Add agent-specific quick commands like /plan-agent, /build-agent
|
|
346
|
+
// Filter to primary/all mode agents (same as /agent command shows)
|
|
347
|
+
const primaryAgents = agents.filter((a) => a.mode === 'primary' || a.mode === 'all')
|
|
348
|
+
for (const agent of primaryAgents) {
|
|
349
|
+
const sanitizedName = sanitizeAgentName(agent.name)
|
|
350
|
+
const commandName = `${sanitizedName}-agent`
|
|
351
|
+
const description = agent.description || `Switch to ${agent.name} agent`
|
|
352
|
+
|
|
353
|
+
commands.push(
|
|
354
|
+
new SlashCommandBuilder()
|
|
355
|
+
.setName(commandName.slice(0, 32)) // Discord limits to 32 chars
|
|
356
|
+
.setDescription(description.slice(0, 100))
|
|
357
|
+
.toJSON(),
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
|
|
332
361
|
const rest = new REST().setToken(token)
|
|
333
362
|
|
|
334
363
|
try {
|
|
@@ -669,8 +698,8 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
669
698
|
|
|
670
699
|
s.start('Fetching OpenCode data...')
|
|
671
700
|
|
|
672
|
-
// Fetch projects and
|
|
673
|
-
const [projects, allUserCommands] = await Promise.all([
|
|
701
|
+
// Fetch projects, commands, and agents in parallel
|
|
702
|
+
const [projects, allUserCommands, allAgents] = await Promise.all([
|
|
674
703
|
getClient()
|
|
675
704
|
.project.list({})
|
|
676
705
|
.then((r) => r.data || [])
|
|
@@ -684,6 +713,10 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
684
713
|
.command.list({ query: { directory: currentDir } })
|
|
685
714
|
.then((r) => r.data || [])
|
|
686
715
|
.catch(() => []),
|
|
716
|
+
getClient()
|
|
717
|
+
.app.agents({ query: { directory: currentDir } })
|
|
718
|
+
.then((r) => r.data || [])
|
|
719
|
+
.catch(() => []),
|
|
687
720
|
])
|
|
688
721
|
|
|
689
722
|
s.stop(`Found ${projects.length} OpenCode project(s)`)
|
|
@@ -805,7 +838,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
805
838
|
}
|
|
806
839
|
|
|
807
840
|
cliLogger.log('Registering slash commands asynchronously...')
|
|
808
|
-
void registerCommands(token, appId, allUserCommands)
|
|
841
|
+
void registerCommands({ token, appId, userCommands: allUserCommands, agents: allAgents })
|
|
809
842
|
.then(() => {
|
|
810
843
|
cliLogger.log('Slash commands registered!')
|
|
811
844
|
})
|
|
@@ -999,12 +1032,7 @@ cli
|
|
|
999
1032
|
}
|
|
1000
1033
|
})
|
|
1001
1034
|
|
|
1002
|
-
|
|
1003
|
-
// The running bot will recognize this prefix and start a session.
|
|
1004
|
-
const BOT_SESSION_PREFIX = '๐ค **Bot-initiated session**'
|
|
1005
|
-
// Notify-only prefix - bot won't start a session, just creates thread for notifications.
|
|
1006
|
-
// Reply to the thread to start a session with the notification as context.
|
|
1007
|
-
const BOT_NOTIFY_PREFIX = '๐ข **Notification**'
|
|
1035
|
+
|
|
1008
1036
|
|
|
1009
1037
|
cli
|
|
1010
1038
|
.command(
|
|
@@ -1263,9 +1291,7 @@ cli
|
|
|
1263
1291
|
|
|
1264
1292
|
s.message('Creating starter message...')
|
|
1265
1293
|
|
|
1266
|
-
// Create starter message with
|
|
1267
|
-
// BOT_SESSION_PREFIX triggers AI session, BOT_NOTIFY_PREFIX is notification-only
|
|
1268
|
-
const messagePrefix = notifyOnly ? BOT_NOTIFY_PREFIX : BOT_SESSION_PREFIX
|
|
1294
|
+
// Create starter message with just the prompt (no prefix)
|
|
1269
1295
|
const starterMessageResponse = await fetch(
|
|
1270
1296
|
`https://discord.com/api/v10/channels/${channelId}/messages`,
|
|
1271
1297
|
{
|
|
@@ -1275,7 +1301,7 @@ cli
|
|
|
1275
1301
|
'Content-Type': 'application/json',
|
|
1276
1302
|
},
|
|
1277
1303
|
body: JSON.stringify({
|
|
1278
|
-
content:
|
|
1304
|
+
content: prompt,
|
|
1279
1305
|
}),
|
|
1280
1306
|
},
|
|
1281
1307
|
)
|
|
@@ -1315,6 +1341,19 @@ cli
|
|
|
1315
1341
|
|
|
1316
1342
|
const threadData = (await threadResponse.json()) as { id: string; name: string }
|
|
1317
1343
|
|
|
1344
|
+
// Mark thread for auto-start if not notify-only
|
|
1345
|
+
// This is optional - only works if local database exists (for local bot auto-start)
|
|
1346
|
+
if (!notifyOnly) {
|
|
1347
|
+
try {
|
|
1348
|
+
const db = getDatabase()
|
|
1349
|
+
db.prepare('INSERT OR REPLACE INTO pending_auto_start (thread_id) VALUES (?)').run(
|
|
1350
|
+
threadData.id,
|
|
1351
|
+
)
|
|
1352
|
+
} catch {
|
|
1353
|
+
// Database not available (e.g., CI environment) - skip auto-start marking
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1318
1357
|
s.stop('Thread created!')
|
|
1319
1358
|
|
|
1320
1359
|
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
|
package/src/commands/agent.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// /agent command - Set the preferred agent for this channel or session.
|
|
2
|
+
// Also provides quick agent commands like /plan-agent, /build-agent that switch instantly.
|
|
2
3
|
|
|
3
4
|
import {
|
|
4
5
|
ChatInputCommandInteraction,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
type TextChannel,
|
|
11
12
|
} from 'discord.js'
|
|
12
13
|
import crypto from 'node:crypto'
|
|
13
|
-
import { getDatabase, setChannelAgent, setSessionAgent, runModelMigrations } from '../database.js'
|
|
14
|
+
import { getDatabase, setChannelAgent, setSessionAgent, clearSessionModel, runModelMigrations } from '../database.js'
|
|
14
15
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
15
16
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
|
|
16
17
|
import { createLogger } from '../logger.js'
|
|
@@ -27,22 +28,40 @@ const pendingAgentContexts = new Map<
|
|
|
27
28
|
}
|
|
28
29
|
>()
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Context for agent commands, containing channel/session info.
|
|
33
|
+
*/
|
|
34
|
+
export type AgentCommandContext = {
|
|
35
|
+
dir: string
|
|
36
|
+
channelId: string
|
|
37
|
+
sessionId?: string
|
|
38
|
+
isThread: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sanitize an agent name to be a valid Discord command name component.
|
|
43
|
+
* Lowercase, alphanumeric and hyphens only.
|
|
44
|
+
*/
|
|
45
|
+
export function sanitizeAgentName(name: string): string {
|
|
46
|
+
return name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the context for an agent command (directory, channel, session).
|
|
51
|
+
* Returns null if the command cannot be executed in this context.
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveAgentCommandContext({
|
|
31
54
|
interaction,
|
|
32
55
|
appId,
|
|
33
56
|
}: {
|
|
34
57
|
interaction: ChatInputCommandInteraction
|
|
35
58
|
appId: string
|
|
36
|
-
}): Promise<
|
|
37
|
-
await interaction.deferReply({ ephemeral: true })
|
|
38
|
-
|
|
39
|
-
runModelMigrations()
|
|
40
|
-
|
|
59
|
+
}): Promise<AgentCommandContext | null> {
|
|
41
60
|
const channel = interaction.channel
|
|
42
61
|
|
|
43
62
|
if (!channel) {
|
|
44
63
|
await interaction.editReply({ content: 'This command can only be used in a channel' })
|
|
45
|
-
return
|
|
64
|
+
return null
|
|
46
65
|
}
|
|
47
66
|
|
|
48
67
|
const isThread = [
|
|
@@ -78,26 +97,73 @@ export async function handleAgentCommand({
|
|
|
78
97
|
await interaction.editReply({
|
|
79
98
|
content: 'This command can only be used in text channels or threads',
|
|
80
99
|
})
|
|
81
|
-
return
|
|
100
|
+
return null
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
if (channelAppId && channelAppId !== appId) {
|
|
85
104
|
await interaction.editReply({ content: 'This channel is not configured for this bot' })
|
|
86
|
-
return
|
|
105
|
+
return null
|
|
87
106
|
}
|
|
88
107
|
|
|
89
108
|
if (!projectDirectory) {
|
|
90
109
|
await interaction.editReply({
|
|
91
110
|
content: 'This channel is not configured with a project directory',
|
|
92
111
|
})
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
dir: projectDirectory,
|
|
117
|
+
channelId: targetChannelId,
|
|
118
|
+
sessionId,
|
|
119
|
+
isThread,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Set the agent preference for a context (session or channel).
|
|
125
|
+
* When switching agents for a session, also clears the session model preference
|
|
126
|
+
* so the new agent's model takes effect.
|
|
127
|
+
*/
|
|
128
|
+
export function setAgentForContext({
|
|
129
|
+
context,
|
|
130
|
+
agentName,
|
|
131
|
+
}: {
|
|
132
|
+
context: AgentCommandContext
|
|
133
|
+
agentName: string
|
|
134
|
+
}): void {
|
|
135
|
+
if (context.isThread && context.sessionId) {
|
|
136
|
+
setSessionAgent(context.sessionId, agentName)
|
|
137
|
+
// Clear session model so the new agent's model takes effect
|
|
138
|
+
clearSessionModel(context.sessionId)
|
|
139
|
+
agentLogger.log(`Set agent ${agentName} for session ${context.sessionId} (cleared model preference)`)
|
|
140
|
+
} else {
|
|
141
|
+
setChannelAgent(context.channelId, agentName)
|
|
142
|
+
agentLogger.log(`Set agent ${agentName} for channel ${context.channelId}`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function handleAgentCommand({
|
|
147
|
+
interaction,
|
|
148
|
+
appId,
|
|
149
|
+
}: {
|
|
150
|
+
interaction: ChatInputCommandInteraction
|
|
151
|
+
appId: string
|
|
152
|
+
}): Promise<void> {
|
|
153
|
+
await interaction.deferReply({ ephemeral: true })
|
|
154
|
+
|
|
155
|
+
runModelMigrations()
|
|
156
|
+
|
|
157
|
+
const context = await resolveAgentCommandContext({ interaction, appId })
|
|
158
|
+
if (!context) {
|
|
93
159
|
return
|
|
94
160
|
}
|
|
95
161
|
|
|
96
162
|
try {
|
|
97
|
-
const getClient = await initializeOpencodeForDirectory(
|
|
163
|
+
const getClient = await initializeOpencodeForDirectory(context.dir)
|
|
98
164
|
|
|
99
165
|
const agentsResponse = await getClient().app.agents({
|
|
100
|
-
query: { directory:
|
|
166
|
+
query: { directory: context.dir },
|
|
101
167
|
})
|
|
102
168
|
|
|
103
169
|
if (!agentsResponse.data || agentsResponse.data.length === 0) {
|
|
@@ -115,12 +181,7 @@ export async function handleAgentCommand({
|
|
|
115
181
|
}
|
|
116
182
|
|
|
117
183
|
const contextHash = crypto.randomBytes(8).toString('hex')
|
|
118
|
-
pendingAgentContexts.set(contextHash,
|
|
119
|
-
dir: projectDirectory,
|
|
120
|
-
channelId: targetChannelId,
|
|
121
|
-
sessionId,
|
|
122
|
-
isThread,
|
|
123
|
-
})
|
|
184
|
+
pendingAgentContexts.set(contextHash, context)
|
|
124
185
|
|
|
125
186
|
const options = agents.map((agent) => ({
|
|
126
187
|
label: agent.name.slice(0, 100),
|
|
@@ -179,18 +240,14 @@ export async function handleAgentSelectMenu(
|
|
|
179
240
|
}
|
|
180
241
|
|
|
181
242
|
try {
|
|
182
|
-
|
|
183
|
-
setSessionAgent(context.sessionId, selectedAgent)
|
|
184
|
-
agentLogger.log(`Set agent ${selectedAgent} for session ${context.sessionId}`)
|
|
243
|
+
setAgentForContext({ context, agentName: selectedAgent })
|
|
185
244
|
|
|
245
|
+
if (context.isThread && context.sessionId) {
|
|
186
246
|
await interaction.editReply({
|
|
187
247
|
content: `Agent preference set for this session: **${selectedAgent}**`,
|
|
188
248
|
components: [],
|
|
189
249
|
})
|
|
190
250
|
} else {
|
|
191
|
-
setChannelAgent(context.channelId, selectedAgent)
|
|
192
|
-
agentLogger.log(`Set agent ${selectedAgent} for channel ${context.channelId}`)
|
|
193
|
-
|
|
194
251
|
await interaction.editReply({
|
|
195
252
|
content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
|
|
196
253
|
components: [],
|
|
@@ -206,3 +263,69 @@ export async function handleAgentSelectMenu(
|
|
|
206
263
|
})
|
|
207
264
|
}
|
|
208
265
|
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Handle quick agent commands like /plan-agent, /build-agent.
|
|
269
|
+
* These instantly switch to the specified agent without showing a dropdown.
|
|
270
|
+
*/
|
|
271
|
+
export async function handleQuickAgentCommand({
|
|
272
|
+
command,
|
|
273
|
+
appId,
|
|
274
|
+
}: {
|
|
275
|
+
command: ChatInputCommandInteraction
|
|
276
|
+
appId: string
|
|
277
|
+
}): Promise<void> {
|
|
278
|
+
await command.deferReply({ ephemeral: true })
|
|
279
|
+
|
|
280
|
+
runModelMigrations()
|
|
281
|
+
|
|
282
|
+
// Extract agent name from command: "plan-agent" โ "plan"
|
|
283
|
+
const sanitizedAgentName = command.commandName.replace(/-agent$/, '')
|
|
284
|
+
|
|
285
|
+
const context = await resolveAgentCommandContext({ interaction: command, appId })
|
|
286
|
+
if (!context) {
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const getClient = await initializeOpencodeForDirectory(context.dir)
|
|
292
|
+
|
|
293
|
+
const agentsResponse = await getClient().app.agents({
|
|
294
|
+
query: { directory: context.dir },
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
if (!agentsResponse.data || agentsResponse.data.length === 0) {
|
|
298
|
+
await command.editReply({ content: 'No agents available in this project' })
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Find the agent matching the sanitized command name
|
|
303
|
+
const matchingAgent = agentsResponse.data.find(
|
|
304
|
+
(a) => sanitizeAgentName(a.name) === sanitizedAgentName
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if (!matchingAgent) {
|
|
308
|
+
await command.editReply({
|
|
309
|
+
content: `Agent not found. Available agents: ${agentsResponse.data.map((a) => a.name).join(', ')}`,
|
|
310
|
+
})
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
setAgentForContext({ context, agentName: matchingAgent.name })
|
|
315
|
+
|
|
316
|
+
if (context.isThread && context.sessionId) {
|
|
317
|
+
await command.editReply({
|
|
318
|
+
content: `Switched to **${matchingAgent.name}** agent for this session`,
|
|
319
|
+
})
|
|
320
|
+
} else {
|
|
321
|
+
await command.editReply({
|
|
322
|
+
content: `Switched to **${matchingAgent.name}** agent for this channel\n\nAll new sessions will use this agent.`,
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
} catch (error) {
|
|
326
|
+
agentLogger.error('Error in quick agent command:', error)
|
|
327
|
+
await command.editReply({
|
|
328
|
+
content: `Failed to switch agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
}
|
package/src/database.ts
CHANGED
|
@@ -68,6 +68,14 @@ export function getDatabase(): Database.Database {
|
|
|
68
68
|
// Column already exists, ignore
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// Table for threads that should auto-start a session (created by CLI without --notify-only)
|
|
72
|
+
db.exec(`
|
|
73
|
+
CREATE TABLE IF NOT EXISTS pending_auto_start (
|
|
74
|
+
thread_id TEXT PRIMARY KEY,
|
|
75
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
76
|
+
)
|
|
77
|
+
`)
|
|
78
|
+
|
|
71
79
|
db.exec(`
|
|
72
80
|
CREATE TABLE IF NOT EXISTS bot_api_keys (
|
|
73
81
|
app_id TEXT PRIMARY KEY,
|
|
@@ -176,6 +184,15 @@ export function setSessionModel(sessionId: string, modelId: string): void {
|
|
|
176
184
|
)
|
|
177
185
|
}
|
|
178
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Clear the model preference for a session.
|
|
189
|
+
* Used when switching agents so the agent's model takes effect.
|
|
190
|
+
*/
|
|
191
|
+
export function clearSessionModel(sessionId: string): void {
|
|
192
|
+
const db = getDatabase()
|
|
193
|
+
db.prepare('DELETE FROM session_models WHERE session_id = ?').run(sessionId)
|
|
194
|
+
}
|
|
195
|
+
|
|
179
196
|
/**
|
|
180
197
|
* Get the agent preference for a channel.
|
|
181
198
|
*/
|
package/src/discord-bot.ts
CHANGED
|
@@ -230,17 +230,11 @@ export async function startDiscordBot({
|
|
|
230
230
|
return
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
// Include starter message
|
|
233
|
+
// Include starter message as context for the session
|
|
234
234
|
let prompt = message.content
|
|
235
235
|
const starterMessage = await thread.fetchStarterMessage().catch(() => null)
|
|
236
|
-
if (starterMessage?.content) {
|
|
237
|
-
|
|
238
|
-
const notificationContent = starterMessage.content
|
|
239
|
-
.replace(/^๐ข \*\*Notification\*\*\n?/, '')
|
|
240
|
-
.trim()
|
|
241
|
-
if (notificationContent) {
|
|
242
|
-
prompt = `Context from notification:\n${notificationContent}\n\nUser request:\n${message.content}`
|
|
243
|
-
}
|
|
236
|
+
if (starterMessage?.content && starterMessage.content !== message.content) {
|
|
237
|
+
prompt = `Context from thread:\n${starterMessage.content}\n\nUser request:\n${message.content}`
|
|
244
238
|
}
|
|
245
239
|
|
|
246
240
|
await handleOpencodeSession({
|
|
@@ -419,42 +413,42 @@ export async function startDiscordBot({
|
|
|
419
413
|
}
|
|
420
414
|
})
|
|
421
415
|
|
|
422
|
-
//
|
|
423
|
-
const BOT_SESSION_PREFIX = '๐ค **Bot-initiated session**'
|
|
424
|
-
|
|
425
|
-
// Handle bot-initiated threads created by `kimaki send`
|
|
416
|
+
// Handle bot-initiated threads created by `kimaki send` (without --notify-only)
|
|
426
417
|
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
427
418
|
try {
|
|
428
419
|
if (!newlyCreated) {
|
|
429
420
|
return
|
|
430
421
|
}
|
|
431
422
|
|
|
423
|
+
// Check if this thread is marked for auto-start in the database
|
|
424
|
+
const db = getDatabase()
|
|
425
|
+
const pendingRow = db
|
|
426
|
+
.prepare('SELECT thread_id FROM pending_auto_start WHERE thread_id = ?')
|
|
427
|
+
.get(thread.id) as { thread_id: string } | undefined
|
|
428
|
+
|
|
429
|
+
if (!pendingRow) {
|
|
430
|
+
return // Not a CLI-initiated auto-start thread
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Remove from pending table
|
|
434
|
+
db.prepare('DELETE FROM pending_auto_start WHERE thread_id = ?').run(thread.id)
|
|
435
|
+
|
|
436
|
+
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
|
|
437
|
+
|
|
432
438
|
// Only handle threads in text channels
|
|
433
439
|
const parent = thread.parent as TextChannel | null
|
|
434
440
|
if (!parent || parent.type !== ChannelType.GuildText) {
|
|
435
441
|
return
|
|
436
442
|
}
|
|
437
443
|
|
|
438
|
-
// Get the starter message
|
|
444
|
+
// Get the starter message for the prompt
|
|
439
445
|
const starterMessage = await thread.fetchStarterMessage().catch(() => null)
|
|
440
446
|
if (!starterMessage) {
|
|
441
447
|
discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`)
|
|
442
448
|
return
|
|
443
449
|
}
|
|
444
450
|
|
|
445
|
-
|
|
446
|
-
if (starterMessage.author.id !== discordClient.user?.id) {
|
|
447
|
-
return
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
|
|
451
|
-
return
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
|
|
455
|
-
|
|
456
|
-
// Extract the prompt (everything after the prefix)
|
|
457
|
-
const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim()
|
|
451
|
+
const prompt = starterMessage.content.trim()
|
|
458
452
|
if (!prompt) {
|
|
459
453
|
discordLogger.log(`[BOT_SESSION] No prompt found in starter message`)
|
|
460
454
|
return
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
handleProviderSelectMenu,
|
|
21
21
|
handleModelSelectMenu,
|
|
22
22
|
} from './commands/model.js'
|
|
23
|
-
import { handleAgentCommand, handleAgentSelectMenu } from './commands/agent.js'
|
|
23
|
+
import { handleAgentCommand, handleAgentSelectMenu, handleQuickAgentCommand } from './commands/agent.js'
|
|
24
24
|
import { handleAskQuestionSelectMenu } from './commands/ask-question.js'
|
|
25
25
|
import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js'
|
|
26
26
|
import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js'
|
|
@@ -136,6 +136,12 @@ export function registerInteractionHandler({
|
|
|
136
136
|
return
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
|
|
140
|
+
if (interaction.commandName.endsWith('-agent') && interaction.commandName !== 'agent') {
|
|
141
|
+
await handleQuickAgentCommand({ command: interaction, appId })
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
139
145
|
// Handle user-defined commands (ending with -cmd suffix)
|
|
140
146
|
if (interaction.commandName.endsWith('-cmd')) {
|
|
141
147
|
await handleUserCommand({ command: interaction, appId })
|
package/src/session-handler.ts
CHANGED
|
@@ -641,6 +641,39 @@ export async function handleOpencodeSession({
|
|
|
641
641
|
requestId: questionRequest.id,
|
|
642
642
|
input: { questions: questionRequest.questions },
|
|
643
643
|
})
|
|
644
|
+
|
|
645
|
+
// Process queued messages if any - queued message will cancel the pending question
|
|
646
|
+
const queue = messageQueue.get(thread.id)
|
|
647
|
+
if (queue && queue.length > 0) {
|
|
648
|
+
const nextMessage = queue.shift()!
|
|
649
|
+
if (queue.length === 0) {
|
|
650
|
+
messageQueue.delete(thread.id)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
sessionLogger.log(
|
|
654
|
+
`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
await sendThreadMessage(
|
|
658
|
+
thread,
|
|
659
|
+
`ยป **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
// handleOpencodeSession will call cancelPendingQuestion, which cancels the dropdown
|
|
663
|
+
setImmediate(() => {
|
|
664
|
+
handleOpencodeSession({
|
|
665
|
+
prompt: nextMessage.prompt,
|
|
666
|
+
thread,
|
|
667
|
+
projectDirectory: directory,
|
|
668
|
+
images: nextMessage.images,
|
|
669
|
+
channelId,
|
|
670
|
+
}).catch(async (e) => {
|
|
671
|
+
sessionLogger.error(`[QUEUE] Failed to process queued message:`, e)
|
|
672
|
+
const errorMsg = e instanceof Error ? e.message : String(e)
|
|
673
|
+
await sendThreadMessage(thread, `โ Queued message failed: ${errorMsg.slice(0, 200)}`)
|
|
674
|
+
})
|
|
675
|
+
})
|
|
676
|
+
}
|
|
644
677
|
} else if (event.type === 'session.idle') {
|
|
645
678
|
// Session is done processing - abort to signal completion
|
|
646
679
|
if (event.properties.sessionID === session.id) {
|
|
@@ -774,10 +807,23 @@ export async function handleOpencodeSession({
|
|
|
774
807
|
const parts = [{ type: 'text' as const, text: promptWithImagePaths }, ...images]
|
|
775
808
|
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
|
|
776
809
|
|
|
810
|
+
// Get agent preference: session-level overrides channel-level
|
|
811
|
+
const agentPreference =
|
|
812
|
+
getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
|
|
813
|
+
if (agentPreference) {
|
|
814
|
+
sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
|
|
815
|
+
}
|
|
816
|
+
|
|
777
817
|
// Get model preference: session-level overrides channel-level
|
|
818
|
+
// BUT: if an agent is set, don't pass model param so the agent's model takes effect
|
|
778
819
|
const modelPreference =
|
|
779
820
|
getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
|
|
780
821
|
const modelParam = (() => {
|
|
822
|
+
// When an agent is set, let the agent's model config take effect
|
|
823
|
+
if (agentPreference) {
|
|
824
|
+
sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`)
|
|
825
|
+
return undefined
|
|
826
|
+
}
|
|
781
827
|
if (!modelPreference) {
|
|
782
828
|
return undefined
|
|
783
829
|
}
|
|
@@ -790,13 +836,6 @@ export async function handleOpencodeSession({
|
|
|
790
836
|
return { providerID, modelID }
|
|
791
837
|
})()
|
|
792
838
|
|
|
793
|
-
// Get agent preference: session-level overrides channel-level
|
|
794
|
-
const agentPreference =
|
|
795
|
-
getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
|
|
796
|
-
if (agentPreference) {
|
|
797
|
-
sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
|
|
798
|
-
}
|
|
799
|
-
|
|
800
839
|
// Use session.command API for slash commands, session.prompt for regular messages
|
|
801
840
|
const response = command
|
|
802
841
|
? await getClient().session.command({
|
|
@@ -850,9 +889,8 @@ export async function handleOpencodeSession({
|
|
|
850
889
|
|
|
851
890
|
return { sessionID: session.id, result: response.data, port }
|
|
852
891
|
} catch (error) {
|
|
853
|
-
sessionLogger.error(`ERROR: Failed to send prompt:`, error)
|
|
854
|
-
|
|
855
892
|
if (!isAbortError(error, abortController.signal)) {
|
|
893
|
+
sessionLogger.error(`ERROR: Failed to send prompt:`, error)
|
|
856
894
|
abortController.abort('error')
|
|
857
895
|
|
|
858
896
|
if (originalMessage) {
|