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
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/dist/system-message.js
CHANGED
|
@@ -28,7 +28,11 @@ ${channelId
|
|
|
28
28
|
|
|
29
29
|
To start a new thread/session in this channel programmatically, run:
|
|
30
30
|
|
|
31
|
-
npx -y kimaki
|
|
31
|
+
npx -y kimaki send --channel ${channelId} --prompt "your prompt here"
|
|
32
|
+
|
|
33
|
+
Use --notify-only to create a notification thread without starting an AI session:
|
|
34
|
+
|
|
35
|
+
npx -y kimaki send --channel ${channelId} --prompt "User cancelled subscription" --notify-only
|
|
32
36
|
|
|
33
37
|
This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
|
|
34
38
|
`
|
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) {
|
|
@@ -90,15 +94,15 @@ export async function createProjectChannels({
|
|
|
90
94
|
|
|
91
95
|
getDatabase()
|
|
92
96
|
.prepare(
|
|
93
|
-
'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
|
|
97
|
+
'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
|
|
94
98
|
)
|
|
95
|
-
.run(textChannel.id, projectDirectory, 'text')
|
|
99
|
+
.run(textChannel.id, projectDirectory, 'text', appId)
|
|
96
100
|
|
|
97
101
|
getDatabase()
|
|
98
102
|
.prepare(
|
|
99
|
-
'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
|
|
103
|
+
'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
|
|
100
104
|
)
|
|
101
|
-
.run(voiceChannel.id, projectDirectory, 'voice')
|
|
105
|
+
.run(voiceChannel.id, projectDirectory, 'voice', appId)
|
|
102
106
|
|
|
103
107
|
return {
|
|
104
108
|
textChannelId: textChannel.id,
|
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 {
|
|
@@ -632,8 +661,8 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
632
661
|
for (const channel of channels) {
|
|
633
662
|
if (channel.kimakiDirectory) {
|
|
634
663
|
db.prepare(
|
|
635
|
-
'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
|
|
636
|
-
).run(channel.id, channel.kimakiDirectory, 'text')
|
|
664
|
+
'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
|
|
665
|
+
).run(channel.id, channel.kimakiDirectory, 'text', channel.kimakiApp || null)
|
|
637
666
|
|
|
638
667
|
const voiceChannel = guild.channels.cache.find(
|
|
639
668
|
(ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name,
|
|
@@ -641,8 +670,8 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
641
670
|
|
|
642
671
|
if (voiceChannel) {
|
|
643
672
|
db.prepare(
|
|
644
|
-
'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
|
|
645
|
-
).run(voiceChannel.id, channel.kimakiDirectory, 'voice')
|
|
673
|
+
'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
|
|
674
|
+
).run(voiceChannel.id, channel.kimakiDirectory, 'voice', channel.kimakiApp || null)
|
|
646
675
|
}
|
|
647
676
|
}
|
|
648
677
|
}
|
|
@@ -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,20 +1032,20 @@ 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**'
|
|
1035
|
+
|
|
1005
1036
|
|
|
1006
1037
|
cli
|
|
1007
1038
|
.command(
|
|
1008
|
-
'
|
|
1009
|
-
'
|
|
1039
|
+
'send',
|
|
1040
|
+
'Send a message to Discord channel, creating a thread. Use --notify-only to skip AI session.',
|
|
1010
1041
|
)
|
|
1042
|
+
.alias('start-session') // backwards compatibility
|
|
1011
1043
|
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
1012
1044
|
.option('-d, --project <path>', 'Project directory (alternative to --channel)')
|
|
1013
|
-
.option('-p, --prompt <prompt>', '
|
|
1045
|
+
.option('-p, --prompt <prompt>', 'Message content')
|
|
1014
1046
|
.option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
|
|
1015
1047
|
.option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
|
|
1048
|
+
.option('--notify-only', 'Create notification thread without starting AI session')
|
|
1016
1049
|
.action(
|
|
1017
1050
|
async (options: {
|
|
1018
1051
|
channel?: string
|
|
@@ -1020,10 +1053,20 @@ cli
|
|
|
1020
1053
|
prompt?: string
|
|
1021
1054
|
name?: string
|
|
1022
1055
|
appId?: string
|
|
1056
|
+
notifyOnly?: boolean
|
|
1023
1057
|
}) => {
|
|
1024
1058
|
try {
|
|
1025
|
-
let { channel: channelId, prompt, name, appId: optionAppId } = options
|
|
1059
|
+
let { channel: channelId, prompt, name, appId: optionAppId, notifyOnly } = options
|
|
1026
1060
|
const { project: projectPath } = options
|
|
1061
|
+
|
|
1062
|
+
// Get raw channel ID from argv to prevent JS number precision loss on large Discord IDs
|
|
1063
|
+
// cac parses large numbers and loses precision, so we extract the original string value
|
|
1064
|
+
if (channelId) {
|
|
1065
|
+
const channelArgIndex = process.argv.findIndex((arg) => arg === '--channel' || arg === '-c')
|
|
1066
|
+
if (channelArgIndex !== -1 && process.argv[channelArgIndex + 1]) {
|
|
1067
|
+
channelId = process.argv[channelArgIndex + 1]
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1027
1070
|
|
|
1028
1071
|
if (!channelId && !projectPath) {
|
|
1029
1072
|
cliLogger.error('Either --channel or --project is required')
|
|
@@ -1092,18 +1135,43 @@ cli
|
|
|
1092
1135
|
|
|
1093
1136
|
s.start('Looking up channel for project...')
|
|
1094
1137
|
|
|
1095
|
-
// Check if channel already exists for this directory
|
|
1138
|
+
// Check if channel already exists for this directory or a parent directory
|
|
1139
|
+
// This allows running from subfolders of a registered project
|
|
1096
1140
|
try {
|
|
1097
1141
|
const db = getDatabase()
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1142
|
+
|
|
1143
|
+
// Helper to find channel for a path (prefers current bot's channel)
|
|
1144
|
+
const findChannelForPath = (dirPath: string): { channel_id: string; directory: string } | undefined => {
|
|
1145
|
+
const withAppId = db
|
|
1146
|
+
.prepare(
|
|
1147
|
+
'SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?',
|
|
1148
|
+
)
|
|
1149
|
+
.get(dirPath, 'text', appId) as { channel_id: string; directory: string } | undefined
|
|
1150
|
+
if (withAppId) return withAppId
|
|
1151
|
+
|
|
1152
|
+
return db
|
|
1153
|
+
.prepare(
|
|
1154
|
+
'SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ?',
|
|
1155
|
+
)
|
|
1156
|
+
.get(dirPath, 'text') as { channel_id: string; directory: string } | undefined
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Try exact match first, then walk up parent directories
|
|
1160
|
+
let existingChannel: { channel_id: string; directory: string } | undefined
|
|
1161
|
+
let searchPath = absolutePath
|
|
1162
|
+
while (searchPath !== path.dirname(searchPath)) {
|
|
1163
|
+
existingChannel = findChannelForPath(searchPath)
|
|
1164
|
+
if (existingChannel) break
|
|
1165
|
+
searchPath = path.dirname(searchPath)
|
|
1166
|
+
}
|
|
1103
1167
|
|
|
1104
1168
|
if (existingChannel) {
|
|
1105
1169
|
channelId = existingChannel.channel_id
|
|
1106
|
-
|
|
1170
|
+
if (existingChannel.directory !== absolutePath) {
|
|
1171
|
+
s.message(`Found parent project channel: ${existingChannel.directory}`)
|
|
1172
|
+
} else {
|
|
1173
|
+
s.message(`Found existing channel: ${channelId}`)
|
|
1174
|
+
}
|
|
1107
1175
|
} else {
|
|
1108
1176
|
// Need to create a new channel
|
|
1109
1177
|
s.message('Creating new channel...')
|
|
@@ -1128,12 +1196,12 @@ cli
|
|
|
1128
1196
|
|
|
1129
1197
|
// Get guild from existing channels or first available
|
|
1130
1198
|
const guild = await (async () => {
|
|
1131
|
-
// Try to find a guild from existing channels
|
|
1199
|
+
// Try to find a guild from existing channels belonging to this bot
|
|
1132
1200
|
const existingChannelRow = db
|
|
1133
1201
|
.prepare(
|
|
1134
|
-
'SELECT channel_id FROM channel_directories ORDER BY created_at DESC LIMIT 1',
|
|
1202
|
+
'SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1',
|
|
1135
1203
|
)
|
|
1136
|
-
.get() as { channel_id: string } | undefined
|
|
1204
|
+
.get(appId) as { channel_id: string } | undefined
|
|
1137
1205
|
|
|
1138
1206
|
if (existingChannelRow) {
|
|
1139
1207
|
try {
|
|
@@ -1145,7 +1213,7 @@ cli
|
|
|
1145
1213
|
// Channel might be deleted, continue
|
|
1146
1214
|
}
|
|
1147
1215
|
}
|
|
1148
|
-
// Fall back to first guild
|
|
1216
|
+
// Fall back to first guild the bot is in
|
|
1149
1217
|
const firstGuild = client.guilds.cache.first()
|
|
1150
1218
|
if (!firstGuild) {
|
|
1151
1219
|
throw new Error('No guild found. Add the bot to a server first.')
|
|
@@ -1223,8 +1291,7 @@ cli
|
|
|
1223
1291
|
|
|
1224
1292
|
s.message('Creating starter message...')
|
|
1225
1293
|
|
|
1226
|
-
// Create starter message with
|
|
1227
|
-
// The full prompt goes in the message so the bot can read it
|
|
1294
|
+
// Create starter message with just the prompt (no prefix)
|
|
1228
1295
|
const starterMessageResponse = await fetch(
|
|
1229
1296
|
`https://discord.com/api/v10/channels/${channelId}/messages`,
|
|
1230
1297
|
{
|
|
@@ -1234,7 +1301,7 @@ cli
|
|
|
1234
1301
|
'Content-Type': 'application/json',
|
|
1235
1302
|
},
|
|
1236
1303
|
body: JSON.stringify({
|
|
1237
|
-
content:
|
|
1304
|
+
content: prompt,
|
|
1238
1305
|
}),
|
|
1239
1306
|
},
|
|
1240
1307
|
)
|
|
@@ -1274,14 +1341,28 @@ cli
|
|
|
1274
1341
|
|
|
1275
1342
|
const threadData = (await threadResponse.json()) as { id: string; name: string }
|
|
1276
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
|
+
|
|
1277
1357
|
s.stop('Thread created!')
|
|
1278
1358
|
|
|
1279
1359
|
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
|
|
1280
1360
|
|
|
1281
|
-
|
|
1282
|
-
`Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\
|
|
1283
|
-
|
|
1284
|
-
|
|
1361
|
+
const successMessage = notifyOnly
|
|
1362
|
+
? `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
|
|
1363
|
+
: `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`
|
|
1364
|
+
|
|
1365
|
+
note(successMessage, '✅ Thread Created')
|
|
1285
1366
|
|
|
1286
1367
|
console.log(threadUrl)
|
|
1287
1368
|
|
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
|
+
}
|