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/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
|
|
@@ -113,10 +140,11 @@ export async function abortAndRetrySession({
|
|
|
113
140
|
sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message)
|
|
114
141
|
return false
|
|
115
142
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
143
|
+
const abortResult = await errore.tryAsync(() => {
|
|
144
|
+
return getClient().session.abort({ path: { id: sessionId } })
|
|
145
|
+
})
|
|
146
|
+
if (abortResult instanceof Error) {
|
|
147
|
+
sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, abortResult)
|
|
120
148
|
}
|
|
121
149
|
|
|
122
150
|
// Small delay to let the abort propagate
|
|
@@ -146,16 +174,25 @@ export async function abortAndRetrySession({
|
|
|
146
174
|
|
|
147
175
|
// Use setImmediate to avoid blocking
|
|
148
176
|
setImmediate(() => {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
177
|
+
void errore
|
|
178
|
+
.tryAsync(async () => {
|
|
179
|
+
return handleOpencodeSession({
|
|
180
|
+
prompt,
|
|
181
|
+
thread,
|
|
182
|
+
projectDirectory,
|
|
183
|
+
images,
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
.then(async (result) => {
|
|
187
|
+
if (!(result instanceof Error)) {
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, result)
|
|
191
|
+
await sendThreadMessage(
|
|
192
|
+
thread,
|
|
193
|
+
`✗ Failed to retry with new model: ${result.message.slice(0, 200)}`,
|
|
194
|
+
)
|
|
195
|
+
})
|
|
159
196
|
})
|
|
160
197
|
|
|
161
198
|
return true
|
|
@@ -191,6 +228,18 @@ export async function handleOpencodeSession({
|
|
|
191
228
|
const directory = projectDirectory || process.cwd()
|
|
192
229
|
sessionLogger.log(`Using directory: ${directory}`)
|
|
193
230
|
|
|
231
|
+
// Get worktree info early so we can use the correct directory for events and prompts
|
|
232
|
+
const worktreeInfo = getThreadWorktree(thread.id)
|
|
233
|
+
const worktreeDirectory =
|
|
234
|
+
worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
235
|
+
? worktreeInfo.worktree_directory
|
|
236
|
+
: undefined
|
|
237
|
+
// Use worktree directory for SDK calls if available, otherwise project directory
|
|
238
|
+
const sdkDirectory = worktreeDirectory || directory
|
|
239
|
+
if (worktreeDirectory) {
|
|
240
|
+
sessionLogger.log(`Using worktree directory for SDK calls: ${worktreeDirectory}`)
|
|
241
|
+
}
|
|
242
|
+
|
|
194
243
|
const getClient = await initializeOpencodeForDirectory(directory)
|
|
195
244
|
if (getClient instanceof Error) {
|
|
196
245
|
await sendThreadMessage(thread, `✗ ${getClient.message}`)
|
|
@@ -208,14 +257,17 @@ export async function handleOpencodeSession({
|
|
|
208
257
|
|
|
209
258
|
if (sessionId) {
|
|
210
259
|
sessionLogger.log(`Attempting to reuse existing session ${sessionId}`)
|
|
211
|
-
|
|
212
|
-
|
|
260
|
+
const sessionResponse = await errore.tryAsync(() => {
|
|
261
|
+
return getClient().session.get({
|
|
213
262
|
path: { id: sessionId },
|
|
263
|
+
query: { directory: sdkDirectory },
|
|
214
264
|
})
|
|
265
|
+
})
|
|
266
|
+
if (sessionResponse instanceof Error) {
|
|
267
|
+
voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`)
|
|
268
|
+
} else {
|
|
215
269
|
session = sessionResponse.data
|
|
216
270
|
sessionLogger.log(`Successfully reused session ${sessionId}`)
|
|
217
|
-
} catch (error) {
|
|
218
|
-
voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`)
|
|
219
271
|
}
|
|
220
272
|
}
|
|
221
273
|
|
|
@@ -224,6 +276,7 @@ export async function handleOpencodeSession({
|
|
|
224
276
|
voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`)
|
|
225
277
|
const sessionResponse = await getClient().session.create({
|
|
226
278
|
body: { title: sessionTitle },
|
|
279
|
+
query: { directory: sdkDirectory },
|
|
227
280
|
})
|
|
228
281
|
session = sessionResponse.data
|
|
229
282
|
sessionLogger.log(`Created new session ${session?.id}`)
|
|
@@ -256,20 +309,25 @@ export async function handleOpencodeSession({
|
|
|
256
309
|
const clientV2 = getOpencodeClientV2(directory)
|
|
257
310
|
let rejectedCount = 0
|
|
258
311
|
for (const [permId, pendingPerm] of threadPermissions) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
await clientV2.permission.reply({
|
|
263
|
-
requestID: permId,
|
|
264
|
-
reply: 'reject',
|
|
265
|
-
})
|
|
266
|
-
}
|
|
312
|
+
sessionLogger.log(`[PERMISSION] Auto-rejecting permission ${permId} due to new message`)
|
|
313
|
+
if (!clientV2) {
|
|
314
|
+
sessionLogger.log(`[PERMISSION] OpenCode v2 client unavailable for permission ${permId}`)
|
|
267
315
|
cleanupPermissionContext(pendingPerm.contextHash)
|
|
268
316
|
rejectedCount++
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
const rejectResult = await errore.tryAsync(() => {
|
|
320
|
+
return clientV2.permission.reply({
|
|
321
|
+
requestID: permId,
|
|
322
|
+
reply: 'reject',
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
if (rejectResult instanceof Error) {
|
|
326
|
+
sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, rejectResult)
|
|
327
|
+
} else {
|
|
328
|
+
rejectedCount++
|
|
272
329
|
}
|
|
330
|
+
cleanupPermissionContext(pendingPerm.contextHash)
|
|
273
331
|
}
|
|
274
332
|
pendingPermissions.delete(thread.id)
|
|
275
333
|
if (rejectedCount > 0) {
|
|
@@ -311,7 +369,7 @@ export async function handleOpencodeSession({
|
|
|
311
369
|
throw new Error(`OpenCode v2 client not found for directory: ${directory}`)
|
|
312
370
|
}
|
|
313
371
|
const eventsResult = await clientV2.event.subscribe(
|
|
314
|
-
{ directory },
|
|
372
|
+
{ directory: sdkDirectory },
|
|
315
373
|
{ signal: abortController.signal },
|
|
316
374
|
)
|
|
317
375
|
|
|
@@ -331,7 +389,7 @@ export async function handleOpencodeSession({
|
|
|
331
389
|
).map((row) => row.part_id),
|
|
332
390
|
)
|
|
333
391
|
|
|
334
|
-
|
|
392
|
+
const partBuffer = new Map<string, Map<string, Part>>()
|
|
335
393
|
let stopTyping: (() => void) | null = null
|
|
336
394
|
let usedModel: string | undefined
|
|
337
395
|
let usedProviderID: string | undefined
|
|
@@ -339,6 +397,7 @@ export async function handleOpencodeSession({
|
|
|
339
397
|
let tokensUsedInSession = 0
|
|
340
398
|
let lastDisplayedContextPercentage = 0
|
|
341
399
|
let modelContextLimit: number | undefined
|
|
400
|
+
let assistantMessageId: string | undefined
|
|
342
401
|
|
|
343
402
|
let typingInterval: NodeJS.Timeout | null = null
|
|
344
403
|
|
|
@@ -352,13 +411,17 @@ export async function handleOpencodeSession({
|
|
|
352
411
|
typingInterval = null
|
|
353
412
|
}
|
|
354
413
|
|
|
355
|
-
thread.sendTyping().
|
|
356
|
-
|
|
414
|
+
void errore.tryAsync(() => thread.sendTyping()).then((result) => {
|
|
415
|
+
if (result instanceof Error) {
|
|
416
|
+
discordLogger.log(`Failed to send initial typing: ${result}`)
|
|
417
|
+
}
|
|
357
418
|
})
|
|
358
419
|
|
|
359
420
|
typingInterval = setInterval(() => {
|
|
360
|
-
thread.sendTyping().
|
|
361
|
-
|
|
421
|
+
void errore.tryAsync(() => thread.sendTyping()).then((result) => {
|
|
422
|
+
if (result instanceof Error) {
|
|
423
|
+
discordLogger.log(`Failed to send periodic typing: ${result}`)
|
|
424
|
+
}
|
|
362
425
|
})
|
|
363
426
|
}, 8000)
|
|
364
427
|
|
|
@@ -383,7 +446,16 @@ export async function handleOpencodeSession({
|
|
|
383
446
|
}
|
|
384
447
|
}
|
|
385
448
|
|
|
449
|
+
// Get verbosity setting for this channel (use parent channel for threads)
|
|
450
|
+
const verbosityChannelId = channelId || thread.parentId || thread.id
|
|
451
|
+
const verbosity = getChannelVerbosity(verbosityChannelId)
|
|
452
|
+
|
|
386
453
|
const sendPartMessage = async (part: Part) => {
|
|
454
|
+
// In text-only mode, only send text parts (the ⬥ diamond messages)
|
|
455
|
+
if (verbosity === 'text-only' && part.type !== 'text') {
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
|
|
387
459
|
const content = formatPart(part) + '\n\n'
|
|
388
460
|
if (!content.trim() || content.length === 0) {
|
|
389
461
|
// discordLogger.log(`SKIP: Part ${part.id} has no content`)
|
|
@@ -394,18 +466,20 @@ export async function handleOpencodeSession({
|
|
|
394
466
|
return
|
|
395
467
|
}
|
|
396
468
|
|
|
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)
|
|
469
|
+
const sendResult = await errore.tryAsync(() => {
|
|
470
|
+
return sendThreadMessage(thread, content)
|
|
471
|
+
})
|
|
472
|
+
if (sendResult instanceof Error) {
|
|
473
|
+
discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult)
|
|
474
|
+
return
|
|
408
475
|
}
|
|
476
|
+
sentPartIds.add(part.id)
|
|
477
|
+
|
|
478
|
+
getDatabase()
|
|
479
|
+
.prepare(
|
|
480
|
+
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
481
|
+
)
|
|
482
|
+
.run(part.id, sendResult.id, thread.id)
|
|
409
483
|
}
|
|
410
484
|
|
|
411
485
|
const eventHandler = async () => {
|
|
@@ -414,390 +488,544 @@ export async function handleOpencodeSession({
|
|
|
414
488
|
// Counts spawned tasks per agent type: "explore" → 2
|
|
415
489
|
const agentSpawnCounts: Record<string, number> = {}
|
|
416
490
|
|
|
417
|
-
|
|
418
|
-
|
|
491
|
+
const storePart = (part: Part) => {
|
|
492
|
+
const messageParts = partBuffer.get(part.messageID) || new Map<string, Part>()
|
|
493
|
+
messageParts.set(part.id, part)
|
|
494
|
+
partBuffer.set(part.messageID, messageParts)
|
|
495
|
+
}
|
|
419
496
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
497
|
+
const getBufferedParts = (messageID: string) => {
|
|
498
|
+
return Array.from(partBuffer.get(messageID)?.values() ?? [])
|
|
499
|
+
}
|
|
423
500
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
501
|
+
const shouldSendPart = ({ part, force }: { part: Part; force: boolean }) => {
|
|
502
|
+
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
503
|
+
return false
|
|
504
|
+
}
|
|
429
505
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
506
|
+
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
507
|
+
return false
|
|
508
|
+
}
|
|
433
509
|
|
|
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
|
-
}
|
|
510
|
+
if (!force && part.type === 'text' && !part.time?.end) {
|
|
511
|
+
return false
|
|
512
|
+
}
|
|
444
513
|
|
|
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
|
-
}
|
|
514
|
+
if (!force && part.type === 'tool' && part.state.status === 'completed') {
|
|
515
|
+
return false
|
|
516
|
+
}
|
|
465
517
|
|
|
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
|
|
518
|
+
return true
|
|
519
|
+
}
|
|
481
520
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
521
|
+
const flushBufferedParts = async ({
|
|
522
|
+
messageID,
|
|
523
|
+
force,
|
|
524
|
+
skipPartId,
|
|
525
|
+
}: {
|
|
526
|
+
messageID: string
|
|
527
|
+
force: boolean
|
|
528
|
+
skipPartId?: string
|
|
529
|
+
}) => {
|
|
530
|
+
if (!messageID) {
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
const parts = getBufferedParts(messageID)
|
|
534
|
+
for (const part of parts) {
|
|
535
|
+
if (skipPartId && part.id === skipPartId) {
|
|
536
|
+
continue
|
|
537
|
+
}
|
|
538
|
+
if (!shouldSendPart({ part, force })) {
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
await sendPartMessage(part)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
485
544
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
545
|
+
const handleMessageUpdated = async (msg: {
|
|
546
|
+
id: string
|
|
547
|
+
sessionID: string
|
|
548
|
+
role: string
|
|
549
|
+
modelID?: string
|
|
550
|
+
providerID?: string
|
|
551
|
+
mode?: string
|
|
552
|
+
tokens?: {
|
|
553
|
+
input: number
|
|
554
|
+
output: number
|
|
555
|
+
reasoning: number
|
|
556
|
+
cache: { read: number; write: number }
|
|
557
|
+
}
|
|
558
|
+
}) => {
|
|
559
|
+
const subtaskInfo = subtaskSessions.get(msg.sessionID)
|
|
560
|
+
if (subtaskInfo && msg.role === 'assistant') {
|
|
561
|
+
subtaskInfo.assistantMessageId = msg.id
|
|
562
|
+
}
|
|
490
563
|
|
|
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
|
-
}
|
|
564
|
+
if (msg.sessionID !== session.id) {
|
|
565
|
+
return
|
|
566
|
+
}
|
|
512
567
|
|
|
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
|
-
}
|
|
568
|
+
if (msg.role !== 'assistant') {
|
|
569
|
+
return
|
|
570
|
+
}
|
|
529
571
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
572
|
+
if (msg.tokens) {
|
|
573
|
+
const newTokensTotal =
|
|
574
|
+
msg.tokens.input +
|
|
575
|
+
msg.tokens.output +
|
|
576
|
+
msg.tokens.reasoning +
|
|
577
|
+
msg.tokens.cache.read +
|
|
578
|
+
msg.tokens.cache.write
|
|
579
|
+
if (newTokensTotal > 0) {
|
|
580
|
+
tokensUsedInSession = newTokensTotal
|
|
581
|
+
}
|
|
582
|
+
}
|
|
534
583
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
currentParts.push(part)
|
|
540
|
-
}
|
|
584
|
+
assistantMessageId = msg.id
|
|
585
|
+
usedModel = msg.modelID
|
|
586
|
+
usedProviderID = msg.providerID
|
|
587
|
+
usedAgent = msg.mode
|
|
541
588
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
)
|
|
547
|
-
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
|
|
548
|
-
if (!hasPendingQuestion && !hasPendingPermission) {
|
|
549
|
-
stopTyping = startTyping()
|
|
550
|
-
}
|
|
551
|
-
}
|
|
589
|
+
await flushBufferedParts({
|
|
590
|
+
messageID: assistantMessageId,
|
|
591
|
+
force: false,
|
|
592
|
+
})
|
|
552
593
|
|
|
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
|
-
}
|
|
594
|
+
if (tokensUsedInSession === 0 || !usedProviderID || !usedModel) {
|
|
595
|
+
return
|
|
596
|
+
}
|
|
577
597
|
|
|
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
|
-
}
|
|
598
|
+
if (!modelContextLimit) {
|
|
599
|
+
const providersResponse = await errore.tryAsync(() => {
|
|
600
|
+
return getClient().provider.list({
|
|
601
|
+
query: { directory: sdkDirectory },
|
|
602
|
+
})
|
|
603
|
+
})
|
|
604
|
+
if (providersResponse instanceof Error) {
|
|
605
|
+
sessionLogger.error('Failed to fetch provider info for context limit:', providersResponse)
|
|
606
|
+
} else {
|
|
607
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
|
|
608
|
+
const model = provider?.models?.[usedModel]
|
|
609
|
+
if (model?.limit?.context) {
|
|
610
|
+
modelContextLimit = model.limit.context
|
|
599
611
|
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
600
614
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
615
|
+
if (!modelContextLimit) {
|
|
616
|
+
return
|
|
617
|
+
}
|
|
604
618
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
619
|
+
const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100)
|
|
620
|
+
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
|
|
621
|
+
if (thresholdCrossed <= lastDisplayedContextPercentage || thresholdCrossed < 10) {
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
lastDisplayedContextPercentage = thresholdCrossed
|
|
625
|
+
const chunk = `⬦ context usage ${currentPercentage}%`
|
|
626
|
+
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
627
|
+
}
|
|
610
628
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
629
|
+
const handleMainPart = async (part: Part) => {
|
|
630
|
+
const isActiveMessage = assistantMessageId ? part.messageID === assistantMessageId : false
|
|
631
|
+
const allowEarlyProcessing =
|
|
632
|
+
!assistantMessageId && part.type === 'tool' && part.state.status === 'running'
|
|
633
|
+
if (!isActiveMessage && !allowEarlyProcessing) {
|
|
634
|
+
if (part.type !== 'step-start') {
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (part.type === 'step-start') {
|
|
640
|
+
const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
|
|
641
|
+
(ctx) => ctx.thread.id === thread.id,
|
|
642
|
+
)
|
|
643
|
+
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
|
|
644
|
+
if (!hasPendingQuestion && !hasPendingPermission) {
|
|
645
|
+
stopTyping = startTyping()
|
|
646
|
+
}
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (part.type === 'tool' && part.state.status === 'running') {
|
|
651
|
+
await flushBufferedParts({
|
|
652
|
+
messageID: assistantMessageId || part.messageID,
|
|
653
|
+
force: true,
|
|
654
|
+
skipPartId: part.id,
|
|
655
|
+
})
|
|
656
|
+
await sendPartMessage(part)
|
|
657
|
+
if (part.tool === 'task' && !sentPartIds.has(part.id)) {
|
|
658
|
+
const description = (part.state.input?.description as string) || ''
|
|
659
|
+
const agent = (part.state.input?.subagent_type as string) || 'task'
|
|
660
|
+
const childSessionId = (part.state.metadata?.sessionId as string) || ''
|
|
661
|
+
if (description && childSessionId) {
|
|
662
|
+
agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1
|
|
663
|
+
const label = `${agent}-${agentSpawnCounts[agent]}`
|
|
664
|
+
subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined })
|
|
665
|
+
// Skip task messages in text-only mode
|
|
666
|
+
if (verbosity !== 'text-only') {
|
|
667
|
+
const taskDisplay = `┣ task **${label}** _${description}_`
|
|
668
|
+
await sendThreadMessage(thread, taskDisplay + '\n\n')
|
|
616
669
|
}
|
|
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)
|
|
670
|
+
sentPartIds.add(part.id)
|
|
627
671
|
}
|
|
672
|
+
}
|
|
673
|
+
return
|
|
674
|
+
}
|
|
628
675
|
|
|
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
|
-
}
|
|
676
|
+
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
677
|
+
const output = part.state.output || ''
|
|
678
|
+
const outputTokens = Math.ceil(output.length / 4)
|
|
679
|
+
const largeOutputThreshold = 3000
|
|
680
|
+
if (outputTokens >= largeOutputThreshold) {
|
|
681
|
+
const formattedTokens =
|
|
682
|
+
outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens)
|
|
683
|
+
const percentageSuffix = (() => {
|
|
684
|
+
if (!modelContextLimit) {
|
|
685
|
+
return ''
|
|
645
686
|
}
|
|
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
|
-
}
|
|
687
|
+
const pct = (outputTokens / modelContextLimit) * 100
|
|
688
|
+
if (pct < 1) {
|
|
689
|
+
return ''
|
|
690
|
+
}
|
|
691
|
+
return ` (${pct.toFixed(1)}%)`
|
|
692
|
+
})()
|
|
693
|
+
const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
|
|
694
|
+
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
695
|
+
}
|
|
696
|
+
}
|
|
660
697
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
`[PERMISSION] Skipping duplicate permission ${permission.id} (already pending)`,
|
|
666
|
-
)
|
|
667
|
-
continue
|
|
668
|
-
}
|
|
698
|
+
if (part.type === 'reasoning') {
|
|
699
|
+
await sendPartMessage(part)
|
|
700
|
+
return
|
|
701
|
+
}
|
|
669
702
|
|
|
670
|
-
|
|
671
|
-
|
|
703
|
+
if (part.type === 'text' && part.time?.end) {
|
|
704
|
+
await sendPartMessage(part)
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (part.type === 'step-finish') {
|
|
709
|
+
await flushBufferedParts({
|
|
710
|
+
messageID: assistantMessageId || part.messageID,
|
|
711
|
+
force: true,
|
|
712
|
+
})
|
|
713
|
+
setTimeout(() => {
|
|
714
|
+
if (abortController.signal.aborted) return
|
|
715
|
+
const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
|
|
716
|
+
(ctx) => ctx.thread.id === thread.id,
|
|
672
717
|
)
|
|
718
|
+
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
|
|
719
|
+
if (hasPendingQuestion || hasPendingPermission) return
|
|
720
|
+
stopTyping = startTyping()
|
|
721
|
+
}, 300)
|
|
722
|
+
}
|
|
723
|
+
}
|
|
673
724
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
725
|
+
const handleSubtaskPart = async (
|
|
726
|
+
part: Part,
|
|
727
|
+
subtaskInfo: { label: string; assistantMessageId?: string },
|
|
728
|
+
) => {
|
|
729
|
+
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
733
|
+
return
|
|
734
|
+
}
|
|
735
|
+
if (part.type === 'text') {
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
if (!subtaskInfo.assistantMessageId || part.messageID !== subtaskInfo.assistantMessageId) {
|
|
739
|
+
return
|
|
740
|
+
}
|
|
679
741
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
742
|
+
const content = formatPart(part, subtaskInfo.label)
|
|
743
|
+
if (!content.trim() || sentPartIds.has(part.id)) {
|
|
744
|
+
return
|
|
745
|
+
}
|
|
746
|
+
const sendResult = await errore.tryAsync(() => {
|
|
747
|
+
return sendThreadMessage(thread, content + '\n\n')
|
|
748
|
+
})
|
|
749
|
+
if (sendResult instanceof Error) {
|
|
750
|
+
discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, sendResult)
|
|
751
|
+
return
|
|
752
|
+
}
|
|
753
|
+
sentPartIds.add(part.id)
|
|
754
|
+
getDatabase()
|
|
755
|
+
.prepare(
|
|
756
|
+
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
757
|
+
)
|
|
758
|
+
.run(part.id, sendResult.id, thread.id)
|
|
759
|
+
}
|
|
686
760
|
|
|
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
|
-
}
|
|
761
|
+
const handlePartUpdated = async (part: Part) => {
|
|
762
|
+
storePart(part)
|
|
702
763
|
|
|
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
|
|
764
|
+
const subtaskInfo = subtaskSessions.get(part.sessionID)
|
|
765
|
+
const isSubtaskEvent = Boolean(subtaskInfo)
|
|
720
766
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
767
|
+
if (part.sessionID !== session.id && !isSubtaskEvent) {
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (isSubtaskEvent && subtaskInfo) {
|
|
772
|
+
await handleSubtaskPart(part, subtaskInfo)
|
|
773
|
+
return
|
|
774
|
+
}
|
|
727
775
|
|
|
776
|
+
await handleMainPart(part)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const handleSessionError = async ({
|
|
780
|
+
sessionID,
|
|
781
|
+
error,
|
|
782
|
+
}: {
|
|
783
|
+
sessionID?: string
|
|
784
|
+
error?: { data?: { message?: string } }
|
|
785
|
+
}) => {
|
|
786
|
+
if (!sessionID || sessionID !== session.id) {
|
|
787
|
+
voiceLogger.log(
|
|
788
|
+
`[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${sessionID})`,
|
|
789
|
+
)
|
|
790
|
+
return
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const errorMessage = error?.data?.message || 'Unknown error'
|
|
794
|
+
sessionLogger.error(`Sending error to thread: ${errorMessage}`)
|
|
795
|
+
await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`)
|
|
796
|
+
|
|
797
|
+
if (!originalMessage) {
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
const reactionResult = await errore.tryAsync(async () => {
|
|
801
|
+
await originalMessage.reactions.removeAll()
|
|
802
|
+
await originalMessage.react('❌')
|
|
803
|
+
})
|
|
804
|
+
if (reactionResult instanceof Error) {
|
|
805
|
+
discordLogger.log(`Could not update reaction:`, reactionResult)
|
|
806
|
+
} else {
|
|
807
|
+
voiceLogger.log(`[REACTION] Added error reaction due to session error`)
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const handlePermissionAsked = async (permission: PermissionRequest) => {
|
|
812
|
+
if (permission.sessionID !== session.id) {
|
|
813
|
+
voiceLogger.log(
|
|
814
|
+
`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
|
|
815
|
+
)
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const dedupeKey = buildPermissionDedupeKey({ permission, directory })
|
|
820
|
+
const threadPermissions = pendingPermissions.get(thread.id)
|
|
821
|
+
const existingPending = threadPermissions
|
|
822
|
+
? Array.from(threadPermissions.values()).find((pending) => {
|
|
823
|
+
return pending.dedupeKey === dedupeKey
|
|
824
|
+
})
|
|
825
|
+
: undefined
|
|
826
|
+
|
|
827
|
+
if (existingPending) {
|
|
828
|
+
sessionLogger.log(
|
|
829
|
+
`[PERMISSION] Deduped permission ${permission.id} (matches pending ${existingPending.permission.id})`,
|
|
830
|
+
)
|
|
831
|
+
if (stopTyping) {
|
|
832
|
+
stopTyping()
|
|
833
|
+
stopTyping = null
|
|
834
|
+
}
|
|
835
|
+
if (!pendingPermissions.has(thread.id)) {
|
|
836
|
+
pendingPermissions.set(thread.id, new Map())
|
|
837
|
+
}
|
|
838
|
+
pendingPermissions.get(thread.id)!.set(permission.id, {
|
|
839
|
+
permission,
|
|
840
|
+
messageId: existingPending.messageId,
|
|
841
|
+
directory,
|
|
842
|
+
contextHash: existingPending.contextHash,
|
|
843
|
+
dedupeKey,
|
|
844
|
+
})
|
|
845
|
+
const added = addPermissionRequestToContext({
|
|
846
|
+
contextHash: existingPending.contextHash,
|
|
847
|
+
requestId: permission.id,
|
|
848
|
+
})
|
|
849
|
+
if (!added) {
|
|
728
850
|
sessionLogger.log(
|
|
729
|
-
`
|
|
851
|
+
`[PERMISSION] Failed to attach duplicate request ${permission.id} to context`,
|
|
730
852
|
)
|
|
853
|
+
}
|
|
854
|
+
return
|
|
855
|
+
}
|
|
731
856
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
stopTyping = null
|
|
736
|
-
}
|
|
857
|
+
sessionLogger.log(
|
|
858
|
+
`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
|
|
859
|
+
)
|
|
737
860
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
await sendPartMessage(p)
|
|
743
|
-
}
|
|
744
|
-
}
|
|
861
|
+
if (stopTyping) {
|
|
862
|
+
stopTyping()
|
|
863
|
+
stopTyping = null
|
|
864
|
+
}
|
|
745
865
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
input: { questions: questionRequest.questions },
|
|
752
|
-
})
|
|
866
|
+
const { messageId, contextHash } = await showPermissionDropdown({
|
|
867
|
+
thread,
|
|
868
|
+
permission,
|
|
869
|
+
directory,
|
|
870
|
+
})
|
|
753
871
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
872
|
+
if (!pendingPermissions.has(thread.id)) {
|
|
873
|
+
pendingPermissions.set(thread.id, new Map())
|
|
874
|
+
}
|
|
875
|
+
pendingPermissions.get(thread.id)!.set(permission.id, {
|
|
876
|
+
permission,
|
|
877
|
+
messageId,
|
|
878
|
+
directory,
|
|
879
|
+
contextHash,
|
|
880
|
+
dedupeKey,
|
|
881
|
+
})
|
|
882
|
+
}
|
|
761
883
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
884
|
+
const handlePermissionReplied = ({
|
|
885
|
+
requestID,
|
|
886
|
+
reply,
|
|
887
|
+
sessionID,
|
|
888
|
+
}: {
|
|
889
|
+
requestID: string
|
|
890
|
+
reply: string
|
|
891
|
+
sessionID: string
|
|
892
|
+
}) => {
|
|
893
|
+
if (sessionID !== session.id) {
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
sessionLogger.log(`Permission ${requestID} replied with: ${reply}`)
|
|
898
|
+
|
|
899
|
+
const threadPermissions = pendingPermissions.get(thread.id)
|
|
900
|
+
if (!threadPermissions) {
|
|
901
|
+
return
|
|
902
|
+
}
|
|
903
|
+
const pending = threadPermissions.get(requestID)
|
|
904
|
+
if (!pending) {
|
|
905
|
+
return
|
|
906
|
+
}
|
|
907
|
+
cleanupPermissionContext(pending.contextHash)
|
|
908
|
+
threadPermissions.delete(requestID)
|
|
909
|
+
if (threadPermissions.size === 0) {
|
|
910
|
+
pendingPermissions.delete(thread.id)
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const handleQuestionAsked = async (questionRequest: QuestionRequest) => {
|
|
915
|
+
if (questionRequest.sessionID !== session.id) {
|
|
916
|
+
sessionLogger.log(
|
|
917
|
+
`[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`,
|
|
918
|
+
)
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
sessionLogger.log(
|
|
923
|
+
`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
if (stopTyping) {
|
|
927
|
+
stopTyping()
|
|
928
|
+
stopTyping = null
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
await flushBufferedParts({
|
|
932
|
+
messageID: assistantMessageId || '',
|
|
933
|
+
force: true,
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
await showAskUserQuestionDropdowns({
|
|
937
|
+
thread,
|
|
938
|
+
sessionId: session.id,
|
|
939
|
+
directory,
|
|
940
|
+
requestId: questionRequest.id,
|
|
941
|
+
input: { questions: questionRequest.questions },
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
const queue = messageQueue.get(thread.id)
|
|
945
|
+
if (!queue || queue.length === 0) {
|
|
946
|
+
return
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const nextMessage = queue.shift()!
|
|
950
|
+
if (queue.length === 0) {
|
|
951
|
+
messageQueue.delete(thread.id)
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
sessionLogger.log(
|
|
955
|
+
`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`,
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
await sendThreadMessage(
|
|
959
|
+
thread,
|
|
960
|
+
`» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`,
|
|
961
|
+
)
|
|
765
962
|
|
|
963
|
+
setImmediate(() => {
|
|
964
|
+
void errore
|
|
965
|
+
.tryAsync(async () => {
|
|
966
|
+
return handleOpencodeSession({
|
|
967
|
+
prompt: nextMessage.prompt,
|
|
968
|
+
thread,
|
|
969
|
+
projectDirectory: directory,
|
|
970
|
+
images: nextMessage.images,
|
|
971
|
+
channelId,
|
|
972
|
+
})
|
|
973
|
+
})
|
|
974
|
+
.then(async (result) => {
|
|
975
|
+
if (!(result instanceof Error)) {
|
|
976
|
+
return
|
|
977
|
+
}
|
|
978
|
+
sessionLogger.error(`[QUEUE] Failed to process queued message:`, result)
|
|
766
979
|
await sendThreadMessage(
|
|
767
980
|
thread,
|
|
768
|
-
|
|
981
|
+
`✗ Queued message failed: ${result.message.slice(0, 200)}`,
|
|
769
982
|
)
|
|
983
|
+
})
|
|
984
|
+
})
|
|
985
|
+
}
|
|
770
986
|
|
|
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
|
-
|
|
987
|
+
const handleSessionIdle = (idleSessionId: string) => {
|
|
988
|
+
if (idleSessionId === session.id) {
|
|
989
|
+
sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`)
|
|
990
|
+
abortController.abort('finished')
|
|
991
|
+
return
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (!subtaskSessions.has(idleSessionId)) {
|
|
995
|
+
return
|
|
996
|
+
}
|
|
997
|
+
const subtask = subtaskSessions.get(idleSessionId)
|
|
998
|
+
sessionLogger.log(`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`)
|
|
999
|
+
subtaskSessions.delete(idleSessionId)
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
try {
|
|
1003
|
+
for await (const event of events) {
|
|
1004
|
+
switch (event.type) {
|
|
1005
|
+
case 'message.updated':
|
|
1006
|
+
await handleMessageUpdated(event.properties.info)
|
|
1007
|
+
break
|
|
1008
|
+
case 'message.part.updated':
|
|
1009
|
+
await handlePartUpdated(event.properties.part)
|
|
1010
|
+
break
|
|
1011
|
+
case 'session.error':
|
|
1012
|
+
sessionLogger.error(`ERROR:`, event.properties)
|
|
1013
|
+
await handleSessionError(event.properties)
|
|
1014
|
+
break
|
|
1015
|
+
case 'permission.asked':
|
|
1016
|
+
await handlePermissionAsked(event.properties)
|
|
1017
|
+
break
|
|
1018
|
+
case 'permission.replied':
|
|
1019
|
+
handlePermissionReplied(event.properties)
|
|
1020
|
+
break
|
|
1021
|
+
case 'question.asked':
|
|
1022
|
+
await handleQuestionAsked(event.properties)
|
|
1023
|
+
break
|
|
1024
|
+
case 'session.idle':
|
|
1025
|
+
handleSessionIdle(event.properties.sessionID)
|
|
1026
|
+
break
|
|
1027
|
+
default:
|
|
1028
|
+
break
|
|
801
1029
|
}
|
|
802
1030
|
}
|
|
803
1031
|
} catch (e) {
|
|
@@ -808,12 +1036,13 @@ export async function handleOpencodeSession({
|
|
|
808
1036
|
sessionLogger.error(`Unexpected error in event handling code`, e)
|
|
809
1037
|
throw e
|
|
810
1038
|
} finally {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1039
|
+
abortControllers.delete(session.id)
|
|
1040
|
+
const finalMessageId = assistantMessageId
|
|
1041
|
+
if (finalMessageId) {
|
|
1042
|
+
const parts = getBufferedParts(finalMessageId)
|
|
1043
|
+
for (const part of parts) {
|
|
1044
|
+
if (!sentPartIds.has(part.id)) {
|
|
814
1045
|
await sendPartMessage(part)
|
|
815
|
-
} catch (error) {
|
|
816
|
-
sessionLogger.error(`Failed to send part ${part.id}:`, error)
|
|
817
1046
|
}
|
|
818
1047
|
}
|
|
819
1048
|
}
|
|
@@ -831,12 +1060,13 @@ export async function handleOpencodeSession({
|
|
|
831
1060
|
usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : ''
|
|
832
1061
|
let contextInfo = ''
|
|
833
1062
|
|
|
834
|
-
|
|
1063
|
+
const contextResult = await errore.tryAsync(async () => {
|
|
835
1064
|
// Fetch final token count from API since message.updated events can arrive
|
|
836
1065
|
// after session.idle due to race conditions in event ordering
|
|
837
1066
|
if (tokensUsedInSession === 0) {
|
|
838
1067
|
const messagesResponse = await getClient().session.messages({
|
|
839
1068
|
path: { id: session.id },
|
|
1069
|
+
query: { directory: sdkDirectory },
|
|
840
1070
|
})
|
|
841
1071
|
const messages = messagesResponse.data || []
|
|
842
1072
|
const lastAssistant = [...messages]
|
|
@@ -858,15 +1088,16 @@ export async function handleOpencodeSession({
|
|
|
858
1088
|
}
|
|
859
1089
|
}
|
|
860
1090
|
|
|
861
|
-
const providersResponse = await getClient().provider.list({ query: { directory } })
|
|
1091
|
+
const providersResponse = await getClient().provider.list({ query: { directory: sdkDirectory } })
|
|
862
1092
|
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
|
|
863
1093
|
const model = provider?.models?.[usedModel || '']
|
|
864
1094
|
if (model?.limit?.context) {
|
|
865
1095
|
const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100)
|
|
866
1096
|
contextInfo = ` ⋅ ${percentage}%`
|
|
867
1097
|
}
|
|
868
|
-
}
|
|
869
|
-
|
|
1098
|
+
})
|
|
1099
|
+
if (contextResult instanceof Error) {
|
|
1100
|
+
sessionLogger.error('Failed to fetch provider info for context percentage:', contextResult)
|
|
870
1101
|
}
|
|
871
1102
|
|
|
872
1103
|
await sendThreadMessage(
|
|
@@ -918,8 +1149,9 @@ export async function handleOpencodeSession({
|
|
|
918
1149
|
}
|
|
919
1150
|
}
|
|
920
1151
|
|
|
921
|
-
|
|
922
|
-
|
|
1152
|
+
const promptResult: Error | { sessionID: string; result: any; port?: number } | undefined =
|
|
1153
|
+
await errore.tryAsync(async () => {
|
|
1154
|
+
const eventHandlerPromise = eventHandler()
|
|
923
1155
|
|
|
924
1156
|
if (abortController.signal.aborted) {
|
|
925
1157
|
sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
|
|
@@ -931,7 +1163,6 @@ export async function handleOpencodeSession({
|
|
|
931
1163
|
voiceLogger.log(
|
|
932
1164
|
`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
|
|
933
1165
|
)
|
|
934
|
-
// append image paths to prompt so ai knows where they are on disk
|
|
935
1166
|
const promptWithImagePaths = (() => {
|
|
936
1167
|
if (images.length === 0) {
|
|
937
1168
|
return prompt
|
|
@@ -951,19 +1182,15 @@ export async function handleOpencodeSession({
|
|
|
951
1182
|
const parts = [{ type: 'text' as const, text: promptWithImagePaths }, ...images]
|
|
952
1183
|
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
|
|
953
1184
|
|
|
954
|
-
// Get agent preference: session-level overrides channel-level
|
|
955
1185
|
const agentPreference =
|
|
956
1186
|
getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
|
|
957
1187
|
if (agentPreference) {
|
|
958
1188
|
sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
|
|
959
1189
|
}
|
|
960
1190
|
|
|
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
1191
|
const modelPreference =
|
|
964
1192
|
getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
|
|
965
1193
|
const modelParam = (() => {
|
|
966
|
-
// When an agent is set, let the agent's model config take effect
|
|
967
1194
|
if (agentPreference) {
|
|
968
1195
|
sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`)
|
|
969
1196
|
return undefined
|
|
@@ -980,8 +1207,7 @@ export async function handleOpencodeSession({
|
|
|
980
1207
|
return { providerID, modelID }
|
|
981
1208
|
})()
|
|
982
1209
|
|
|
983
|
-
//
|
|
984
|
-
const worktreeInfo = getThreadWorktree(thread.id)
|
|
1210
|
+
// Build worktree info for system message (worktreeInfo was fetched at the start)
|
|
985
1211
|
const worktree: WorktreeInfo | undefined =
|
|
986
1212
|
worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
987
1213
|
? {
|
|
@@ -991,10 +1217,10 @@ export async function handleOpencodeSession({
|
|
|
991
1217
|
}
|
|
992
1218
|
: undefined
|
|
993
1219
|
|
|
994
|
-
// Use session.command API for slash commands, session.prompt for regular messages
|
|
995
1220
|
const response = command
|
|
996
1221
|
? await getClient().session.command({
|
|
997
1222
|
path: { id: session.id },
|
|
1223
|
+
query: { directory: sdkDirectory },
|
|
998
1224
|
body: {
|
|
999
1225
|
command: command.name,
|
|
1000
1226
|
arguments: command.arguments,
|
|
@@ -1004,6 +1230,7 @@ export async function handleOpencodeSession({
|
|
|
1004
1230
|
})
|
|
1005
1231
|
: await getClient().session.prompt({
|
|
1006
1232
|
path: { id: session.id },
|
|
1233
|
+
query: { directory: sdkDirectory },
|
|
1007
1234
|
body: {
|
|
1008
1235
|
parts,
|
|
1009
1236
|
system: getOpencodeSystemMessage({ sessionId: session.id, channelId, worktree }),
|
|
@@ -1034,40 +1261,46 @@ export async function handleOpencodeSession({
|
|
|
1034
1261
|
sessionLogger.log(`Successfully sent prompt, got response`)
|
|
1035
1262
|
|
|
1036
1263
|
if (originalMessage) {
|
|
1037
|
-
|
|
1264
|
+
const reactionResult = await errore.tryAsync(async () => {
|
|
1038
1265
|
await originalMessage.reactions.removeAll()
|
|
1039
1266
|
await originalMessage.react('✅')
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1267
|
+
})
|
|
1268
|
+
if (reactionResult instanceof Error) {
|
|
1269
|
+
discordLogger.log(`Could not update reactions:`, reactionResult)
|
|
1042
1270
|
}
|
|
1043
1271
|
}
|
|
1044
1272
|
|
|
1045
1273
|
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
|
-
await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`)
|
|
1274
|
+
})
|
|
1275
|
+
|
|
1276
|
+
if (!errore.isError(promptResult)) {
|
|
1277
|
+
return promptResult
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const promptError: Error = promptResult instanceof Error ? promptResult : new Error('Unknown error')
|
|
1281
|
+
if (isAbortError(promptError, abortController.signal)) {
|
|
1282
|
+
return
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
sessionLogger.error(`ERROR: Failed to send prompt:`, promptError)
|
|
1286
|
+
abortController.abort('error')
|
|
1287
|
+
|
|
1288
|
+
if (originalMessage) {
|
|
1289
|
+
const reactionResult = await errore.tryAsync(async () => {
|
|
1290
|
+
await originalMessage.reactions.removeAll()
|
|
1291
|
+
await originalMessage.react('❌')
|
|
1292
|
+
})
|
|
1293
|
+
if (reactionResult instanceof Error) {
|
|
1294
|
+
discordLogger.log(`Could not update reaction:`, reactionResult)
|
|
1295
|
+
} else {
|
|
1296
|
+
discordLogger.log(`Added error reaction to message`)
|
|
1071
1297
|
}
|
|
1072
1298
|
}
|
|
1299
|
+
const errorDisplay = (() => {
|
|
1300
|
+
const promptErrorValue = promptError as unknown as Error
|
|
1301
|
+
const name = promptErrorValue.name || 'Error'
|
|
1302
|
+
const message = promptErrorValue.stack || promptErrorValue.message
|
|
1303
|
+
return `[${name}]\n${message}`
|
|
1304
|
+
})()
|
|
1305
|
+
await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`)
|
|
1073
1306
|
}
|