kimaki 0.4.24 → 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 +6 -1
- package/dist/acp-client.test.js +149 -0
- package/dist/ai-tool-to-genai.js +3 -0
- package/dist/channel-management.js +14 -9
- package/dist/cli.js +148 -17
- 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 +54 -0
- package/dist/discord-bot.js +35 -32
- package/dist/discord-utils.js +81 -15
- package/dist/format-tables.js +3 -0
- package/dist/genai-worker-wrapper.js +3 -0
- package/dist/genai-worker.js +3 -0
- package/dist/genai.js +3 -0
- package/dist/interaction-handler.js +89 -695
- package/dist/logger.js +46 -5
- package/dist/markdown.js +107 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +113 -28
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +73 -16
- package/dist/session-handler.js +176 -63
- package/dist/system-message.js +7 -38
- package/dist/tools.js +3 -0
- package/dist/utils.js +3 -0
- package/dist/voice-handler.js +21 -8
- package/dist/voice.js +31 -12
- package/dist/worker-types.js +3 -0
- package/dist/xml.js +3 -0
- package/package.json +3 -3
- 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.ts +4 -0
- package/src/channel-management.ts +24 -8
- package/src/cli.ts +163 -18
- 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/{fork.ts → commands/fork.ts} +40 -7
- package/src/{model-command.ts → commands/model.ts} +31 -9
- 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 +65 -0
- package/src/discord-bot.ts +40 -33
- package/src/discord-utils.ts +88 -14
- package/src/format-tables.ts +4 -0
- package/src/genai-worker-wrapper.ts +4 -0
- package/src/genai-worker.ts +4 -0
- package/src/genai.ts +4 -0
- package/src/interaction-handler.ts +111 -924
- package/src/logger.ts +51 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +136 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +143 -30
- package/src/opencode.ts +84 -21
- package/src/session-handler.ts +248 -91
- package/src/system-message.ts +8 -38
- package/src/tools.ts +4 -0
- package/src/utils.ts +4 -0
- package/src/voice-handler.ts +24 -9
- package/src/voice.ts +36 -13
- package/src/worker-types.ts +4 -0
- package/src/xml.ts +4 -0
- package/README.md +0 -48
package/dist/opencode.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
// OpenCode server process manager.
|
|
2
|
+
// Spawns and maintains OpenCode API servers per project directory,
|
|
3
|
+
// handles automatic restarts on failure, and provides typed SDK clients.
|
|
1
4
|
import { spawn } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
2
6
|
import net from 'node:net';
|
|
3
7
|
import { createOpencodeClient, } from '@opencode-ai/sdk';
|
|
8
|
+
import { createOpencodeClient as createOpencodeClientV2, } from '@opencode-ai/sdk/v2';
|
|
4
9
|
import { createLogger } from './logger.js';
|
|
5
10
|
const opencodeLogger = createLogger('OPENCODE');
|
|
6
11
|
const opencodeServers = new Map();
|
|
@@ -27,22 +32,37 @@ async function waitForServer(port, maxAttempts = 30) {
|
|
|
27
32
|
for (let i = 0; i < maxAttempts; i++) {
|
|
28
33
|
try {
|
|
29
34
|
const endpoints = [
|
|
30
|
-
`http://
|
|
31
|
-
`http://
|
|
32
|
-
`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`,
|
|
33
38
|
];
|
|
34
39
|
for (const endpoint of endpoints) {
|
|
35
40
|
try {
|
|
36
41
|
const response = await fetch(endpoint);
|
|
37
42
|
if (response.status < 500) {
|
|
38
|
-
opencodeLogger.log(`Server ready on port `);
|
|
39
43
|
return true;
|
|
40
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
|
+
}
|
|
41
56
|
}
|
|
42
|
-
catch (e) { }
|
|
43
57
|
}
|
|
44
58
|
}
|
|
45
|
-
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
|
+
}
|
|
46
66
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
47
67
|
}
|
|
48
68
|
throw new Error(`Server did not start on port ${port} after ${maxAttempts} seconds`);
|
|
@@ -59,8 +79,16 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
59
79
|
return entry.client;
|
|
60
80
|
};
|
|
61
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
|
+
}
|
|
62
89
|
const port = await getOpenPort();
|
|
63
|
-
const
|
|
90
|
+
const opencodeBinDir = `${process.env.HOME}/.opencode/bin`;
|
|
91
|
+
const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`;
|
|
64
92
|
const serverProcess = spawn(opencodeCommand, ['serve', '--port', port.toString()], {
|
|
65
93
|
stdio: 'pipe',
|
|
66
94
|
detached: false,
|
|
@@ -80,14 +108,17 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
80
108
|
OPENCODE_PORT: port.toString(),
|
|
81
109
|
},
|
|
82
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})`);
|
|
83
114
|
serverProcess.stdout?.on('data', (data) => {
|
|
84
|
-
|
|
115
|
+
logBuffer.push(`[stdout] ${data.toString().trim()}`);
|
|
85
116
|
});
|
|
86
117
|
serverProcess.stderr?.on('data', (data) => {
|
|
87
|
-
|
|
118
|
+
logBuffer.push(`[stderr] ${data.toString().trim()}`);
|
|
88
119
|
});
|
|
89
120
|
serverProcess.on('error', (error) => {
|
|
90
|
-
|
|
121
|
+
logBuffer.push(`Failed to start server on port ${port}: ${error}`);
|
|
91
122
|
});
|
|
92
123
|
serverProcess.on('exit', (code) => {
|
|
93
124
|
opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code);
|
|
@@ -109,17 +140,35 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
109
140
|
serverRetryCount.delete(directory);
|
|
110
141
|
}
|
|
111
142
|
});
|
|
112
|
-
|
|
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
|
+
});
|
|
113
160
|
const client = createOpencodeClient({
|
|
114
|
-
baseUrl
|
|
115
|
-
fetch:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
161
|
+
baseUrl,
|
|
162
|
+
fetch: fetchWithTimeout,
|
|
163
|
+
});
|
|
164
|
+
const clientV2 = createOpencodeClientV2({
|
|
165
|
+
baseUrl,
|
|
166
|
+
fetch: fetchWithTimeout,
|
|
119
167
|
});
|
|
120
168
|
opencodeServers.set(directory, {
|
|
121
169
|
process: serverProcess,
|
|
122
170
|
client,
|
|
171
|
+
clientV2,
|
|
123
172
|
port,
|
|
124
173
|
});
|
|
125
174
|
return () => {
|
|
@@ -133,3 +182,11 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
133
182
|
export function getOpencodeServers() {
|
|
134
183
|
return opencodeServers;
|
|
135
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
|
@@ -1,30 +1,90 @@
|
|
|
1
|
+
// OpenCode session lifecycle manager.
|
|
2
|
+
// Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
|
|
3
|
+
// Handles streaming events, permissions, abort signals, and message queuing.
|
|
1
4
|
import prettyMilliseconds from 'pretty-ms';
|
|
2
|
-
import { getDatabase, getSessionModel, getChannelModel } from './database.js';
|
|
3
|
-
import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js';
|
|
4
|
-
import { sendThreadMessage } from './discord-utils.js';
|
|
5
|
+
import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js';
|
|
6
|
+
import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
|
|
7
|
+
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js';
|
|
5
8
|
import { formatPart } from './message-formatting.js';
|
|
6
9
|
import { getOpencodeSystemMessage } from './system-message.js';
|
|
7
10
|
import { createLogger } from './logger.js';
|
|
8
11
|
import { isAbortError } from './utils.js';
|
|
12
|
+
import { showAskUserQuestionDropdowns } from './commands/ask-question.js';
|
|
9
13
|
const sessionLogger = createLogger('SESSION');
|
|
10
14
|
const voiceLogger = createLogger('VOICE');
|
|
11
15
|
const discordLogger = createLogger('DISCORD');
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
export const abortControllers = new Map();
|
|
17
|
+
export const pendingPermissions = new Map();
|
|
18
|
+
// Queue of messages waiting to be sent after current response finishes
|
|
19
|
+
// Key is threadId, value is array of queued messages
|
|
20
|
+
export const messageQueue = new Map();
|
|
21
|
+
export function addToQueue({ threadId, message, }) {
|
|
22
|
+
const queue = messageQueue.get(threadId) || [];
|
|
23
|
+
queue.push(message);
|
|
24
|
+
messageQueue.set(threadId, queue);
|
|
25
|
+
return queue.length;
|
|
26
|
+
}
|
|
27
|
+
export function getQueueLength(threadId) {
|
|
28
|
+
return messageQueue.get(threadId)?.length || 0;
|
|
29
|
+
}
|
|
30
|
+
export function clearQueue(threadId) {
|
|
31
|
+
messageQueue.delete(threadId);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Abort a running session and retry with the last user message.
|
|
35
|
+
* Used when model preference changes mid-request.
|
|
36
|
+
* Fetches last user message from OpenCode API instead of tracking in memory.
|
|
37
|
+
* @returns true if aborted and retry scheduled, false if no active request
|
|
38
|
+
*/
|
|
39
|
+
export async function abortAndRetrySession({ sessionId, thread, projectDirectory, }) {
|
|
40
|
+
const controller = abortControllers.get(sessionId);
|
|
41
|
+
if (!controller) {
|
|
42
|
+
sessionLogger.log(`[ABORT+RETRY] No active request for session ${sessionId}`);
|
|
43
|
+
return false;
|
|
16
44
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
45
|
+
sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`);
|
|
46
|
+
// Abort with special reason so we don't show "completed" message
|
|
47
|
+
controller.abort('model-change');
|
|
48
|
+
// Also call the API abort endpoint
|
|
49
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
50
|
+
try {
|
|
51
|
+
await getClient().session.abort({ path: { id: sessionId } });
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, e);
|
|
20
55
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
56
|
+
// Small delay to let the abort propagate
|
|
57
|
+
await new Promise((resolve) => { setTimeout(resolve, 300); });
|
|
58
|
+
// Fetch last user message from API
|
|
59
|
+
sessionLogger.log(`[ABORT+RETRY] Fetching last user message for session ${sessionId}`);
|
|
60
|
+
const messagesResponse = await getClient().session.messages({ path: { id: sessionId } });
|
|
61
|
+
const messages = messagesResponse.data || [];
|
|
62
|
+
const lastUserMessage = [...messages].reverse().find((m) => m.info.role === 'user');
|
|
63
|
+
if (!lastUserMessage) {
|
|
64
|
+
sessionLogger.log(`[ABORT+RETRY] No user message found in session ${sessionId}`);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
// Extract text and images from parts
|
|
68
|
+
const textPart = lastUserMessage.parts.find((p) => p.type === 'text');
|
|
69
|
+
const prompt = textPart?.text || '';
|
|
70
|
+
const images = lastUserMessage.parts.filter((p) => p.type === 'file');
|
|
71
|
+
sessionLogger.log(`[ABORT+RETRY] Re-triggering session ${sessionId} with new model`);
|
|
72
|
+
// Use setImmediate to avoid blocking
|
|
73
|
+
setImmediate(() => {
|
|
74
|
+
handleOpencodeSession({
|
|
75
|
+
prompt,
|
|
76
|
+
thread,
|
|
77
|
+
projectDirectory,
|
|
78
|
+
images,
|
|
79
|
+
}).catch(async (e) => {
|
|
80
|
+
sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, e);
|
|
81
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
82
|
+
await sendThreadMessage(thread, `✗ Failed to retry with new model: ${errorMsg.slice(0, 200)}`);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
return true;
|
|
24
86
|
}
|
|
25
|
-
export
|
|
26
|
-
export const pendingPermissions = new Map();
|
|
27
|
-
export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], parsedCommand, channelId, }) {
|
|
87
|
+
export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], channelId, command, }) {
|
|
28
88
|
voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
|
|
29
89
|
const sessionStartTime = Date.now();
|
|
30
90
|
const directory = projectDirectory || process.cwd();
|
|
@@ -103,9 +163,12 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
103
163
|
sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`);
|
|
104
164
|
return;
|
|
105
165
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
166
|
+
// Use v2 client for event subscription (has proper types for question.asked events)
|
|
167
|
+
const clientV2 = getOpencodeClientV2(directory);
|
|
168
|
+
if (!clientV2) {
|
|
169
|
+
throw new Error(`OpenCode v2 client not found for directory: ${directory}`);
|
|
170
|
+
}
|
|
171
|
+
const eventsResult = await clientV2.event.subscribe({ directory }, { signal: abortController.signal });
|
|
109
172
|
if (abortController.signal.aborted) {
|
|
110
173
|
sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
|
|
111
174
|
return;
|
|
@@ -120,6 +183,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
120
183
|
let stopTyping = null;
|
|
121
184
|
let usedModel;
|
|
122
185
|
let usedProviderID;
|
|
186
|
+
let usedAgent;
|
|
123
187
|
let tokensUsedInSession = 0;
|
|
124
188
|
let lastDisplayedContextPercentage = 0;
|
|
125
189
|
let modelContextLimit;
|
|
@@ -159,7 +223,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
159
223
|
const sendPartMessage = async (part) => {
|
|
160
224
|
const content = formatPart(part) + '\n\n';
|
|
161
225
|
if (!content.trim() || content.length === 0) {
|
|
162
|
-
discordLogger.log(`SKIP: Part ${part.id} has no content`)
|
|
226
|
+
// discordLogger.log(`SKIP: Part ${part.id} has no content`)
|
|
163
227
|
return;
|
|
164
228
|
}
|
|
165
229
|
if (sentPartIds.has(part.id)) {
|
|
@@ -193,6 +257,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
193
257
|
assistantMessageId = msg.id;
|
|
194
258
|
usedModel = msg.modelID;
|
|
195
259
|
usedProviderID = msg.providerID;
|
|
260
|
+
usedAgent = msg.mode;
|
|
196
261
|
if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
|
|
197
262
|
if (!modelContextLimit) {
|
|
198
263
|
try {
|
|
@@ -212,7 +277,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
212
277
|
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
|
|
213
278
|
if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
|
|
214
279
|
lastDisplayedContextPercentage = thresholdCrossed;
|
|
215
|
-
await sendThreadMessage(thread,
|
|
280
|
+
await sendThreadMessage(thread, `⬥ context usage ${currentPercentage}%`);
|
|
216
281
|
}
|
|
217
282
|
}
|
|
218
283
|
}
|
|
@@ -278,19 +343,16 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
278
343
|
}
|
|
279
344
|
break;
|
|
280
345
|
}
|
|
281
|
-
else if (event.type === 'permission.
|
|
346
|
+
else if (event.type === 'permission.asked') {
|
|
282
347
|
const permission = event.properties;
|
|
283
348
|
if (permission.sessionID !== session.id) {
|
|
284
349
|
voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
|
|
285
350
|
continue;
|
|
286
351
|
}
|
|
287
|
-
sessionLogger.log(`Permission requested:
|
|
288
|
-
const patternStr =
|
|
289
|
-
? permission.pattern.join(', ')
|
|
290
|
-
: permission.pattern || '';
|
|
352
|
+
sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
|
|
353
|
+
const patternStr = permission.patterns.join(', ');
|
|
291
354
|
const permissionMessage = await sendThreadMessage(thread, `⚠️ **Permission Required**\n\n` +
|
|
292
|
-
`**Type:** \`${permission.
|
|
293
|
-
`**Action:** ${permission.title}\n` +
|
|
355
|
+
`**Type:** \`${permission.permission}\`\n` +
|
|
294
356
|
(patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
|
|
295
357
|
`\nUse \`/accept\` or \`/reject\` to respond.`);
|
|
296
358
|
pendingPermissions.set(thread.id, {
|
|
@@ -300,16 +362,31 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
300
362
|
});
|
|
301
363
|
}
|
|
302
364
|
else if (event.type === 'permission.replied') {
|
|
303
|
-
const {
|
|
365
|
+
const { requestID, reply, sessionID } = event.properties;
|
|
304
366
|
if (sessionID !== session.id) {
|
|
305
367
|
continue;
|
|
306
368
|
}
|
|
307
|
-
sessionLogger.log(`Permission ${
|
|
369
|
+
sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
|
|
308
370
|
const pending = pendingPermissions.get(thread.id);
|
|
309
|
-
if (pending && pending.permission.id ===
|
|
371
|
+
if (pending && pending.permission.id === requestID) {
|
|
310
372
|
pendingPermissions.delete(thread.id);
|
|
311
373
|
}
|
|
312
374
|
}
|
|
375
|
+
else if (event.type === 'question.asked') {
|
|
376
|
+
const questionRequest = event.properties;
|
|
377
|
+
if (questionRequest.sessionID !== session.id) {
|
|
378
|
+
sessionLogger.log(`[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
|
|
382
|
+
await showAskUserQuestionDropdowns({
|
|
383
|
+
thread,
|
|
384
|
+
sessionId: session.id,
|
|
385
|
+
directory,
|
|
386
|
+
requestId: questionRequest.id,
|
|
387
|
+
input: { questions: questionRequest.questions },
|
|
388
|
+
});
|
|
389
|
+
}
|
|
313
390
|
}
|
|
314
391
|
}
|
|
315
392
|
catch (e) {
|
|
@@ -340,6 +417,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
340
417
|
const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
|
|
341
418
|
const attachCommand = port ? ` ⋅ ${session.id}` : '';
|
|
342
419
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
|
|
420
|
+
const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : '';
|
|
343
421
|
let contextInfo = '';
|
|
344
422
|
try {
|
|
345
423
|
const providersResponse = await getClient().provider.list({ query: { directory } });
|
|
@@ -353,8 +431,34 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
353
431
|
catch (e) {
|
|
354
432
|
sessionLogger.error('Failed to fetch provider info for context percentage:', e);
|
|
355
433
|
}
|
|
356
|
-
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}
|
|
434
|
+
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
357
435
|
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
|
|
436
|
+
// Process queued messages after completion
|
|
437
|
+
const queue = messageQueue.get(thread.id);
|
|
438
|
+
if (queue && queue.length > 0) {
|
|
439
|
+
const nextMessage = queue.shift();
|
|
440
|
+
if (queue.length === 0) {
|
|
441
|
+
messageQueue.delete(thread.id);
|
|
442
|
+
}
|
|
443
|
+
sessionLogger.log(`[QUEUE] Processing queued message from ${nextMessage.username}`);
|
|
444
|
+
// Show that queued message is being sent
|
|
445
|
+
await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
|
|
446
|
+
// Send the queued message as a new prompt (recursive call)
|
|
447
|
+
// Use setImmediate to avoid blocking and allow this finally to complete
|
|
448
|
+
setImmediate(() => {
|
|
449
|
+
handleOpencodeSession({
|
|
450
|
+
prompt: nextMessage.prompt,
|
|
451
|
+
thread,
|
|
452
|
+
projectDirectory,
|
|
453
|
+
images: nextMessage.images,
|
|
454
|
+
channelId,
|
|
455
|
+
}).catch(async (e) => {
|
|
456
|
+
sessionLogger.error(`[QUEUE] Failed to process queued message:`, e);
|
|
457
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
458
|
+
await sendThreadMessage(thread, `✗ Queued message failed: ${errorMsg.slice(0, 200)}`);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
}
|
|
358
462
|
}
|
|
359
463
|
else {
|
|
360
464
|
sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
|
|
@@ -368,49 +472,58 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
368
472
|
return;
|
|
369
473
|
}
|
|
370
474
|
stopTyping = startTyping();
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
475
|
+
voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
|
476
|
+
// append image paths to prompt so ai knows where they are on disk
|
|
477
|
+
const promptWithImagePaths = (() => {
|
|
478
|
+
if (images.length === 0) {
|
|
479
|
+
return prompt;
|
|
480
|
+
}
|
|
481
|
+
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
|
|
482
|
+
const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n');
|
|
483
|
+
return `${prompt}\n\n**attached images:**\n${imagePathsList}`;
|
|
484
|
+
})();
|
|
485
|
+
const parts = [{ type: 'text', text: promptWithImagePaths }, ...images];
|
|
486
|
+
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
|
|
487
|
+
// Get model preference: session-level overrides channel-level
|
|
488
|
+
const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
|
|
489
|
+
const modelParam = (() => {
|
|
490
|
+
if (!modelPreference) {
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
const [providerID, ...modelParts] = modelPreference.split('/');
|
|
494
|
+
const modelID = modelParts.join('/');
|
|
495
|
+
if (!providerID || !modelID) {
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
|
|
499
|
+
return { providerID, modelID };
|
|
500
|
+
})();
|
|
501
|
+
// Get agent preference: session-level overrides channel-level
|
|
502
|
+
const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined);
|
|
503
|
+
if (agentPreference) {
|
|
504
|
+
sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`);
|
|
505
|
+
}
|
|
506
|
+
// Use session.command API for slash commands, session.prompt for regular messages
|
|
507
|
+
const response = command
|
|
508
|
+
? await getClient().session.command({
|
|
375
509
|
path: { id: session.id },
|
|
376
510
|
body: {
|
|
377
|
-
command:
|
|
378
|
-
arguments:
|
|
511
|
+
command: command.name,
|
|
512
|
+
arguments: command.arguments,
|
|
513
|
+
agent: agentPreference,
|
|
379
514
|
},
|
|
380
515
|
signal: abortController.signal,
|
|
381
|
-
})
|
|
382
|
-
|
|
383
|
-
else {
|
|
384
|
-
voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
|
385
|
-
if (images.length > 0) {
|
|
386
|
-
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
|
|
387
|
-
}
|
|
388
|
-
const parts = [{ type: 'text', text: prompt }, ...images];
|
|
389
|
-
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
|
|
390
|
-
// Get model preference: session-level overrides channel-level
|
|
391
|
-
const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
|
|
392
|
-
const modelParam = (() => {
|
|
393
|
-
if (!modelPreference) {
|
|
394
|
-
return undefined;
|
|
395
|
-
}
|
|
396
|
-
const [providerID, ...modelParts] = modelPreference.split('/');
|
|
397
|
-
const modelID = modelParts.join('/');
|
|
398
|
-
if (!providerID || !modelID) {
|
|
399
|
-
return undefined;
|
|
400
|
-
}
|
|
401
|
-
sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
|
|
402
|
-
return { providerID, modelID };
|
|
403
|
-
})();
|
|
404
|
-
response = await getClient().session.prompt({
|
|
516
|
+
})
|
|
517
|
+
: await getClient().session.prompt({
|
|
405
518
|
path: { id: session.id },
|
|
406
519
|
body: {
|
|
407
520
|
parts,
|
|
408
521
|
system: getOpencodeSystemMessage({ sessionId: session.id }),
|
|
409
522
|
model: modelParam,
|
|
523
|
+
agent: agentPreference,
|
|
410
524
|
},
|
|
411
525
|
signal: abortController.signal,
|
|
412
526
|
});
|
|
413
|
-
}
|
|
414
527
|
if (response.error) {
|
|
415
528
|
const errorMessage = (() => {
|
|
416
529
|
const err = response.error;
|
package/dist/system-message.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// OpenCode system prompt generator.
|
|
2
|
+
// Creates the system message injected into every OpenCode session,
|
|
3
|
+
// including Discord-specific formatting rules, diff commands, and permissions info.
|
|
1
4
|
export function getOpencodeSystemMessage({ sessionId }) {
|
|
2
5
|
return `
|
|
3
6
|
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
@@ -14,24 +17,6 @@ Only users with these Discord permissions can send messages to the bot:
|
|
|
14
17
|
- Manage Server permission
|
|
15
18
|
- "Kimaki" role (case-insensitive)
|
|
16
19
|
|
|
17
|
-
## changing the model
|
|
18
|
-
|
|
19
|
-
To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
|
|
20
|
-
|
|
21
|
-
\`\`\`json
|
|
22
|
-
{
|
|
23
|
-
"model": "anthropic/claude-sonnet-4-20250514"
|
|
24
|
-
}
|
|
25
|
-
\`\`\`
|
|
26
|
-
|
|
27
|
-
Examples:
|
|
28
|
-
- \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
|
|
29
|
-
- \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
|
|
30
|
-
- \`"openai/gpt-4o"\` - GPT-4o
|
|
31
|
-
- \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
|
|
32
|
-
|
|
33
|
-
Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
|
|
34
|
-
|
|
35
20
|
## uploading files to discord
|
|
36
21
|
|
|
37
22
|
To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
|
|
@@ -52,7 +37,9 @@ bunx critique web -- path/to/file1.ts path/to/file2.ts
|
|
|
52
37
|
|
|
53
38
|
You can also show latest commit changes using:
|
|
54
39
|
|
|
55
|
-
bunx critique web HEAD
|
|
40
|
+
bunx critique web HEAD
|
|
41
|
+
|
|
42
|
+
bunx critique web HEAD~1 to get the one before last
|
|
56
43
|
|
|
57
44
|
Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
|
|
58
45
|
|
|
@@ -66,27 +53,9 @@ the max heading level is 3, so do not use ####
|
|
|
66
53
|
|
|
67
54
|
headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
|
|
68
55
|
|
|
69
|
-
## tables
|
|
70
|
-
|
|
71
|
-
discord does NOT support markdown gfm tables.
|
|
72
|
-
|
|
73
|
-
so instead of using full markdown tables ALWAYS show code snippets with space aligned cells:
|
|
74
|
-
|
|
75
|
-
\`\`\`
|
|
76
|
-
Item Qty Price
|
|
77
|
-
---------- --- -----
|
|
78
|
-
Apples 10 $5
|
|
79
|
-
Oranges 3 $2
|
|
80
|
-
\`\`\`
|
|
81
|
-
|
|
82
|
-
Using code blocks will make the content use monospaced font so that space will be aligned correctly
|
|
83
|
-
|
|
84
|
-
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
|
|
85
|
-
|
|
86
|
-
code blocks for tables and diagrams MUST have Max length of 85 characters. otherwise the content will wrap
|
|
87
56
|
|
|
88
57
|
## diagrams
|
|
89
58
|
|
|
90
|
-
you can create diagrams wrapping them in code blocks
|
|
59
|
+
you can create diagrams wrapping them in code blocks.
|
|
91
60
|
`;
|
|
92
61
|
}
|
package/dist/tools.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Voice assistant tool definitions for the GenAI worker.
|
|
2
|
+
// Provides tools for managing OpenCode sessions (create, submit, abort),
|
|
3
|
+
// listing chats, searching files, and reading session messages.
|
|
1
4
|
import { tool } from 'ai';
|
|
2
5
|
import { z } from 'zod';
|
|
3
6
|
import { spawn } from 'node:child_process';
|
package/dist/utils.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// General utility functions for the bot.
|
|
2
|
+
// Includes Discord OAuth URL generation, array deduplication,
|
|
3
|
+
// abort error detection, and date/time formatting helpers.
|
|
1
4
|
import { PermissionsBitField } from 'discord.js';
|
|
2
5
|
export function generateBotInstallUrl({ clientId, permissions = [
|
|
3
6
|
PermissionsBitField.Flags.ViewChannel,
|
package/dist/voice-handler.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Discord voice channel connection and audio stream handler.
|
|
2
|
+
// Manages joining/leaving voice channels, captures user audio, resamples to 16kHz,
|
|
3
|
+
// and routes audio to the GenAI worker for real-time voice assistant interactions.
|
|
1
4
|
import { VoiceConnectionStatus, EndBehaviorType, joinVoiceChannel, entersState, } from '@discordjs/voice';
|
|
2
5
|
import { exec } from 'node:child_process';
|
|
3
6
|
import fs, { createWriteStream } from 'node:fs';
|
|
@@ -311,7 +314,7 @@ export async function cleanupVoiceConnection(guildId) {
|
|
|
311
314
|
voiceConnections.delete(guildId);
|
|
312
315
|
}
|
|
313
316
|
}
|
|
314
|
-
export async function processVoiceAttachment({ message, thread, projectDirectory, isNewThread = false, appId,
|
|
317
|
+
export async function processVoiceAttachment({ message, thread, projectDirectory, isNewThread = false, appId, currentSessionContext, lastSessionContext, }) {
|
|
315
318
|
const audioAttachment = Array.from(message.attachments.values()).find((attachment) => attachment.contentType?.startsWith('audio/'));
|
|
316
319
|
if (!audioAttachment)
|
|
317
320
|
return null;
|
|
@@ -347,13 +350,23 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
|
|
|
347
350
|
geminiApiKey = apiKeys.gemini_api_key;
|
|
348
351
|
}
|
|
349
352
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
+
}
|
|
357
370
|
voiceLogger.log(`Transcription successful: "${transcription.slice(0, 50)}${transcription.length > 50 ? '...' : ''}"`);
|
|
358
371
|
if (isNewThread) {
|
|
359
372
|
const threadName = transcription.replace(/\s+/g, ' ').trim().slice(0, 80);
|