kimaki 0.4.44 → 0.4.45
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 +6 -15
- package/dist/cli.js +54 -37
- package/dist/commands/permissions.js +21 -5
- package/dist/commands/queue.js +5 -1
- package/dist/commands/resume.js +8 -16
- package/dist/commands/session.js +18 -42
- package/dist/commands/user-command.js +8 -17
- package/dist/commands/verbosity.js +53 -0
- package/dist/commands/worktree-settings.js +2 -2
- package/dist/commands/worktree.js +132 -25
- package/dist/database.js +49 -0
- package/dist/discord-bot.js +24 -38
- package/dist/discord-utils.js +51 -13
- package/dist/discord-utils.test.js +20 -0
- package/dist/escape-backticks.test.js +14 -3
- package/dist/interaction-handler.js +4 -0
- package/dist/session-handler.js +541 -413
- package/package.json +1 -1
- package/src/__snapshots__/first-session-no-info.md +1344 -0
- package/src/__snapshots__/first-session-with-info.md +1350 -0
- package/src/__snapshots__/session-1.md +1344 -0
- package/src/__snapshots__/session-2.md +291 -0
- package/src/__snapshots__/session-3.md +20324 -0
- package/src/__snapshots__/session-with-tools.md +1344 -0
- package/src/channel-management.ts +6 -17
- package/src/cli.ts +63 -45
- package/src/commands/permissions.ts +31 -5
- package/src/commands/queue.ts +5 -1
- package/src/commands/resume.ts +8 -18
- package/src/commands/session.ts +18 -44
- package/src/commands/user-command.ts +8 -19
- package/src/commands/verbosity.ts +71 -0
- package/src/commands/worktree-settings.ts +2 -2
- package/src/commands/worktree.ts +160 -27
- package/src/database.ts +65 -0
- package/src/discord-bot.ts +26 -42
- package/src/discord-utils.test.ts +23 -0
- package/src/discord-utils.ts +52 -13
- package/src/escape-backticks.test.ts +14 -3
- package/src/interaction-handler.ts +5 -0
- package/src/session-handler.ts +669 -436
package/dist/session-handler.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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, getSessionAgent, getChannelAgent, setSessionAgent, getThreadWorktree, } from './database.js';
|
|
5
|
+
import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent, getThreadWorktree, getChannelVerbosity, } from './database.js';
|
|
6
6
|
import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2, } from './opencode.js';
|
|
7
7
|
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js';
|
|
8
8
|
import { formatPart } from './message-formatting.js';
|
|
@@ -10,7 +10,7 @@ import { getOpencodeSystemMessage } from './system-message.js';
|
|
|
10
10
|
import { createLogger } from './logger.js';
|
|
11
11
|
import { isAbortError } from './utils.js';
|
|
12
12
|
import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts, } from './commands/ask-question.js';
|
|
13
|
-
import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js';
|
|
13
|
+
import { showPermissionDropdown, cleanupPermissionContext, addPermissionRequestToContext, } from './commands/permissions.js';
|
|
14
14
|
import * as errore from 'errore';
|
|
15
15
|
const sessionLogger = createLogger('SESSION');
|
|
16
16
|
const voiceLogger = createLogger('VOICE');
|
|
@@ -20,6 +20,12 @@ export const abortControllers = new Map();
|
|
|
20
20
|
// OpenCode handles blocking/sequencing - we just need to track all pending permissions
|
|
21
21
|
// to avoid duplicates and properly clean up on auto-reject
|
|
22
22
|
export const pendingPermissions = new Map();
|
|
23
|
+
function buildPermissionDedupeKey({ permission, directory, }) {
|
|
24
|
+
const normalizedPatterns = [...permission.patterns].sort((a, b) => {
|
|
25
|
+
return a.localeCompare(b);
|
|
26
|
+
});
|
|
27
|
+
return `${directory}::${permission.permission}::${normalizedPatterns.join('|')}`;
|
|
28
|
+
}
|
|
23
29
|
// Queue of messages waiting to be sent after current response finishes
|
|
24
30
|
// Key is threadId, value is array of queued messages
|
|
25
31
|
export const messageQueue = new Map();
|
|
@@ -56,11 +62,11 @@ export async function abortAndRetrySession({ sessionId, thread, projectDirectory
|
|
|
56
62
|
sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message);
|
|
57
63
|
return false;
|
|
58
64
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`,
|
|
65
|
+
const abortResult = await errore.tryAsync(() => {
|
|
66
|
+
return getClient().session.abort({ path: { id: sessionId } });
|
|
67
|
+
});
|
|
68
|
+
if (abortResult instanceof Error) {
|
|
69
|
+
sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, abortResult);
|
|
64
70
|
}
|
|
65
71
|
// Small delay to let the abort propagate
|
|
66
72
|
await new Promise((resolve) => {
|
|
@@ -82,15 +88,21 @@ export async function abortAndRetrySession({ sessionId, thread, projectDirectory
|
|
|
82
88
|
sessionLogger.log(`[ABORT+RETRY] Re-triggering session ${sessionId} with new model`);
|
|
83
89
|
// Use setImmediate to avoid blocking
|
|
84
90
|
setImmediate(() => {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
void errore
|
|
92
|
+
.tryAsync(async () => {
|
|
93
|
+
return handleOpencodeSession({
|
|
94
|
+
prompt,
|
|
95
|
+
thread,
|
|
96
|
+
projectDirectory,
|
|
97
|
+
images,
|
|
98
|
+
});
|
|
99
|
+
})
|
|
100
|
+
.then(async (result) => {
|
|
101
|
+
if (!(result instanceof Error)) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, result);
|
|
105
|
+
await sendThreadMessage(thread, `✗ Failed to retry with new model: ${result.message.slice(0, 200)}`);
|
|
94
106
|
});
|
|
95
107
|
});
|
|
96
108
|
return true;
|
|
@@ -100,6 +112,16 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
100
112
|
const sessionStartTime = Date.now();
|
|
101
113
|
const directory = projectDirectory || process.cwd();
|
|
102
114
|
sessionLogger.log(`Using directory: ${directory}`);
|
|
115
|
+
// Get worktree info early so we can use the correct directory for events and prompts
|
|
116
|
+
const worktreeInfo = getThreadWorktree(thread.id);
|
|
117
|
+
const worktreeDirectory = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
118
|
+
? worktreeInfo.worktree_directory
|
|
119
|
+
: undefined;
|
|
120
|
+
// Use worktree directory for SDK calls if available, otherwise project directory
|
|
121
|
+
const sdkDirectory = worktreeDirectory || directory;
|
|
122
|
+
if (worktreeDirectory) {
|
|
123
|
+
sessionLogger.log(`Using worktree directory for SDK calls: ${worktreeDirectory}`);
|
|
124
|
+
}
|
|
103
125
|
const getClient = await initializeOpencodeForDirectory(directory);
|
|
104
126
|
if (getClient instanceof Error) {
|
|
105
127
|
await sendThreadMessage(thread, `✗ ${getClient.message}`);
|
|
@@ -114,22 +136,26 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
114
136
|
let session;
|
|
115
137
|
if (sessionId) {
|
|
116
138
|
sessionLogger.log(`Attempting to reuse existing session ${sessionId}`);
|
|
117
|
-
|
|
118
|
-
|
|
139
|
+
const sessionResponse = await errore.tryAsync(() => {
|
|
140
|
+
return getClient().session.get({
|
|
119
141
|
path: { id: sessionId },
|
|
142
|
+
query: { directory: sdkDirectory },
|
|
120
143
|
});
|
|
144
|
+
});
|
|
145
|
+
if (sessionResponse instanceof Error) {
|
|
146
|
+
voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
121
149
|
session = sessionResponse.data;
|
|
122
150
|
sessionLogger.log(`Successfully reused session ${sessionId}`);
|
|
123
151
|
}
|
|
124
|
-
catch (error) {
|
|
125
|
-
voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`);
|
|
126
|
-
}
|
|
127
152
|
}
|
|
128
153
|
if (!session) {
|
|
129
154
|
const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80);
|
|
130
155
|
voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`);
|
|
131
156
|
const sessionResponse = await getClient().session.create({
|
|
132
157
|
body: { title: sessionTitle },
|
|
158
|
+
query: { directory: sdkDirectory },
|
|
133
159
|
});
|
|
134
160
|
session = sessionResponse.data;
|
|
135
161
|
sessionLogger.log(`Created new session ${session?.id}`);
|
|
@@ -157,21 +183,26 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
157
183
|
const clientV2 = getOpencodeClientV2(directory);
|
|
158
184
|
let rejectedCount = 0;
|
|
159
185
|
for (const [permId, pendingPerm] of threadPermissions) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
await clientV2.permission.reply({
|
|
164
|
-
requestID: permId,
|
|
165
|
-
reply: 'reject',
|
|
166
|
-
});
|
|
167
|
-
}
|
|
186
|
+
sessionLogger.log(`[PERMISSION] Auto-rejecting permission ${permId} due to new message`);
|
|
187
|
+
if (!clientV2) {
|
|
188
|
+
sessionLogger.log(`[PERMISSION] OpenCode v2 client unavailable for permission ${permId}`);
|
|
168
189
|
cleanupPermissionContext(pendingPerm.contextHash);
|
|
169
190
|
rejectedCount++;
|
|
191
|
+
continue;
|
|
170
192
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
193
|
+
const rejectResult = await errore.tryAsync(() => {
|
|
194
|
+
return clientV2.permission.reply({
|
|
195
|
+
requestID: permId,
|
|
196
|
+
reply: 'reject',
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
if (rejectResult instanceof Error) {
|
|
200
|
+
sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, rejectResult);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
rejectedCount++;
|
|
174
204
|
}
|
|
205
|
+
cleanupPermissionContext(pendingPerm.contextHash);
|
|
175
206
|
}
|
|
176
207
|
pendingPermissions.delete(thread.id);
|
|
177
208
|
if (rejectedCount > 0) {
|
|
@@ -204,7 +235,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
204
235
|
if (!clientV2) {
|
|
205
236
|
throw new Error(`OpenCode v2 client not found for directory: ${directory}`);
|
|
206
237
|
}
|
|
207
|
-
const eventsResult = await clientV2.event.subscribe({ directory }, { signal: abortController.signal });
|
|
238
|
+
const eventsResult = await clientV2.event.subscribe({ directory: sdkDirectory }, { signal: abortController.signal });
|
|
208
239
|
if (abortController.signal.aborted) {
|
|
209
240
|
sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
|
|
210
241
|
return;
|
|
@@ -214,7 +245,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
214
245
|
const sentPartIds = new Set(getDatabase()
|
|
215
246
|
.prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
|
|
216
247
|
.all(thread.id).map((row) => row.part_id));
|
|
217
|
-
|
|
248
|
+
const partBuffer = new Map();
|
|
218
249
|
let stopTyping = null;
|
|
219
250
|
let usedModel;
|
|
220
251
|
let usedProviderID;
|
|
@@ -222,6 +253,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
222
253
|
let tokensUsedInSession = 0;
|
|
223
254
|
let lastDisplayedContextPercentage = 0;
|
|
224
255
|
let modelContextLimit;
|
|
256
|
+
let assistantMessageId;
|
|
225
257
|
let typingInterval = null;
|
|
226
258
|
function startTyping() {
|
|
227
259
|
if (abortController.signal.aborted) {
|
|
@@ -232,12 +264,16 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
232
264
|
clearInterval(typingInterval);
|
|
233
265
|
typingInterval = null;
|
|
234
266
|
}
|
|
235
|
-
thread.sendTyping().
|
|
236
|
-
|
|
267
|
+
void errore.tryAsync(() => thread.sendTyping()).then((result) => {
|
|
268
|
+
if (result instanceof Error) {
|
|
269
|
+
discordLogger.log(`Failed to send initial typing: ${result}`);
|
|
270
|
+
}
|
|
237
271
|
});
|
|
238
272
|
typingInterval = setInterval(() => {
|
|
239
|
-
thread.sendTyping().
|
|
240
|
-
|
|
273
|
+
void errore.tryAsync(() => thread.sendTyping()).then((result) => {
|
|
274
|
+
if (result instanceof Error) {
|
|
275
|
+
discordLogger.log(`Failed to send periodic typing: ${result}`);
|
|
276
|
+
}
|
|
241
277
|
});
|
|
242
278
|
}, 8000);
|
|
243
279
|
if (!abortController.signal.aborted) {
|
|
@@ -255,7 +291,14 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
255
291
|
}
|
|
256
292
|
};
|
|
257
293
|
}
|
|
294
|
+
// Get verbosity setting for this channel (use parent channel for threads)
|
|
295
|
+
const verbosityChannelId = channelId || thread.parentId || thread.id;
|
|
296
|
+
const verbosity = getChannelVerbosity(verbosityChannelId);
|
|
258
297
|
const sendPartMessage = async (part) => {
|
|
298
|
+
// In text-only mode, only send text parts (the ⬥ diamond messages)
|
|
299
|
+
if (verbosity === 'text-only' && part.type !== 'text') {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
259
302
|
const content = formatPart(part) + '\n\n';
|
|
260
303
|
if (!content.trim() || content.length === 0) {
|
|
261
304
|
// discordLogger.log(`SKIP: Part ${part.id} has no content`)
|
|
@@ -264,351 +307,439 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
264
307
|
if (sentPartIds.has(part.id)) {
|
|
265
308
|
return;
|
|
266
309
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
catch (error) {
|
|
275
|
-
discordLogger.error(`ERROR: Failed to send part ${part.id}:`, error);
|
|
310
|
+
const sendResult = await errore.tryAsync(() => {
|
|
311
|
+
return sendThreadMessage(thread, content);
|
|
312
|
+
});
|
|
313
|
+
if (sendResult instanceof Error) {
|
|
314
|
+
discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult);
|
|
315
|
+
return;
|
|
276
316
|
}
|
|
317
|
+
sentPartIds.add(part.id);
|
|
318
|
+
getDatabase()
|
|
319
|
+
.prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
|
|
320
|
+
.run(part.id, sendResult.id, thread.id);
|
|
277
321
|
};
|
|
278
322
|
const eventHandler = async () => {
|
|
279
323
|
// Subtask tracking: child sessionId → { label, assistantMessageId }
|
|
280
324
|
const subtaskSessions = new Map();
|
|
281
325
|
// Counts spawned tasks per agent type: "explore" → 2
|
|
282
326
|
const agentSpawnCounts = {};
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
});
|
|
315
|
-
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
|
|
316
|
-
const model = provider?.models?.[usedModel];
|
|
317
|
-
if (model?.limit?.context) {
|
|
318
|
-
modelContextLimit = model.limit.context;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
catch (e) {
|
|
322
|
-
sessionLogger.error('Failed to fetch provider info for context limit:', e);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
if (modelContextLimit) {
|
|
326
|
-
const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100);
|
|
327
|
-
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
|
|
328
|
-
if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
|
|
329
|
-
lastDisplayedContextPercentage = thresholdCrossed;
|
|
330
|
-
const chunk = `⬦ context usage ${currentPercentage}%`;
|
|
331
|
-
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
327
|
+
const storePart = (part) => {
|
|
328
|
+
const messageParts = partBuffer.get(part.messageID) || new Map();
|
|
329
|
+
messageParts.set(part.id, part);
|
|
330
|
+
partBuffer.set(part.messageID, messageParts);
|
|
331
|
+
};
|
|
332
|
+
const getBufferedParts = (messageID) => {
|
|
333
|
+
return Array.from(partBuffer.get(messageID)?.values() ?? []);
|
|
334
|
+
};
|
|
335
|
+
const shouldSendPart = ({ part, force }) => {
|
|
336
|
+
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
if (!force && part.type === 'text' && !part.time?.end) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
if (!force && part.type === 'tool' && part.state.status === 'completed') {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
return true;
|
|
349
|
+
};
|
|
350
|
+
const flushBufferedParts = async ({ messageID, force, skipPartId, }) => {
|
|
351
|
+
if (!messageID) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const parts = getBufferedParts(messageID);
|
|
355
|
+
for (const part of parts) {
|
|
356
|
+
if (skipPartId && part.id === skipPartId) {
|
|
357
|
+
continue;
|
|
336
358
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
// Check if this is a subtask event (child session we're tracking)
|
|
340
|
-
const subtaskInfo = subtaskSessions.get(part.sessionID);
|
|
341
|
-
const isSubtaskEvent = Boolean(subtaskInfo);
|
|
342
|
-
// Accept events from main session OR tracked subtask sessions
|
|
343
|
-
if (part.sessionID !== session.id && !isSubtaskEvent) {
|
|
344
|
-
continue;
|
|
345
|
-
}
|
|
346
|
-
// For subtask events, send them immediately with prefix (don't buffer in currentParts)
|
|
347
|
-
if (isSubtaskEvent && subtaskInfo) {
|
|
348
|
-
// Skip parts that aren't useful to show (step-start, step-finish, pending tools)
|
|
349
|
-
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
350
|
-
continue;
|
|
351
|
-
}
|
|
352
|
-
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
355
|
-
// Skip text parts - the outer agent will report the task result anyway
|
|
356
|
-
if (part.type === 'text') {
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
// Only show parts from assistant messages (not user prompts sent to subtask)
|
|
360
|
-
// Skip if we haven't seen an assistant message yet, or if this part is from a different message
|
|
361
|
-
if (!subtaskInfo.assistantMessageId ||
|
|
362
|
-
part.messageID !== subtaskInfo.assistantMessageId) {
|
|
363
|
-
continue;
|
|
364
|
-
}
|
|
365
|
-
const content = formatPart(part, subtaskInfo.label);
|
|
366
|
-
if (content.trim() && !sentPartIds.has(part.id)) {
|
|
367
|
-
try {
|
|
368
|
-
const msg = await sendThreadMessage(thread, content + '\n\n');
|
|
369
|
-
sentPartIds.add(part.id);
|
|
370
|
-
getDatabase()
|
|
371
|
-
.prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
|
|
372
|
-
.run(part.id, msg.id, thread.id);
|
|
373
|
-
}
|
|
374
|
-
catch (error) {
|
|
375
|
-
discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, error);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
// Main session events: require matching assistantMessageId
|
|
381
|
-
if (part.messageID !== assistantMessageId) {
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
const existingIndex = currentParts.findIndex((p) => p.id === part.id);
|
|
385
|
-
if (existingIndex >= 0) {
|
|
386
|
-
currentParts[existingIndex] = part;
|
|
387
|
-
}
|
|
388
|
-
else {
|
|
389
|
-
currentParts.push(part);
|
|
390
|
-
}
|
|
391
|
-
if (part.type === 'step-start') {
|
|
392
|
-
// Don't start typing if user needs to respond to a question or permission
|
|
393
|
-
const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
|
|
394
|
-
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
|
|
395
|
-
if (!hasPendingQuestion && !hasPendingPermission) {
|
|
396
|
-
stopTyping = startTyping();
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
if (part.type === 'tool' && part.state.status === 'running') {
|
|
400
|
-
// Flush any pending text/reasoning parts before showing the tool
|
|
401
|
-
// This ensures text the LLM generated before the tool call is shown first
|
|
402
|
-
for (const p of currentParts) {
|
|
403
|
-
if (p.type !== 'step-start' && p.type !== 'step-finish' && p.id !== part.id) {
|
|
404
|
-
await sendPartMessage(p);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
await sendPartMessage(part);
|
|
408
|
-
// Track task tool and register child session when sessionId is available
|
|
409
|
-
if (part.tool === 'task' && !sentPartIds.has(part.id)) {
|
|
410
|
-
const description = part.state.input?.description || '';
|
|
411
|
-
const agent = part.state.input?.subagent_type || 'task';
|
|
412
|
-
const childSessionId = part.state.metadata?.sessionId || '';
|
|
413
|
-
if (description && childSessionId) {
|
|
414
|
-
agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1;
|
|
415
|
-
const label = `${agent}-${agentSpawnCounts[agent]}`;
|
|
416
|
-
subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined });
|
|
417
|
-
const taskDisplay = `┣ task **${label}** _${description}_`;
|
|
418
|
-
await sendThreadMessage(thread, taskDisplay + '\n\n');
|
|
419
|
-
sentPartIds.add(part.id);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
// Show token usage for completed tools with large output (>5k tokens)
|
|
424
|
-
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
425
|
-
const output = part.state.output || '';
|
|
426
|
-
const outputTokens = Math.ceil(output.length / 4);
|
|
427
|
-
const LARGE_OUTPUT_THRESHOLD = 3000;
|
|
428
|
-
if (outputTokens >= LARGE_OUTPUT_THRESHOLD) {
|
|
429
|
-
const formattedTokens = outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens);
|
|
430
|
-
const percentageSuffix = (() => {
|
|
431
|
-
if (!modelContextLimit) {
|
|
432
|
-
return '';
|
|
433
|
-
}
|
|
434
|
-
const pct = (outputTokens / modelContextLimit) * 100;
|
|
435
|
-
if (pct < 1) {
|
|
436
|
-
return '';
|
|
437
|
-
}
|
|
438
|
-
return ` (${pct.toFixed(1)}%)`;
|
|
439
|
-
})();
|
|
440
|
-
const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`;
|
|
441
|
-
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
if (part.type === 'reasoning') {
|
|
445
|
-
await sendPartMessage(part);
|
|
446
|
-
}
|
|
447
|
-
// Send text parts when complete (time.end is set)
|
|
448
|
-
// Text parts stream incrementally; only send when finished to avoid partial text
|
|
449
|
-
if (part.type === 'text' && part.time?.end) {
|
|
450
|
-
await sendPartMessage(part);
|
|
451
|
-
}
|
|
452
|
-
if (part.type === 'step-finish') {
|
|
453
|
-
for (const p of currentParts) {
|
|
454
|
-
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
455
|
-
await sendPartMessage(p);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
setTimeout(() => {
|
|
459
|
-
if (abortController.signal.aborted)
|
|
460
|
-
return;
|
|
461
|
-
// Don't restart typing if user needs to respond to a question or permission
|
|
462
|
-
const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
|
|
463
|
-
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
|
|
464
|
-
if (hasPendingQuestion || hasPendingPermission)
|
|
465
|
-
return;
|
|
466
|
-
stopTyping = startTyping();
|
|
467
|
-
}, 300);
|
|
468
|
-
}
|
|
359
|
+
if (!shouldSendPart({ part, force })) {
|
|
360
|
+
continue;
|
|
469
361
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
362
|
+
await sendPartMessage(part);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
const handleMessageUpdated = async (msg) => {
|
|
366
|
+
const subtaskInfo = subtaskSessions.get(msg.sessionID);
|
|
367
|
+
if (subtaskInfo && msg.role === 'assistant') {
|
|
368
|
+
subtaskInfo.assistantMessageId = msg.id;
|
|
369
|
+
}
|
|
370
|
+
if (msg.sessionID !== session.id) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (msg.role !== 'assistant') {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (msg.tokens) {
|
|
377
|
+
const newTokensTotal = msg.tokens.input +
|
|
378
|
+
msg.tokens.output +
|
|
379
|
+
msg.tokens.reasoning +
|
|
380
|
+
msg.tokens.cache.read +
|
|
381
|
+
msg.tokens.cache.write;
|
|
382
|
+
if (newTokensTotal > 0) {
|
|
383
|
+
tokensUsedInSession = newTokensTotal;
|
|
492
384
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
stopTyping = null;
|
|
510
|
-
}
|
|
511
|
-
// Show dropdown instead of text message
|
|
512
|
-
const { messageId, contextHash } = await showPermissionDropdown({
|
|
513
|
-
thread,
|
|
514
|
-
permission,
|
|
515
|
-
directory,
|
|
516
|
-
});
|
|
517
|
-
// Track permission in nested map (threadId -> permissionId -> data)
|
|
518
|
-
if (!pendingPermissions.has(thread.id)) {
|
|
519
|
-
pendingPermissions.set(thread.id, new Map());
|
|
520
|
-
}
|
|
521
|
-
pendingPermissions.get(thread.id).set(permission.id, {
|
|
522
|
-
permission,
|
|
523
|
-
messageId,
|
|
524
|
-
directory,
|
|
525
|
-
contextHash,
|
|
385
|
+
}
|
|
386
|
+
assistantMessageId = msg.id;
|
|
387
|
+
usedModel = msg.modelID;
|
|
388
|
+
usedProviderID = msg.providerID;
|
|
389
|
+
usedAgent = msg.mode;
|
|
390
|
+
await flushBufferedParts({
|
|
391
|
+
messageID: assistantMessageId,
|
|
392
|
+
force: false,
|
|
393
|
+
});
|
|
394
|
+
if (tokensUsedInSession === 0 || !usedProviderID || !usedModel) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (!modelContextLimit) {
|
|
398
|
+
const providersResponse = await errore.tryAsync(() => {
|
|
399
|
+
return getClient().provider.list({
|
|
400
|
+
query: { directory: sdkDirectory },
|
|
526
401
|
});
|
|
402
|
+
});
|
|
403
|
+
if (providersResponse instanceof Error) {
|
|
404
|
+
sessionLogger.error('Failed to fetch provider info for context limit:', providersResponse);
|
|
527
405
|
}
|
|
528
|
-
else
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
406
|
+
else {
|
|
407
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
|
|
408
|
+
const model = provider?.models?.[usedModel];
|
|
409
|
+
if (model?.limit?.context) {
|
|
410
|
+
modelContextLimit = model.limit.context;
|
|
532
411
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (!modelContextLimit) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100);
|
|
418
|
+
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
|
|
419
|
+
if (thresholdCrossed <= lastDisplayedContextPercentage || thresholdCrossed < 10) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
lastDisplayedContextPercentage = thresholdCrossed;
|
|
423
|
+
const chunk = `⬦ context usage ${currentPercentage}%`;
|
|
424
|
+
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
|
|
425
|
+
};
|
|
426
|
+
const handleMainPart = async (part) => {
|
|
427
|
+
const isActiveMessage = assistantMessageId ? part.messageID === assistantMessageId : false;
|
|
428
|
+
const allowEarlyProcessing = !assistantMessageId && part.type === 'tool' && part.state.status === 'running';
|
|
429
|
+
if (!isActiveMessage && !allowEarlyProcessing) {
|
|
430
|
+
if (part.type !== 'step-start') {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (part.type === 'step-start') {
|
|
435
|
+
const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
|
|
436
|
+
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
|
|
437
|
+
if (!hasPendingQuestion && !hasPendingPermission) {
|
|
438
|
+
stopTyping = startTyping();
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (part.type === 'tool' && part.state.status === 'running') {
|
|
443
|
+
await flushBufferedParts({
|
|
444
|
+
messageID: assistantMessageId || part.messageID,
|
|
445
|
+
force: true,
|
|
446
|
+
skipPartId: part.id,
|
|
447
|
+
});
|
|
448
|
+
await sendPartMessage(part);
|
|
449
|
+
if (part.tool === 'task' && !sentPartIds.has(part.id)) {
|
|
450
|
+
const description = part.state.input?.description || '';
|
|
451
|
+
const agent = part.state.input?.subagent_type || 'task';
|
|
452
|
+
const childSessionId = part.state.metadata?.sessionId || '';
|
|
453
|
+
if (description && childSessionId) {
|
|
454
|
+
agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1;
|
|
455
|
+
const label = `${agent}-${agentSpawnCounts[agent]}`;
|
|
456
|
+
subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined });
|
|
457
|
+
// Skip task messages in text-only mode
|
|
458
|
+
if (verbosity !== 'text-only') {
|
|
459
|
+
const taskDisplay = `┣ task **${label}** _${description}_`;
|
|
460
|
+
await sendThreadMessage(thread, taskDisplay + '\n\n');
|
|
545
461
|
}
|
|
462
|
+
sentPartIds.add(part.id);
|
|
546
463
|
}
|
|
547
464
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
// Flush any pending text/reasoning parts before showing the dropdown
|
|
561
|
-
// This ensures text the LLM generated before the question tool is shown first
|
|
562
|
-
for (const p of currentParts) {
|
|
563
|
-
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
564
|
-
await sendPartMessage(p);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
468
|
+
const output = part.state.output || '';
|
|
469
|
+
const outputTokens = Math.ceil(output.length / 4);
|
|
470
|
+
const largeOutputThreshold = 3000;
|
|
471
|
+
if (outputTokens >= largeOutputThreshold) {
|
|
472
|
+
const formattedTokens = outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens);
|
|
473
|
+
const percentageSuffix = (() => {
|
|
474
|
+
if (!modelContextLimit) {
|
|
475
|
+
return '';
|
|
565
476
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
sessionId: session.id,
|
|
570
|
-
directory,
|
|
571
|
-
requestId: questionRequest.id,
|
|
572
|
-
input: { questions: questionRequest.questions },
|
|
573
|
-
});
|
|
574
|
-
// Process queued messages if any - queued message will cancel the pending question
|
|
575
|
-
const queue = messageQueue.get(thread.id);
|
|
576
|
-
if (queue && queue.length > 0) {
|
|
577
|
-
const nextMessage = queue.shift();
|
|
578
|
-
if (queue.length === 0) {
|
|
579
|
-
messageQueue.delete(thread.id);
|
|
477
|
+
const pct = (outputTokens / modelContextLimit) * 100;
|
|
478
|
+
if (pct < 1) {
|
|
479
|
+
return '';
|
|
580
480
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
handleOpencodeSession({
|
|
586
|
-
prompt: nextMessage.prompt,
|
|
587
|
-
thread,
|
|
588
|
-
projectDirectory: directory,
|
|
589
|
-
images: nextMessage.images,
|
|
590
|
-
channelId,
|
|
591
|
-
}).catch(async (e) => {
|
|
592
|
-
sessionLogger.error(`[QUEUE] Failed to process queued message:`, e);
|
|
593
|
-
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
594
|
-
await sendThreadMessage(thread, `✗ Queued message failed: ${errorMsg.slice(0, 200)}`);
|
|
595
|
-
});
|
|
596
|
-
});
|
|
597
|
-
}
|
|
481
|
+
return ` (${pct.toFixed(1)}%)`;
|
|
482
|
+
})();
|
|
483
|
+
const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`;
|
|
484
|
+
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
|
|
598
485
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
486
|
+
}
|
|
487
|
+
if (part.type === 'reasoning') {
|
|
488
|
+
await sendPartMessage(part);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (part.type === 'text' && part.time?.end) {
|
|
492
|
+
await sendPartMessage(part);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (part.type === 'step-finish') {
|
|
496
|
+
await flushBufferedParts({
|
|
497
|
+
messageID: assistantMessageId || part.messageID,
|
|
498
|
+
force: true,
|
|
499
|
+
});
|
|
500
|
+
setTimeout(() => {
|
|
501
|
+
if (abortController.signal.aborted)
|
|
502
|
+
return;
|
|
503
|
+
const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
|
|
504
|
+
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
|
|
505
|
+
if (hasPendingQuestion || hasPendingPermission)
|
|
506
|
+
return;
|
|
507
|
+
stopTyping = startTyping();
|
|
508
|
+
}, 300);
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
const handleSubtaskPart = async (part, subtaskInfo) => {
|
|
512
|
+
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (part.type === 'text') {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (!subtaskInfo.assistantMessageId || part.messageID !== subtaskInfo.assistantMessageId) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const content = formatPart(part, subtaskInfo.label);
|
|
525
|
+
if (!content.trim() || sentPartIds.has(part.id)) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const sendResult = await errore.tryAsync(() => {
|
|
529
|
+
return sendThreadMessage(thread, content + '\n\n');
|
|
530
|
+
});
|
|
531
|
+
if (sendResult instanceof Error) {
|
|
532
|
+
discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, sendResult);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
sentPartIds.add(part.id);
|
|
536
|
+
getDatabase()
|
|
537
|
+
.prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
|
|
538
|
+
.run(part.id, sendResult.id, thread.id);
|
|
539
|
+
};
|
|
540
|
+
const handlePartUpdated = async (part) => {
|
|
541
|
+
storePart(part);
|
|
542
|
+
const subtaskInfo = subtaskSessions.get(part.sessionID);
|
|
543
|
+
const isSubtaskEvent = Boolean(subtaskInfo);
|
|
544
|
+
if (part.sessionID !== session.id && !isSubtaskEvent) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (isSubtaskEvent && subtaskInfo) {
|
|
548
|
+
await handleSubtaskPart(part, subtaskInfo);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
await handleMainPart(part);
|
|
552
|
+
};
|
|
553
|
+
const handleSessionError = async ({ sessionID, error, }) => {
|
|
554
|
+
if (!sessionID || sessionID !== session.id) {
|
|
555
|
+
voiceLogger.log(`[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${sessionID})`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const errorMessage = error?.data?.message || 'Unknown error';
|
|
559
|
+
sessionLogger.error(`Sending error to thread: ${errorMessage}`);
|
|
560
|
+
await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`);
|
|
561
|
+
if (!originalMessage) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const reactionResult = await errore.tryAsync(async () => {
|
|
565
|
+
await originalMessage.reactions.removeAll();
|
|
566
|
+
await originalMessage.react('❌');
|
|
567
|
+
});
|
|
568
|
+
if (reactionResult instanceof Error) {
|
|
569
|
+
discordLogger.log(`Could not update reaction:`, reactionResult);
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
voiceLogger.log(`[REACTION] Added error reaction due to session error`);
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
const handlePermissionAsked = async (permission) => {
|
|
576
|
+
if (permission.sessionID !== session.id) {
|
|
577
|
+
voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const dedupeKey = buildPermissionDedupeKey({ permission, directory });
|
|
581
|
+
const threadPermissions = pendingPermissions.get(thread.id);
|
|
582
|
+
const existingPending = threadPermissions
|
|
583
|
+
? Array.from(threadPermissions.values()).find((pending) => {
|
|
584
|
+
return pending.dedupeKey === dedupeKey;
|
|
585
|
+
})
|
|
586
|
+
: undefined;
|
|
587
|
+
if (existingPending) {
|
|
588
|
+
sessionLogger.log(`[PERMISSION] Deduped permission ${permission.id} (matches pending ${existingPending.permission.id})`);
|
|
589
|
+
if (stopTyping) {
|
|
590
|
+
stopTyping();
|
|
591
|
+
stopTyping = null;
|
|
592
|
+
}
|
|
593
|
+
if (!pendingPermissions.has(thread.id)) {
|
|
594
|
+
pendingPermissions.set(thread.id, new Map());
|
|
595
|
+
}
|
|
596
|
+
pendingPermissions.get(thread.id).set(permission.id, {
|
|
597
|
+
permission,
|
|
598
|
+
messageId: existingPending.messageId,
|
|
599
|
+
directory,
|
|
600
|
+
contextHash: existingPending.contextHash,
|
|
601
|
+
dedupeKey,
|
|
602
|
+
});
|
|
603
|
+
const added = addPermissionRequestToContext({
|
|
604
|
+
contextHash: existingPending.contextHash,
|
|
605
|
+
requestId: permission.id,
|
|
606
|
+
});
|
|
607
|
+
if (!added) {
|
|
608
|
+
sessionLogger.log(`[PERMISSION] Failed to attach duplicate request ${permission.id} to context`);
|
|
609
|
+
}
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
|
|
613
|
+
if (stopTyping) {
|
|
614
|
+
stopTyping();
|
|
615
|
+
stopTyping = null;
|
|
616
|
+
}
|
|
617
|
+
const { messageId, contextHash } = await showPermissionDropdown({
|
|
618
|
+
thread,
|
|
619
|
+
permission,
|
|
620
|
+
directory,
|
|
621
|
+
});
|
|
622
|
+
if (!pendingPermissions.has(thread.id)) {
|
|
623
|
+
pendingPermissions.set(thread.id, new Map());
|
|
624
|
+
}
|
|
625
|
+
pendingPermissions.get(thread.id).set(permission.id, {
|
|
626
|
+
permission,
|
|
627
|
+
messageId,
|
|
628
|
+
directory,
|
|
629
|
+
contextHash,
|
|
630
|
+
dedupeKey,
|
|
631
|
+
});
|
|
632
|
+
};
|
|
633
|
+
const handlePermissionReplied = ({ requestID, reply, sessionID, }) => {
|
|
634
|
+
if (sessionID !== session.id) {
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
|
|
638
|
+
const threadPermissions = pendingPermissions.get(thread.id);
|
|
639
|
+
if (!threadPermissions) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const pending = threadPermissions.get(requestID);
|
|
643
|
+
if (!pending) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
cleanupPermissionContext(pending.contextHash);
|
|
647
|
+
threadPermissions.delete(requestID);
|
|
648
|
+
if (threadPermissions.size === 0) {
|
|
649
|
+
pendingPermissions.delete(thread.id);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
const handleQuestionAsked = async (questionRequest) => {
|
|
653
|
+
if (questionRequest.sessionID !== session.id) {
|
|
654
|
+
sessionLogger.log(`[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
|
|
658
|
+
if (stopTyping) {
|
|
659
|
+
stopTyping();
|
|
660
|
+
stopTyping = null;
|
|
661
|
+
}
|
|
662
|
+
await flushBufferedParts({
|
|
663
|
+
messageID: assistantMessageId || '',
|
|
664
|
+
force: true,
|
|
665
|
+
});
|
|
666
|
+
await showAskUserQuestionDropdowns({
|
|
667
|
+
thread,
|
|
668
|
+
sessionId: session.id,
|
|
669
|
+
directory,
|
|
670
|
+
requestId: questionRequest.id,
|
|
671
|
+
input: { questions: questionRequest.questions },
|
|
672
|
+
});
|
|
673
|
+
const queue = messageQueue.get(thread.id);
|
|
674
|
+
if (!queue || queue.length === 0) {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const nextMessage = queue.shift();
|
|
678
|
+
if (queue.length === 0) {
|
|
679
|
+
messageQueue.delete(thread.id);
|
|
680
|
+
}
|
|
681
|
+
sessionLogger.log(`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`);
|
|
682
|
+
await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
|
|
683
|
+
setImmediate(() => {
|
|
684
|
+
void errore
|
|
685
|
+
.tryAsync(async () => {
|
|
686
|
+
return handleOpencodeSession({
|
|
687
|
+
prompt: nextMessage.prompt,
|
|
688
|
+
thread,
|
|
689
|
+
projectDirectory: directory,
|
|
690
|
+
images: nextMessage.images,
|
|
691
|
+
channelId,
|
|
692
|
+
});
|
|
693
|
+
})
|
|
694
|
+
.then(async (result) => {
|
|
695
|
+
if (!(result instanceof Error)) {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
sessionLogger.error(`[QUEUE] Failed to process queued message:`, result);
|
|
699
|
+
await sendThreadMessage(thread, `✗ Queued message failed: ${result.message.slice(0, 200)}`);
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
};
|
|
703
|
+
const handleSessionIdle = (idleSessionId) => {
|
|
704
|
+
if (idleSessionId === session.id) {
|
|
705
|
+
sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`);
|
|
706
|
+
abortController.abort('finished');
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (!subtaskSessions.has(idleSessionId)) {
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const subtask = subtaskSessions.get(idleSessionId);
|
|
713
|
+
sessionLogger.log(`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`);
|
|
714
|
+
subtaskSessions.delete(idleSessionId);
|
|
715
|
+
};
|
|
716
|
+
try {
|
|
717
|
+
for await (const event of events) {
|
|
718
|
+
switch (event.type) {
|
|
719
|
+
case 'message.updated':
|
|
720
|
+
await handleMessageUpdated(event.properties.info);
|
|
721
|
+
break;
|
|
722
|
+
case 'message.part.updated':
|
|
723
|
+
await handlePartUpdated(event.properties.part);
|
|
724
|
+
break;
|
|
725
|
+
case 'session.error':
|
|
726
|
+
sessionLogger.error(`ERROR:`, event.properties);
|
|
727
|
+
await handleSessionError(event.properties);
|
|
728
|
+
break;
|
|
729
|
+
case 'permission.asked':
|
|
730
|
+
await handlePermissionAsked(event.properties);
|
|
731
|
+
break;
|
|
732
|
+
case 'permission.replied':
|
|
733
|
+
handlePermissionReplied(event.properties);
|
|
734
|
+
break;
|
|
735
|
+
case 'question.asked':
|
|
736
|
+
await handleQuestionAsked(event.properties);
|
|
737
|
+
break;
|
|
738
|
+
case 'session.idle':
|
|
739
|
+
handleSessionIdle(event.properties.sessionID);
|
|
740
|
+
break;
|
|
741
|
+
default:
|
|
742
|
+
break;
|
|
612
743
|
}
|
|
613
744
|
}
|
|
614
745
|
}
|
|
@@ -621,14 +752,14 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
621
752
|
throw e;
|
|
622
753
|
}
|
|
623
754
|
finally {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
755
|
+
abortControllers.delete(session.id);
|
|
756
|
+
const finalMessageId = assistantMessageId;
|
|
757
|
+
if (finalMessageId) {
|
|
758
|
+
const parts = getBufferedParts(finalMessageId);
|
|
759
|
+
for (const part of parts) {
|
|
760
|
+
if (!sentPartIds.has(part.id)) {
|
|
627
761
|
await sendPartMessage(part);
|
|
628
762
|
}
|
|
629
|
-
catch (error) {
|
|
630
|
-
sessionLogger.error(`Failed to send part ${part.id}:`, error);
|
|
631
|
-
}
|
|
632
763
|
}
|
|
633
764
|
}
|
|
634
765
|
if (stopTyping) {
|
|
@@ -641,12 +772,13 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
641
772
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
|
|
642
773
|
const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : '';
|
|
643
774
|
let contextInfo = '';
|
|
644
|
-
|
|
775
|
+
const contextResult = await errore.tryAsync(async () => {
|
|
645
776
|
// Fetch final token count from API since message.updated events can arrive
|
|
646
777
|
// after session.idle due to race conditions in event ordering
|
|
647
778
|
if (tokensUsedInSession === 0) {
|
|
648
779
|
const messagesResponse = await getClient().session.messages({
|
|
649
780
|
path: { id: session.id },
|
|
781
|
+
query: { directory: sdkDirectory },
|
|
650
782
|
});
|
|
651
783
|
const messages = messagesResponse.data || [];
|
|
652
784
|
const lastAssistant = [...messages]
|
|
@@ -662,16 +794,16 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
662
794
|
tokens.cache.write;
|
|
663
795
|
}
|
|
664
796
|
}
|
|
665
|
-
const providersResponse = await getClient().provider.list({ query: { directory } });
|
|
797
|
+
const providersResponse = await getClient().provider.list({ query: { directory: sdkDirectory } });
|
|
666
798
|
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
|
|
667
799
|
const model = provider?.models?.[usedModel || ''];
|
|
668
800
|
if (model?.limit?.context) {
|
|
669
801
|
const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100);
|
|
670
802
|
contextInfo = ` ⋅ ${percentage}%`;
|
|
671
803
|
}
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
sessionLogger.error('Failed to fetch provider info for context percentage:',
|
|
804
|
+
});
|
|
805
|
+
if (contextResult instanceof Error) {
|
|
806
|
+
sessionLogger.error('Failed to fetch provider info for context percentage:', contextResult);
|
|
675
807
|
}
|
|
676
808
|
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
677
809
|
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
|
|
@@ -707,7 +839,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
707
839
|
}
|
|
708
840
|
}
|
|
709
841
|
};
|
|
710
|
-
|
|
842
|
+
const promptResult = await errore.tryAsync(async () => {
|
|
711
843
|
const eventHandlerPromise = eventHandler();
|
|
712
844
|
if (abortController.signal.aborted) {
|
|
713
845
|
sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
|
|
@@ -715,7 +847,6 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
715
847
|
}
|
|
716
848
|
stopTyping = startTyping();
|
|
717
849
|
voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
|
718
|
-
// append image paths to prompt so ai knows where they are on disk
|
|
719
850
|
const promptWithImagePaths = (() => {
|
|
720
851
|
if (images.length === 0) {
|
|
721
852
|
return prompt;
|
|
@@ -730,16 +861,12 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
730
861
|
})();
|
|
731
862
|
const parts = [{ type: 'text', text: promptWithImagePaths }, ...images];
|
|
732
863
|
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
|
|
733
|
-
// Get agent preference: session-level overrides channel-level
|
|
734
864
|
const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined);
|
|
735
865
|
if (agentPreference) {
|
|
736
866
|
sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`);
|
|
737
867
|
}
|
|
738
|
-
// Get model preference: session-level overrides channel-level
|
|
739
|
-
// BUT: if an agent is set, don't pass model param so the agent's model takes effect
|
|
740
868
|
const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
|
|
741
869
|
const modelParam = (() => {
|
|
742
|
-
// When an agent is set, let the agent's model config take effect
|
|
743
870
|
if (agentPreference) {
|
|
744
871
|
sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`);
|
|
745
872
|
return undefined;
|
|
@@ -755,8 +882,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
755
882
|
sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
|
|
756
883
|
return { providerID, modelID };
|
|
757
884
|
})();
|
|
758
|
-
//
|
|
759
|
-
const worktreeInfo = getThreadWorktree(thread.id);
|
|
885
|
+
// Build worktree info for system message (worktreeInfo was fetched at the start)
|
|
760
886
|
const worktree = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
761
887
|
? {
|
|
762
888
|
worktreeDirectory: worktreeInfo.worktree_directory,
|
|
@@ -764,10 +890,10 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
764
890
|
mainRepoDirectory: worktreeInfo.project_directory,
|
|
765
891
|
}
|
|
766
892
|
: undefined;
|
|
767
|
-
// Use session.command API for slash commands, session.prompt for regular messages
|
|
768
893
|
const response = command
|
|
769
894
|
? await getClient().session.command({
|
|
770
895
|
path: { id: session.id },
|
|
896
|
+
query: { directory: sdkDirectory },
|
|
771
897
|
body: {
|
|
772
898
|
command: command.name,
|
|
773
899
|
arguments: command.arguments,
|
|
@@ -777,6 +903,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
777
903
|
})
|
|
778
904
|
: await getClient().session.prompt({
|
|
779
905
|
path: { id: session.id },
|
|
906
|
+
query: { directory: sdkDirectory },
|
|
780
907
|
body: {
|
|
781
908
|
parts,
|
|
782
909
|
system: getOpencodeSystemMessage({ sessionId: session.id, channelId, worktree }),
|
|
@@ -803,41 +930,42 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
803
930
|
abortController.abort('finished');
|
|
804
931
|
sessionLogger.log(`Successfully sent prompt, got response`);
|
|
805
932
|
if (originalMessage) {
|
|
806
|
-
|
|
933
|
+
const reactionResult = await errore.tryAsync(async () => {
|
|
807
934
|
await originalMessage.reactions.removeAll();
|
|
808
935
|
await originalMessage.react('✅');
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
discordLogger.log(`Could not update reactions:`,
|
|
936
|
+
});
|
|
937
|
+
if (reactionResult instanceof Error) {
|
|
938
|
+
discordLogger.log(`Could not update reactions:`, reactionResult);
|
|
812
939
|
}
|
|
813
940
|
}
|
|
814
941
|
return { sessionID: session.id, result: response.data, port };
|
|
942
|
+
});
|
|
943
|
+
if (!errore.isError(promptResult)) {
|
|
944
|
+
return promptResult;
|
|
815
945
|
}
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
const name = error.constructor.name || 'Error';
|
|
833
|
-
return `[${name}]\n${error.stack || error.message}`;
|
|
834
|
-
}
|
|
835
|
-
if (typeof error === 'string') {
|
|
836
|
-
return error;
|
|
837
|
-
}
|
|
838
|
-
return String(error);
|
|
839
|
-
})();
|
|
840
|
-
await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`);
|
|
946
|
+
const promptError = promptResult instanceof Error ? promptResult : new Error('Unknown error');
|
|
947
|
+
if (isAbortError(promptError, abortController.signal)) {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
sessionLogger.error(`ERROR: Failed to send prompt:`, promptError);
|
|
951
|
+
abortController.abort('error');
|
|
952
|
+
if (originalMessage) {
|
|
953
|
+
const reactionResult = await errore.tryAsync(async () => {
|
|
954
|
+
await originalMessage.reactions.removeAll();
|
|
955
|
+
await originalMessage.react('❌');
|
|
956
|
+
});
|
|
957
|
+
if (reactionResult instanceof Error) {
|
|
958
|
+
discordLogger.log(`Could not update reaction:`, reactionResult);
|
|
959
|
+
}
|
|
960
|
+
else {
|
|
961
|
+
discordLogger.log(`Added error reaction to message`);
|
|
841
962
|
}
|
|
842
963
|
}
|
|
964
|
+
const errorDisplay = (() => {
|
|
965
|
+
const promptErrorValue = promptError;
|
|
966
|
+
const name = promptErrorValue.name || 'Error';
|
|
967
|
+
const message = promptErrorValue.stack || promptErrorValue.message;
|
|
968
|
+
return `[${name}]\n${message}`;
|
|
969
|
+
})();
|
|
970
|
+
await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`);
|
|
843
971
|
}
|