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/opencode.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// Spawns and maintains OpenCode API servers per project directory,
|
|
3
3
|
// handles automatic restarts on failure, and provides typed SDK clients.
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
5
6
|
import net from 'node:net';
|
|
6
7
|
import { createOpencodeClient, } from '@opencode-ai/sdk';
|
|
8
|
+
import { createOpencodeClient as createOpencodeClientV2, } from '@opencode-ai/sdk/v2';
|
|
7
9
|
import { createLogger } from './logger.js';
|
|
8
10
|
const opencodeLogger = createLogger('OPENCODE');
|
|
9
11
|
const opencodeServers = new Map();
|
|
@@ -30,22 +32,37 @@ async function waitForServer(port, maxAttempts = 30) {
|
|
|
30
32
|
for (let i = 0; i < maxAttempts; i++) {
|
|
31
33
|
try {
|
|
32
34
|
const endpoints = [
|
|
33
|
-
`http://
|
|
34
|
-
`http://
|
|
35
|
-
`http://
|
|
35
|
+
`http://127.0.0.1:${port}/api/health`,
|
|
36
|
+
`http://127.0.0.1:${port}/`,
|
|
37
|
+
`http://127.0.0.1:${port}/api`,
|
|
36
38
|
];
|
|
37
39
|
for (const endpoint of endpoints) {
|
|
38
40
|
try {
|
|
39
41
|
const response = await fetch(endpoint);
|
|
40
42
|
if (response.status < 500) {
|
|
41
|
-
opencodeLogger.log(`Server ready on port `);
|
|
42
43
|
return true;
|
|
43
44
|
}
|
|
45
|
+
const body = await response.text();
|
|
46
|
+
// Fatal errors that won't resolve with retrying
|
|
47
|
+
if (body.includes('BunInstallFailedError')) {
|
|
48
|
+
throw new Error(`Server failed to start: ${body.slice(0, 200)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
// Re-throw fatal errors
|
|
53
|
+
if (e.message?.includes('Server failed to start')) {
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
44
56
|
}
|
|
45
|
-
catch (e) { }
|
|
46
57
|
}
|
|
47
58
|
}
|
|
48
|
-
catch (e) {
|
|
59
|
+
catch (e) {
|
|
60
|
+
// Re-throw fatal errors that won't resolve with retrying
|
|
61
|
+
if (e.message?.includes('Server failed to start')) {
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
opencodeLogger.debug(`Server polling attempt failed: ${e.message}`);
|
|
65
|
+
}
|
|
49
66
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
50
67
|
}
|
|
51
68
|
throw new Error(`Server did not start on port ${port} after ${maxAttempts} seconds`);
|
|
@@ -62,8 +79,16 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
62
79
|
return entry.client;
|
|
63
80
|
};
|
|
64
81
|
}
|
|
82
|
+
// Verify directory exists and is accessible before spawning
|
|
83
|
+
try {
|
|
84
|
+
fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new Error(`Directory does not exist or is not accessible: ${directory}`);
|
|
88
|
+
}
|
|
65
89
|
const port = await getOpenPort();
|
|
66
|
-
const
|
|
90
|
+
const opencodeBinDir = `${process.env.HOME}/.opencode/bin`;
|
|
91
|
+
const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`;
|
|
67
92
|
const serverProcess = spawn(opencodeCommand, ['serve', '--port', port.toString()], {
|
|
68
93
|
stdio: 'pipe',
|
|
69
94
|
detached: false,
|
|
@@ -83,14 +108,17 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
83
108
|
OPENCODE_PORT: port.toString(),
|
|
84
109
|
},
|
|
85
110
|
});
|
|
111
|
+
// Buffer logs until we know if server started successfully
|
|
112
|
+
const logBuffer = [];
|
|
113
|
+
logBuffer.push(`Spawned opencode serve --port ${port} in ${directory} (pid: ${serverProcess.pid})`);
|
|
86
114
|
serverProcess.stdout?.on('data', (data) => {
|
|
87
|
-
|
|
115
|
+
logBuffer.push(`[stdout] ${data.toString().trim()}`);
|
|
88
116
|
});
|
|
89
117
|
serverProcess.stderr?.on('data', (data) => {
|
|
90
|
-
|
|
118
|
+
logBuffer.push(`[stderr] ${data.toString().trim()}`);
|
|
91
119
|
});
|
|
92
120
|
serverProcess.on('error', (error) => {
|
|
93
|
-
|
|
121
|
+
logBuffer.push(`Failed to start server on port ${port}: ${error}`);
|
|
94
122
|
});
|
|
95
123
|
serverProcess.on('exit', (code) => {
|
|
96
124
|
opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code);
|
|
@@ -112,17 +140,35 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
112
140
|
serverRetryCount.delete(directory);
|
|
113
141
|
}
|
|
114
142
|
});
|
|
115
|
-
|
|
143
|
+
try {
|
|
144
|
+
await waitForServer(port);
|
|
145
|
+
opencodeLogger.log(`Server ready on port ${port}`);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
// Dump buffered logs on failure
|
|
149
|
+
opencodeLogger.error(`Server failed to start for ${directory}:`);
|
|
150
|
+
for (const line of logBuffer) {
|
|
151
|
+
opencodeLogger.error(` ${line}`);
|
|
152
|
+
}
|
|
153
|
+
throw e;
|
|
154
|
+
}
|
|
155
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
156
|
+
const fetchWithTimeout = (request) => fetch(request, {
|
|
157
|
+
// @ts-ignore
|
|
158
|
+
timeout: false,
|
|
159
|
+
});
|
|
116
160
|
const client = createOpencodeClient({
|
|
117
|
-
baseUrl
|
|
118
|
-
fetch:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
161
|
+
baseUrl,
|
|
162
|
+
fetch: fetchWithTimeout,
|
|
163
|
+
});
|
|
164
|
+
const clientV2 = createOpencodeClientV2({
|
|
165
|
+
baseUrl,
|
|
166
|
+
fetch: fetchWithTimeout,
|
|
122
167
|
});
|
|
123
168
|
opencodeServers.set(directory, {
|
|
124
169
|
process: serverProcess,
|
|
125
170
|
client,
|
|
171
|
+
clientV2,
|
|
126
172
|
port,
|
|
127
173
|
});
|
|
128
174
|
return () => {
|
|
@@ -136,3 +182,11 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
136
182
|
export function getOpencodeServers() {
|
|
137
183
|
return opencodeServers;
|
|
138
184
|
}
|
|
185
|
+
export function getOpencodeServerPort(directory) {
|
|
186
|
+
const entry = opencodeServers.get(directory);
|
|
187
|
+
return entry?.port ?? null;
|
|
188
|
+
}
|
|
189
|
+
export function getOpencodeClientV2(directory) {
|
|
190
|
+
const entry = opencodeServers.get(directory);
|
|
191
|
+
return entry?.clientV2 ?? null;
|
|
192
|
+
}
|
package/dist/session-handler.js
CHANGED
|
@@ -2,29 +2,18 @@
|
|
|
2
2
|
// Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
|
|
3
3
|
// Handles streaming events, permissions, abort signals, and message queuing.
|
|
4
4
|
import prettyMilliseconds from 'pretty-ms';
|
|
5
|
-
import { getDatabase, getSessionModel, getChannelModel } from './database.js';
|
|
6
|
-
import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js';
|
|
5
|
+
import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js';
|
|
6
|
+
import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
|
|
7
7
|
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js';
|
|
8
8
|
import { formatPart } from './message-formatting.js';
|
|
9
9
|
import { getOpencodeSystemMessage } from './system-message.js';
|
|
10
10
|
import { createLogger } from './logger.js';
|
|
11
11
|
import { isAbortError } from './utils.js';
|
|
12
|
+
import { showAskUserQuestionDropdowns } from './commands/ask-question.js';
|
|
13
|
+
import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js';
|
|
12
14
|
const sessionLogger = createLogger('SESSION');
|
|
13
15
|
const voiceLogger = createLogger('VOICE');
|
|
14
16
|
const discordLogger = createLogger('DISCORD');
|
|
15
|
-
export function parseSlashCommand(text) {
|
|
16
|
-
const trimmed = text.trim();
|
|
17
|
-
if (!trimmed.startsWith('/')) {
|
|
18
|
-
return { isCommand: false };
|
|
19
|
-
}
|
|
20
|
-
const match = trimmed.match(/^\/(\S+)(?:\s+(.*))?$/);
|
|
21
|
-
if (!match) {
|
|
22
|
-
return { isCommand: false };
|
|
23
|
-
}
|
|
24
|
-
const command = match[1];
|
|
25
|
-
const args = match[2]?.trim() || '';
|
|
26
|
-
return { isCommand: true, command, arguments: args };
|
|
27
|
-
}
|
|
28
17
|
export const abortControllers = new Map();
|
|
29
18
|
export const pendingPermissions = new Map();
|
|
30
19
|
// Queue of messages waiting to be sent after current response finishes
|
|
@@ -42,7 +31,61 @@ export function getQueueLength(threadId) {
|
|
|
42
31
|
export function clearQueue(threadId) {
|
|
43
32
|
messageQueue.delete(threadId);
|
|
44
33
|
}
|
|
45
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Abort a running session and retry with the last user message.
|
|
36
|
+
* Used when model preference changes mid-request.
|
|
37
|
+
* Fetches last user message from OpenCode API instead of tracking in memory.
|
|
38
|
+
* @returns true if aborted and retry scheduled, false if no active request
|
|
39
|
+
*/
|
|
40
|
+
export async function abortAndRetrySession({ sessionId, thread, projectDirectory, }) {
|
|
41
|
+
const controller = abortControllers.get(sessionId);
|
|
42
|
+
if (!controller) {
|
|
43
|
+
sessionLogger.log(`[ABORT+RETRY] No active request for session ${sessionId}`);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`);
|
|
47
|
+
// Abort with special reason so we don't show "completed" message
|
|
48
|
+
controller.abort('model-change');
|
|
49
|
+
// Also call the API abort endpoint
|
|
50
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
51
|
+
try {
|
|
52
|
+
await getClient().session.abort({ path: { id: sessionId } });
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, e);
|
|
56
|
+
}
|
|
57
|
+
// Small delay to let the abort propagate
|
|
58
|
+
await new Promise((resolve) => { setTimeout(resolve, 300); });
|
|
59
|
+
// Fetch last user message from API
|
|
60
|
+
sessionLogger.log(`[ABORT+RETRY] Fetching last user message for session ${sessionId}`);
|
|
61
|
+
const messagesResponse = await getClient().session.messages({ path: { id: sessionId } });
|
|
62
|
+
const messages = messagesResponse.data || [];
|
|
63
|
+
const lastUserMessage = [...messages].reverse().find((m) => m.info.role === 'user');
|
|
64
|
+
if (!lastUserMessage) {
|
|
65
|
+
sessionLogger.log(`[ABORT+RETRY] No user message found in session ${sessionId}`);
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
// Extract text and images from parts
|
|
69
|
+
const textPart = lastUserMessage.parts.find((p) => p.type === 'text');
|
|
70
|
+
const prompt = textPart?.text || '';
|
|
71
|
+
const images = lastUserMessage.parts.filter((p) => p.type === 'file');
|
|
72
|
+
sessionLogger.log(`[ABORT+RETRY] Re-triggering session ${sessionId} with new model`);
|
|
73
|
+
// Use setImmediate to avoid blocking
|
|
74
|
+
setImmediate(() => {
|
|
75
|
+
handleOpencodeSession({
|
|
76
|
+
prompt,
|
|
77
|
+
thread,
|
|
78
|
+
projectDirectory,
|
|
79
|
+
images,
|
|
80
|
+
}).catch(async (e) => {
|
|
81
|
+
sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, e);
|
|
82
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
83
|
+
await sendThreadMessage(thread, `✗ Failed to retry with new model: ${errorMsg.slice(0, 200)}`);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], channelId, command, }) {
|
|
46
89
|
voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
|
|
47
90
|
const sessionStartTime = Date.now();
|
|
48
91
|
const directory = projectDirectory || process.cwd();
|
|
@@ -100,11 +143,14 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
100
143
|
},
|
|
101
144
|
body: { response: 'reject' },
|
|
102
145
|
});
|
|
146
|
+
// Clean up both the pending permission and its dropdown context
|
|
147
|
+
cleanupPermissionContext(pendingPerm.contextHash);
|
|
103
148
|
pendingPermissions.delete(thread.id);
|
|
104
149
|
await sendThreadMessage(thread, `⚠️ Previous permission request auto-rejected due to new message`);
|
|
105
150
|
}
|
|
106
151
|
catch (e) {
|
|
107
152
|
sessionLogger.log(`[PERMISSION] Failed to auto-reject permission:`, e);
|
|
153
|
+
cleanupPermissionContext(pendingPerm.contextHash);
|
|
108
154
|
pendingPermissions.delete(thread.id);
|
|
109
155
|
}
|
|
110
156
|
}
|
|
@@ -121,9 +167,12 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
121
167
|
sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`);
|
|
122
168
|
return;
|
|
123
169
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
170
|
+
// Use v2 client for event subscription (has proper types for question.asked events)
|
|
171
|
+
const clientV2 = getOpencodeClientV2(directory);
|
|
172
|
+
if (!clientV2) {
|
|
173
|
+
throw new Error(`OpenCode v2 client not found for directory: ${directory}`);
|
|
174
|
+
}
|
|
175
|
+
const eventsResult = await clientV2.event.subscribe({ directory }, { signal: abortController.signal });
|
|
127
176
|
if (abortController.signal.aborted) {
|
|
128
177
|
sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
|
|
129
178
|
return;
|
|
@@ -138,6 +187,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
138
187
|
let stopTyping = null;
|
|
139
188
|
let usedModel;
|
|
140
189
|
let usedProviderID;
|
|
190
|
+
let usedAgent;
|
|
141
191
|
let tokensUsedInSession = 0;
|
|
142
192
|
let lastDisplayedContextPercentage = 0;
|
|
143
193
|
let modelContextLimit;
|
|
@@ -177,7 +227,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
177
227
|
const sendPartMessage = async (part) => {
|
|
178
228
|
const content = formatPart(part) + '\n\n';
|
|
179
229
|
if (!content.trim() || content.length === 0) {
|
|
180
|
-
discordLogger.log(`SKIP: Part ${part.id} has no content`)
|
|
230
|
+
// discordLogger.log(`SKIP: Part ${part.id} has no content`)
|
|
181
231
|
return;
|
|
182
232
|
}
|
|
183
233
|
if (sentPartIds.has(part.id)) {
|
|
@@ -211,6 +261,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
211
261
|
assistantMessageId = msg.id;
|
|
212
262
|
usedModel = msg.modelID;
|
|
213
263
|
usedProviderID = msg.providerID;
|
|
264
|
+
usedAgent = msg.mode;
|
|
214
265
|
if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
|
|
215
266
|
if (!modelContextLimit) {
|
|
216
267
|
try {
|
|
@@ -296,38 +347,53 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
296
347
|
}
|
|
297
348
|
break;
|
|
298
349
|
}
|
|
299
|
-
else if (event.type === 'permission.
|
|
350
|
+
else if (event.type === 'permission.asked') {
|
|
300
351
|
const permission = event.properties;
|
|
301
352
|
if (permission.sessionID !== session.id) {
|
|
302
353
|
voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
|
|
303
354
|
continue;
|
|
304
355
|
}
|
|
305
|
-
sessionLogger.log(`Permission requested:
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
(patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
|
|
313
|
-
`\nUse \`/accept\` or \`/reject\` to respond.`);
|
|
356
|
+
sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
|
|
357
|
+
// Show dropdown instead of text message
|
|
358
|
+
const { messageId, contextHash } = await showPermissionDropdown({
|
|
359
|
+
thread,
|
|
360
|
+
permission,
|
|
361
|
+
directory,
|
|
362
|
+
});
|
|
314
363
|
pendingPermissions.set(thread.id, {
|
|
315
364
|
permission,
|
|
316
|
-
messageId
|
|
365
|
+
messageId,
|
|
317
366
|
directory,
|
|
367
|
+
contextHash,
|
|
318
368
|
});
|
|
319
369
|
}
|
|
320
370
|
else if (event.type === 'permission.replied') {
|
|
321
|
-
const {
|
|
371
|
+
const { requestID, reply, sessionID } = event.properties;
|
|
322
372
|
if (sessionID !== session.id) {
|
|
323
373
|
continue;
|
|
324
374
|
}
|
|
325
|
-
sessionLogger.log(`Permission ${
|
|
375
|
+
sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
|
|
326
376
|
const pending = pendingPermissions.get(thread.id);
|
|
327
|
-
if (pending && pending.permission.id ===
|
|
377
|
+
if (pending && pending.permission.id === requestID) {
|
|
378
|
+
cleanupPermissionContext(pending.contextHash);
|
|
328
379
|
pendingPermissions.delete(thread.id);
|
|
329
380
|
}
|
|
330
381
|
}
|
|
382
|
+
else if (event.type === 'question.asked') {
|
|
383
|
+
const questionRequest = event.properties;
|
|
384
|
+
if (questionRequest.sessionID !== session.id) {
|
|
385
|
+
sessionLogger.log(`[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
|
|
389
|
+
await showAskUserQuestionDropdowns({
|
|
390
|
+
thread,
|
|
391
|
+
sessionId: session.id,
|
|
392
|
+
directory,
|
|
393
|
+
requestId: questionRequest.id,
|
|
394
|
+
input: { questions: questionRequest.questions },
|
|
395
|
+
});
|
|
396
|
+
}
|
|
331
397
|
}
|
|
332
398
|
}
|
|
333
399
|
catch (e) {
|
|
@@ -358,6 +424,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
358
424
|
const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
|
|
359
425
|
const attachCommand = port ? ` ⋅ ${session.id}` : '';
|
|
360
426
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
|
|
427
|
+
const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : '';
|
|
361
428
|
let contextInfo = '';
|
|
362
429
|
try {
|
|
363
430
|
const providersResponse = await getClient().provider.list({ query: { directory } });
|
|
@@ -371,7 +438,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
371
438
|
catch (e) {
|
|
372
439
|
sessionLogger.error('Failed to fetch provider info for context percentage:', e);
|
|
373
440
|
}
|
|
374
|
-
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
441
|
+
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
375
442
|
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
|
|
376
443
|
// Process queued messages after completion
|
|
377
444
|
const queue = messageQueue.get(thread.id);
|
|
@@ -412,49 +479,58 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
412
479
|
return;
|
|
413
480
|
}
|
|
414
481
|
stopTyping = startTyping();
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
482
|
+
voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
|
483
|
+
// append image paths to prompt so ai knows where they are on disk
|
|
484
|
+
const promptWithImagePaths = (() => {
|
|
485
|
+
if (images.length === 0) {
|
|
486
|
+
return prompt;
|
|
487
|
+
}
|
|
488
|
+
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
|
|
489
|
+
const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n');
|
|
490
|
+
return `${prompt}\n\n**attached images:**\n${imagePathsList}`;
|
|
491
|
+
})();
|
|
492
|
+
const parts = [{ type: 'text', text: promptWithImagePaths }, ...images];
|
|
493
|
+
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
|
|
494
|
+
// Get model preference: session-level overrides channel-level
|
|
495
|
+
const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
|
|
496
|
+
const modelParam = (() => {
|
|
497
|
+
if (!modelPreference) {
|
|
498
|
+
return undefined;
|
|
499
|
+
}
|
|
500
|
+
const [providerID, ...modelParts] = modelPreference.split('/');
|
|
501
|
+
const modelID = modelParts.join('/');
|
|
502
|
+
if (!providerID || !modelID) {
|
|
503
|
+
return undefined;
|
|
504
|
+
}
|
|
505
|
+
sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
|
|
506
|
+
return { providerID, modelID };
|
|
507
|
+
})();
|
|
508
|
+
// Get agent preference: session-level overrides channel-level
|
|
509
|
+
const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined);
|
|
510
|
+
if (agentPreference) {
|
|
511
|
+
sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`);
|
|
512
|
+
}
|
|
513
|
+
// Use session.command API for slash commands, session.prompt for regular messages
|
|
514
|
+
const response = command
|
|
515
|
+
? await getClient().session.command({
|
|
419
516
|
path: { id: session.id },
|
|
420
517
|
body: {
|
|
421
|
-
command:
|
|
422
|
-
arguments:
|
|
518
|
+
command: command.name,
|
|
519
|
+
arguments: command.arguments,
|
|
520
|
+
agent: agentPreference,
|
|
423
521
|
},
|
|
424
522
|
signal: abortController.signal,
|
|
425
|
-
})
|
|
426
|
-
|
|
427
|
-
else {
|
|
428
|
-
voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
|
429
|
-
if (images.length > 0) {
|
|
430
|
-
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
|
|
431
|
-
}
|
|
432
|
-
const parts = [{ type: 'text', text: prompt }, ...images];
|
|
433
|
-
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
|
|
434
|
-
// Get model preference: session-level overrides channel-level
|
|
435
|
-
const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
|
|
436
|
-
const modelParam = (() => {
|
|
437
|
-
if (!modelPreference) {
|
|
438
|
-
return undefined;
|
|
439
|
-
}
|
|
440
|
-
const [providerID, ...modelParts] = modelPreference.split('/');
|
|
441
|
-
const modelID = modelParts.join('/');
|
|
442
|
-
if (!providerID || !modelID) {
|
|
443
|
-
return undefined;
|
|
444
|
-
}
|
|
445
|
-
sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
|
|
446
|
-
return { providerID, modelID };
|
|
447
|
-
})();
|
|
448
|
-
response = await getClient().session.prompt({
|
|
523
|
+
})
|
|
524
|
+
: await getClient().session.prompt({
|
|
449
525
|
path: { id: session.id },
|
|
450
526
|
body: {
|
|
451
527
|
parts,
|
|
452
528
|
system: getOpencodeSystemMessage({ sessionId: session.id }),
|
|
453
529
|
model: modelParam,
|
|
530
|
+
agent: agentPreference,
|
|
454
531
|
},
|
|
455
532
|
signal: abortController.signal,
|
|
456
533
|
});
|
|
457
|
-
}
|
|
458
534
|
if (response.error) {
|
|
459
535
|
const errorMessage = (() => {
|
|
460
536
|
const err = response.error;
|
package/dist/system-message.js
CHANGED
|
@@ -17,24 +17,6 @@ Only users with these Discord permissions can send messages to the bot:
|
|
|
17
17
|
- Manage Server permission
|
|
18
18
|
- "Kimaki" role (case-insensitive)
|
|
19
19
|
|
|
20
|
-
## changing the model
|
|
21
|
-
|
|
22
|
-
To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
|
|
23
|
-
|
|
24
|
-
\`\`\`json
|
|
25
|
-
{
|
|
26
|
-
"model": "anthropic/claude-sonnet-4-20250514"
|
|
27
|
-
}
|
|
28
|
-
\`\`\`
|
|
29
|
-
|
|
30
|
-
Examples:
|
|
31
|
-
- \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
|
|
32
|
-
- \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
|
|
33
|
-
- \`"openai/gpt-4o"\` - GPT-4o
|
|
34
|
-
- \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
|
|
35
|
-
|
|
36
|
-
Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
|
|
37
|
-
|
|
38
20
|
## uploading files to discord
|
|
39
21
|
|
|
40
22
|
To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
|
|
@@ -55,7 +37,9 @@ bunx critique web -- path/to/file1.ts path/to/file2.ts
|
|
|
55
37
|
|
|
56
38
|
You can also show latest commit changes using:
|
|
57
39
|
|
|
58
|
-
bunx critique web HEAD
|
|
40
|
+
bunx critique web HEAD
|
|
41
|
+
|
|
42
|
+
bunx critique web HEAD~1 to get the one before last
|
|
59
43
|
|
|
60
44
|
Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
|
|
61
45
|
|
|
@@ -69,40 +53,9 @@ the max heading level is 3, so do not use ####
|
|
|
69
53
|
|
|
70
54
|
headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
|
|
71
55
|
|
|
72
|
-
## capitalization
|
|
73
|
-
|
|
74
|
-
write casually like a discord user. never capitalize the initials of phrases or acronyms in your messages. use all lowercase instead.
|
|
75
|
-
|
|
76
|
-
examples:
|
|
77
|
-
- write "api" not "API"
|
|
78
|
-
- write "url" not "URL"
|
|
79
|
-
- write "json" not "JSON"
|
|
80
|
-
- write "cli" not "CLI"
|
|
81
|
-
- write "sdk" not "SDK"
|
|
82
|
-
|
|
83
|
-
this makes your messages blend in naturally with how people actually type on discord.
|
|
84
|
-
|
|
85
|
-
## tables
|
|
86
|
-
|
|
87
|
-
discord does NOT support markdown gfm tables.
|
|
88
|
-
|
|
89
|
-
so instead of using full markdown tables ALWAYS show code snippets with space aligned cells:
|
|
90
|
-
|
|
91
|
-
\`\`\`
|
|
92
|
-
Item Qty Price
|
|
93
|
-
---------- --- -----
|
|
94
|
-
Apples 10 $5
|
|
95
|
-
Oranges 3 $2
|
|
96
|
-
\`\`\`
|
|
97
|
-
|
|
98
|
-
Using code blocks will make the content use monospaced font so that space will be aligned correctly
|
|
99
|
-
|
|
100
|
-
IMPORTANT: add enough space characters to align the table! otherwise the content will not look good and will be difficult to understand for the user
|
|
101
|
-
|
|
102
|
-
code blocks for tables and diagrams MUST have Max length of 85 characters. otherwise the content will wrap
|
|
103
56
|
|
|
104
57
|
## diagrams
|
|
105
58
|
|
|
106
|
-
you can create diagrams wrapping them in code blocks
|
|
59
|
+
you can create diagrams wrapping them in code blocks.
|
|
107
60
|
`;
|
|
108
61
|
}
|
package/dist/voice-handler.js
CHANGED
|
@@ -314,7 +314,7 @@ export async function cleanupVoiceConnection(guildId) {
|
|
|
314
314
|
voiceConnections.delete(guildId);
|
|
315
315
|
}
|
|
316
316
|
}
|
|
317
|
-
export async function processVoiceAttachment({ message, thread, projectDirectory, isNewThread = false, appId,
|
|
317
|
+
export async function processVoiceAttachment({ message, thread, projectDirectory, isNewThread = false, appId, currentSessionContext, lastSessionContext, }) {
|
|
318
318
|
const audioAttachment = Array.from(message.attachments.values()).find((attachment) => attachment.contentType?.startsWith('audio/'));
|
|
319
319
|
if (!audioAttachment)
|
|
320
320
|
return null;
|
|
@@ -350,13 +350,23 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
|
|
|
350
350
|
geminiApiKey = apiKeys.gemini_api_key;
|
|
351
351
|
}
|
|
352
352
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
353
|
+
let transcription;
|
|
354
|
+
try {
|
|
355
|
+
transcription = await transcribeAudio({
|
|
356
|
+
audio: audioBuffer,
|
|
357
|
+
prompt: transcriptionPrompt,
|
|
358
|
+
geminiApiKey,
|
|
359
|
+
directory: projectDirectory,
|
|
360
|
+
currentSessionContext,
|
|
361
|
+
lastSessionContext,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
366
|
+
voiceLogger.error(`Transcription failed:`, error);
|
|
367
|
+
await sendThreadMessage(thread, `⚠️ Transcription failed: ${errMsg}`);
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
360
370
|
voiceLogger.log(`Transcription successful: "${transcription.slice(0, 50)}${transcription.length > 50 ? '...' : ''}"`);
|
|
361
371
|
if (isNewThread) {
|
|
362
372
|
const threadName = transcription.replace(/\s+/g, ' ').trim().slice(0, 80);
|