kimaki 0.4.44 → 0.4.46
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/create-new-project.js +2 -0
- package/dist/commands/fork.js +2 -0
- package/dist/commands/permissions.js +21 -5
- package/dist/commands/queue.js +5 -1
- package/dist/commands/resume.js +10 -16
- package/dist/commands/session.js +20 -42
- package/dist/commands/user-command.js +10 -17
- package/dist/commands/verbosity.js +53 -0
- package/dist/commands/worktree-settings.js +2 -2
- package/dist/commands/worktree.js +134 -25
- package/dist/database.js +49 -0
- package/dist/discord-bot.js +26 -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 +581 -414
- 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/create-new-project.ts +3 -0
- package/src/commands/fork.ts +3 -0
- package/src/commands/permissions.ts +31 -5
- package/src/commands/queue.ts +5 -1
- package/src/commands/resume.ts +11 -18
- package/src/commands/session.ts +21 -44
- package/src/commands/user-command.ts +11 -19
- package/src/commands/verbosity.ts +71 -0
- package/src/commands/worktree-settings.ts +2 -2
- package/src/commands/worktree.ts +163 -27
- package/src/database.ts +65 -0
- package/src/discord-bot.ts +29 -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 +711 -436
package/src/session-handler.ts
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
|
|
|
5
|
-
import type { Part, PermissionRequest } from '@opencode-ai/sdk/v2'
|
|
5
|
+
import type { Part, PermissionRequest, QuestionRequest } from '@opencode-ai/sdk/v2'
|
|
6
6
|
import type { FilePartInput } from '@opencode-ai/sdk'
|
|
7
7
|
import type { Message, ThreadChannel } from 'discord.js'
|
|
8
8
|
import prettyMilliseconds from 'pretty-ms'
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
getChannelAgent,
|
|
15
15
|
setSessionAgent,
|
|
16
16
|
getThreadWorktree,
|
|
17
|
+
getChannelVerbosity,
|
|
17
18
|
} from './database.js'
|
|
18
19
|
import {
|
|
19
20
|
initializeOpencodeForDirectory,
|
|
@@ -30,7 +31,11 @@ import {
|
|
|
30
31
|
cancelPendingQuestion,
|
|
31
32
|
pendingQuestionContexts,
|
|
32
33
|
} from './commands/ask-question.js'
|
|
33
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
showPermissionDropdown,
|
|
36
|
+
cleanupPermissionContext,
|
|
37
|
+
addPermissionRequestToContext,
|
|
38
|
+
} from './commands/permissions.js'
|
|
34
39
|
import * as errore from 'errore'
|
|
35
40
|
|
|
36
41
|
const sessionLogger = createLogger('SESSION')
|
|
@@ -44,9 +49,31 @@ export const abortControllers = new Map<string, AbortController>()
|
|
|
44
49
|
// to avoid duplicates and properly clean up on auto-reject
|
|
45
50
|
export const pendingPermissions = new Map<
|
|
46
51
|
string, // threadId
|
|
47
|
-
Map<
|
|
52
|
+
Map<
|
|
53
|
+
string,
|
|
54
|
+
{
|
|
55
|
+
permission: PermissionRequest
|
|
56
|
+
messageId: string
|
|
57
|
+
directory: string
|
|
58
|
+
contextHash: string
|
|
59
|
+
dedupeKey: string
|
|
60
|
+
}
|
|
61
|
+
> // permissionId -> data
|
|
48
62
|
>()
|
|
49
63
|
|
|
64
|
+
function buildPermissionDedupeKey({
|
|
65
|
+
permission,
|
|
66
|
+
directory,
|
|
67
|
+
}: {
|
|
68
|
+
permission: PermissionRequest
|
|
69
|
+
directory: string
|
|
70
|
+
}): string {
|
|
71
|
+
const normalizedPatterns = [...permission.patterns].sort((a, b) => {
|
|
72
|
+
return a.localeCompare(b)
|
|
73
|
+
})
|
|
74
|
+
return `${directory}::${permission.permission}::${normalizedPatterns.join('|')}`
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
export type QueuedMessage = {
|
|
51
78
|
prompt: string
|
|
52
79
|
userId: string
|
|
@@ -59,6 +86,8 @@ export type QueuedMessage = {
|
|
|
59
86
|
// Key is threadId, value is array of queued messages
|
|
60
87
|
export const messageQueue = new Map<string, QueuedMessage[]>()
|
|
61
88
|
|
|
89
|
+
const activeEventHandlers = new Map<string, Promise<void>>()
|
|
90
|
+
|
|
62
91
|
export function addToQueue({
|
|
63
92
|
threadId,
|
|
64
93
|
message,
|
|
@@ -113,10 +142,11 @@ export async function abortAndRetrySession({
|
|
|
113
142
|
sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message)
|
|
114
143
|
return false
|
|
115
144
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
145
|
+
const abortResult = await errore.tryAsync(() => {
|
|
146
|
+
return getClient().session.abort({ path: { id: sessionId } })
|
|
147
|
+
})
|
|
148
|
+
if (abortResult instanceof Error) {
|
|
149
|
+
sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, abortResult)
|
|
120
150
|
}
|
|
121
151
|
|
|
122
152
|
// Small delay to let the abort propagate
|
|
@@ -146,16 +176,25 @@ export async function abortAndRetrySession({
|
|
|
146
176
|
|
|
147
177
|
// Use setImmediate to avoid blocking
|
|
148
178
|
setImmediate(() => {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
179
|
+
void errore
|
|
180
|
+
.tryAsync(async () => {
|
|
181
|
+
return handleOpencodeSession({
|
|
182
|
+
prompt,
|
|
183
|
+
thread,
|
|
184
|
+
projectDirectory,
|
|
185
|
+
images,
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
.then(async (result) => {
|
|
189
|
+
if (!(result instanceof Error)) {
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, result)
|
|
193
|
+
await sendThreadMessage(
|
|
194
|
+
thread,
|
|
195
|
+
`✗ Failed to retry with new model: ${result.message.slice(0, 200)}`,
|
|
196
|
+
)
|
|
197
|
+
})
|
|
159
198
|
})
|
|
160
199
|
|
|
161
200
|
return true
|
|
@@ -191,6 +230,18 @@ export async function handleOpencodeSession({
|
|
|
191
230
|
const directory = projectDirectory || process.cwd()
|
|
192
231
|
sessionLogger.log(`Using directory: ${directory}`)
|
|
193
232
|
|
|
233
|
+
// Get worktree info early so we can use the correct directory for events and prompts
|
|
234
|
+
const worktreeInfo = getThreadWorktree(thread.id)
|
|
235
|
+
const worktreeDirectory =
|
|
236
|
+
worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
237
|
+
? worktreeInfo.worktree_directory
|
|
238
|
+
: undefined
|
|
239
|
+
// Use worktree directory for SDK calls if available, otherwise project directory
|
|
240
|
+
const sdkDirectory = worktreeDirectory || directory
|
|
241
|
+
if (worktreeDirectory) {
|
|
242
|
+
sessionLogger.log(`Using worktree directory for SDK calls: ${worktreeDirectory}`)
|
|
243
|
+
}
|
|
244
|
+
|
|
194
245
|
const getClient = await initializeOpencodeForDirectory(directory)
|
|
195
246
|
if (getClient instanceof Error) {
|
|
196
247
|
await sendThreadMessage(thread, `✗ ${getClient.message}`)
|
|
@@ -208,14 +259,17 @@ export async function handleOpencodeSession({
|
|
|
208
259
|
|
|
209
260
|
if (sessionId) {
|
|
210
261
|
sessionLogger.log(`Attempting to reuse existing session ${sessionId}`)
|
|
211
|
-
|
|
212
|
-
|
|
262
|
+
const sessionResponse = await errore.tryAsync(() => {
|
|
263
|
+
return getClient().session.get({
|
|
213
264
|
path: { id: sessionId },
|
|
265
|
+
query: { directory: sdkDirectory },
|
|
214
266
|
})
|
|
267
|
+
})
|
|
268
|
+
if (sessionResponse instanceof Error) {
|
|
269
|
+
voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`)
|
|
270
|
+
} else {
|
|
215
271
|
session = sessionResponse.data
|
|
216
272
|
sessionLogger.log(`Successfully reused session ${sessionId}`)
|
|
217
|
-
} catch (error) {
|
|
218
|
-
voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`)
|
|
219
273
|
}
|
|
220
274
|
}
|
|
221
275
|
|
|
@@ -224,6 +278,7 @@ export async function handleOpencodeSession({
|
|
|
224
278
|
voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`)
|
|
225
279
|
const sessionResponse = await getClient().session.create({
|
|
226
280
|
body: { title: sessionTitle },
|
|
281
|
+
query: { directory: sdkDirectory },
|
|
227
282
|
})
|
|
228
283
|
session = sessionResponse.data
|
|
229
284
|
sessionLogger.log(`Created new session ${session?.id}`)
|
|
@@ -248,6 +303,15 @@ export async function handleOpencodeSession({
|
|
|
248
303
|
if (existingController) {
|
|
249
304
|
voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`)
|
|
250
305
|
existingController.abort(new Error('New request started'))
|
|
306
|
+
const abortResult = await errore.tryAsync(() => {
|
|
307
|
+
return getClient().session.abort({
|
|
308
|
+
path: { id: session.id },
|
|
309
|
+
query: { directory: sdkDirectory },
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
if (abortResult instanceof Error) {
|
|
313
|
+
sessionLogger.log(`[ABORT] Server abort failed (may be already done):`, abortResult)
|
|
314
|
+
}
|
|
251
315
|
}
|
|
252
316
|
|
|
253
317
|
// Auto-reject ALL pending permissions for this thread
|
|
@@ -256,20 +320,25 @@ export async function handleOpencodeSession({
|
|
|
256
320
|
const clientV2 = getOpencodeClientV2(directory)
|
|
257
321
|
let rejectedCount = 0
|
|
258
322
|
for (const [permId, pendingPerm] of threadPermissions) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
await clientV2.permission.reply({
|
|
263
|
-
requestID: permId,
|
|
264
|
-
reply: 'reject',
|
|
265
|
-
})
|
|
266
|
-
}
|
|
323
|
+
sessionLogger.log(`[PERMISSION] Auto-rejecting permission ${permId} due to new message`)
|
|
324
|
+
if (!clientV2) {
|
|
325
|
+
sessionLogger.log(`[PERMISSION] OpenCode v2 client unavailable for permission ${permId}`)
|
|
267
326
|
cleanupPermissionContext(pendingPerm.contextHash)
|
|
268
327
|
rejectedCount++
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
const rejectResult = await errore.tryAsync(() => {
|
|
331
|
+
return clientV2.permission.reply({
|
|
332
|
+
requestID: permId,
|
|
333
|
+
reply: 'reject',
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
if (rejectResult instanceof Error) {
|
|
337
|
+
sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, rejectResult)
|
|
338
|
+
} else {
|
|
339
|
+
rejectedCount++
|
|
272
340
|
}
|
|
341
|
+
cleanupPermissionContext(pendingPerm.contextHash)
|
|
273
342
|
}
|
|
274
343
|
pendingPermissions.delete(thread.id)
|
|
275
344
|
if (rejectedCount > 0) {
|
|
@@ -305,13 +374,24 @@ export async function handleOpencodeSession({
|
|
|
305
374
|
return
|
|
306
375
|
}
|
|
307
376
|
|
|
377
|
+
const previousHandler = activeEventHandlers.get(thread.id)
|
|
378
|
+
if (previousHandler) {
|
|
379
|
+
sessionLogger.log(`[EVENT] Waiting for previous handler to finish`)
|
|
380
|
+
await Promise.race([
|
|
381
|
+
previousHandler,
|
|
382
|
+
new Promise((resolve) => {
|
|
383
|
+
setTimeout(resolve, 1000)
|
|
384
|
+
}),
|
|
385
|
+
])
|
|
386
|
+
}
|
|
387
|
+
|
|
308
388
|
// Use v2 client for event subscription (has proper types for question.asked events)
|
|
309
389
|
const clientV2 = getOpencodeClientV2(directory)
|
|
310
390
|
if (!clientV2) {
|
|
311
391
|
throw new Error(`OpenCode v2 client not found for directory: ${directory}`)
|
|
312
392
|
}
|
|
313
393
|
const eventsResult = await clientV2.event.subscribe(
|
|
314
|
-
{ directory },
|
|
394
|
+
{ directory: sdkDirectory },
|
|
315
395
|
{ signal: abortController.signal },
|
|
316
396
|
)
|
|
317
397
|
|
|
@@ -331,7 +411,7 @@ export async function handleOpencodeSession({
|
|
|
331
411
|
).map((row) => row.part_id),
|
|
332
412
|
)
|
|
333
413
|
|
|
334
|
-
|
|
414
|
+
const partBuffer = new Map<string, Map<string, Part>>()
|
|
335
415
|
let stopTyping: (() => void) | null = null
|
|
336
416
|
let usedModel: string | undefined
|
|
337
417
|
let usedProviderID: string | undefined
|
|
@@ -339,6 +419,8 @@ export async function handleOpencodeSession({
|
|
|
339
419
|
let tokensUsedInSession = 0
|
|
340
420
|
let lastDisplayedContextPercentage = 0
|
|
341
421
|
let modelContextLimit: number | undefined
|
|
422
|
+
let assistantMessageId: string | undefined
|
|
423
|
+
let handlerPromise: Promise<void> | null = null
|
|
342
424
|
|
|
343
425
|
let typingInterval: NodeJS.Timeout | null = null
|
|
344
426
|
|
|
@@ -352,13 +434,17 @@ export async function handleOpencodeSession({
|
|
|
352
434
|
typingInterval = null
|
|
353
435
|
}
|
|
354
436
|
|
|
355
|
-
thread.sendTyping().
|
|
356
|
-
|
|
437
|
+
void errore.tryAsync(() => thread.sendTyping()).then((result) => {
|
|
438
|
+
if (result instanceof Error) {
|
|
439
|
+
discordLogger.log(`Failed to send initial typing: ${result}`)
|
|
440
|
+
}
|
|
357
441
|
})
|
|
358
442
|
|
|
359
443
|
typingInterval = setInterval(() => {
|
|
360
|
-
thread.sendTyping().
|
|
361
|
-
|
|
444
|
+
void errore.tryAsync(() => thread.sendTyping()).then((result) => {
|
|
445
|
+
if (result instanceof Error) {
|
|
446
|
+
discordLogger.log(`Failed to send periodic typing: ${result}`)
|
|
447
|
+
}
|
|
362
448
|
})
|
|
363
449
|
}, 8000)
|
|
364
450
|
|
|
@@ -383,7 +469,16 @@ export async function handleOpencodeSession({
|
|
|
383
469
|
}
|
|
384
470
|
}
|
|
385
471
|
|
|
472
|
+
// Get verbosity setting for this channel (use parent channel for threads)
|
|
473
|
+
const verbosityChannelId = channelId || thread.parentId || thread.id
|
|
474
|
+
const verbosity = getChannelVerbosity(verbosityChannelId)
|
|
475
|
+
|
|
386
476
|
const sendPartMessage = async (part: Part) => {
|
|
477
|
+
// In text-only mode, only send text parts (the ⬥ diamond messages)
|
|
478
|
+
if (verbosity === 'text-only' && part.type !== 'text') {
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
|
|
387
482
|
const content = formatPart(part) + '\n\n'
|
|
388
483
|
if (!content.trim() || content.length === 0) {
|
|
389
484
|
// discordLogger.log(`SKIP: Part ${part.id} has no content`)
|
|
@@ -394,18 +489,20 @@ export async function handleOpencodeSession({
|
|
|
394
489
|
return
|
|
395
490
|
}
|
|
396
491
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
404
|
-
)
|
|
405
|
-
.run(part.id, firstMessage.id, thread.id)
|
|
406
|
-
} catch (error) {
|
|
407
|
-
discordLogger.error(`ERROR: Failed to send part ${part.id}:`, error)
|
|
492
|
+
const sendResult = await errore.tryAsync(() => {
|
|
493
|
+
return sendThreadMessage(thread, content)
|
|
494
|
+
})
|
|
495
|
+
if (sendResult instanceof Error) {
|
|
496
|
+
discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult)
|
|
497
|
+
return
|
|
408
498
|
}
|
|
499
|
+
sentPartIds.add(part.id)
|
|
500
|
+
|
|
501
|
+
getDatabase()
|
|
502
|
+
.prepare(
|
|
503
|
+
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
504
|
+
)
|
|
505
|
+
.run(part.id, sendResult.id, thread.id)
|
|
409
506
|
}
|
|
410
507
|
|
|
411
508
|
const eventHandler = async () => {
|
|
@@ -414,390 +511,548 @@ export async function handleOpencodeSession({
|
|
|
414
511
|
// Counts spawned tasks per agent type: "explore" → 2
|
|
415
512
|
const agentSpawnCounts: Record<string, number> = {}
|
|
416
513
|
|
|
417
|
-
|
|
418
|
-
|
|
514
|
+
const storePart = (part: Part) => {
|
|
515
|
+
const messageParts = partBuffer.get(part.messageID) || new Map<string, Part>()
|
|
516
|
+
messageParts.set(part.id, part)
|
|
517
|
+
partBuffer.set(part.messageID, messageParts)
|
|
518
|
+
}
|
|
419
519
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
520
|
+
const getBufferedParts = (messageID: string) => {
|
|
521
|
+
return Array.from(partBuffer.get(messageID)?.values() ?? [])
|
|
522
|
+
}
|
|
423
523
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
524
|
+
const shouldSendPart = ({ part, force }: { part: Part; force: boolean }) => {
|
|
525
|
+
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
526
|
+
return false
|
|
527
|
+
}
|
|
429
528
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
529
|
+
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
530
|
+
return false
|
|
531
|
+
}
|
|
433
532
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
msg.tokens.output +
|
|
438
|
-
msg.tokens.reasoning +
|
|
439
|
-
msg.tokens.cache.read +
|
|
440
|
-
msg.tokens.cache.write
|
|
441
|
-
if (newTokensTotal > 0) {
|
|
442
|
-
tokensUsedInSession = newTokensTotal
|
|
443
|
-
}
|
|
533
|
+
if (!force && part.type === 'text' && !part.time?.end) {
|
|
534
|
+
return false
|
|
535
|
+
}
|
|
444
536
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
usedAgent = msg.mode
|
|
449
|
-
|
|
450
|
-
if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
|
|
451
|
-
if (!modelContextLimit) {
|
|
452
|
-
try {
|
|
453
|
-
const providersResponse = await getClient().provider.list({
|
|
454
|
-
query: { directory },
|
|
455
|
-
})
|
|
456
|
-
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
|
|
457
|
-
const model = provider?.models?.[usedModel]
|
|
458
|
-
if (model?.limit?.context) {
|
|
459
|
-
modelContextLimit = model.limit.context
|
|
460
|
-
}
|
|
461
|
-
} catch (e) {
|
|
462
|
-
sessionLogger.error('Failed to fetch provider info for context limit:', e)
|
|
463
|
-
}
|
|
464
|
-
}
|
|
537
|
+
if (!force && part.type === 'tool' && part.state.status === 'completed') {
|
|
538
|
+
return false
|
|
539
|
+
}
|
|
465
540
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
(tokensUsedInSession / modelContextLimit) * 100,
|
|
469
|
-
)
|
|
470
|
-
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
|
|
471
|
-
if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
|
|
472
|
-
lastDisplayedContextPercentage = thresholdCrossed
|
|
473
|
-
const chunk = `⬦ context usage ${currentPercentage}%`
|
|
474
|
-
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
} else if (event.type === 'message.part.updated') {
|
|
480
|
-
const part = event.properties.part
|
|
541
|
+
return true
|
|
542
|
+
}
|
|
481
543
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
544
|
+
const flushBufferedParts = async ({
|
|
545
|
+
messageID,
|
|
546
|
+
force,
|
|
547
|
+
skipPartId,
|
|
548
|
+
}: {
|
|
549
|
+
messageID: string
|
|
550
|
+
force: boolean
|
|
551
|
+
skipPartId?: string
|
|
552
|
+
}) => {
|
|
553
|
+
if (!messageID) {
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
const parts = getBufferedParts(messageID)
|
|
557
|
+
for (const part of parts) {
|
|
558
|
+
if (skipPartId && part.id === skipPartId) {
|
|
559
|
+
continue
|
|
560
|
+
}
|
|
561
|
+
if (!shouldSendPart({ part, force })) {
|
|
562
|
+
continue
|
|
563
|
+
}
|
|
564
|
+
await sendPartMessage(part)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
485
567
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
568
|
+
const handleMessageUpdated = async (msg: {
|
|
569
|
+
id: string
|
|
570
|
+
sessionID: string
|
|
571
|
+
role: string
|
|
572
|
+
modelID?: string
|
|
573
|
+
providerID?: string
|
|
574
|
+
mode?: string
|
|
575
|
+
tokens?: {
|
|
576
|
+
input: number
|
|
577
|
+
output: number
|
|
578
|
+
reasoning: number
|
|
579
|
+
cache: { read: number; write: number }
|
|
580
|
+
}
|
|
581
|
+
}) => {
|
|
582
|
+
const subtaskInfo = subtaskSessions.get(msg.sessionID)
|
|
583
|
+
if (subtaskInfo && msg.role === 'assistant') {
|
|
584
|
+
subtaskInfo.assistantMessageId = msg.id
|
|
585
|
+
}
|
|
490
586
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
495
|
-
continue
|
|
496
|
-
}
|
|
497
|
-
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
498
|
-
continue
|
|
499
|
-
}
|
|
500
|
-
// Skip text parts - the outer agent will report the task result anyway
|
|
501
|
-
if (part.type === 'text') {
|
|
502
|
-
continue
|
|
503
|
-
}
|
|
504
|
-
// Only show parts from assistant messages (not user prompts sent to subtask)
|
|
505
|
-
// Skip if we haven't seen an assistant message yet, or if this part is from a different message
|
|
506
|
-
if (
|
|
507
|
-
!subtaskInfo.assistantMessageId ||
|
|
508
|
-
part.messageID !== subtaskInfo.assistantMessageId
|
|
509
|
-
) {
|
|
510
|
-
continue
|
|
511
|
-
}
|
|
587
|
+
if (msg.sessionID !== session.id) {
|
|
588
|
+
return
|
|
589
|
+
}
|
|
512
590
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const msg = await sendThreadMessage(thread, content + '\n\n')
|
|
517
|
-
sentPartIds.add(part.id)
|
|
518
|
-
getDatabase()
|
|
519
|
-
.prepare(
|
|
520
|
-
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
521
|
-
)
|
|
522
|
-
.run(part.id, msg.id, thread.id)
|
|
523
|
-
} catch (error) {
|
|
524
|
-
discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, error)
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
continue
|
|
528
|
-
}
|
|
591
|
+
if (msg.role !== 'assistant') {
|
|
592
|
+
return
|
|
593
|
+
}
|
|
529
594
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
595
|
+
if (msg.tokens) {
|
|
596
|
+
const newTokensTotal =
|
|
597
|
+
msg.tokens.input +
|
|
598
|
+
msg.tokens.output +
|
|
599
|
+
msg.tokens.reasoning +
|
|
600
|
+
msg.tokens.cache.read +
|
|
601
|
+
msg.tokens.cache.write
|
|
602
|
+
if (newTokensTotal > 0) {
|
|
603
|
+
tokensUsedInSession = newTokensTotal
|
|
604
|
+
}
|
|
605
|
+
}
|
|
534
606
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
currentParts.push(part)
|
|
540
|
-
}
|
|
607
|
+
assistantMessageId = msg.id
|
|
608
|
+
usedModel = msg.modelID
|
|
609
|
+
usedProviderID = msg.providerID
|
|
610
|
+
usedAgent = msg.mode
|
|
541
611
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
)
|
|
547
|
-
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
|
|
548
|
-
if (!hasPendingQuestion && !hasPendingPermission) {
|
|
549
|
-
stopTyping = startTyping()
|
|
550
|
-
}
|
|
551
|
-
}
|
|
612
|
+
await flushBufferedParts({
|
|
613
|
+
messageID: assistantMessageId,
|
|
614
|
+
force: false,
|
|
615
|
+
})
|
|
552
616
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
for (const p of currentParts) {
|
|
557
|
-
if (p.type !== 'step-start' && p.type !== 'step-finish' && p.id !== part.id) {
|
|
558
|
-
await sendPartMessage(p)
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
await sendPartMessage(part)
|
|
562
|
-
// Track task tool and register child session when sessionId is available
|
|
563
|
-
if (part.tool === 'task' && !sentPartIds.has(part.id)) {
|
|
564
|
-
const description = (part.state.input?.description as string) || ''
|
|
565
|
-
const agent = (part.state.input?.subagent_type as string) || 'task'
|
|
566
|
-
const childSessionId = (part.state.metadata?.sessionId as string) || ''
|
|
567
|
-
if (description && childSessionId) {
|
|
568
|
-
agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1
|
|
569
|
-
const label = `${agent}-${agentSpawnCounts[agent]}`
|
|
570
|
-
subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined })
|
|
571
|
-
const taskDisplay = `┣ task **${label}** _${description}_`
|
|
572
|
-
await sendThreadMessage(thread, taskDisplay + '\n\n')
|
|
573
|
-
sentPartIds.add(part.id)
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
}
|
|
617
|
+
if (tokensUsedInSession === 0 || !usedProviderID || !usedModel) {
|
|
618
|
+
return
|
|
619
|
+
}
|
|
577
620
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
if (pct < 1) {
|
|
592
|
-
return ''
|
|
593
|
-
}
|
|
594
|
-
return ` (${pct.toFixed(1)}%)`
|
|
595
|
-
})()
|
|
596
|
-
const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
|
|
597
|
-
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
598
|
-
}
|
|
621
|
+
if (!modelContextLimit) {
|
|
622
|
+
const providersResponse = await errore.tryAsync(() => {
|
|
623
|
+
return getClient().provider.list({
|
|
624
|
+
query: { directory: sdkDirectory },
|
|
625
|
+
})
|
|
626
|
+
})
|
|
627
|
+
if (providersResponse instanceof Error) {
|
|
628
|
+
sessionLogger.error('Failed to fetch provider info for context limit:', providersResponse)
|
|
629
|
+
} else {
|
|
630
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
|
|
631
|
+
const model = provider?.models?.[usedModel]
|
|
632
|
+
if (model?.limit?.context) {
|
|
633
|
+
modelContextLimit = model.limit.context
|
|
599
634
|
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
600
637
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
638
|
+
if (!modelContextLimit) {
|
|
639
|
+
return
|
|
640
|
+
}
|
|
604
641
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
642
|
+
const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100)
|
|
643
|
+
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
|
|
644
|
+
if (thresholdCrossed <= lastDisplayedContextPercentage || thresholdCrossed < 10) {
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
lastDisplayedContextPercentage = thresholdCrossed
|
|
648
|
+
const chunk = `⬦ context usage ${currentPercentage}%`
|
|
649
|
+
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
650
|
+
}
|
|
610
651
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
652
|
+
const handleMainPart = async (part: Part) => {
|
|
653
|
+
const isActiveMessage = assistantMessageId ? part.messageID === assistantMessageId : false
|
|
654
|
+
const allowEarlyProcessing =
|
|
655
|
+
!assistantMessageId && part.type === 'tool' && part.state.status === 'running'
|
|
656
|
+
if (!isActiveMessage && !allowEarlyProcessing) {
|
|
657
|
+
if (part.type !== 'step-start') {
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (part.type === 'step-start') {
|
|
663
|
+
const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
|
|
664
|
+
(ctx) => ctx.thread.id === thread.id,
|
|
665
|
+
)
|
|
666
|
+
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
|
|
667
|
+
if (!hasPendingQuestion && !hasPendingPermission) {
|
|
668
|
+
stopTyping = startTyping()
|
|
669
|
+
}
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (part.type === 'tool' && part.state.status === 'running') {
|
|
674
|
+
await flushBufferedParts({
|
|
675
|
+
messageID: assistantMessageId || part.messageID,
|
|
676
|
+
force: true,
|
|
677
|
+
skipPartId: part.id,
|
|
678
|
+
})
|
|
679
|
+
await sendPartMessage(part)
|
|
680
|
+
if (part.tool === 'task' && !sentPartIds.has(part.id)) {
|
|
681
|
+
const description = (part.state.input?.description as string) || ''
|
|
682
|
+
const agent = (part.state.input?.subagent_type as string) || 'task'
|
|
683
|
+
const childSessionId = (part.state.metadata?.sessionId as string) || ''
|
|
684
|
+
if (description && childSessionId) {
|
|
685
|
+
agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1
|
|
686
|
+
const label = `${agent}-${agentSpawnCounts[agent]}`
|
|
687
|
+
subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined })
|
|
688
|
+
// Skip task messages in text-only mode
|
|
689
|
+
if (verbosity !== 'text-only') {
|
|
690
|
+
const taskDisplay = `┣ task **${label}** _${description}_`
|
|
691
|
+
await sendThreadMessage(thread, taskDisplay + '\n\n')
|
|
616
692
|
}
|
|
617
|
-
|
|
618
|
-
if (abortController.signal.aborted) return
|
|
619
|
-
// Don't restart typing if user needs to respond to a question or permission
|
|
620
|
-
const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
|
|
621
|
-
(ctx) => ctx.thread.id === thread.id,
|
|
622
|
-
)
|
|
623
|
-
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
|
|
624
|
-
if (hasPendingQuestion || hasPendingPermission) return
|
|
625
|
-
stopTyping = startTyping()
|
|
626
|
-
}, 300)
|
|
693
|
+
sentPartIds.add(part.id)
|
|
627
694
|
}
|
|
695
|
+
}
|
|
696
|
+
return
|
|
697
|
+
}
|
|
628
698
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
if (
|
|
638
|
-
|
|
639
|
-
await originalMessage.reactions.removeAll()
|
|
640
|
-
await originalMessage.react('❌')
|
|
641
|
-
voiceLogger.log(`[REACTION] Added error reaction due to session error`)
|
|
642
|
-
} catch (e) {
|
|
643
|
-
discordLogger.log(`Could not update reaction:`, e)
|
|
644
|
-
}
|
|
699
|
+
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
700
|
+
const output = part.state.output || ''
|
|
701
|
+
const outputTokens = Math.ceil(output.length / 4)
|
|
702
|
+
const largeOutputThreshold = 3000
|
|
703
|
+
if (outputTokens >= largeOutputThreshold) {
|
|
704
|
+
const formattedTokens =
|
|
705
|
+
outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens)
|
|
706
|
+
const percentageSuffix = (() => {
|
|
707
|
+
if (!modelContextLimit) {
|
|
708
|
+
return ''
|
|
645
709
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
|
|
657
|
-
)
|
|
658
|
-
continue
|
|
659
|
-
}
|
|
710
|
+
const pct = (outputTokens / modelContextLimit) * 100
|
|
711
|
+
if (pct < 1) {
|
|
712
|
+
return ''
|
|
713
|
+
}
|
|
714
|
+
return ` (${pct.toFixed(1)}%)`
|
|
715
|
+
})()
|
|
716
|
+
const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
|
|
717
|
+
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
718
|
+
}
|
|
719
|
+
}
|
|
660
720
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
`[PERMISSION] Skipping duplicate permission ${permission.id} (already pending)`,
|
|
666
|
-
)
|
|
667
|
-
continue
|
|
668
|
-
}
|
|
721
|
+
if (part.type === 'reasoning') {
|
|
722
|
+
await sendPartMessage(part)
|
|
723
|
+
return
|
|
724
|
+
}
|
|
669
725
|
|
|
670
|
-
|
|
671
|
-
|
|
726
|
+
if (part.type === 'text' && part.time?.end) {
|
|
727
|
+
await sendPartMessage(part)
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (part.type === 'step-finish') {
|
|
732
|
+
await flushBufferedParts({
|
|
733
|
+
messageID: assistantMessageId || part.messageID,
|
|
734
|
+
force: true,
|
|
735
|
+
})
|
|
736
|
+
setTimeout(() => {
|
|
737
|
+
if (abortController.signal.aborted) return
|
|
738
|
+
const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
|
|
739
|
+
(ctx) => ctx.thread.id === thread.id,
|
|
672
740
|
)
|
|
741
|
+
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
|
|
742
|
+
if (hasPendingQuestion || hasPendingPermission) return
|
|
743
|
+
stopTyping = startTyping()
|
|
744
|
+
}, 300)
|
|
745
|
+
}
|
|
746
|
+
}
|
|
673
747
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
748
|
+
const handleSubtaskPart = async (
|
|
749
|
+
part: Part,
|
|
750
|
+
subtaskInfo: { label: string; assistantMessageId?: string },
|
|
751
|
+
) => {
|
|
752
|
+
// In text-only mode, skip all subtask output (they're tool-related)
|
|
753
|
+
if (verbosity === 'text-only') {
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
760
|
+
return
|
|
761
|
+
}
|
|
762
|
+
if (part.type === 'text') {
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
if (!subtaskInfo.assistantMessageId || part.messageID !== subtaskInfo.assistantMessageId) {
|
|
766
|
+
return
|
|
767
|
+
}
|
|
679
768
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
769
|
+
const content = formatPart(part, subtaskInfo.label)
|
|
770
|
+
if (!content.trim() || sentPartIds.has(part.id)) {
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
const sendResult = await errore.tryAsync(() => {
|
|
774
|
+
return sendThreadMessage(thread, content + '\n\n')
|
|
775
|
+
})
|
|
776
|
+
if (sendResult instanceof Error) {
|
|
777
|
+
discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, sendResult)
|
|
778
|
+
return
|
|
779
|
+
}
|
|
780
|
+
sentPartIds.add(part.id)
|
|
781
|
+
getDatabase()
|
|
782
|
+
.prepare(
|
|
783
|
+
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
784
|
+
)
|
|
785
|
+
.run(part.id, sendResult.id, thread.id)
|
|
786
|
+
}
|
|
686
787
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
pendingPermissions.set(thread.id, new Map())
|
|
690
|
-
}
|
|
691
|
-
pendingPermissions.get(thread.id)!.set(permission.id, {
|
|
692
|
-
permission,
|
|
693
|
-
messageId,
|
|
694
|
-
directory,
|
|
695
|
-
contextHash,
|
|
696
|
-
})
|
|
697
|
-
} else if (event.type === 'permission.replied') {
|
|
698
|
-
const { requestID, reply, sessionID } = event.properties
|
|
699
|
-
if (sessionID !== session.id) {
|
|
700
|
-
continue
|
|
701
|
-
}
|
|
788
|
+
const handlePartUpdated = async (part: Part) => {
|
|
789
|
+
storePart(part)
|
|
702
790
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
// Clean up the specific permission from nested map
|
|
706
|
-
const threadPermissions = pendingPermissions.get(thread.id)
|
|
707
|
-
if (threadPermissions) {
|
|
708
|
-
const pending = threadPermissions.get(requestID)
|
|
709
|
-
if (pending) {
|
|
710
|
-
cleanupPermissionContext(pending.contextHash)
|
|
711
|
-
threadPermissions.delete(requestID)
|
|
712
|
-
// Remove thread entry if no more pending permissions
|
|
713
|
-
if (threadPermissions.size === 0) {
|
|
714
|
-
pendingPermissions.delete(thread.id)
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
} else if (event.type === 'question.asked') {
|
|
719
|
-
const questionRequest = event.properties
|
|
791
|
+
const subtaskInfo = subtaskSessions.get(part.sessionID)
|
|
792
|
+
const isSubtaskEvent = Boolean(subtaskInfo)
|
|
720
793
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
794
|
+
if (part.sessionID !== session.id && !isSubtaskEvent) {
|
|
795
|
+
return
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (isSubtaskEvent && subtaskInfo) {
|
|
799
|
+
await handleSubtaskPart(part, subtaskInfo)
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
await handleMainPart(part)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const handleSessionError = async ({
|
|
807
|
+
sessionID,
|
|
808
|
+
error,
|
|
809
|
+
}: {
|
|
810
|
+
sessionID?: string
|
|
811
|
+
error?: { data?: { message?: string } }
|
|
812
|
+
}) => {
|
|
813
|
+
if (!sessionID || sessionID !== session.id) {
|
|
814
|
+
voiceLogger.log(
|
|
815
|
+
`[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${sessionID})`,
|
|
816
|
+
)
|
|
817
|
+
return
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const errorMessage = error?.data?.message || 'Unknown error'
|
|
821
|
+
sessionLogger.error(`Sending error to thread: ${errorMessage}`)
|
|
822
|
+
await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`)
|
|
823
|
+
|
|
824
|
+
if (!originalMessage) {
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
const reactionResult = await errore.tryAsync(async () => {
|
|
828
|
+
await originalMessage.reactions.removeAll()
|
|
829
|
+
await originalMessage.react('❌')
|
|
830
|
+
})
|
|
831
|
+
if (reactionResult instanceof Error) {
|
|
832
|
+
discordLogger.log(`Could not update reaction:`, reactionResult)
|
|
833
|
+
} else {
|
|
834
|
+
voiceLogger.log(`[REACTION] Added error reaction due to session error`)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const handlePermissionAsked = async (permission: PermissionRequest) => {
|
|
839
|
+
if (permission.sessionID !== session.id) {
|
|
840
|
+
voiceLogger.log(
|
|
841
|
+
`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
|
|
842
|
+
)
|
|
843
|
+
return
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const dedupeKey = buildPermissionDedupeKey({ permission, directory })
|
|
847
|
+
const threadPermissions = pendingPermissions.get(thread.id)
|
|
848
|
+
const existingPending = threadPermissions
|
|
849
|
+
? Array.from(threadPermissions.values()).find((pending) => {
|
|
850
|
+
return pending.dedupeKey === dedupeKey
|
|
851
|
+
})
|
|
852
|
+
: undefined
|
|
727
853
|
|
|
854
|
+
if (existingPending) {
|
|
855
|
+
sessionLogger.log(
|
|
856
|
+
`[PERMISSION] Deduped permission ${permission.id} (matches pending ${existingPending.permission.id})`,
|
|
857
|
+
)
|
|
858
|
+
if (stopTyping) {
|
|
859
|
+
stopTyping()
|
|
860
|
+
stopTyping = null
|
|
861
|
+
}
|
|
862
|
+
if (!pendingPermissions.has(thread.id)) {
|
|
863
|
+
pendingPermissions.set(thread.id, new Map())
|
|
864
|
+
}
|
|
865
|
+
pendingPermissions.get(thread.id)!.set(permission.id, {
|
|
866
|
+
permission,
|
|
867
|
+
messageId: existingPending.messageId,
|
|
868
|
+
directory,
|
|
869
|
+
contextHash: existingPending.contextHash,
|
|
870
|
+
dedupeKey,
|
|
871
|
+
})
|
|
872
|
+
const added = addPermissionRequestToContext({
|
|
873
|
+
contextHash: existingPending.contextHash,
|
|
874
|
+
requestId: permission.id,
|
|
875
|
+
})
|
|
876
|
+
if (!added) {
|
|
728
877
|
sessionLogger.log(
|
|
729
|
-
`
|
|
878
|
+
`[PERMISSION] Failed to attach duplicate request ${permission.id} to context`,
|
|
730
879
|
)
|
|
880
|
+
}
|
|
881
|
+
return
|
|
882
|
+
}
|
|
731
883
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
stopTyping = null
|
|
736
|
-
}
|
|
884
|
+
sessionLogger.log(
|
|
885
|
+
`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
|
|
886
|
+
)
|
|
737
887
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
await sendPartMessage(p)
|
|
743
|
-
}
|
|
744
|
-
}
|
|
888
|
+
if (stopTyping) {
|
|
889
|
+
stopTyping()
|
|
890
|
+
stopTyping = null
|
|
891
|
+
}
|
|
745
892
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
input: { questions: questionRequest.questions },
|
|
752
|
-
})
|
|
893
|
+
const { messageId, contextHash } = await showPermissionDropdown({
|
|
894
|
+
thread,
|
|
895
|
+
permission,
|
|
896
|
+
directory,
|
|
897
|
+
})
|
|
753
898
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
899
|
+
if (!pendingPermissions.has(thread.id)) {
|
|
900
|
+
pendingPermissions.set(thread.id, new Map())
|
|
901
|
+
}
|
|
902
|
+
pendingPermissions.get(thread.id)!.set(permission.id, {
|
|
903
|
+
permission,
|
|
904
|
+
messageId,
|
|
905
|
+
directory,
|
|
906
|
+
contextHash,
|
|
907
|
+
dedupeKey,
|
|
908
|
+
})
|
|
909
|
+
}
|
|
761
910
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
911
|
+
const handlePermissionReplied = ({
|
|
912
|
+
requestID,
|
|
913
|
+
reply,
|
|
914
|
+
sessionID,
|
|
915
|
+
}: {
|
|
916
|
+
requestID: string
|
|
917
|
+
reply: string
|
|
918
|
+
sessionID: string
|
|
919
|
+
}) => {
|
|
920
|
+
if (sessionID !== session.id) {
|
|
921
|
+
return
|
|
922
|
+
}
|
|
765
923
|
|
|
924
|
+
sessionLogger.log(`Permission ${requestID} replied with: ${reply}`)
|
|
925
|
+
|
|
926
|
+
const threadPermissions = pendingPermissions.get(thread.id)
|
|
927
|
+
if (!threadPermissions) {
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
const pending = threadPermissions.get(requestID)
|
|
931
|
+
if (!pending) {
|
|
932
|
+
return
|
|
933
|
+
}
|
|
934
|
+
cleanupPermissionContext(pending.contextHash)
|
|
935
|
+
threadPermissions.delete(requestID)
|
|
936
|
+
if (threadPermissions.size === 0) {
|
|
937
|
+
pendingPermissions.delete(thread.id)
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const handleQuestionAsked = async (questionRequest: QuestionRequest) => {
|
|
942
|
+
if (questionRequest.sessionID !== session.id) {
|
|
943
|
+
sessionLogger.log(
|
|
944
|
+
`[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`,
|
|
945
|
+
)
|
|
946
|
+
return
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
sessionLogger.log(
|
|
950
|
+
`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
if (stopTyping) {
|
|
954
|
+
stopTyping()
|
|
955
|
+
stopTyping = null
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
await flushBufferedParts({
|
|
959
|
+
messageID: assistantMessageId || '',
|
|
960
|
+
force: true,
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
await showAskUserQuestionDropdowns({
|
|
964
|
+
thread,
|
|
965
|
+
sessionId: session.id,
|
|
966
|
+
directory,
|
|
967
|
+
requestId: questionRequest.id,
|
|
968
|
+
input: { questions: questionRequest.questions },
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
const queue = messageQueue.get(thread.id)
|
|
972
|
+
if (!queue || queue.length === 0) {
|
|
973
|
+
return
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const nextMessage = queue.shift()!
|
|
977
|
+
if (queue.length === 0) {
|
|
978
|
+
messageQueue.delete(thread.id)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
sessionLogger.log(
|
|
982
|
+
`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`,
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
await sendThreadMessage(
|
|
986
|
+
thread,
|
|
987
|
+
`» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`,
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
setImmediate(() => {
|
|
991
|
+
void errore
|
|
992
|
+
.tryAsync(async () => {
|
|
993
|
+
return handleOpencodeSession({
|
|
994
|
+
prompt: nextMessage.prompt,
|
|
995
|
+
thread,
|
|
996
|
+
projectDirectory: directory,
|
|
997
|
+
images: nextMessage.images,
|
|
998
|
+
channelId,
|
|
999
|
+
})
|
|
1000
|
+
})
|
|
1001
|
+
.then(async (result) => {
|
|
1002
|
+
if (!(result instanceof Error)) {
|
|
1003
|
+
return
|
|
1004
|
+
}
|
|
1005
|
+
sessionLogger.error(`[QUEUE] Failed to process queued message:`, result)
|
|
766
1006
|
await sendThreadMessage(
|
|
767
1007
|
thread,
|
|
768
|
-
|
|
1008
|
+
`✗ Queued message failed: ${result.message.slice(0, 200)}`,
|
|
769
1009
|
)
|
|
1010
|
+
})
|
|
1011
|
+
})
|
|
1012
|
+
}
|
|
770
1013
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1014
|
+
const handleSessionIdle = (idleSessionId: string) => {
|
|
1015
|
+
if (idleSessionId === session.id) {
|
|
1016
|
+
sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`)
|
|
1017
|
+
abortController.abort('finished')
|
|
1018
|
+
return
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (!subtaskSessions.has(idleSessionId)) {
|
|
1022
|
+
return
|
|
1023
|
+
}
|
|
1024
|
+
const subtask = subtaskSessions.get(idleSessionId)
|
|
1025
|
+
sessionLogger.log(`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`)
|
|
1026
|
+
subtaskSessions.delete(idleSessionId)
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
try {
|
|
1030
|
+
for await (const event of events) {
|
|
1031
|
+
switch (event.type) {
|
|
1032
|
+
case 'message.updated':
|
|
1033
|
+
await handleMessageUpdated(event.properties.info)
|
|
1034
|
+
break
|
|
1035
|
+
case 'message.part.updated':
|
|
1036
|
+
await handlePartUpdated(event.properties.part)
|
|
1037
|
+
break
|
|
1038
|
+
case 'session.error':
|
|
1039
|
+
sessionLogger.error(`ERROR:`, event.properties)
|
|
1040
|
+
await handleSessionError(event.properties)
|
|
1041
|
+
break
|
|
1042
|
+
case 'permission.asked':
|
|
1043
|
+
await handlePermissionAsked(event.properties)
|
|
1044
|
+
break
|
|
1045
|
+
case 'permission.replied':
|
|
1046
|
+
handlePermissionReplied(event.properties)
|
|
1047
|
+
break
|
|
1048
|
+
case 'question.asked':
|
|
1049
|
+
await handleQuestionAsked(event.properties)
|
|
1050
|
+
break
|
|
1051
|
+
case 'session.idle':
|
|
1052
|
+
handleSessionIdle(event.properties.sessionID)
|
|
1053
|
+
break
|
|
1054
|
+
default:
|
|
1055
|
+
break
|
|
801
1056
|
}
|
|
802
1057
|
}
|
|
803
1058
|
} catch (e) {
|
|
@@ -808,12 +1063,13 @@ export async function handleOpencodeSession({
|
|
|
808
1063
|
sessionLogger.error(`Unexpected error in event handling code`, e)
|
|
809
1064
|
throw e
|
|
810
1065
|
} finally {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1066
|
+
abortControllers.delete(session.id)
|
|
1067
|
+
const finalMessageId = assistantMessageId
|
|
1068
|
+
if (finalMessageId) {
|
|
1069
|
+
const parts = getBufferedParts(finalMessageId)
|
|
1070
|
+
for (const part of parts) {
|
|
1071
|
+
if (!sentPartIds.has(part.id)) {
|
|
814
1072
|
await sendPartMessage(part)
|
|
815
|
-
} catch (error) {
|
|
816
|
-
sessionLogger.error(`Failed to send part ${part.id}:`, error)
|
|
817
1073
|
}
|
|
818
1074
|
}
|
|
819
1075
|
}
|
|
@@ -831,12 +1087,13 @@ export async function handleOpencodeSession({
|
|
|
831
1087
|
usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : ''
|
|
832
1088
|
let contextInfo = ''
|
|
833
1089
|
|
|
834
|
-
|
|
1090
|
+
const contextResult = await errore.tryAsync(async () => {
|
|
835
1091
|
// Fetch final token count from API since message.updated events can arrive
|
|
836
1092
|
// after session.idle due to race conditions in event ordering
|
|
837
1093
|
if (tokensUsedInSession === 0) {
|
|
838
1094
|
const messagesResponse = await getClient().session.messages({
|
|
839
1095
|
path: { id: session.id },
|
|
1096
|
+
query: { directory: sdkDirectory },
|
|
840
1097
|
})
|
|
841
1098
|
const messages = messagesResponse.data || []
|
|
842
1099
|
const lastAssistant = [...messages]
|
|
@@ -858,15 +1115,16 @@ export async function handleOpencodeSession({
|
|
|
858
1115
|
}
|
|
859
1116
|
}
|
|
860
1117
|
|
|
861
|
-
const providersResponse = await getClient().provider.list({ query: { directory } })
|
|
1118
|
+
const providersResponse = await getClient().provider.list({ query: { directory: sdkDirectory } })
|
|
862
1119
|
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
|
|
863
1120
|
const model = provider?.models?.[usedModel || '']
|
|
864
1121
|
if (model?.limit?.context) {
|
|
865
1122
|
const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100)
|
|
866
1123
|
contextInfo = ` ⋅ ${percentage}%`
|
|
867
1124
|
}
|
|
868
|
-
}
|
|
869
|
-
|
|
1125
|
+
})
|
|
1126
|
+
if (contextResult instanceof Error) {
|
|
1127
|
+
sessionLogger.error('Failed to fetch provider info for context percentage:', contextResult)
|
|
870
1128
|
}
|
|
871
1129
|
|
|
872
1130
|
await sendThreadMessage(
|
|
@@ -918,8 +1176,15 @@ export async function handleOpencodeSession({
|
|
|
918
1176
|
}
|
|
919
1177
|
}
|
|
920
1178
|
|
|
921
|
-
|
|
922
|
-
|
|
1179
|
+
const promptResult: Error | { sessionID: string; result: any; port?: number } | undefined =
|
|
1180
|
+
await errore.tryAsync(async () => {
|
|
1181
|
+
const newHandlerPromise = eventHandler().finally(() => {
|
|
1182
|
+
if (activeEventHandlers.get(thread.id) === newHandlerPromise) {
|
|
1183
|
+
activeEventHandlers.delete(thread.id)
|
|
1184
|
+
}
|
|
1185
|
+
})
|
|
1186
|
+
activeEventHandlers.set(thread.id, newHandlerPromise)
|
|
1187
|
+
handlerPromise = newHandlerPromise
|
|
923
1188
|
|
|
924
1189
|
if (abortController.signal.aborted) {
|
|
925
1190
|
sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
|
|
@@ -931,7 +1196,6 @@ export async function handleOpencodeSession({
|
|
|
931
1196
|
voiceLogger.log(
|
|
932
1197
|
`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
|
|
933
1198
|
)
|
|
934
|
-
// append image paths to prompt so ai knows where they are on disk
|
|
935
1199
|
const promptWithImagePaths = (() => {
|
|
936
1200
|
if (images.length === 0) {
|
|
937
1201
|
return prompt
|
|
@@ -951,19 +1215,15 @@ export async function handleOpencodeSession({
|
|
|
951
1215
|
const parts = [{ type: 'text' as const, text: promptWithImagePaths }, ...images]
|
|
952
1216
|
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
|
|
953
1217
|
|
|
954
|
-
// Get agent preference: session-level overrides channel-level
|
|
955
1218
|
const agentPreference =
|
|
956
1219
|
getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
|
|
957
1220
|
if (agentPreference) {
|
|
958
1221
|
sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
|
|
959
1222
|
}
|
|
960
1223
|
|
|
961
|
-
// Get model preference: session-level overrides channel-level
|
|
962
|
-
// BUT: if an agent is set, don't pass model param so the agent's model takes effect
|
|
963
1224
|
const modelPreference =
|
|
964
1225
|
getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
|
|
965
1226
|
const modelParam = (() => {
|
|
966
|
-
// When an agent is set, let the agent's model config take effect
|
|
967
1227
|
if (agentPreference) {
|
|
968
1228
|
sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`)
|
|
969
1229
|
return undefined
|
|
@@ -980,8 +1240,7 @@ export async function handleOpencodeSession({
|
|
|
980
1240
|
return { providerID, modelID }
|
|
981
1241
|
})()
|
|
982
1242
|
|
|
983
|
-
//
|
|
984
|
-
const worktreeInfo = getThreadWorktree(thread.id)
|
|
1243
|
+
// Build worktree info for system message (worktreeInfo was fetched at the start)
|
|
985
1244
|
const worktree: WorktreeInfo | undefined =
|
|
986
1245
|
worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
987
1246
|
? {
|
|
@@ -991,10 +1250,10 @@ export async function handleOpencodeSession({
|
|
|
991
1250
|
}
|
|
992
1251
|
: undefined
|
|
993
1252
|
|
|
994
|
-
// Use session.command API for slash commands, session.prompt for regular messages
|
|
995
1253
|
const response = command
|
|
996
1254
|
? await getClient().session.command({
|
|
997
1255
|
path: { id: session.id },
|
|
1256
|
+
query: { directory: sdkDirectory },
|
|
998
1257
|
body: {
|
|
999
1258
|
command: command.name,
|
|
1000
1259
|
arguments: command.arguments,
|
|
@@ -1004,6 +1263,7 @@ export async function handleOpencodeSession({
|
|
|
1004
1263
|
})
|
|
1005
1264
|
: await getClient().session.prompt({
|
|
1006
1265
|
path: { id: session.id },
|
|
1266
|
+
query: { directory: sdkDirectory },
|
|
1007
1267
|
body: {
|
|
1008
1268
|
parts,
|
|
1009
1269
|
system: getOpencodeSystemMessage({ sessionId: session.id, channelId, worktree }),
|
|
@@ -1034,40 +1294,55 @@ export async function handleOpencodeSession({
|
|
|
1034
1294
|
sessionLogger.log(`Successfully sent prompt, got response`)
|
|
1035
1295
|
|
|
1036
1296
|
if (originalMessage) {
|
|
1037
|
-
|
|
1297
|
+
const reactionResult = await errore.tryAsync(async () => {
|
|
1038
1298
|
await originalMessage.reactions.removeAll()
|
|
1039
1299
|
await originalMessage.react('✅')
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1300
|
+
})
|
|
1301
|
+
if (reactionResult instanceof Error) {
|
|
1302
|
+
discordLogger.log(`Could not update reactions:`, reactionResult)
|
|
1042
1303
|
}
|
|
1043
1304
|
}
|
|
1044
1305
|
|
|
1045
1306
|
return { sessionID: session.id, result: response.data, port }
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1307
|
+
})
|
|
1308
|
+
|
|
1309
|
+
if (handlerPromise) {
|
|
1310
|
+
await Promise.race([
|
|
1311
|
+
handlerPromise,
|
|
1312
|
+
new Promise((resolve) => {
|
|
1313
|
+
setTimeout(resolve, 1000)
|
|
1314
|
+
}),
|
|
1315
|
+
])
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
if (!errore.isError(promptResult)) {
|
|
1319
|
+
return promptResult
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
const promptError: Error = promptResult instanceof Error ? promptResult : new Error('Unknown error')
|
|
1323
|
+
if (isAbortError(promptError, abortController.signal)) {
|
|
1324
|
+
return
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
sessionLogger.error(`ERROR: Failed to send prompt:`, promptError)
|
|
1328
|
+
abortController.abort('error')
|
|
1329
|
+
|
|
1330
|
+
if (originalMessage) {
|
|
1331
|
+
const reactionResult = await errore.tryAsync(async () => {
|
|
1332
|
+
await originalMessage.reactions.removeAll()
|
|
1333
|
+
await originalMessage.react('❌')
|
|
1334
|
+
})
|
|
1335
|
+
if (reactionResult instanceof Error) {
|
|
1336
|
+
discordLogger.log(`Could not update reaction:`, reactionResult)
|
|
1337
|
+
} else {
|
|
1338
|
+
discordLogger.log(`Added error reaction to message`)
|
|
1071
1339
|
}
|
|
1072
1340
|
}
|
|
1341
|
+
const errorDisplay = (() => {
|
|
1342
|
+
const promptErrorValue = promptError as unknown as Error
|
|
1343
|
+
const name = promptErrorValue.name || 'Error'
|
|
1344
|
+
const message = promptErrorValue.stack || promptErrorValue.message
|
|
1345
|
+
return `[${name}]\n${message}`
|
|
1346
|
+
})()
|
|
1347
|
+
await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`)
|
|
1073
1348
|
}
|