shuvmaki 0.4.26
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/bin.js +70 -0
- package/dist/ai-tool-to-genai.js +210 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/channel-management.js +97 -0
- package/dist/cli.js +709 -0
- package/dist/commands/abort.js +78 -0
- package/dist/commands/add-project.js +98 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +183 -0
- package/dist/commands/create-new-project.js +78 -0
- package/dist/commands/fork.js +186 -0
- package/dist/commands/model.js +313 -0
- package/dist/commands/permissions.js +126 -0
- package/dist/commands/queue.js +129 -0
- package/dist/commands/resume.js +145 -0
- package/dist/commands/session.js +142 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +161 -0
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +184 -0
- package/dist/discord-bot.js +384 -0
- package/dist/discord-utils.js +217 -0
- package/dist/escape-backticks.test.js +410 -0
- package/dist/format-tables.js +96 -0
- package/dist/format-tables.test.js +418 -0
- package/dist/genai-worker-wrapper.js +109 -0
- package/dist/genai-worker.js +297 -0
- package/dist/genai.js +232 -0
- package/dist/interaction-handler.js +144 -0
- package/dist/logger.js +51 -0
- package/dist/markdown.js +310 -0
- package/dist/markdown.test.js +262 -0
- package/dist/message-formatting.js +273 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +228 -0
- package/dist/opencode.js +216 -0
- package/dist/session-handler.js +580 -0
- package/dist/system-message.js +61 -0
- package/dist/tools.js +356 -0
- package/dist/utils.js +85 -0
- package/dist/voice-handler.js +541 -0
- package/dist/voice.js +314 -0
- package/dist/worker-types.js +4 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +60 -0
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +255 -0
- package/src/channel-management.ts +161 -0
- package/src/cli.ts +1010 -0
- package/src/commands/abort.ts +94 -0
- package/src/commands/add-project.ts +139 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +276 -0
- package/src/commands/create-new-project.ts +111 -0
- package/src/commands/fork.ts +257 -0
- package/src/commands/model.ts +402 -0
- package/src/commands/permissions.ts +146 -0
- package/src/commands/queue.ts +181 -0
- package/src/commands/resume.ts +230 -0
- package/src/commands/session.ts +184 -0
- package/src/commands/share.ts +96 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +213 -0
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +220 -0
- package/src/discord-bot.ts +513 -0
- package/src/discord-utils.ts +282 -0
- package/src/escape-backticks.test.ts +447 -0
- package/src/format-tables.test.ts +440 -0
- package/src/format-tables.ts +110 -0
- package/src/genai-worker-wrapper.ts +160 -0
- package/src/genai-worker.ts +366 -0
- package/src/genai.ts +321 -0
- package/src/interaction-handler.ts +187 -0
- package/src/logger.ts +57 -0
- package/src/markdown.test.ts +358 -0
- package/src/markdown.ts +365 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +340 -0
- package/src/openai-realtime.ts +363 -0
- package/src/opencode.ts +277 -0
- package/src/session-handler.ts +758 -0
- package/src/system-message.ts +62 -0
- package/src/tools.ts +428 -0
- package/src/utils.ts +118 -0
- package/src/voice-handler.ts +760 -0
- package/src/voice.ts +432 -0
- package/src/worker-types.ts +66 -0
- package/src/xml.test.ts +37 -0
- package/src/xml.ts +121 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// /fork command - Fork the session from a past user message.
|
|
2
|
+
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
3
|
+
import { getDatabase } from '../database.js';
|
|
4
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
5
|
+
import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js';
|
|
6
|
+
import { collectLastAssistantParts } from '../message-formatting.js';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
const sessionLogger = createLogger('SESSION');
|
|
9
|
+
const forkLogger = createLogger('FORK');
|
|
10
|
+
export async function handleForkCommand(interaction) {
|
|
11
|
+
const channel = interaction.channel;
|
|
12
|
+
if (!channel) {
|
|
13
|
+
await interaction.reply({
|
|
14
|
+
content: 'This command can only be used in a channel',
|
|
15
|
+
ephemeral: true,
|
|
16
|
+
});
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const isThread = [
|
|
20
|
+
ChannelType.PublicThread,
|
|
21
|
+
ChannelType.PrivateThread,
|
|
22
|
+
ChannelType.AnnouncementThread,
|
|
23
|
+
].includes(channel.type);
|
|
24
|
+
if (!isThread) {
|
|
25
|
+
await interaction.reply({
|
|
26
|
+
content: 'This command can only be used in a thread with an active session',
|
|
27
|
+
ephemeral: true,
|
|
28
|
+
});
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const textChannel = await resolveTextChannel(channel);
|
|
32
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel);
|
|
33
|
+
if (!directory) {
|
|
34
|
+
await interaction.reply({
|
|
35
|
+
content: 'Could not determine project directory for this channel',
|
|
36
|
+
ephemeral: true,
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const row = getDatabase()
|
|
41
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
42
|
+
.get(channel.id);
|
|
43
|
+
if (!row?.session_id) {
|
|
44
|
+
await interaction.reply({
|
|
45
|
+
content: 'No active session in this thread',
|
|
46
|
+
ephemeral: true,
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Defer reply before API calls to avoid 3-second timeout
|
|
51
|
+
await interaction.deferReply({ ephemeral: true });
|
|
52
|
+
const sessionId = row.session_id;
|
|
53
|
+
try {
|
|
54
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
55
|
+
const messagesResponse = await getClient().session.messages({
|
|
56
|
+
path: { id: sessionId },
|
|
57
|
+
});
|
|
58
|
+
if (!messagesResponse.data) {
|
|
59
|
+
await interaction.editReply({
|
|
60
|
+
content: 'Failed to fetch session messages',
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const userMessages = messagesResponse.data.filter((m) => m.info.role === 'user');
|
|
65
|
+
if (userMessages.length === 0) {
|
|
66
|
+
await interaction.editReply({
|
|
67
|
+
content: 'No user messages found in this session',
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const recentMessages = userMessages.slice(-25);
|
|
72
|
+
const options = recentMessages.map((m, index) => {
|
|
73
|
+
const textPart = m.parts.find((p) => p.type === 'text');
|
|
74
|
+
const preview = textPart?.text?.slice(0, 80) || '(no text)';
|
|
75
|
+
const label = `${index + 1}. ${preview}${preview.length >= 80 ? '...' : ''}`;
|
|
76
|
+
return {
|
|
77
|
+
label: label.slice(0, 100),
|
|
78
|
+
value: m.info.id,
|
|
79
|
+
description: new Date(m.info.time.created).toLocaleString().slice(0, 50),
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
const encodedDir = Buffer.from(directory).toString('base64');
|
|
83
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
84
|
+
.setCustomId(`fork_select:${sessionId}:${encodedDir}`)
|
|
85
|
+
.setPlaceholder('Select a message to fork from')
|
|
86
|
+
.addOptions(options);
|
|
87
|
+
const actionRow = new ActionRowBuilder()
|
|
88
|
+
.addComponents(selectMenu);
|
|
89
|
+
await interaction.editReply({
|
|
90
|
+
content: '**Fork Session**\nSelect the user message to fork from. The forked session will continue as if you had not sent that message:',
|
|
91
|
+
components: [actionRow],
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
forkLogger.error('Error loading messages:', error);
|
|
96
|
+
await interaction.editReply({
|
|
97
|
+
content: `Failed to load messages: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export async function handleForkSelectMenu(interaction) {
|
|
102
|
+
const customId = interaction.customId;
|
|
103
|
+
if (!customId.startsWith('fork_select:')) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const [, sessionId, encodedDir] = customId.split(':');
|
|
107
|
+
if (!sessionId || !encodedDir) {
|
|
108
|
+
await interaction.reply({
|
|
109
|
+
content: 'Invalid selection data',
|
|
110
|
+
ephemeral: true,
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const directory = Buffer.from(encodedDir, 'base64').toString('utf-8');
|
|
115
|
+
const selectedMessageId = interaction.values[0];
|
|
116
|
+
if (!selectedMessageId) {
|
|
117
|
+
await interaction.reply({
|
|
118
|
+
content: 'No message selected',
|
|
119
|
+
ephemeral: true,
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await interaction.deferReply({ ephemeral: false });
|
|
124
|
+
try {
|
|
125
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
126
|
+
const forkResponse = await getClient().session.fork({
|
|
127
|
+
path: { id: sessionId },
|
|
128
|
+
body: { messageID: selectedMessageId },
|
|
129
|
+
});
|
|
130
|
+
if (!forkResponse.data) {
|
|
131
|
+
await interaction.editReply('Failed to fork session');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const forkedSession = forkResponse.data;
|
|
135
|
+
const parentChannel = interaction.channel;
|
|
136
|
+
if (!parentChannel || ![
|
|
137
|
+
ChannelType.PublicThread,
|
|
138
|
+
ChannelType.PrivateThread,
|
|
139
|
+
ChannelType.AnnouncementThread,
|
|
140
|
+
].includes(parentChannel.type)) {
|
|
141
|
+
await interaction.editReply('Could not access parent channel');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const textChannel = await resolveTextChannel(parentChannel);
|
|
145
|
+
if (!textChannel) {
|
|
146
|
+
await interaction.editReply('Could not resolve parent text channel');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const thread = await textChannel.threads.create({
|
|
150
|
+
name: `Fork: ${forkedSession.title}`.slice(0, 100),
|
|
151
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
152
|
+
reason: `Forked from session ${sessionId}`,
|
|
153
|
+
});
|
|
154
|
+
getDatabase()
|
|
155
|
+
.prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
|
|
156
|
+
.run(thread.id, forkedSession.id);
|
|
157
|
+
sessionLogger.log(`Created forked session ${forkedSession.id} in thread ${thread.id}`);
|
|
158
|
+
await sendThreadMessage(thread, `**Forked session created!**\nFrom: \`${sessionId}\`\nNew session: \`${forkedSession.id}\``);
|
|
159
|
+
// Fetch and display the last assistant messages from the forked session
|
|
160
|
+
const messagesResponse = await getClient().session.messages({
|
|
161
|
+
path: { id: forkedSession.id },
|
|
162
|
+
});
|
|
163
|
+
if (messagesResponse.data) {
|
|
164
|
+
const { partIds, content } = collectLastAssistantParts({
|
|
165
|
+
messages: messagesResponse.data,
|
|
166
|
+
});
|
|
167
|
+
if (content.trim()) {
|
|
168
|
+
const discordMessage = await sendThreadMessage(thread, content);
|
|
169
|
+
// Store part-message mappings for future reference
|
|
170
|
+
const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
|
|
171
|
+
const transaction = getDatabase().transaction((ids) => {
|
|
172
|
+
for (const partId of ids) {
|
|
173
|
+
stmt.run(partId, discordMessage.id, thread.id);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
transaction(partIds);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
await sendThreadMessage(thread, `You can now continue the conversation from this point.`);
|
|
180
|
+
await interaction.editReply(`Session forked! Continue in ${thread.toString()}`);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
forkLogger.error('Error forking session:', error);
|
|
184
|
+
await interaction.editReply(`Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// /model command - Set the preferred model for this channel or session.
|
|
2
|
+
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, } from 'discord.js';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from '../database.js';
|
|
5
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
6
|
+
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
7
|
+
import { abortAndRetrySession } from '../session-handler.js';
|
|
8
|
+
import { createLogger } from '../logger.js';
|
|
9
|
+
const modelLogger = createLogger('MODEL');
|
|
10
|
+
// Store context by hash to avoid customId length limits (Discord max: 100 chars)
|
|
11
|
+
const pendingModelContexts = new Map();
|
|
12
|
+
/**
|
|
13
|
+
* Handle the /model slash command.
|
|
14
|
+
* Shows a select menu with available providers.
|
|
15
|
+
*/
|
|
16
|
+
export async function handleModelCommand({ interaction, appId, }) {
|
|
17
|
+
modelLogger.log('[MODEL] handleModelCommand called');
|
|
18
|
+
// Defer reply immediately to avoid 3-second timeout
|
|
19
|
+
await interaction.deferReply({ ephemeral: true });
|
|
20
|
+
modelLogger.log('[MODEL] Deferred reply');
|
|
21
|
+
// Ensure migrations are run
|
|
22
|
+
runModelMigrations();
|
|
23
|
+
const channel = interaction.channel;
|
|
24
|
+
if (!channel) {
|
|
25
|
+
await interaction.editReply({
|
|
26
|
+
content: 'This command can only be used in a channel',
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Determine if we're in a thread or text channel
|
|
31
|
+
const isThread = [
|
|
32
|
+
ChannelType.PublicThread,
|
|
33
|
+
ChannelType.PrivateThread,
|
|
34
|
+
ChannelType.AnnouncementThread,
|
|
35
|
+
].includes(channel.type);
|
|
36
|
+
let projectDirectory;
|
|
37
|
+
let channelAppId;
|
|
38
|
+
let targetChannelId;
|
|
39
|
+
let sessionId;
|
|
40
|
+
if (isThread) {
|
|
41
|
+
const thread = channel;
|
|
42
|
+
const textChannel = await resolveTextChannel(thread);
|
|
43
|
+
const metadata = getKimakiMetadata(textChannel);
|
|
44
|
+
projectDirectory = metadata.projectDirectory;
|
|
45
|
+
channelAppId = metadata.channelAppId;
|
|
46
|
+
targetChannelId = textChannel?.id || channel.id;
|
|
47
|
+
// Get session ID for this thread
|
|
48
|
+
const row = getDatabase()
|
|
49
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
50
|
+
.get(thread.id);
|
|
51
|
+
sessionId = row?.session_id;
|
|
52
|
+
}
|
|
53
|
+
else if (channel.type === ChannelType.GuildText) {
|
|
54
|
+
const textChannel = channel;
|
|
55
|
+
const metadata = getKimakiMetadata(textChannel);
|
|
56
|
+
projectDirectory = metadata.projectDirectory;
|
|
57
|
+
channelAppId = metadata.channelAppId;
|
|
58
|
+
targetChannelId = channel.id;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
await interaction.editReply({
|
|
62
|
+
content: 'This command can only be used in text channels or threads',
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (channelAppId && channelAppId !== appId) {
|
|
67
|
+
await interaction.editReply({
|
|
68
|
+
content: 'This channel is not configured for this bot',
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!projectDirectory) {
|
|
73
|
+
await interaction.editReply({
|
|
74
|
+
content: 'This channel is not configured with a project directory',
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
80
|
+
const providersResponse = await getClient().provider.list({
|
|
81
|
+
query: { directory: projectDirectory },
|
|
82
|
+
});
|
|
83
|
+
if (!providersResponse.data) {
|
|
84
|
+
await interaction.editReply({
|
|
85
|
+
content: 'Failed to fetch providers',
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const { all: allProviders, connected } = providersResponse.data;
|
|
90
|
+
// Filter to only connected providers (have credentials)
|
|
91
|
+
const availableProviders = allProviders.filter((p) => {
|
|
92
|
+
return connected.includes(p.id);
|
|
93
|
+
});
|
|
94
|
+
if (availableProviders.length === 0) {
|
|
95
|
+
await interaction.editReply({
|
|
96
|
+
content: 'No providers with credentials found. Use `/connect` in OpenCode TUI to add provider credentials.',
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Store context with a short hash key to avoid customId length limits
|
|
101
|
+
const context = {
|
|
102
|
+
dir: projectDirectory,
|
|
103
|
+
channelId: targetChannelId,
|
|
104
|
+
sessionId: sessionId,
|
|
105
|
+
isThread: isThread,
|
|
106
|
+
thread: isThread ? channel : undefined,
|
|
107
|
+
};
|
|
108
|
+
const contextHash = crypto.randomBytes(8).toString('hex');
|
|
109
|
+
pendingModelContexts.set(contextHash, context);
|
|
110
|
+
const options = availableProviders.slice(0, 25).map((provider) => {
|
|
111
|
+
const modelCount = Object.keys(provider.models || {}).length;
|
|
112
|
+
return {
|
|
113
|
+
label: provider.name.slice(0, 100),
|
|
114
|
+
value: provider.id,
|
|
115
|
+
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
119
|
+
.setCustomId(`model_provider:${contextHash}`)
|
|
120
|
+
.setPlaceholder('Select a provider')
|
|
121
|
+
.addOptions(options);
|
|
122
|
+
const actionRow = new ActionRowBuilder().addComponents(selectMenu);
|
|
123
|
+
await interaction.editReply({
|
|
124
|
+
content: '**Set Model Preference**\nSelect a provider:',
|
|
125
|
+
components: [actionRow],
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
modelLogger.error('Error loading providers:', error);
|
|
130
|
+
await interaction.editReply({
|
|
131
|
+
content: `Failed to load providers: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Handle the provider select menu interaction.
|
|
137
|
+
* Shows a second select menu with models for the chosen provider.
|
|
138
|
+
*/
|
|
139
|
+
export async function handleProviderSelectMenu(interaction) {
|
|
140
|
+
const customId = interaction.customId;
|
|
141
|
+
if (!customId.startsWith('model_provider:')) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Defer update immediately to avoid timeout
|
|
145
|
+
await interaction.deferUpdate();
|
|
146
|
+
const contextHash = customId.replace('model_provider:', '');
|
|
147
|
+
const context = pendingModelContexts.get(contextHash);
|
|
148
|
+
if (!context) {
|
|
149
|
+
await interaction.editReply({
|
|
150
|
+
content: 'Selection expired. Please run /model again.',
|
|
151
|
+
components: [],
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const selectedProviderId = interaction.values[0];
|
|
156
|
+
if (!selectedProviderId) {
|
|
157
|
+
await interaction.editReply({
|
|
158
|
+
content: 'No provider selected',
|
|
159
|
+
components: [],
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const getClient = await initializeOpencodeForDirectory(context.dir);
|
|
165
|
+
const providersResponse = await getClient().provider.list({
|
|
166
|
+
query: { directory: context.dir },
|
|
167
|
+
});
|
|
168
|
+
if (!providersResponse.data) {
|
|
169
|
+
await interaction.editReply({
|
|
170
|
+
content: 'Failed to fetch providers',
|
|
171
|
+
components: [],
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const provider = providersResponse.data.all.find((p) => p.id === selectedProviderId);
|
|
176
|
+
if (!provider) {
|
|
177
|
+
await interaction.editReply({
|
|
178
|
+
content: 'Provider not found',
|
|
179
|
+
components: [],
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const models = Object.entries(provider.models || {})
|
|
184
|
+
.map(([modelId, model]) => ({
|
|
185
|
+
id: modelId,
|
|
186
|
+
name: model.name,
|
|
187
|
+
releaseDate: model.release_date,
|
|
188
|
+
}))
|
|
189
|
+
// Sort by release date descending (most recent first)
|
|
190
|
+
.sort((a, b) => {
|
|
191
|
+
const dateA = a.releaseDate ? new Date(a.releaseDate).getTime() : 0;
|
|
192
|
+
const dateB = b.releaseDate ? new Date(b.releaseDate).getTime() : 0;
|
|
193
|
+
return dateB - dateA;
|
|
194
|
+
});
|
|
195
|
+
if (models.length === 0) {
|
|
196
|
+
await interaction.editReply({
|
|
197
|
+
content: `No models available for ${provider.name}`,
|
|
198
|
+
components: [],
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Take first 25 models (most recent since sorted descending)
|
|
203
|
+
const recentModels = models.slice(0, 25);
|
|
204
|
+
// Update context with provider info and reuse the same hash
|
|
205
|
+
context.providerId = selectedProviderId;
|
|
206
|
+
context.providerName = provider.name;
|
|
207
|
+
pendingModelContexts.set(contextHash, context);
|
|
208
|
+
const options = recentModels.map((model) => {
|
|
209
|
+
const dateStr = model.releaseDate
|
|
210
|
+
? new Date(model.releaseDate).toLocaleDateString()
|
|
211
|
+
: 'Unknown date';
|
|
212
|
+
return {
|
|
213
|
+
label: model.name.slice(0, 100),
|
|
214
|
+
value: model.id,
|
|
215
|
+
description: dateStr.slice(0, 100),
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
219
|
+
.setCustomId(`model_select:${contextHash}`)
|
|
220
|
+
.setPlaceholder('Select a model')
|
|
221
|
+
.addOptions(options);
|
|
222
|
+
const actionRow = new ActionRowBuilder().addComponents(selectMenu);
|
|
223
|
+
await interaction.editReply({
|
|
224
|
+
content: `**Set Model Preference**\nProvider: **${provider.name}**\nSelect a model:`,
|
|
225
|
+
components: [actionRow],
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
modelLogger.error('Error loading models:', error);
|
|
230
|
+
await interaction.editReply({
|
|
231
|
+
content: `Failed to load models: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
232
|
+
components: [],
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Handle the model select menu interaction.
|
|
238
|
+
* Stores the model preference in the database.
|
|
239
|
+
*/
|
|
240
|
+
export async function handleModelSelectMenu(interaction) {
|
|
241
|
+
const customId = interaction.customId;
|
|
242
|
+
if (!customId.startsWith('model_select:')) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// Defer update immediately
|
|
246
|
+
await interaction.deferUpdate();
|
|
247
|
+
const contextHash = customId.replace('model_select:', '');
|
|
248
|
+
const context = pendingModelContexts.get(contextHash);
|
|
249
|
+
if (!context || !context.providerId || !context.providerName) {
|
|
250
|
+
await interaction.editReply({
|
|
251
|
+
content: 'Selection expired. Please run /model again.',
|
|
252
|
+
components: [],
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const selectedModelId = interaction.values[0];
|
|
257
|
+
if (!selectedModelId) {
|
|
258
|
+
await interaction.editReply({
|
|
259
|
+
content: 'No model selected',
|
|
260
|
+
components: [],
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Build full model ID: provider_id/model_id
|
|
265
|
+
const fullModelId = `${context.providerId}/${selectedModelId}`;
|
|
266
|
+
try {
|
|
267
|
+
// Store in appropriate table based on context
|
|
268
|
+
if (context.isThread && context.sessionId) {
|
|
269
|
+
// Store for session
|
|
270
|
+
setSessionModel(context.sessionId, fullModelId);
|
|
271
|
+
modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`);
|
|
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
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
// Store for channel
|
|
296
|
+
setChannelModel(context.channelId, fullModelId);
|
|
297
|
+
modelLogger.log(`Set model ${fullModelId} for channel ${context.channelId}`);
|
|
298
|
+
await interaction.editReply({
|
|
299
|
+
content: `Model preference set for this channel:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\`\n\nAll new sessions in this channel will use this model.`,
|
|
300
|
+
components: [],
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
// Clean up the context from memory
|
|
304
|
+
pendingModelContexts.delete(contextHash);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
modelLogger.error('Error saving model preference:', error);
|
|
308
|
+
await interaction.editReply({
|
|
309
|
+
content: `Failed to save model preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
310
|
+
components: [],
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Permission commands - /accept, /accept-always, /reject
|
|
2
|
+
import { ChannelType } from 'discord.js';
|
|
3
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
4
|
+
import { pendingPermissions } from '../session-handler.js';
|
|
5
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
6
|
+
import { createLogger } from '../logger.js';
|
|
7
|
+
const logger = createLogger('PERMISSIONS');
|
|
8
|
+
export async function handleAcceptCommand({ command, }) {
|
|
9
|
+
const scope = command.commandName === 'accept-always' ? 'always' : 'once';
|
|
10
|
+
const channel = command.channel;
|
|
11
|
+
if (!channel) {
|
|
12
|
+
await command.reply({
|
|
13
|
+
content: 'This command can only be used in a channel',
|
|
14
|
+
ephemeral: true,
|
|
15
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
16
|
+
});
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const isThread = [
|
|
20
|
+
ChannelType.PublicThread,
|
|
21
|
+
ChannelType.PrivateThread,
|
|
22
|
+
ChannelType.AnnouncementThread,
|
|
23
|
+
].includes(channel.type);
|
|
24
|
+
if (!isThread) {
|
|
25
|
+
await command.reply({
|
|
26
|
+
content: 'This command can only be used in a thread with an active session',
|
|
27
|
+
ephemeral: true,
|
|
28
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const pending = pendingPermissions.get(channel.id);
|
|
33
|
+
if (!pending) {
|
|
34
|
+
await command.reply({
|
|
35
|
+
content: 'No pending permission request in this thread',
|
|
36
|
+
ephemeral: true,
|
|
37
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const getClient = await initializeOpencodeForDirectory(pending.directory);
|
|
43
|
+
await getClient().postSessionIdPermissionsPermissionId({
|
|
44
|
+
path: {
|
|
45
|
+
id: pending.permission.sessionID,
|
|
46
|
+
permissionID: pending.permission.id,
|
|
47
|
+
},
|
|
48
|
+
body: {
|
|
49
|
+
response: scope,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
pendingPermissions.delete(channel.id);
|
|
53
|
+
const msg = scope === 'always'
|
|
54
|
+
? `✅ Permission **accepted** (auto-approve similar requests)`
|
|
55
|
+
: `✅ Permission **accepted**`;
|
|
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
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export async function handleRejectCommand({ command, }) {
|
|
69
|
+
const channel = command.channel;
|
|
70
|
+
if (!channel) {
|
|
71
|
+
await command.reply({
|
|
72
|
+
content: 'This command can only be used in a channel',
|
|
73
|
+
ephemeral: true,
|
|
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
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const pending = pendingPermissions.get(channel.id);
|
|
92
|
+
if (!pending) {
|
|
93
|
+
await command.reply({
|
|
94
|
+
content: 'No pending permission request in this thread',
|
|
95
|
+
ephemeral: true,
|
|
96
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const getClient = await initializeOpencodeForDirectory(pending.directory);
|
|
102
|
+
await getClient().postSessionIdPermissionsPermissionId({
|
|
103
|
+
path: {
|
|
104
|
+
id: pending.permission.sessionID,
|
|
105
|
+
permissionID: pending.permission.id,
|
|
106
|
+
},
|
|
107
|
+
body: {
|
|
108
|
+
response: 'reject',
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
pendingPermissions.delete(channel.id);
|
|
112
|
+
await command.reply({
|
|
113
|
+
content: `❌ Permission **rejected**`,
|
|
114
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
115
|
+
});
|
|
116
|
+
logger.log(`Permission ${pending.permission.id} rejected`);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
logger.error('[REJECT] Error:', error);
|
|
120
|
+
await command.reply({
|
|
121
|
+
content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
122
|
+
ephemeral: true,
|
|
123
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|