kimaki 0.4.25 → 0.4.27
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/acp-client.test.js +149 -0
- package/dist/channel-management.js +11 -9
- package/dist/cli.js +58 -18
- package/dist/commands/add-project.js +1 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +184 -0
- package/dist/commands/model.js +23 -4
- package/dist/commands/permissions.js +101 -105
- package/dist/commands/session.js +1 -3
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +51 -0
- package/dist/discord-bot.js +32 -32
- package/dist/discord-utils.js +71 -14
- package/dist/interaction-handler.js +25 -8
- package/dist/logger.js +43 -5
- package/dist/markdown.js +104 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +72 -22
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +70 -16
- package/dist/session-handler.js +142 -66
- package/dist/system-message.js +4 -51
- package/dist/voice-handler.js +18 -8
- package/dist/voice.js +28 -12
- package/package.json +14 -13
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/channel-management.ts +20 -8
- package/src/cli.ts +73 -19
- package/src/commands/add-project.ts +1 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +277 -0
- package/src/commands/fork.ts +1 -2
- package/src/commands/model.ts +24 -4
- package/src/commands/permissions.ts +139 -114
- package/src/commands/session.ts +1 -3
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +61 -0
- package/src/discord-bot.ts +36 -33
- package/src/discord-utils.ts +76 -14
- package/src/interaction-handler.ts +31 -10
- package/src/logger.ts +47 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +132 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +93 -25
- package/src/opencode.ts +80 -21
- package/src/session-handler.ts +190 -97
- package/src/system-message.ts +4 -51
- package/src/voice-handler.ts +20 -9
- package/src/voice.ts +32 -13
- package/LICENSE +0 -21
package/dist/commands/model.js
CHANGED
|
@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
|
|
|
4
4
|
import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from '../database.js';
|
|
5
5
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
6
6
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
7
|
+
import { abortAndRetrySession } from '../session-handler.js';
|
|
7
8
|
import { createLogger } from '../logger.js';
|
|
8
9
|
const modelLogger = createLogger('MODEL');
|
|
9
10
|
// Store context by hash to avoid customId length limits (Discord max: 100 chars)
|
|
@@ -102,6 +103,7 @@ export async function handleModelCommand({ interaction, appId, }) {
|
|
|
102
103
|
channelId: targetChannelId,
|
|
103
104
|
sessionId: sessionId,
|
|
104
105
|
isThread: isThread,
|
|
106
|
+
thread: isThread ? channel : undefined,
|
|
105
107
|
};
|
|
106
108
|
const contextHash = crypto.randomBytes(8).toString('hex');
|
|
107
109
|
pendingModelContexts.set(contextHash, context);
|
|
@@ -267,10 +269,27 @@ export async function handleModelSelectMenu(interaction) {
|
|
|
267
269
|
// Store for session
|
|
268
270
|
setSessionModel(context.sessionId, fullModelId);
|
|
269
271
|
modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
272
|
+
// Check if there's a running request and abort+retry with new model
|
|
273
|
+
let retried = false;
|
|
274
|
+
if (context.thread) {
|
|
275
|
+
retried = await abortAndRetrySession({
|
|
276
|
+
sessionId: context.sessionId,
|
|
277
|
+
thread: context.thread,
|
|
278
|
+
projectDirectory: context.dir,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (retried) {
|
|
282
|
+
await interaction.editReply({
|
|
283
|
+
content: `Model changed for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\`\n\n_Retrying current request with new model..._`,
|
|
284
|
+
components: [],
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
await interaction.editReply({
|
|
289
|
+
content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
|
|
290
|
+
components: [],
|
|
291
|
+
});
|
|
292
|
+
}
|
|
274
293
|
}
|
|
275
294
|
else {
|
|
276
295
|
// Store for channel
|
|
@@ -1,126 +1,122 @@
|
|
|
1
|
-
// Permission
|
|
2
|
-
|
|
1
|
+
// Permission dropdown handler - Shows dropdown for permission requests.
|
|
2
|
+
// When OpenCode asks for permission, this module renders a dropdown
|
|
3
|
+
// with Accept, Accept Always, and Deny options.
|
|
4
|
+
import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder, } from 'discord.js';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
3
6
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
4
|
-
import {
|
|
5
|
-
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
7
|
+
import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
6
8
|
import { createLogger } from '../logger.js';
|
|
7
9
|
const logger = createLogger('PERMISSIONS');
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
},
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
await command.reply({ content: msg, flags: SILENT_MESSAGE_FLAGS });
|
|
57
|
-
logger.log(`Permission ${pending.permission.id} accepted with scope: ${scope}`);
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
logger.error('[ACCEPT] Error:', error);
|
|
61
|
-
await command.reply({
|
|
62
|
-
content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
63
|
-
ephemeral: true,
|
|
64
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
65
|
-
});
|
|
66
|
-
}
|
|
10
|
+
// Store pending permission contexts by hash
|
|
11
|
+
export const pendingPermissionContexts = new Map();
|
|
12
|
+
/**
|
|
13
|
+
* Show permission dropdown for a permission request.
|
|
14
|
+
* Returns the message ID and context hash for tracking.
|
|
15
|
+
*/
|
|
16
|
+
export async function showPermissionDropdown({ thread, permission, directory, }) {
|
|
17
|
+
const contextHash = crypto.randomBytes(8).toString('hex');
|
|
18
|
+
const context = {
|
|
19
|
+
permission,
|
|
20
|
+
directory,
|
|
21
|
+
thread,
|
|
22
|
+
contextHash,
|
|
23
|
+
};
|
|
24
|
+
pendingPermissionContexts.set(contextHash, context);
|
|
25
|
+
const patternStr = permission.patterns.join(', ');
|
|
26
|
+
// Build dropdown options
|
|
27
|
+
const options = [
|
|
28
|
+
{
|
|
29
|
+
label: 'Accept',
|
|
30
|
+
value: 'once',
|
|
31
|
+
description: 'Allow this request only',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
label: 'Accept Always',
|
|
35
|
+
value: 'always',
|
|
36
|
+
description: 'Auto-approve similar requests',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
label: 'Deny',
|
|
40
|
+
value: 'reject',
|
|
41
|
+
description: 'Reject this permission request',
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
45
|
+
.setCustomId(`permission:${contextHash}`)
|
|
46
|
+
.setPlaceholder('Choose an action')
|
|
47
|
+
.addOptions(options);
|
|
48
|
+
const actionRow = new ActionRowBuilder().addComponents(selectMenu);
|
|
49
|
+
const permissionMessage = await thread.send({
|
|
50
|
+
content: `⚠️ **Permission Required**\n\n` +
|
|
51
|
+
`**Type:** \`${permission.permission}\`\n` +
|
|
52
|
+
(patternStr ? `**Pattern:** \`${patternStr}\`` : ''),
|
|
53
|
+
components: [actionRow],
|
|
54
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
55
|
+
});
|
|
56
|
+
logger.log(`Showed permission dropdown for ${permission.id}`);
|
|
57
|
+
return { messageId: permissionMessage.id, contextHash };
|
|
67
58
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
75
|
-
});
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
const isThread = [
|
|
79
|
-
ChannelType.PublicThread,
|
|
80
|
-
ChannelType.PrivateThread,
|
|
81
|
-
ChannelType.AnnouncementThread,
|
|
82
|
-
].includes(channel.type);
|
|
83
|
-
if (!isThread) {
|
|
84
|
-
await command.reply({
|
|
85
|
-
content: 'This command can only be used in a thread with an active session',
|
|
86
|
-
ephemeral: true,
|
|
87
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
88
|
-
});
|
|
59
|
+
/**
|
|
60
|
+
* Handle dropdown selection for permission.
|
|
61
|
+
*/
|
|
62
|
+
export async function handlePermissionSelectMenu(interaction) {
|
|
63
|
+
const customId = interaction.customId;
|
|
64
|
+
if (!customId.startsWith('permission:')) {
|
|
89
65
|
return;
|
|
90
66
|
}
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
67
|
+
const contextHash = customId.replace('permission:', '');
|
|
68
|
+
const context = pendingPermissionContexts.get(contextHash);
|
|
69
|
+
if (!context) {
|
|
70
|
+
await interaction.reply({
|
|
71
|
+
content: 'This permission request has expired or was already handled.',
|
|
95
72
|
ephemeral: true,
|
|
96
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
97
73
|
});
|
|
98
74
|
return;
|
|
99
75
|
}
|
|
76
|
+
await interaction.deferUpdate();
|
|
77
|
+
const response = interaction.values[0];
|
|
100
78
|
try {
|
|
101
|
-
const getClient = await initializeOpencodeForDirectory(
|
|
79
|
+
const getClient = await initializeOpencodeForDirectory(context.directory);
|
|
102
80
|
await getClient().postSessionIdPermissionsPermissionId({
|
|
103
81
|
path: {
|
|
104
|
-
id:
|
|
105
|
-
permissionID:
|
|
106
|
-
},
|
|
107
|
-
body: {
|
|
108
|
-
response: 'reject',
|
|
82
|
+
id: context.permission.sessionID,
|
|
83
|
+
permissionID: context.permission.id,
|
|
109
84
|
},
|
|
85
|
+
body: { response },
|
|
110
86
|
});
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
87
|
+
pendingPermissionContexts.delete(contextHash);
|
|
88
|
+
// Update message: show result and remove dropdown
|
|
89
|
+
const resultText = (() => {
|
|
90
|
+
switch (response) {
|
|
91
|
+
case 'once':
|
|
92
|
+
return '✅ Permission **accepted**';
|
|
93
|
+
case 'always':
|
|
94
|
+
return '✅ Permission **accepted** (auto-approve similar requests)';
|
|
95
|
+
case 'reject':
|
|
96
|
+
return '❌ Permission **rejected**';
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
const patternStr = context.permission.patterns.join(', ');
|
|
100
|
+
await interaction.editReply({
|
|
101
|
+
content: `⚠️ **Permission Required**\n\n` +
|
|
102
|
+
`**Type:** \`${context.permission.permission}\`\n` +
|
|
103
|
+
(patternStr ? `**Pattern:** \`${patternStr}\`\n\n` : '\n') +
|
|
104
|
+
resultText,
|
|
105
|
+
components: [], // Remove the dropdown
|
|
115
106
|
});
|
|
116
|
-
logger.log(`Permission ${
|
|
107
|
+
logger.log(`Permission ${context.permission.id} ${response}`);
|
|
117
108
|
}
|
|
118
109
|
catch (error) {
|
|
119
|
-
logger.error('
|
|
120
|
-
await
|
|
121
|
-
content: `Failed to
|
|
122
|
-
|
|
123
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
110
|
+
logger.error('Error handling permission:', error);
|
|
111
|
+
await interaction.editReply({
|
|
112
|
+
content: `Failed to process permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
113
|
+
components: [],
|
|
124
114
|
});
|
|
125
115
|
}
|
|
126
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Clean up a pending permission context (e.g., on auto-reject).
|
|
119
|
+
*/
|
|
120
|
+
export function cleanupPermissionContext(contextHash) {
|
|
121
|
+
pendingPermissionContexts.delete(contextHash);
|
|
122
|
+
}
|
package/dist/commands/session.js
CHANGED
|
@@ -6,7 +6,7 @@ import { getDatabase } from '../database.js';
|
|
|
6
6
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
7
7
|
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
8
8
|
import { extractTagsArrays } from '../xml.js';
|
|
9
|
-
import { handleOpencodeSession
|
|
9
|
+
import { handleOpencodeSession } from '../session-handler.js';
|
|
10
10
|
import { createLogger } from '../logger.js';
|
|
11
11
|
const logger = createLogger('SESSION');
|
|
12
12
|
export async function handleSessionCommand({ command, appId, }) {
|
|
@@ -61,12 +61,10 @@ export async function handleSessionCommand({ command, appId, }) {
|
|
|
61
61
|
reason: 'OpenCode session',
|
|
62
62
|
});
|
|
63
63
|
await command.editReply(`Created new session in ${thread.toString()}`);
|
|
64
|
-
const parsedCommand = parseSlashCommand(fullPrompt);
|
|
65
64
|
await handleOpencodeSession({
|
|
66
65
|
prompt: fullPrompt,
|
|
67
66
|
thread,
|
|
68
67
|
projectDirectory,
|
|
69
|
-
parsedCommand,
|
|
70
68
|
channelId: textChannel.id,
|
|
71
69
|
});
|
|
72
70
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// User-defined OpenCode command handler.
|
|
2
|
+
// Handles slash commands that map to user-configured commands in opencode.json.
|
|
3
|
+
import { ChannelType } from 'discord.js';
|
|
4
|
+
import { extractTagsArrays } from '../xml.js';
|
|
5
|
+
import { handleOpencodeSession } from '../session-handler.js';
|
|
6
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
import { getDatabase } from '../database.js';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
const userCommandLogger = createLogger('USER_CMD');
|
|
11
|
+
export const handleUserCommand = async ({ command, appId, }) => {
|
|
12
|
+
const discordCommandName = command.commandName;
|
|
13
|
+
// Strip the -cmd suffix to get the actual OpenCode command name
|
|
14
|
+
const commandName = discordCommandName.replace(/-cmd$/, '');
|
|
15
|
+
const args = command.options.getString('arguments') || '';
|
|
16
|
+
userCommandLogger.log(`Executing /${commandName} (from /${discordCommandName}) with args: ${args}`);
|
|
17
|
+
const channel = command.channel;
|
|
18
|
+
userCommandLogger.log(`Channel info: type=${channel?.type}, id=${channel?.id}, isNull=${channel === null}`);
|
|
19
|
+
const isThread = channel && [
|
|
20
|
+
ChannelType.PublicThread,
|
|
21
|
+
ChannelType.PrivateThread,
|
|
22
|
+
ChannelType.AnnouncementThread,
|
|
23
|
+
].includes(channel.type);
|
|
24
|
+
const isTextChannel = channel?.type === ChannelType.GuildText;
|
|
25
|
+
if (!channel || (!isTextChannel && !isThread)) {
|
|
26
|
+
await command.reply({
|
|
27
|
+
content: 'This command can only be used in text channels or threads',
|
|
28
|
+
ephemeral: true,
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let projectDirectory;
|
|
33
|
+
let channelAppId;
|
|
34
|
+
let textChannel = null;
|
|
35
|
+
let thread = null;
|
|
36
|
+
if (isThread) {
|
|
37
|
+
// Running in an existing thread - get project directory from parent channel
|
|
38
|
+
thread = channel;
|
|
39
|
+
textChannel = thread.parent;
|
|
40
|
+
// Verify this thread has an existing session
|
|
41
|
+
const row = getDatabase()
|
|
42
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
43
|
+
.get(thread.id);
|
|
44
|
+
if (!row) {
|
|
45
|
+
await command.reply({
|
|
46
|
+
content: 'This thread does not have an active session. Use this command in a project channel to create a new thread.',
|
|
47
|
+
ephemeral: true,
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (textChannel?.topic) {
|
|
52
|
+
const extracted = extractTagsArrays({
|
|
53
|
+
xml: textChannel.topic,
|
|
54
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
55
|
+
});
|
|
56
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
57
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// Running in a text channel - will create a new thread
|
|
62
|
+
textChannel = channel;
|
|
63
|
+
if (textChannel.topic) {
|
|
64
|
+
const extracted = extractTagsArrays({
|
|
65
|
+
xml: textChannel.topic,
|
|
66
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
67
|
+
});
|
|
68
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
69
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (channelAppId && channelAppId !== appId) {
|
|
73
|
+
await command.reply({
|
|
74
|
+
content: 'This channel is not configured for this bot',
|
|
75
|
+
ephemeral: true,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!projectDirectory) {
|
|
80
|
+
await command.reply({
|
|
81
|
+
content: 'This channel is not configured with a project directory',
|
|
82
|
+
ephemeral: true,
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
87
|
+
await command.reply({
|
|
88
|
+
content: `Directory does not exist: ${projectDirectory}`,
|
|
89
|
+
ephemeral: true,
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
await command.deferReply({ ephemeral: false });
|
|
94
|
+
try {
|
|
95
|
+
// Use the dedicated session.command API instead of formatting as text prompt
|
|
96
|
+
const commandPayload = { name: commandName, arguments: args };
|
|
97
|
+
if (isThread && thread) {
|
|
98
|
+
// Running in existing thread - just send the command
|
|
99
|
+
await command.editReply(`Running /${commandName}...`);
|
|
100
|
+
await handleOpencodeSession({
|
|
101
|
+
prompt: '', // Not used when command is set
|
|
102
|
+
thread,
|
|
103
|
+
projectDirectory,
|
|
104
|
+
channelId: textChannel?.id,
|
|
105
|
+
command: commandPayload,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
else if (textChannel) {
|
|
109
|
+
// Running in text channel - create a new thread
|
|
110
|
+
const starterMessage = await textChannel.send({
|
|
111
|
+
content: `**/${commandName}**${args ? ` ${args.slice(0, 200)}${args.length > 200 ? '…' : ''}` : ''}`,
|
|
112
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
113
|
+
});
|
|
114
|
+
const threadName = `/${commandName} ${args.slice(0, 80)}${args.length > 80 ? '…' : ''}`;
|
|
115
|
+
const newThread = await starterMessage.startThread({
|
|
116
|
+
name: threadName.slice(0, 100),
|
|
117
|
+
autoArchiveDuration: 1440,
|
|
118
|
+
reason: `OpenCode command: ${commandName}`,
|
|
119
|
+
});
|
|
120
|
+
await command.editReply(`Started /${commandName} in ${newThread.toString()}`);
|
|
121
|
+
await handleOpencodeSession({
|
|
122
|
+
prompt: '', // Not used when command is set
|
|
123
|
+
thread: newThread,
|
|
124
|
+
projectDirectory,
|
|
125
|
+
channelId: textChannel.id,
|
|
126
|
+
command: commandPayload,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
userCommandLogger.error(`Error executing /${commandName}:`, error);
|
|
132
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
133
|
+
if (command.deferred) {
|
|
134
|
+
await command.editReply({
|
|
135
|
+
content: `Failed to execute /${commandName}: ${errorMessage}`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
await command.reply({
|
|
140
|
+
content: `Failed to execute /${commandName}: ${errorMessage}`,
|
|
141
|
+
ephemeral: true,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
package/dist/database.js
CHANGED
|
@@ -82,6 +82,21 @@ export function runModelMigrations(database) {
|
|
|
82
82
|
model_id TEXT NOT NULL,
|
|
83
83
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
84
84
|
)
|
|
85
|
+
`);
|
|
86
|
+
targetDb.exec(`
|
|
87
|
+
CREATE TABLE IF NOT EXISTS channel_agents (
|
|
88
|
+
channel_id TEXT PRIMARY KEY,
|
|
89
|
+
agent_name TEXT NOT NULL,
|
|
90
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
91
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
92
|
+
)
|
|
93
|
+
`);
|
|
94
|
+
targetDb.exec(`
|
|
95
|
+
CREATE TABLE IF NOT EXISTS session_agents (
|
|
96
|
+
session_id TEXT PRIMARY KEY,
|
|
97
|
+
agent_name TEXT NOT NULL,
|
|
98
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
99
|
+
)
|
|
85
100
|
`);
|
|
86
101
|
dbLogger.log('Model preferences migrations complete');
|
|
87
102
|
}
|
|
@@ -125,6 +140,42 @@ export function setSessionModel(sessionId, modelId) {
|
|
|
125
140
|
const db = getDatabase();
|
|
126
141
|
db.prepare(`INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`).run(sessionId, modelId);
|
|
127
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Get the agent preference for a channel.
|
|
145
|
+
*/
|
|
146
|
+
export function getChannelAgent(channelId) {
|
|
147
|
+
const db = getDatabase();
|
|
148
|
+
const row = db
|
|
149
|
+
.prepare('SELECT agent_name FROM channel_agents WHERE channel_id = ?')
|
|
150
|
+
.get(channelId);
|
|
151
|
+
return row?.agent_name;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Set the agent preference for a channel.
|
|
155
|
+
*/
|
|
156
|
+
export function setChannelAgent(channelId, agentName) {
|
|
157
|
+
const db = getDatabase();
|
|
158
|
+
db.prepare(`INSERT INTO channel_agents (channel_id, agent_name, updated_at)
|
|
159
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
160
|
+
ON CONFLICT(channel_id) DO UPDATE SET agent_name = ?, updated_at = CURRENT_TIMESTAMP`).run(channelId, agentName, agentName);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Get the agent preference for a session.
|
|
164
|
+
*/
|
|
165
|
+
export function getSessionAgent(sessionId) {
|
|
166
|
+
const db = getDatabase();
|
|
167
|
+
const row = db
|
|
168
|
+
.prepare('SELECT agent_name FROM session_agents WHERE session_id = ?')
|
|
169
|
+
.get(sessionId);
|
|
170
|
+
return row?.agent_name;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Set the agent preference for a session.
|
|
174
|
+
*/
|
|
175
|
+
export function setSessionAgent(sessionId, agentName) {
|
|
176
|
+
const db = getDatabase();
|
|
177
|
+
db.prepare(`INSERT OR REPLACE INTO session_agents (session_id, agent_name) VALUES (?, ?)`).run(sessionId, agentName);
|
|
178
|
+
}
|
|
128
179
|
export function closeDatabase() {
|
|
129
180
|
if (db) {
|
|
130
181
|
db.close();
|
package/dist/discord-bot.js
CHANGED
|
@@ -8,7 +8,8 @@ import { getOpencodeSystemMessage } from './system-message.js';
|
|
|
8
8
|
import { getFileAttachments, getTextAttachments } from './message-formatting.js';
|
|
9
9
|
import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
|
|
10
10
|
import { voiceConnections, cleanupVoiceConnection, processVoiceAttachment, registerVoiceStateHandler, } from './voice-handler.js';
|
|
11
|
-
import {
|
|
11
|
+
import { getCompactSessionContext, getLastSessionId, } from './markdown.js';
|
|
12
|
+
import { handleOpencodeSession } from './session-handler.js';
|
|
12
13
|
import { registerInteractionHandler } from './interaction-handler.js';
|
|
13
14
|
export { getDatabase, closeDatabase } from './database.js';
|
|
14
15
|
export { initializeOpencodeForDirectory } from './opencode.js';
|
|
@@ -153,35 +154,37 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
153
154
|
return;
|
|
154
155
|
}
|
|
155
156
|
let messageContent = message.content || '';
|
|
156
|
-
let
|
|
157
|
-
|
|
157
|
+
let currentSessionContext;
|
|
158
|
+
let lastSessionContext;
|
|
159
|
+
if (projectDirectory) {
|
|
158
160
|
try {
|
|
159
161
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
+
const client = getClient();
|
|
163
|
+
// get current session context (without system prompt, it would be duplicated)
|
|
164
|
+
if (row.session_id) {
|
|
165
|
+
currentSessionContext = await getCompactSessionContext({
|
|
166
|
+
client,
|
|
167
|
+
sessionId: row.session_id,
|
|
168
|
+
includeSystemPrompt: false,
|
|
169
|
+
maxMessages: 15,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// get last session context (with system prompt for project context)
|
|
173
|
+
const lastSessionId = await getLastSessionId({
|
|
174
|
+
client,
|
|
175
|
+
excludeSessionId: row.session_id,
|
|
162
176
|
});
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return textParts
|
|
172
|
-
.map((p) => ('text' in p ? p.text : ''))
|
|
173
|
-
.filter(Boolean)
|
|
174
|
-
.join('\n');
|
|
175
|
-
}
|
|
176
|
-
const assistantInfo = m.info;
|
|
177
|
-
return assistantInfo.text?.slice(0, 500);
|
|
178
|
-
})();
|
|
179
|
-
return `[${role}]: ${text || '(no text)'}`;
|
|
180
|
-
})
|
|
181
|
-
.join('\n\n');
|
|
177
|
+
if (lastSessionId) {
|
|
178
|
+
lastSessionContext = await getCompactSessionContext({
|
|
179
|
+
client,
|
|
180
|
+
sessionId: lastSessionId,
|
|
181
|
+
includeSystemPrompt: true,
|
|
182
|
+
maxMessages: 10,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
182
185
|
}
|
|
183
186
|
catch (e) {
|
|
184
|
-
voiceLogger.
|
|
187
|
+
voiceLogger.error(`Could not get session context:`, e);
|
|
185
188
|
}
|
|
186
189
|
}
|
|
187
190
|
const transcription = await processVoiceAttachment({
|
|
@@ -189,24 +192,23 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
189
192
|
thread,
|
|
190
193
|
projectDirectory,
|
|
191
194
|
appId: currentAppId,
|
|
192
|
-
|
|
195
|
+
currentSessionContext,
|
|
196
|
+
lastSessionContext,
|
|
193
197
|
});
|
|
194
198
|
if (transcription) {
|
|
195
199
|
messageContent = transcription;
|
|
196
200
|
}
|
|
197
|
-
const fileAttachments = getFileAttachments(message);
|
|
201
|
+
const fileAttachments = await getFileAttachments(message);
|
|
198
202
|
const textAttachmentsContent = await getTextAttachments(message);
|
|
199
203
|
const promptWithAttachments = textAttachmentsContent
|
|
200
204
|
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
201
205
|
: messageContent;
|
|
202
|
-
const parsedCommand = parseSlashCommand(messageContent);
|
|
203
206
|
await handleOpencodeSession({
|
|
204
207
|
prompt: promptWithAttachments,
|
|
205
208
|
thread,
|
|
206
209
|
projectDirectory,
|
|
207
210
|
originalMessage: message,
|
|
208
211
|
images: fileAttachments,
|
|
209
|
-
parsedCommand,
|
|
210
212
|
channelId: parent?.id,
|
|
211
213
|
});
|
|
212
214
|
return;
|
|
@@ -265,19 +267,17 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
265
267
|
if (transcription) {
|
|
266
268
|
messageContent = transcription;
|
|
267
269
|
}
|
|
268
|
-
const fileAttachments = getFileAttachments(message);
|
|
270
|
+
const fileAttachments = await getFileAttachments(message);
|
|
269
271
|
const textAttachmentsContent = await getTextAttachments(message);
|
|
270
272
|
const promptWithAttachments = textAttachmentsContent
|
|
271
273
|
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
272
274
|
: messageContent;
|
|
273
|
-
const parsedCommand = parseSlashCommand(messageContent);
|
|
274
275
|
await handleOpencodeSession({
|
|
275
276
|
prompt: promptWithAttachments,
|
|
276
277
|
thread,
|
|
277
278
|
projectDirectory,
|
|
278
279
|
originalMessage: message,
|
|
279
280
|
images: fileAttachments,
|
|
280
|
-
parsedCommand,
|
|
281
281
|
channelId: textChannel.id,
|
|
282
282
|
});
|
|
283
283
|
}
|