kimaki 0.4.22 → 0.4.23
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 +92 -0
- package/dist/cli.js +9 -1
- package/dist/database.js +130 -0
- package/dist/discord-bot.js +381 -0
- package/dist/discord-utils.js +151 -0
- package/dist/escape-backticks.test.js +1 -1
- package/dist/fork.js +163 -0
- package/dist/interaction-handler.js +750 -0
- package/dist/message-formatting.js +188 -0
- package/dist/model-command.js +293 -0
- package/dist/opencode.js +135 -0
- package/dist/session-handler.js +467 -0
- package/dist/system-message.js +92 -0
- package/dist/tools.js +1 -1
- package/dist/voice-handler.js +528 -0
- package/dist/voice.js +257 -35
- package/package.json +3 -1
- package/src/channel-management.ts +145 -0
- package/src/cli.ts +9 -1
- package/src/database.ts +155 -0
- package/src/discord-bot.ts +506 -0
- package/src/discord-utils.ts +208 -0
- package/src/escape-backticks.test.ts +1 -1
- package/src/fork.ts +224 -0
- package/src/interaction-handler.ts +1000 -0
- package/src/message-formatting.ts +227 -0
- package/src/model-command.ts +380 -0
- package/src/opencode.ts +180 -0
- package/src/session-handler.ts +601 -0
- package/src/system-message.ts +92 -0
- package/src/tools.ts +1 -1
- package/src/voice-handler.ts +745 -0
- package/src/voice.ts +354 -36
- package/src/discordBot.ts +0 -3671
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createLogger } from './logger.js';
|
|
2
|
+
const logger = createLogger('FORMATTING');
|
|
3
|
+
export const TEXT_MIME_TYPES = [
|
|
4
|
+
'text/',
|
|
5
|
+
'application/json',
|
|
6
|
+
'application/xml',
|
|
7
|
+
'application/javascript',
|
|
8
|
+
'application/typescript',
|
|
9
|
+
'application/x-yaml',
|
|
10
|
+
'application/toml',
|
|
11
|
+
];
|
|
12
|
+
export function isTextMimeType(contentType) {
|
|
13
|
+
if (!contentType) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix));
|
|
17
|
+
}
|
|
18
|
+
export async function getTextAttachments(message) {
|
|
19
|
+
const textAttachments = Array.from(message.attachments.values()).filter((attachment) => isTextMimeType(attachment.contentType));
|
|
20
|
+
if (textAttachments.length === 0) {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
const textContents = await Promise.all(textAttachments.map(async (attachment) => {
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch(attachment.url);
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`;
|
|
28
|
+
}
|
|
29
|
+
const text = await response.text();
|
|
30
|
+
return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
34
|
+
return `<attachment filename="${attachment.name}" error="${errMsg}" />`;
|
|
35
|
+
}
|
|
36
|
+
}));
|
|
37
|
+
return textContents.join('\n\n');
|
|
38
|
+
}
|
|
39
|
+
export function getFileAttachments(message) {
|
|
40
|
+
const fileAttachments = Array.from(message.attachments.values()).filter((attachment) => {
|
|
41
|
+
const contentType = attachment.contentType || '';
|
|
42
|
+
return (contentType.startsWith('image/') || contentType === 'application/pdf');
|
|
43
|
+
});
|
|
44
|
+
return fileAttachments.map((attachment) => ({
|
|
45
|
+
type: 'file',
|
|
46
|
+
mime: attachment.contentType || 'application/octet-stream',
|
|
47
|
+
filename: attachment.name,
|
|
48
|
+
url: attachment.url,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
export function getToolSummaryText(part) {
|
|
52
|
+
if (part.type !== 'tool')
|
|
53
|
+
return '';
|
|
54
|
+
if (part.tool === 'edit') {
|
|
55
|
+
const filePath = part.state.input?.filePath || '';
|
|
56
|
+
const newString = part.state.input?.newString || '';
|
|
57
|
+
const oldString = part.state.input?.oldString || '';
|
|
58
|
+
const added = newString.split('\n').length;
|
|
59
|
+
const removed = oldString.split('\n').length;
|
|
60
|
+
const fileName = filePath.split('/').pop() || '';
|
|
61
|
+
return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`;
|
|
62
|
+
}
|
|
63
|
+
if (part.tool === 'write') {
|
|
64
|
+
const filePath = part.state.input?.filePath || '';
|
|
65
|
+
const content = part.state.input?.content || '';
|
|
66
|
+
const lines = content.split('\n').length;
|
|
67
|
+
const fileName = filePath.split('/').pop() || '';
|
|
68
|
+
return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`;
|
|
69
|
+
}
|
|
70
|
+
if (part.tool === 'webfetch') {
|
|
71
|
+
const url = part.state.input?.url || '';
|
|
72
|
+
const urlWithoutProtocol = url.replace(/^https?:\/\//, '');
|
|
73
|
+
return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : '';
|
|
74
|
+
}
|
|
75
|
+
if (part.tool === 'read') {
|
|
76
|
+
const filePath = part.state.input?.filePath || '';
|
|
77
|
+
const fileName = filePath.split('/').pop() || '';
|
|
78
|
+
return fileName ? `*${fileName}*` : '';
|
|
79
|
+
}
|
|
80
|
+
if (part.tool === 'list') {
|
|
81
|
+
const path = part.state.input?.path || '';
|
|
82
|
+
const dirName = path.split('/').pop() || path;
|
|
83
|
+
return dirName ? `*${dirName}*` : '';
|
|
84
|
+
}
|
|
85
|
+
if (part.tool === 'glob') {
|
|
86
|
+
const pattern = part.state.input?.pattern || '';
|
|
87
|
+
return pattern ? `*${pattern}*` : '';
|
|
88
|
+
}
|
|
89
|
+
if (part.tool === 'grep') {
|
|
90
|
+
const pattern = part.state.input?.pattern || '';
|
|
91
|
+
return pattern ? `*${pattern}*` : '';
|
|
92
|
+
}
|
|
93
|
+
if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
if (part.tool === 'task') {
|
|
97
|
+
const description = part.state.input?.description || '';
|
|
98
|
+
return description ? `_${description}_` : '';
|
|
99
|
+
}
|
|
100
|
+
if (part.tool === 'skill') {
|
|
101
|
+
const name = part.state.input?.name || '';
|
|
102
|
+
return name ? `_${name}_` : '';
|
|
103
|
+
}
|
|
104
|
+
if (!part.state.input)
|
|
105
|
+
return '';
|
|
106
|
+
const inputFields = Object.entries(part.state.input)
|
|
107
|
+
.map(([key, value]) => {
|
|
108
|
+
if (value === null || value === undefined)
|
|
109
|
+
return null;
|
|
110
|
+
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
111
|
+
const truncatedValue = stringValue.length > 300 ? stringValue.slice(0, 300) + '…' : stringValue;
|
|
112
|
+
return `${key}: ${truncatedValue}`;
|
|
113
|
+
})
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
if (inputFields.length === 0)
|
|
116
|
+
return '';
|
|
117
|
+
return `(${inputFields.join(', ')})`;
|
|
118
|
+
}
|
|
119
|
+
export function formatTodoList(part) {
|
|
120
|
+
if (part.type !== 'tool' || part.tool !== 'todowrite')
|
|
121
|
+
return '';
|
|
122
|
+
const todos = part.state.input?.todos || [];
|
|
123
|
+
const activeIndex = todos.findIndex((todo) => {
|
|
124
|
+
return todo.status === 'in_progress';
|
|
125
|
+
});
|
|
126
|
+
const activeTodo = todos[activeIndex];
|
|
127
|
+
if (activeIndex === -1 || !activeTodo)
|
|
128
|
+
return '';
|
|
129
|
+
return `${activeIndex + 1}. **${activeTodo.content}**`;
|
|
130
|
+
}
|
|
131
|
+
export function formatPart(part) {
|
|
132
|
+
if (part.type === 'text') {
|
|
133
|
+
return part.text || '';
|
|
134
|
+
}
|
|
135
|
+
if (part.type === 'reasoning') {
|
|
136
|
+
if (!part.text?.trim())
|
|
137
|
+
return '';
|
|
138
|
+
return `◼︎ thinking`;
|
|
139
|
+
}
|
|
140
|
+
if (part.type === 'file') {
|
|
141
|
+
return `📄 ${part.filename || 'File'}`;
|
|
142
|
+
}
|
|
143
|
+
if (part.type === 'step-start' || part.type === 'step-finish' || part.type === 'patch') {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
146
|
+
if (part.type === 'agent') {
|
|
147
|
+
return `◼︎ agent ${part.id}`;
|
|
148
|
+
}
|
|
149
|
+
if (part.type === 'snapshot') {
|
|
150
|
+
return `◼︎ snapshot ${part.snapshot}`;
|
|
151
|
+
}
|
|
152
|
+
if (part.type === 'tool') {
|
|
153
|
+
if (part.tool === 'todowrite') {
|
|
154
|
+
return formatTodoList(part);
|
|
155
|
+
}
|
|
156
|
+
if (part.state.status === 'pending') {
|
|
157
|
+
return '';
|
|
158
|
+
}
|
|
159
|
+
const summaryText = getToolSummaryText(part);
|
|
160
|
+
const stateTitle = 'title' in part.state ? part.state.title : undefined;
|
|
161
|
+
let toolTitle = '';
|
|
162
|
+
if (part.state.status === 'error') {
|
|
163
|
+
toolTitle = part.state.error || 'error';
|
|
164
|
+
}
|
|
165
|
+
else if (part.tool === 'bash') {
|
|
166
|
+
const command = part.state.input?.command || '';
|
|
167
|
+
const description = part.state.input?.description || '';
|
|
168
|
+
const isSingleLine = !command.includes('\n');
|
|
169
|
+
const hasUnderscores = command.includes('_');
|
|
170
|
+
if (isSingleLine && !hasUnderscores && command.length <= 50) {
|
|
171
|
+
toolTitle = `_${command}_`;
|
|
172
|
+
}
|
|
173
|
+
else if (description) {
|
|
174
|
+
toolTitle = `_${description}_`;
|
|
175
|
+
}
|
|
176
|
+
else if (stateTitle) {
|
|
177
|
+
toolTitle = `_${stateTitle}_`;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else if (stateTitle) {
|
|
181
|
+
toolTitle = `_${stateTitle}_`;
|
|
182
|
+
}
|
|
183
|
+
const icon = part.state.status === 'error' ? '⨯' : '◼︎';
|
|
184
|
+
return `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
|
|
185
|
+
}
|
|
186
|
+
logger.warn('Unknown part type:', part);
|
|
187
|
+
return '';
|
|
188
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, } from 'discord.js';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from './database.js';
|
|
4
|
+
import { initializeOpencodeForDirectory } from './opencode.js';
|
|
5
|
+
import { resolveTextChannel, getKimakiMetadata } from './discord-utils.js';
|
|
6
|
+
import { createLogger } from './logger.js';
|
|
7
|
+
const modelLogger = createLogger('MODEL');
|
|
8
|
+
// Store context by hash to avoid customId length limits (Discord max: 100 chars)
|
|
9
|
+
const pendingModelContexts = new Map();
|
|
10
|
+
/**
|
|
11
|
+
* Handle the /model slash command.
|
|
12
|
+
* Shows a select menu with available providers.
|
|
13
|
+
*/
|
|
14
|
+
export async function handleModelCommand({ interaction, appId, }) {
|
|
15
|
+
modelLogger.log('[MODEL] handleModelCommand called');
|
|
16
|
+
// Defer reply immediately to avoid 3-second timeout
|
|
17
|
+
await interaction.deferReply({ ephemeral: true });
|
|
18
|
+
modelLogger.log('[MODEL] Deferred reply');
|
|
19
|
+
// Ensure migrations are run
|
|
20
|
+
runModelMigrations();
|
|
21
|
+
const channel = interaction.channel;
|
|
22
|
+
if (!channel) {
|
|
23
|
+
await interaction.editReply({
|
|
24
|
+
content: 'This command can only be used in a channel',
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// Determine if we're in a thread or text channel
|
|
29
|
+
const isThread = [
|
|
30
|
+
ChannelType.PublicThread,
|
|
31
|
+
ChannelType.PrivateThread,
|
|
32
|
+
ChannelType.AnnouncementThread,
|
|
33
|
+
].includes(channel.type);
|
|
34
|
+
let projectDirectory;
|
|
35
|
+
let channelAppId;
|
|
36
|
+
let targetChannelId;
|
|
37
|
+
let sessionId;
|
|
38
|
+
if (isThread) {
|
|
39
|
+
const thread = channel;
|
|
40
|
+
const textChannel = await resolveTextChannel(thread);
|
|
41
|
+
const metadata = getKimakiMetadata(textChannel);
|
|
42
|
+
projectDirectory = metadata.projectDirectory;
|
|
43
|
+
channelAppId = metadata.channelAppId;
|
|
44
|
+
targetChannelId = textChannel?.id || channel.id;
|
|
45
|
+
// Get session ID for this thread
|
|
46
|
+
const row = getDatabase()
|
|
47
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
48
|
+
.get(thread.id);
|
|
49
|
+
sessionId = row?.session_id;
|
|
50
|
+
}
|
|
51
|
+
else if (channel.type === ChannelType.GuildText) {
|
|
52
|
+
const textChannel = channel;
|
|
53
|
+
const metadata = getKimakiMetadata(textChannel);
|
|
54
|
+
projectDirectory = metadata.projectDirectory;
|
|
55
|
+
channelAppId = metadata.channelAppId;
|
|
56
|
+
targetChannelId = channel.id;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
await interaction.editReply({
|
|
60
|
+
content: 'This command can only be used in text channels or threads',
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (channelAppId && channelAppId !== appId) {
|
|
65
|
+
await interaction.editReply({
|
|
66
|
+
content: 'This channel is not configured for this bot',
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!projectDirectory) {
|
|
71
|
+
await interaction.editReply({
|
|
72
|
+
content: 'This channel is not configured with a project directory',
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
78
|
+
const providersResponse = await getClient().provider.list({
|
|
79
|
+
query: { directory: projectDirectory },
|
|
80
|
+
});
|
|
81
|
+
if (!providersResponse.data) {
|
|
82
|
+
await interaction.editReply({
|
|
83
|
+
content: 'Failed to fetch providers',
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const { all: allProviders, connected } = providersResponse.data;
|
|
88
|
+
// Filter to only connected providers (have credentials)
|
|
89
|
+
const availableProviders = allProviders.filter((p) => {
|
|
90
|
+
return connected.includes(p.id);
|
|
91
|
+
});
|
|
92
|
+
if (availableProviders.length === 0) {
|
|
93
|
+
await interaction.editReply({
|
|
94
|
+
content: 'No providers with credentials found. Use `/connect` in OpenCode TUI to add provider credentials.',
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Store context with a short hash key to avoid customId length limits
|
|
99
|
+
const context = {
|
|
100
|
+
dir: projectDirectory,
|
|
101
|
+
channelId: targetChannelId,
|
|
102
|
+
sessionId: sessionId,
|
|
103
|
+
isThread: isThread,
|
|
104
|
+
};
|
|
105
|
+
const contextHash = crypto.randomBytes(8).toString('hex');
|
|
106
|
+
pendingModelContexts.set(contextHash, context);
|
|
107
|
+
const options = availableProviders.slice(0, 25).map((provider) => {
|
|
108
|
+
const modelCount = Object.keys(provider.models || {}).length;
|
|
109
|
+
return {
|
|
110
|
+
label: provider.name.slice(0, 100),
|
|
111
|
+
value: provider.id,
|
|
112
|
+
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
116
|
+
.setCustomId(`model_provider:${contextHash}`)
|
|
117
|
+
.setPlaceholder('Select a provider')
|
|
118
|
+
.addOptions(options);
|
|
119
|
+
const actionRow = new ActionRowBuilder().addComponents(selectMenu);
|
|
120
|
+
await interaction.editReply({
|
|
121
|
+
content: '**Set Model Preference**\nSelect a provider:',
|
|
122
|
+
components: [actionRow],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
modelLogger.error('Error loading providers:', error);
|
|
127
|
+
await interaction.editReply({
|
|
128
|
+
content: `Failed to load providers: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Handle the provider select menu interaction.
|
|
134
|
+
* Shows a second select menu with models for the chosen provider.
|
|
135
|
+
*/
|
|
136
|
+
export async function handleProviderSelectMenu(interaction) {
|
|
137
|
+
const customId = interaction.customId;
|
|
138
|
+
if (!customId.startsWith('model_provider:')) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Defer update immediately to avoid timeout
|
|
142
|
+
await interaction.deferUpdate();
|
|
143
|
+
const contextHash = customId.replace('model_provider:', '');
|
|
144
|
+
const context = pendingModelContexts.get(contextHash);
|
|
145
|
+
if (!context) {
|
|
146
|
+
await interaction.editReply({
|
|
147
|
+
content: 'Selection expired. Please run /model again.',
|
|
148
|
+
components: [],
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const selectedProviderId = interaction.values[0];
|
|
153
|
+
if (!selectedProviderId) {
|
|
154
|
+
await interaction.editReply({
|
|
155
|
+
content: 'No provider selected',
|
|
156
|
+
components: [],
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const getClient = await initializeOpencodeForDirectory(context.dir);
|
|
162
|
+
const providersResponse = await getClient().provider.list({
|
|
163
|
+
query: { directory: context.dir },
|
|
164
|
+
});
|
|
165
|
+
if (!providersResponse.data) {
|
|
166
|
+
await interaction.editReply({
|
|
167
|
+
content: 'Failed to fetch providers',
|
|
168
|
+
components: [],
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const provider = providersResponse.data.all.find((p) => p.id === selectedProviderId);
|
|
173
|
+
if (!provider) {
|
|
174
|
+
await interaction.editReply({
|
|
175
|
+
content: 'Provider not found',
|
|
176
|
+
components: [],
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const models = Object.entries(provider.models || {})
|
|
181
|
+
.map(([modelId, model]) => ({
|
|
182
|
+
id: modelId,
|
|
183
|
+
name: model.name,
|
|
184
|
+
releaseDate: model.release_date,
|
|
185
|
+
}))
|
|
186
|
+
// Sort by release date descending (most recent first)
|
|
187
|
+
.sort((a, b) => {
|
|
188
|
+
const dateA = a.releaseDate ? new Date(a.releaseDate).getTime() : 0;
|
|
189
|
+
const dateB = b.releaseDate ? new Date(b.releaseDate).getTime() : 0;
|
|
190
|
+
return dateB - dateA;
|
|
191
|
+
});
|
|
192
|
+
if (models.length === 0) {
|
|
193
|
+
await interaction.editReply({
|
|
194
|
+
content: `No models available for ${provider.name}`,
|
|
195
|
+
components: [],
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// Take first 25 models (most recent since sorted descending)
|
|
200
|
+
const recentModels = models.slice(0, 25);
|
|
201
|
+
// Update context with provider info and reuse the same hash
|
|
202
|
+
context.providerId = selectedProviderId;
|
|
203
|
+
context.providerName = provider.name;
|
|
204
|
+
pendingModelContexts.set(contextHash, context);
|
|
205
|
+
const options = recentModels.map((model) => {
|
|
206
|
+
const dateStr = model.releaseDate
|
|
207
|
+
? new Date(model.releaseDate).toLocaleDateString()
|
|
208
|
+
: 'Unknown date';
|
|
209
|
+
return {
|
|
210
|
+
label: model.name.slice(0, 100),
|
|
211
|
+
value: model.id,
|
|
212
|
+
description: dateStr.slice(0, 100),
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
216
|
+
.setCustomId(`model_select:${contextHash}`)
|
|
217
|
+
.setPlaceholder('Select a model')
|
|
218
|
+
.addOptions(options);
|
|
219
|
+
const actionRow = new ActionRowBuilder().addComponents(selectMenu);
|
|
220
|
+
await interaction.editReply({
|
|
221
|
+
content: `**Set Model Preference**\nProvider: **${provider.name}**\nSelect a model:`,
|
|
222
|
+
components: [actionRow],
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
modelLogger.error('Error loading models:', error);
|
|
227
|
+
await interaction.editReply({
|
|
228
|
+
content: `Failed to load models: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
229
|
+
components: [],
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Handle the model select menu interaction.
|
|
235
|
+
* Stores the model preference in the database.
|
|
236
|
+
*/
|
|
237
|
+
export async function handleModelSelectMenu(interaction) {
|
|
238
|
+
const customId = interaction.customId;
|
|
239
|
+
if (!customId.startsWith('model_select:')) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// Defer update immediately
|
|
243
|
+
await interaction.deferUpdate();
|
|
244
|
+
const contextHash = customId.replace('model_select:', '');
|
|
245
|
+
const context = pendingModelContexts.get(contextHash);
|
|
246
|
+
if (!context || !context.providerId || !context.providerName) {
|
|
247
|
+
await interaction.editReply({
|
|
248
|
+
content: 'Selection expired. Please run /model again.',
|
|
249
|
+
components: [],
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const selectedModelId = interaction.values[0];
|
|
254
|
+
if (!selectedModelId) {
|
|
255
|
+
await interaction.editReply({
|
|
256
|
+
content: 'No model selected',
|
|
257
|
+
components: [],
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// Build full model ID: provider_id/model_id
|
|
262
|
+
const fullModelId = `${context.providerId}/${selectedModelId}`;
|
|
263
|
+
try {
|
|
264
|
+
// Store in appropriate table based on context
|
|
265
|
+
if (context.isThread && context.sessionId) {
|
|
266
|
+
// Store for session
|
|
267
|
+
setSessionModel(context.sessionId, fullModelId);
|
|
268
|
+
modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`);
|
|
269
|
+
await interaction.editReply({
|
|
270
|
+
content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
|
|
271
|
+
components: [],
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
// Store for channel
|
|
276
|
+
setChannelModel(context.channelId, fullModelId);
|
|
277
|
+
modelLogger.log(`Set model ${fullModelId} for channel ${context.channelId}`);
|
|
278
|
+
await interaction.editReply({
|
|
279
|
+
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.`,
|
|
280
|
+
components: [],
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
// Clean up the context from memory
|
|
284
|
+
pendingModelContexts.delete(contextHash);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
modelLogger.error('Error saving model preference:', error);
|
|
288
|
+
await interaction.editReply({
|
|
289
|
+
content: `Failed to save model preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
290
|
+
components: [],
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
package/dist/opencode.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { createOpencodeClient, } from '@opencode-ai/sdk';
|
|
4
|
+
import { createLogger } from './logger.js';
|
|
5
|
+
const opencodeLogger = createLogger('OPENCODE');
|
|
6
|
+
const opencodeServers = new Map();
|
|
7
|
+
const serverRetryCount = new Map();
|
|
8
|
+
async function getOpenPort() {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const server = net.createServer();
|
|
11
|
+
server.listen(0, () => {
|
|
12
|
+
const address = server.address();
|
|
13
|
+
if (address && typeof address === 'object') {
|
|
14
|
+
const port = address.port;
|
|
15
|
+
server.close(() => {
|
|
16
|
+
resolve(port);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
reject(new Error('Failed to get port'));
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
server.on('error', reject);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
async function waitForServer(port, maxAttempts = 30) {
|
|
27
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
28
|
+
try {
|
|
29
|
+
const endpoints = [
|
|
30
|
+
`http://localhost:${port}/api/health`,
|
|
31
|
+
`http://localhost:${port}/`,
|
|
32
|
+
`http://localhost:${port}/api`,
|
|
33
|
+
];
|
|
34
|
+
for (const endpoint of endpoints) {
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(endpoint);
|
|
37
|
+
if (response.status < 500) {
|
|
38
|
+
opencodeLogger.log(`Server ready on port `);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (e) { }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (e) { }
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Server did not start on port ${port} after ${maxAttempts} seconds`);
|
|
49
|
+
}
|
|
50
|
+
export async function initializeOpencodeForDirectory(directory) {
|
|
51
|
+
const existing = opencodeServers.get(directory);
|
|
52
|
+
if (existing && !existing.process.killed) {
|
|
53
|
+
opencodeLogger.log(`Reusing existing server on port ${existing.port} for directory: ${directory}`);
|
|
54
|
+
return () => {
|
|
55
|
+
const entry = opencodeServers.get(directory);
|
|
56
|
+
if (!entry?.client) {
|
|
57
|
+
throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
|
|
58
|
+
}
|
|
59
|
+
return entry.client;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const port = await getOpenPort();
|
|
63
|
+
const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
|
|
64
|
+
const serverProcess = spawn(opencodeCommand, ['serve', '--port', port.toString()], {
|
|
65
|
+
stdio: 'pipe',
|
|
66
|
+
detached: false,
|
|
67
|
+
cwd: directory,
|
|
68
|
+
env: {
|
|
69
|
+
...process.env,
|
|
70
|
+
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
71
|
+
$schema: 'https://opencode.ai/config.json',
|
|
72
|
+
lsp: false,
|
|
73
|
+
formatter: false,
|
|
74
|
+
permission: {
|
|
75
|
+
edit: 'allow',
|
|
76
|
+
bash: 'allow',
|
|
77
|
+
webfetch: 'allow',
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
OPENCODE_PORT: port.toString(),
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
serverProcess.stdout?.on('data', (data) => {
|
|
84
|
+
opencodeLogger.log(`opencode ${directory}: ${data.toString().trim()}`);
|
|
85
|
+
});
|
|
86
|
+
serverProcess.stderr?.on('data', (data) => {
|
|
87
|
+
opencodeLogger.error(`opencode ${directory}: ${data.toString().trim()}`);
|
|
88
|
+
});
|
|
89
|
+
serverProcess.on('error', (error) => {
|
|
90
|
+
opencodeLogger.error(`Failed to start server on port :`, port, error);
|
|
91
|
+
});
|
|
92
|
+
serverProcess.on('exit', (code) => {
|
|
93
|
+
opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code);
|
|
94
|
+
opencodeServers.delete(directory);
|
|
95
|
+
if (code !== 0) {
|
|
96
|
+
const retryCount = serverRetryCount.get(directory) || 0;
|
|
97
|
+
if (retryCount < 5) {
|
|
98
|
+
serverRetryCount.set(directory, retryCount + 1);
|
|
99
|
+
opencodeLogger.log(`Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`);
|
|
100
|
+
initializeOpencodeForDirectory(directory).catch((e) => {
|
|
101
|
+
opencodeLogger.error(`Failed to restart opencode server:`, e);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
opencodeLogger.error(`Server for ${directory} crashed too many times (5), not restarting`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
serverRetryCount.delete(directory);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
await waitForServer(port);
|
|
113
|
+
const client = createOpencodeClient({
|
|
114
|
+
baseUrl: `http://localhost:${port}`,
|
|
115
|
+
fetch: (request) => fetch(request, {
|
|
116
|
+
// @ts-ignore
|
|
117
|
+
timeout: false,
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
opencodeServers.set(directory, {
|
|
121
|
+
process: serverProcess,
|
|
122
|
+
client,
|
|
123
|
+
port,
|
|
124
|
+
});
|
|
125
|
+
return () => {
|
|
126
|
+
const entry = opencodeServers.get(directory);
|
|
127
|
+
if (!entry?.client) {
|
|
128
|
+
throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
|
|
129
|
+
}
|
|
130
|
+
return entry.client;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export function getOpencodeServers() {
|
|
134
|
+
return opencodeServers;
|
|
135
|
+
}
|