kimaki 0.4.43 → 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 +210 -32
- package/dist/commands/merge-worktree.js +152 -0
- 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 +88 -0
- package/dist/commands/worktree.js +146 -50
- package/dist/database.js +85 -0
- package/dist/discord-bot.js +97 -55
- 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 +15 -0
- package/dist/session-handler.js +549 -412
- package/dist/system-message.js +25 -1
- package/dist/worktree-utils.js +50 -0
- 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 +250 -35
- package/src/commands/merge-worktree.ts +186 -0
- 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 +122 -0
- package/src/commands/worktree.ts +174 -55
- package/src/database.ts +108 -0
- package/src/discord-bot.ts +119 -63
- 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 +22 -0
- package/src/session-handler.ts +681 -436
- package/src/system-message.ts +37 -0
- package/src/worktree-utils.ts +78 -0
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'
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
getSessionAgent,
|
|
14
14
|
getChannelAgent,
|
|
15
15
|
setSessionAgent,
|
|
16
|
+
getThreadWorktree,
|
|
17
|
+
getChannelVerbosity,
|
|
16
18
|
} from './database.js'
|
|
17
19
|
import {
|
|
18
20
|
initializeOpencodeForDirectory,
|
|
@@ -21,7 +23,7 @@ import {
|
|
|
21
23
|
} from './opencode.js'
|
|
22
24
|
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js'
|
|
23
25
|
import { formatPart } from './message-formatting.js'
|
|
24
|
-
import { getOpencodeSystemMessage } from './system-message.js'
|
|
26
|
+
import { getOpencodeSystemMessage, type WorktreeInfo } from './system-message.js'
|
|
25
27
|
import { createLogger } from './logger.js'
|
|
26
28
|
import { isAbortError } from './utils.js'
|
|
27
29
|
import {
|
|
@@ -29,7 +31,11 @@ import {
|
|
|
29
31
|
cancelPendingQuestion,
|
|
30
32
|
pendingQuestionContexts,
|
|
31
33
|
} from './commands/ask-question.js'
|
|
32
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
showPermissionDropdown,
|
|
36
|
+
cleanupPermissionContext,
|
|
37
|
+
addPermissionRequestToContext,
|
|
38
|
+
} from './commands/permissions.js'
|
|
33
39
|
import * as errore from 'errore'
|
|
34
40
|
|
|
35
41
|
const sessionLogger = createLogger('SESSION')
|
|
@@ -43,9 +49,31 @@ export const abortControllers = new Map<string, AbortController>()
|
|
|
43
49
|
// to avoid duplicates and properly clean up on auto-reject
|
|
44
50
|
export const pendingPermissions = new Map<
|
|
45
51
|
string, // threadId
|
|
46
|
-
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
|
|
47
62
|
>()
|
|
48
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
|
+
|
|
49
77
|
export type QueuedMessage = {
|
|
50
78
|
prompt: string
|
|
51
79
|
userId: string
|
|
@@ -112,10 +140,11 @@ export async function abortAndRetrySession({
|
|
|
112
140
|
sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message)
|
|
113
141
|
return false
|
|
114
142
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
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)
|
|
119
148
|
}
|
|
120
149
|
|
|
121
150
|
// Small delay to let the abort propagate
|
|
@@ -145,16 +174,25 @@ export async function abortAndRetrySession({
|
|
|
145
174
|
|
|
146
175
|
// Use setImmediate to avoid blocking
|
|
147
176
|
setImmediate(() => {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
})
|
|
158
196
|
})
|
|
159
197
|
|
|
160
198
|
return true
|
|
@@ -190,6 +228,18 @@ export async function handleOpencodeSession({
|
|
|
190
228
|
const directory = projectDirectory || process.cwd()
|
|
191
229
|
sessionLogger.log(`Using directory: ${directory}`)
|
|
192
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
|
+
|
|
193
243
|
const getClient = await initializeOpencodeForDirectory(directory)
|
|
194
244
|
if (getClient instanceof Error) {
|
|
195
245
|
await sendThreadMessage(thread, `✗ ${getClient.message}`)
|
|
@@ -207,14 +257,17 @@ export async function handleOpencodeSession({
|
|
|
207
257
|
|
|
208
258
|
if (sessionId) {
|
|
209
259
|
sessionLogger.log(`Attempting to reuse existing session ${sessionId}`)
|
|
210
|
-
|
|
211
|
-
|
|
260
|
+
const sessionResponse = await errore.tryAsync(() => {
|
|
261
|
+
return getClient().session.get({
|
|
212
262
|
path: { id: sessionId },
|
|
263
|
+
query: { directory: sdkDirectory },
|
|
213
264
|
})
|
|
265
|
+
})
|
|
266
|
+
if (sessionResponse instanceof Error) {
|
|
267
|
+
voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`)
|
|
268
|
+
} else {
|
|
214
269
|
session = sessionResponse.data
|
|
215
270
|
sessionLogger.log(`Successfully reused session ${sessionId}`)
|
|
216
|
-
} catch (error) {
|
|
217
|
-
voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`)
|
|
218
271
|
}
|
|
219
272
|
}
|
|
220
273
|
|
|
@@ -223,6 +276,7 @@ export async function handleOpencodeSession({
|
|
|
223
276
|
voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`)
|
|
224
277
|
const sessionResponse = await getClient().session.create({
|
|
225
278
|
body: { title: sessionTitle },
|
|
279
|
+
query: { directory: sdkDirectory },
|
|
226
280
|
})
|
|
227
281
|
session = sessionResponse.data
|
|
228
282
|
sessionLogger.log(`Created new session ${session?.id}`)
|
|
@@ -255,20 +309,25 @@ export async function handleOpencodeSession({
|
|
|
255
309
|
const clientV2 = getOpencodeClientV2(directory)
|
|
256
310
|
let rejectedCount = 0
|
|
257
311
|
for (const [permId, pendingPerm] of threadPermissions) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
await clientV2.permission.reply({
|
|
262
|
-
requestID: permId,
|
|
263
|
-
reply: 'reject',
|
|
264
|
-
})
|
|
265
|
-
}
|
|
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}`)
|
|
266
315
|
cleanupPermissionContext(pendingPerm.contextHash)
|
|
267
316
|
rejectedCount++
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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++
|
|
271
329
|
}
|
|
330
|
+
cleanupPermissionContext(pendingPerm.contextHash)
|
|
272
331
|
}
|
|
273
332
|
pendingPermissions.delete(thread.id)
|
|
274
333
|
if (rejectedCount > 0) {
|
|
@@ -310,7 +369,7 @@ export async function handleOpencodeSession({
|
|
|
310
369
|
throw new Error(`OpenCode v2 client not found for directory: ${directory}`)
|
|
311
370
|
}
|
|
312
371
|
const eventsResult = await clientV2.event.subscribe(
|
|
313
|
-
{ directory },
|
|
372
|
+
{ directory: sdkDirectory },
|
|
314
373
|
{ signal: abortController.signal },
|
|
315
374
|
)
|
|
316
375
|
|
|
@@ -330,7 +389,7 @@ export async function handleOpencodeSession({
|
|
|
330
389
|
).map((row) => row.part_id),
|
|
331
390
|
)
|
|
332
391
|
|
|
333
|
-
|
|
392
|
+
const partBuffer = new Map<string, Map<string, Part>>()
|
|
334
393
|
let stopTyping: (() => void) | null = null
|
|
335
394
|
let usedModel: string | undefined
|
|
336
395
|
let usedProviderID: string | undefined
|
|
@@ -338,6 +397,7 @@ export async function handleOpencodeSession({
|
|
|
338
397
|
let tokensUsedInSession = 0
|
|
339
398
|
let lastDisplayedContextPercentage = 0
|
|
340
399
|
let modelContextLimit: number | undefined
|
|
400
|
+
let assistantMessageId: string | undefined
|
|
341
401
|
|
|
342
402
|
let typingInterval: NodeJS.Timeout | null = null
|
|
343
403
|
|
|
@@ -351,13 +411,17 @@ export async function handleOpencodeSession({
|
|
|
351
411
|
typingInterval = null
|
|
352
412
|
}
|
|
353
413
|
|
|
354
|
-
thread.sendTyping().
|
|
355
|
-
|
|
414
|
+
void errore.tryAsync(() => thread.sendTyping()).then((result) => {
|
|
415
|
+
if (result instanceof Error) {
|
|
416
|
+
discordLogger.log(`Failed to send initial typing: ${result}`)
|
|
417
|
+
}
|
|
356
418
|
})
|
|
357
419
|
|
|
358
420
|
typingInterval = setInterval(() => {
|
|
359
|
-
thread.sendTyping().
|
|
360
|
-
|
|
421
|
+
void errore.tryAsync(() => thread.sendTyping()).then((result) => {
|
|
422
|
+
if (result instanceof Error) {
|
|
423
|
+
discordLogger.log(`Failed to send periodic typing: ${result}`)
|
|
424
|
+
}
|
|
361
425
|
})
|
|
362
426
|
}, 8000)
|
|
363
427
|
|
|
@@ -382,7 +446,16 @@ export async function handleOpencodeSession({
|
|
|
382
446
|
}
|
|
383
447
|
}
|
|
384
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
|
+
|
|
385
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
|
+
|
|
386
459
|
const content = formatPart(part) + '\n\n'
|
|
387
460
|
if (!content.trim() || content.length === 0) {
|
|
388
461
|
// discordLogger.log(`SKIP: Part ${part.id} has no content`)
|
|
@@ -393,18 +466,20 @@ export async function handleOpencodeSession({
|
|
|
393
466
|
return
|
|
394
467
|
}
|
|
395
468
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
403
|
-
)
|
|
404
|
-
.run(part.id, firstMessage.id, thread.id)
|
|
405
|
-
} catch (error) {
|
|
406
|
-
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
|
|
407
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)
|
|
408
483
|
}
|
|
409
484
|
|
|
410
485
|
const eventHandler = async () => {
|
|
@@ -413,390 +488,544 @@ export async function handleOpencodeSession({
|
|
|
413
488
|
// Counts spawned tasks per agent type: "explore" → 2
|
|
414
489
|
const agentSpawnCounts: Record<string, number> = {}
|
|
415
490
|
|
|
416
|
-
|
|
417
|
-
|
|
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
|
+
}
|
|
418
496
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
497
|
+
const getBufferedParts = (messageID: string) => {
|
|
498
|
+
return Array.from(partBuffer.get(messageID)?.values() ?? [])
|
|
499
|
+
}
|
|
422
500
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
501
|
+
const shouldSendPart = ({ part, force }: { part: Part; force: boolean }) => {
|
|
502
|
+
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
503
|
+
return false
|
|
504
|
+
}
|
|
428
505
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
506
|
+
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
507
|
+
return false
|
|
508
|
+
}
|
|
432
509
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
msg.tokens.output +
|
|
437
|
-
msg.tokens.reasoning +
|
|
438
|
-
msg.tokens.cache.read +
|
|
439
|
-
msg.tokens.cache.write
|
|
440
|
-
if (newTokensTotal > 0) {
|
|
441
|
-
tokensUsedInSession = newTokensTotal
|
|
442
|
-
}
|
|
510
|
+
if (!force && part.type === 'text' && !part.time?.end) {
|
|
511
|
+
return false
|
|
512
|
+
}
|
|
443
513
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
usedAgent = msg.mode
|
|
448
|
-
|
|
449
|
-
if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
|
|
450
|
-
if (!modelContextLimit) {
|
|
451
|
-
try {
|
|
452
|
-
const providersResponse = await getClient().provider.list({
|
|
453
|
-
query: { directory },
|
|
454
|
-
})
|
|
455
|
-
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
|
|
456
|
-
const model = provider?.models?.[usedModel]
|
|
457
|
-
if (model?.limit?.context) {
|
|
458
|
-
modelContextLimit = model.limit.context
|
|
459
|
-
}
|
|
460
|
-
} catch (e) {
|
|
461
|
-
sessionLogger.error('Failed to fetch provider info for context limit:', e)
|
|
462
|
-
}
|
|
463
|
-
}
|
|
514
|
+
if (!force && part.type === 'tool' && part.state.status === 'completed') {
|
|
515
|
+
return false
|
|
516
|
+
}
|
|
464
517
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
(tokensUsedInSession / modelContextLimit) * 100,
|
|
468
|
-
)
|
|
469
|
-
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
|
|
470
|
-
if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
|
|
471
|
-
lastDisplayedContextPercentage = thresholdCrossed
|
|
472
|
-
const chunk = `⬦ context usage ${currentPercentage}%`
|
|
473
|
-
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
} else if (event.type === 'message.part.updated') {
|
|
479
|
-
const part = event.properties.part
|
|
518
|
+
return true
|
|
519
|
+
}
|
|
480
520
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
+
}
|
|
484
544
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
+
}
|
|
489
563
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
494
|
-
continue
|
|
495
|
-
}
|
|
496
|
-
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
497
|
-
continue
|
|
498
|
-
}
|
|
499
|
-
// Skip text parts - the outer agent will report the task result anyway
|
|
500
|
-
if (part.type === 'text') {
|
|
501
|
-
continue
|
|
502
|
-
}
|
|
503
|
-
// Only show parts from assistant messages (not user prompts sent to subtask)
|
|
504
|
-
// Skip if we haven't seen an assistant message yet, or if this part is from a different message
|
|
505
|
-
if (
|
|
506
|
-
!subtaskInfo.assistantMessageId ||
|
|
507
|
-
part.messageID !== subtaskInfo.assistantMessageId
|
|
508
|
-
) {
|
|
509
|
-
continue
|
|
510
|
-
}
|
|
564
|
+
if (msg.sessionID !== session.id) {
|
|
565
|
+
return
|
|
566
|
+
}
|
|
511
567
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const msg = await sendThreadMessage(thread, content + '\n\n')
|
|
516
|
-
sentPartIds.add(part.id)
|
|
517
|
-
getDatabase()
|
|
518
|
-
.prepare(
|
|
519
|
-
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
520
|
-
)
|
|
521
|
-
.run(part.id, msg.id, thread.id)
|
|
522
|
-
} catch (error) {
|
|
523
|
-
discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, error)
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
continue
|
|
527
|
-
}
|
|
568
|
+
if (msg.role !== 'assistant') {
|
|
569
|
+
return
|
|
570
|
+
}
|
|
528
571
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
+
}
|
|
533
583
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
currentParts.push(part)
|
|
539
|
-
}
|
|
584
|
+
assistantMessageId = msg.id
|
|
585
|
+
usedModel = msg.modelID
|
|
586
|
+
usedProviderID = msg.providerID
|
|
587
|
+
usedAgent = msg.mode
|
|
540
588
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
)
|
|
546
|
-
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
|
|
547
|
-
if (!hasPendingQuestion && !hasPendingPermission) {
|
|
548
|
-
stopTyping = startTyping()
|
|
549
|
-
}
|
|
550
|
-
}
|
|
589
|
+
await flushBufferedParts({
|
|
590
|
+
messageID: assistantMessageId,
|
|
591
|
+
force: false,
|
|
592
|
+
})
|
|
551
593
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
for (const p of currentParts) {
|
|
556
|
-
if (p.type !== 'step-start' && p.type !== 'step-finish' && p.id !== part.id) {
|
|
557
|
-
await sendPartMessage(p)
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
await sendPartMessage(part)
|
|
561
|
-
// Track task tool and register child session when sessionId is available
|
|
562
|
-
if (part.tool === 'task' && !sentPartIds.has(part.id)) {
|
|
563
|
-
const description = (part.state.input?.description as string) || ''
|
|
564
|
-
const agent = (part.state.input?.subagent_type as string) || 'task'
|
|
565
|
-
const childSessionId = (part.state.metadata?.sessionId as string) || ''
|
|
566
|
-
if (description && childSessionId) {
|
|
567
|
-
agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1
|
|
568
|
-
const label = `${agent}-${agentSpawnCounts[agent]}`
|
|
569
|
-
subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined })
|
|
570
|
-
const taskDisplay = `┣ task **${label}** _${description}_`
|
|
571
|
-
await sendThreadMessage(thread, taskDisplay + '\n\n')
|
|
572
|
-
sentPartIds.add(part.id)
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
594
|
+
if (tokensUsedInSession === 0 || !usedProviderID || !usedModel) {
|
|
595
|
+
return
|
|
596
|
+
}
|
|
576
597
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
if (pct < 1) {
|
|
591
|
-
return ''
|
|
592
|
-
}
|
|
593
|
-
return ` (${pct.toFixed(1)}%)`
|
|
594
|
-
})()
|
|
595
|
-
const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
|
|
596
|
-
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
597
|
-
}
|
|
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
|
|
598
611
|
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
599
614
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
615
|
+
if (!modelContextLimit) {
|
|
616
|
+
return
|
|
617
|
+
}
|
|
603
618
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
+
}
|
|
609
628
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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')
|
|
615
669
|
}
|
|
616
|
-
|
|
617
|
-
if (abortController.signal.aborted) return
|
|
618
|
-
// Don't restart typing if user needs to respond to a question or permission
|
|
619
|
-
const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
|
|
620
|
-
(ctx) => ctx.thread.id === thread.id,
|
|
621
|
-
)
|
|
622
|
-
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
|
|
623
|
-
if (hasPendingQuestion || hasPendingPermission) return
|
|
624
|
-
stopTyping = startTyping()
|
|
625
|
-
}, 300)
|
|
670
|
+
sentPartIds.add(part.id)
|
|
626
671
|
}
|
|
672
|
+
}
|
|
673
|
+
return
|
|
674
|
+
}
|
|
627
675
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
if (
|
|
637
|
-
|
|
638
|
-
await originalMessage.reactions.removeAll()
|
|
639
|
-
await originalMessage.react('❌')
|
|
640
|
-
voiceLogger.log(`[REACTION] Added error reaction due to session error`)
|
|
641
|
-
} catch (e) {
|
|
642
|
-
discordLogger.log(`Could not update reaction:`, e)
|
|
643
|
-
}
|
|
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 ''
|
|
644
686
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
|
|
656
|
-
)
|
|
657
|
-
continue
|
|
658
|
-
}
|
|
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
|
+
}
|
|
659
697
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
`[PERMISSION] Skipping duplicate permission ${permission.id} (already pending)`,
|
|
665
|
-
)
|
|
666
|
-
continue
|
|
667
|
-
}
|
|
698
|
+
if (part.type === 'reasoning') {
|
|
699
|
+
await sendPartMessage(part)
|
|
700
|
+
return
|
|
701
|
+
}
|
|
668
702
|
|
|
669
|
-
|
|
670
|
-
|
|
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,
|
|
671
717
|
)
|
|
718
|
+
const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0
|
|
719
|
+
if (hasPendingQuestion || hasPendingPermission) return
|
|
720
|
+
stopTyping = startTyping()
|
|
721
|
+
}, 300)
|
|
722
|
+
}
|
|
723
|
+
}
|
|
672
724
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
+
}
|
|
678
741
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
+
}
|
|
685
760
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
pendingPermissions.set(thread.id, new Map())
|
|
689
|
-
}
|
|
690
|
-
pendingPermissions.get(thread.id)!.set(permission.id, {
|
|
691
|
-
permission,
|
|
692
|
-
messageId,
|
|
693
|
-
directory,
|
|
694
|
-
contextHash,
|
|
695
|
-
})
|
|
696
|
-
} else if (event.type === 'permission.replied') {
|
|
697
|
-
const { requestID, reply, sessionID } = event.properties
|
|
698
|
-
if (sessionID !== session.id) {
|
|
699
|
-
continue
|
|
700
|
-
}
|
|
761
|
+
const handlePartUpdated = async (part: Part) => {
|
|
762
|
+
storePart(part)
|
|
701
763
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
// Clean up the specific permission from nested map
|
|
705
|
-
const threadPermissions = pendingPermissions.get(thread.id)
|
|
706
|
-
if (threadPermissions) {
|
|
707
|
-
const pending = threadPermissions.get(requestID)
|
|
708
|
-
if (pending) {
|
|
709
|
-
cleanupPermissionContext(pending.contextHash)
|
|
710
|
-
threadPermissions.delete(requestID)
|
|
711
|
-
// Remove thread entry if no more pending permissions
|
|
712
|
-
if (threadPermissions.size === 0) {
|
|
713
|
-
pendingPermissions.delete(thread.id)
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
} else if (event.type === 'question.asked') {
|
|
718
|
-
const questionRequest = event.properties
|
|
764
|
+
const subtaskInfo = subtaskSessions.get(part.sessionID)
|
|
765
|
+
const isSubtaskEvent = Boolean(subtaskInfo)
|
|
719
766
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
767
|
+
if (part.sessionID !== session.id && !isSubtaskEvent) {
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (isSubtaskEvent && subtaskInfo) {
|
|
772
|
+
await handleSubtaskPart(part, subtaskInfo)
|
|
773
|
+
return
|
|
774
|
+
}
|
|
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
|
+
}
|
|
726
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) {
|
|
727
850
|
sessionLogger.log(
|
|
728
|
-
`
|
|
851
|
+
`[PERMISSION] Failed to attach duplicate request ${permission.id} to context`,
|
|
729
852
|
)
|
|
853
|
+
}
|
|
854
|
+
return
|
|
855
|
+
}
|
|
730
856
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
stopTyping = null
|
|
735
|
-
}
|
|
857
|
+
sessionLogger.log(
|
|
858
|
+
`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
|
|
859
|
+
)
|
|
736
860
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
await sendPartMessage(p)
|
|
742
|
-
}
|
|
743
|
-
}
|
|
861
|
+
if (stopTyping) {
|
|
862
|
+
stopTyping()
|
|
863
|
+
stopTyping = null
|
|
864
|
+
}
|
|
744
865
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
input: { questions: questionRequest.questions },
|
|
751
|
-
})
|
|
866
|
+
const { messageId, contextHash } = await showPermissionDropdown({
|
|
867
|
+
thread,
|
|
868
|
+
permission,
|
|
869
|
+
directory,
|
|
870
|
+
})
|
|
752
871
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
+
}
|
|
760
883
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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}`)
|
|
764
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
|
+
)
|
|
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)
|
|
765
979
|
await sendThreadMessage(
|
|
766
980
|
thread,
|
|
767
|
-
|
|
981
|
+
`✗ Queued message failed: ${result.message.slice(0, 200)}`,
|
|
768
982
|
)
|
|
983
|
+
})
|
|
984
|
+
})
|
|
985
|
+
}
|
|
769
986
|
|
|
770
|
-
|
|
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
|
-
|
|
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
|
|
800
1029
|
}
|
|
801
1030
|
}
|
|
802
1031
|
} catch (e) {
|
|
@@ -807,12 +1036,13 @@ export async function handleOpencodeSession({
|
|
|
807
1036
|
sessionLogger.error(`Unexpected error in event handling code`, e)
|
|
808
1037
|
throw e
|
|
809
1038
|
} finally {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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)) {
|
|
813
1045
|
await sendPartMessage(part)
|
|
814
|
-
} catch (error) {
|
|
815
|
-
sessionLogger.error(`Failed to send part ${part.id}:`, error)
|
|
816
1046
|
}
|
|
817
1047
|
}
|
|
818
1048
|
}
|
|
@@ -830,12 +1060,13 @@ export async function handleOpencodeSession({
|
|
|
830
1060
|
usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : ''
|
|
831
1061
|
let contextInfo = ''
|
|
832
1062
|
|
|
833
|
-
|
|
1063
|
+
const contextResult = await errore.tryAsync(async () => {
|
|
834
1064
|
// Fetch final token count from API since message.updated events can arrive
|
|
835
1065
|
// after session.idle due to race conditions in event ordering
|
|
836
1066
|
if (tokensUsedInSession === 0) {
|
|
837
1067
|
const messagesResponse = await getClient().session.messages({
|
|
838
1068
|
path: { id: session.id },
|
|
1069
|
+
query: { directory: sdkDirectory },
|
|
839
1070
|
})
|
|
840
1071
|
const messages = messagesResponse.data || []
|
|
841
1072
|
const lastAssistant = [...messages]
|
|
@@ -857,15 +1088,16 @@ export async function handleOpencodeSession({
|
|
|
857
1088
|
}
|
|
858
1089
|
}
|
|
859
1090
|
|
|
860
|
-
const providersResponse = await getClient().provider.list({ query: { directory } })
|
|
1091
|
+
const providersResponse = await getClient().provider.list({ query: { directory: sdkDirectory } })
|
|
861
1092
|
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
|
|
862
1093
|
const model = provider?.models?.[usedModel || '']
|
|
863
1094
|
if (model?.limit?.context) {
|
|
864
1095
|
const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100)
|
|
865
1096
|
contextInfo = ` ⋅ ${percentage}%`
|
|
866
1097
|
}
|
|
867
|
-
}
|
|
868
|
-
|
|
1098
|
+
})
|
|
1099
|
+
if (contextResult instanceof Error) {
|
|
1100
|
+
sessionLogger.error('Failed to fetch provider info for context percentage:', contextResult)
|
|
869
1101
|
}
|
|
870
1102
|
|
|
871
1103
|
await sendThreadMessage(
|
|
@@ -917,8 +1149,9 @@ export async function handleOpencodeSession({
|
|
|
917
1149
|
}
|
|
918
1150
|
}
|
|
919
1151
|
|
|
920
|
-
|
|
921
|
-
|
|
1152
|
+
const promptResult: Error | { sessionID: string; result: any; port?: number } | undefined =
|
|
1153
|
+
await errore.tryAsync(async () => {
|
|
1154
|
+
const eventHandlerPromise = eventHandler()
|
|
922
1155
|
|
|
923
1156
|
if (abortController.signal.aborted) {
|
|
924
1157
|
sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
|
|
@@ -930,7 +1163,6 @@ export async function handleOpencodeSession({
|
|
|
930
1163
|
voiceLogger.log(
|
|
931
1164
|
`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
|
|
932
1165
|
)
|
|
933
|
-
// append image paths to prompt so ai knows where they are on disk
|
|
934
1166
|
const promptWithImagePaths = (() => {
|
|
935
1167
|
if (images.length === 0) {
|
|
936
1168
|
return prompt
|
|
@@ -950,19 +1182,15 @@ export async function handleOpencodeSession({
|
|
|
950
1182
|
const parts = [{ type: 'text' as const, text: promptWithImagePaths }, ...images]
|
|
951
1183
|
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
|
|
952
1184
|
|
|
953
|
-
// Get agent preference: session-level overrides channel-level
|
|
954
1185
|
const agentPreference =
|
|
955
1186
|
getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
|
|
956
1187
|
if (agentPreference) {
|
|
957
1188
|
sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
|
|
958
1189
|
}
|
|
959
1190
|
|
|
960
|
-
// Get model preference: session-level overrides channel-level
|
|
961
|
-
// BUT: if an agent is set, don't pass model param so the agent's model takes effect
|
|
962
1191
|
const modelPreference =
|
|
963
1192
|
getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
|
|
964
1193
|
const modelParam = (() => {
|
|
965
|
-
// When an agent is set, let the agent's model config take effect
|
|
966
1194
|
if (agentPreference) {
|
|
967
1195
|
sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`)
|
|
968
1196
|
return undefined
|
|
@@ -979,10 +1207,20 @@ export async function handleOpencodeSession({
|
|
|
979
1207
|
return { providerID, modelID }
|
|
980
1208
|
})()
|
|
981
1209
|
|
|
982
|
-
//
|
|
1210
|
+
// Build worktree info for system message (worktreeInfo was fetched at the start)
|
|
1211
|
+
const worktree: WorktreeInfo | undefined =
|
|
1212
|
+
worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
1213
|
+
? {
|
|
1214
|
+
worktreeDirectory: worktreeInfo.worktree_directory,
|
|
1215
|
+
branch: worktreeInfo.worktree_name,
|
|
1216
|
+
mainRepoDirectory: worktreeInfo.project_directory,
|
|
1217
|
+
}
|
|
1218
|
+
: undefined
|
|
1219
|
+
|
|
983
1220
|
const response = command
|
|
984
1221
|
? await getClient().session.command({
|
|
985
1222
|
path: { id: session.id },
|
|
1223
|
+
query: { directory: sdkDirectory },
|
|
986
1224
|
body: {
|
|
987
1225
|
command: command.name,
|
|
988
1226
|
arguments: command.arguments,
|
|
@@ -992,9 +1230,10 @@ export async function handleOpencodeSession({
|
|
|
992
1230
|
})
|
|
993
1231
|
: await getClient().session.prompt({
|
|
994
1232
|
path: { id: session.id },
|
|
1233
|
+
query: { directory: sdkDirectory },
|
|
995
1234
|
body: {
|
|
996
1235
|
parts,
|
|
997
|
-
system: getOpencodeSystemMessage({ sessionId: session.id, channelId }),
|
|
1236
|
+
system: getOpencodeSystemMessage({ sessionId: session.id, channelId, worktree }),
|
|
998
1237
|
model: modelParam,
|
|
999
1238
|
agent: agentPreference,
|
|
1000
1239
|
},
|
|
@@ -1022,40 +1261,46 @@ export async function handleOpencodeSession({
|
|
|
1022
1261
|
sessionLogger.log(`Successfully sent prompt, got response`)
|
|
1023
1262
|
|
|
1024
1263
|
if (originalMessage) {
|
|
1025
|
-
|
|
1264
|
+
const reactionResult = await errore.tryAsync(async () => {
|
|
1026
1265
|
await originalMessage.reactions.removeAll()
|
|
1027
1266
|
await originalMessage.react('✅')
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1267
|
+
})
|
|
1268
|
+
if (reactionResult instanceof Error) {
|
|
1269
|
+
discordLogger.log(`Could not update reactions:`, reactionResult)
|
|
1030
1270
|
}
|
|
1031
1271
|
}
|
|
1032
1272
|
|
|
1033
1273
|
return { sessionID: session.id, result: response.data, port }
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
})()
|
|
1058
|
-
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`)
|
|
1059
1297
|
}
|
|
1060
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}`)
|
|
1061
1306
|
}
|