kimaki 0.4.36 → 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 +10 -6
- package/dist/cli.js +85 -25
- package/dist/commands/agent.js +101 -19
- package/dist/database.js +22 -0
- package/dist/discord-bot.js +39 -22
- package/dist/interaction-handler.js +6 -1
- package/dist/session-handler.js +36 -6
- package/dist/system-message.js +5 -1
- package/package.json +1 -1
- package/src/channel-management.ts +10 -6
- package/src/cli.ts +118 -37
- package/src/commands/agent.ts +147 -24
- package/src/database.ts +24 -0
- package/src/discord-bot.ts +49 -29
- package/src/interaction-handler.ts +7 -1
- package/src/session-handler.ts +47 -9
- package/src/system-message.ts +5 -1
|
@@ -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;
|
|
@@ -57,11 +61,11 @@ export async function createProjectChannels({ guild, projectDirectory, appId, bo
|
|
|
57
61
|
parent: kimakiAudioCategory,
|
|
58
62
|
});
|
|
59
63
|
getDatabase()
|
|
60
|
-
.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)')
|
|
61
|
-
.run(textChannel.id, projectDirectory, 'text');
|
|
64
|
+
.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)')
|
|
65
|
+
.run(textChannel.id, projectDirectory, 'text', appId);
|
|
62
66
|
getDatabase()
|
|
63
|
-
.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)')
|
|
64
|
-
.run(voiceChannel.id, projectDirectory, 'voice');
|
|
67
|
+
.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)')
|
|
68
|
+
.run(voiceChannel.id, projectDirectory, 'voice', appId);
|
|
65
69
|
return {
|
|
66
70
|
textChannelId: textChannel.id,
|
|
67
71
|
voiceChannelId: voiceChannel.id,
|
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), {
|
|
@@ -484,10 +497,10 @@ async function run({ restart, addChannels }) {
|
|
|
484
497
|
for (const { guild, channels } of kimakiChannels) {
|
|
485
498
|
for (const channel of channels) {
|
|
486
499
|
if (channel.kimakiDirectory) {
|
|
487
|
-
db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(channel.id, channel.kimakiDirectory, 'text');
|
|
500
|
+
db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)').run(channel.id, channel.kimakiDirectory, 'text', channel.kimakiApp || null);
|
|
488
501
|
const voiceChannel = guild.channels.cache.find((ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name);
|
|
489
502
|
if (voiceChannel) {
|
|
490
|
-
db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(voiceChannel.id, channel.kimakiDirectory, 'voice');
|
|
503
|
+
db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)').run(voiceChannel.id, channel.kimakiDirectory, 'voice', channel.kimakiApp || null);
|
|
491
504
|
}
|
|
492
505
|
}
|
|
493
506
|
}
|
|
@@ -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,20 +763,27 @@ 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
766
|
cli
|
|
753
|
-
.command('
|
|
767
|
+
.command('send', 'Send a message to Discord channel, creating a thread. Use --notify-only to skip AI session.')
|
|
768
|
+
.alias('start-session') // backwards compatibility
|
|
754
769
|
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
755
770
|
.option('-d, --project <path>', 'Project directory (alternative to --channel)')
|
|
756
|
-
.option('-p, --prompt <prompt>', '
|
|
771
|
+
.option('-p, --prompt <prompt>', 'Message content')
|
|
757
772
|
.option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
|
|
758
773
|
.option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
|
|
774
|
+
.option('--notify-only', 'Create notification thread without starting AI session')
|
|
759
775
|
.action(async (options) => {
|
|
760
776
|
try {
|
|
761
|
-
let { channel: channelId, prompt, name, appId: optionAppId } = options;
|
|
777
|
+
let { channel: channelId, prompt, name, appId: optionAppId, notifyOnly } = options;
|
|
762
778
|
const { project: projectPath } = options;
|
|
779
|
+
// Get raw channel ID from argv to prevent JS number precision loss on large Discord IDs
|
|
780
|
+
// cac parses large numbers and loses precision, so we extract the original string value
|
|
781
|
+
if (channelId) {
|
|
782
|
+
const channelArgIndex = process.argv.findIndex((arg) => arg === '--channel' || arg === '-c');
|
|
783
|
+
if (channelArgIndex !== -1 && process.argv[channelArgIndex + 1]) {
|
|
784
|
+
channelId = process.argv[channelArgIndex + 1];
|
|
785
|
+
}
|
|
786
|
+
}
|
|
763
787
|
if (!channelId && !projectPath) {
|
|
764
788
|
cliLogger.error('Either --channel or --project is required');
|
|
765
789
|
process.exit(EXIT_NO_RESTART);
|
|
@@ -818,15 +842,38 @@ cli
|
|
|
818
842
|
process.exit(EXIT_NO_RESTART);
|
|
819
843
|
}
|
|
820
844
|
s.start('Looking up channel for project...');
|
|
821
|
-
// Check if channel already exists for this directory
|
|
845
|
+
// Check if channel already exists for this directory or a parent directory
|
|
846
|
+
// This allows running from subfolders of a registered project
|
|
822
847
|
try {
|
|
823
848
|
const db = getDatabase();
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
849
|
+
// Helper to find channel for a path (prefers current bot's channel)
|
|
850
|
+
const findChannelForPath = (dirPath) => {
|
|
851
|
+
const withAppId = db
|
|
852
|
+
.prepare('SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?')
|
|
853
|
+
.get(dirPath, 'text', appId);
|
|
854
|
+
if (withAppId)
|
|
855
|
+
return withAppId;
|
|
856
|
+
return db
|
|
857
|
+
.prepare('SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ?')
|
|
858
|
+
.get(dirPath, 'text');
|
|
859
|
+
};
|
|
860
|
+
// Try exact match first, then walk up parent directories
|
|
861
|
+
let existingChannel;
|
|
862
|
+
let searchPath = absolutePath;
|
|
863
|
+
while (searchPath !== path.dirname(searchPath)) {
|
|
864
|
+
existingChannel = findChannelForPath(searchPath);
|
|
865
|
+
if (existingChannel)
|
|
866
|
+
break;
|
|
867
|
+
searchPath = path.dirname(searchPath);
|
|
868
|
+
}
|
|
827
869
|
if (existingChannel) {
|
|
828
870
|
channelId = existingChannel.channel_id;
|
|
829
|
-
|
|
871
|
+
if (existingChannel.directory !== absolutePath) {
|
|
872
|
+
s.message(`Found parent project channel: ${existingChannel.directory}`);
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
s.message(`Found existing channel: ${channelId}`);
|
|
876
|
+
}
|
|
830
877
|
}
|
|
831
878
|
else {
|
|
832
879
|
// Need to create a new channel
|
|
@@ -846,10 +893,10 @@ cli
|
|
|
846
893
|
});
|
|
847
894
|
// Get guild from existing channels or first available
|
|
848
895
|
const guild = await (async () => {
|
|
849
|
-
// Try to find a guild from existing channels
|
|
896
|
+
// Try to find a guild from existing channels belonging to this bot
|
|
850
897
|
const existingChannelRow = db
|
|
851
|
-
.prepare('SELECT channel_id FROM channel_directories ORDER BY created_at DESC LIMIT 1')
|
|
852
|
-
.get();
|
|
898
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1')
|
|
899
|
+
.get(appId);
|
|
853
900
|
if (existingChannelRow) {
|
|
854
901
|
try {
|
|
855
902
|
const ch = await client.channels.fetch(existingChannelRow.channel_id);
|
|
@@ -861,7 +908,7 @@ cli
|
|
|
861
908
|
// Channel might be deleted, continue
|
|
862
909
|
}
|
|
863
910
|
}
|
|
864
|
-
// Fall back to first guild
|
|
911
|
+
// Fall back to first guild the bot is in
|
|
865
912
|
const firstGuild = client.guilds.cache.first();
|
|
866
913
|
if (!firstGuild) {
|
|
867
914
|
throw new Error('No guild found. Add the bot to a server first.');
|
|
@@ -917,8 +964,7 @@ cli
|
|
|
917
964
|
throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`);
|
|
918
965
|
}
|
|
919
966
|
s.message('Creating starter message...');
|
|
920
|
-
// Create starter message with
|
|
921
|
-
// The full prompt goes in the message so the bot can read it
|
|
967
|
+
// Create starter message with just the prompt (no prefix)
|
|
922
968
|
const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
923
969
|
method: 'POST',
|
|
924
970
|
headers: {
|
|
@@ -926,7 +972,7 @@ cli
|
|
|
926
972
|
'Content-Type': 'application/json',
|
|
927
973
|
},
|
|
928
974
|
body: JSON.stringify({
|
|
929
|
-
content:
|
|
975
|
+
content: prompt,
|
|
930
976
|
}),
|
|
931
977
|
});
|
|
932
978
|
if (!starterMessageResponse.ok) {
|
|
@@ -955,9 +1001,23 @@ cli
|
|
|
955
1001
|
throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
|
|
956
1002
|
}
|
|
957
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
|
+
}
|
|
958
1015
|
s.stop('Thread created!');
|
|
959
1016
|
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
|
|
960
|
-
|
|
1017
|
+
const successMessage = notifyOnly
|
|
1018
|
+
? `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
|
|
1019
|
+
: `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`;
|
|
1020
|
+
note(successMessage, '✅ Thread Created');
|
|
961
1021
|
console.log(threadUrl);
|
|
962
1022
|
process.exit(0);
|
|
963
1023
|
}
|
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
|
@@ -49,6 +49,20 @@ export function getDatabase() {
|
|
|
49
49
|
channel_type TEXT NOT NULL,
|
|
50
50
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
51
51
|
)
|
|
52
|
+
`);
|
|
53
|
+
// Migration: add app_id column to channel_directories for multi-bot support
|
|
54
|
+
try {
|
|
55
|
+
db.exec(`ALTER TABLE channel_directories ADD COLUMN app_id TEXT`);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Column already exists, ignore
|
|
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
|
+
)
|
|
52
66
|
`);
|
|
53
67
|
db.exec(`
|
|
54
68
|
CREATE TABLE IF NOT EXISTS bot_api_keys (
|
|
@@ -140,6 +154,14 @@ export function setSessionModel(sessionId, modelId) {
|
|
|
140
154
|
const db = getDatabase();
|
|
141
155
|
db.prepare(`INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`).run(sessionId, modelId);
|
|
142
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
|
+
}
|
|
143
165
|
/**
|
|
144
166
|
* Get the agent preference for a channel.
|
|
145
167
|
*/
|
package/dist/discord-bot.js
CHANGED
|
@@ -119,14 +119,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
119
119
|
if (isThread) {
|
|
120
120
|
const thread = channel;
|
|
121
121
|
discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
|
|
122
|
-
const row = getDatabase()
|
|
123
|
-
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
124
|
-
.get(thread.id);
|
|
125
|
-
if (!row) {
|
|
126
|
-
discordLogger.log(`No session found for thread ${thread.id}`);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`);
|
|
130
122
|
const parent = thread.parent;
|
|
131
123
|
let projectDirectory;
|
|
132
124
|
let channelAppId;
|
|
@@ -150,6 +142,31 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
150
142
|
});
|
|
151
143
|
return;
|
|
152
144
|
}
|
|
145
|
+
const row = getDatabase()
|
|
146
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
147
|
+
.get(thread.id);
|
|
148
|
+
// No existing session - start a new one (e.g., replying to a notification thread)
|
|
149
|
+
if (!row) {
|
|
150
|
+
discordLogger.log(`No session for thread ${thread.id}, starting new session`);
|
|
151
|
+
if (!projectDirectory) {
|
|
152
|
+
discordLogger.log(`Cannot start session: no project directory for thread ${thread.id}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Include starter message as context for the session
|
|
156
|
+
let prompt = message.content;
|
|
157
|
+
const starterMessage = await thread.fetchStarterMessage().catch(() => null);
|
|
158
|
+
if (starterMessage?.content && starterMessage.content !== message.content) {
|
|
159
|
+
prompt = `Context from thread:\n${starterMessage.content}\n\nUser request:\n${message.content}`;
|
|
160
|
+
}
|
|
161
|
+
await handleOpencodeSession({
|
|
162
|
+
prompt,
|
|
163
|
+
thread,
|
|
164
|
+
projectDirectory,
|
|
165
|
+
channelId: parent?.id || '',
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`);
|
|
153
170
|
let messageContent = message.content || '';
|
|
154
171
|
let currentSessionContext;
|
|
155
172
|
let lastSessionContext;
|
|
@@ -293,35 +310,35 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
293
310
|
}
|
|
294
311
|
}
|
|
295
312
|
});
|
|
296
|
-
//
|
|
297
|
-
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
|
|
298
|
-
// Handle bot-initiated threads created by `kimaki start-session`
|
|
313
|
+
// Handle bot-initiated threads created by `kimaki send` (without --notify-only)
|
|
299
314
|
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
300
315
|
try {
|
|
301
316
|
if (!newlyCreated) {
|
|
302
317
|
return;
|
|
303
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}`);
|
|
304
330
|
// Only handle threads in text channels
|
|
305
331
|
const parent = thread.parent;
|
|
306
332
|
if (!parent || parent.type !== ChannelType.GuildText) {
|
|
307
333
|
return;
|
|
308
334
|
}
|
|
309
|
-
// Get the starter message
|
|
335
|
+
// Get the starter message for the prompt
|
|
310
336
|
const starterMessage = await thread.fetchStarterMessage().catch(() => null);
|
|
311
337
|
if (!starterMessage) {
|
|
312
338
|
discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`);
|
|
313
339
|
return;
|
|
314
340
|
}
|
|
315
|
-
|
|
316
|
-
if (starterMessage.author.id !== discordClient.user?.id) {
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
|
|
323
|
-
// Extract the prompt (everything after the prefix)
|
|
324
|
-
const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim();
|
|
341
|
+
const prompt = starterMessage.content.trim();
|
|
325
342
|
if (!prompt) {
|
|
326
343
|
discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
|
|
327
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 });
|