kimaki 0.4.47 → 0.4.49
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/cli.js +70 -29
- package/dist/commands/abort.js +2 -0
- package/dist/commands/create-new-project.js +57 -27
- package/dist/commands/merge-worktree.js +21 -8
- package/dist/commands/permissions.js +3 -1
- package/dist/commands/session.js +4 -1
- package/dist/commands/verbosity.js +3 -3
- package/dist/config.js +7 -0
- package/dist/database.js +8 -5
- package/dist/discord-bot.js +27 -9
- package/dist/message-formatting.js +74 -52
- package/dist/opencode.js +17 -23
- package/dist/session-handler.js +42 -21
- package/dist/system-message.js +11 -9
- package/dist/tools.js +1 -0
- package/dist/utils.js +1 -0
- package/package.json +3 -3
- package/src/cli.ts +114 -29
- package/src/commands/abort.ts +2 -0
- package/src/commands/create-new-project.ts +84 -33
- package/src/commands/merge-worktree.ts +45 -8
- package/src/commands/permissions.ts +4 -0
- package/src/commands/session.ts +4 -1
- package/src/commands/verbosity.ts +3 -3
- package/src/config.ts +14 -0
- package/src/database.ts +11 -5
- package/src/discord-bot.ts +42 -9
- package/src/message-formatting.ts +81 -63
- package/src/opencode.ts +17 -24
- package/src/session-handler.ts +46 -21
- package/src/system-message.ts +11 -9
- package/src/tools.ts +1 -0
- package/src/utils.ts +1 -0
package/src/discord-bot.ts
CHANGED
|
@@ -77,6 +77,10 @@ setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections:
|
|
|
77
77
|
const discordLogger = createLogger(LogPrefix.DISCORD)
|
|
78
78
|
const voiceLogger = createLogger(LogPrefix.VOICE)
|
|
79
79
|
|
|
80
|
+
function prefixWithDiscordUser({ username, prompt }: { username: string; prompt: string }): string {
|
|
81
|
+
return `<discord-user name="${username}" />\n${prompt}`
|
|
82
|
+
}
|
|
83
|
+
|
|
80
84
|
type StartOptions = {
|
|
81
85
|
token: string
|
|
82
86
|
appId?: string
|
|
@@ -165,6 +169,17 @@ export async function startDiscordBot({
|
|
|
165
169
|
if (message.author?.bot) {
|
|
166
170
|
return
|
|
167
171
|
}
|
|
172
|
+
|
|
173
|
+
// Ignore messages that start with a mention of another user (not the bot).
|
|
174
|
+
// These are likely users talking to each other, not the bot.
|
|
175
|
+
const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/)
|
|
176
|
+
if (leadingMentionMatch) {
|
|
177
|
+
const mentionedUserId = leadingMentionMatch[1]
|
|
178
|
+
if (mentionedUserId !== discordClient.user?.id) {
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
168
183
|
if (message.partial) {
|
|
169
184
|
discordLogger.log(`Fetching partial message ${message.id}`)
|
|
170
185
|
const fetched = await errore.tryAsync({
|
|
@@ -275,13 +290,19 @@ export async function startDiscordBot({
|
|
|
275
290
|
|
|
276
291
|
// Include starter message as context for the session
|
|
277
292
|
let prompt = message.content
|
|
278
|
-
const starterMessage = await thread.fetchStarterMessage().catch(() =>
|
|
293
|
+
const starterMessage = await thread.fetchStarterMessage().catch((error) => {
|
|
294
|
+
discordLogger.warn(
|
|
295
|
+
`[SESSION] Failed to fetch starter message for thread ${thread.id}:`,
|
|
296
|
+
error instanceof Error ? error.message : String(error),
|
|
297
|
+
)
|
|
298
|
+
return null
|
|
299
|
+
})
|
|
279
300
|
if (starterMessage?.content && starterMessage.content !== message.content) {
|
|
280
301
|
prompt = `Context from thread:\n${starterMessage.content}\n\nUser request:\n${message.content}`
|
|
281
302
|
}
|
|
282
303
|
|
|
283
304
|
await handleOpencodeSession({
|
|
284
|
-
prompt,
|
|
305
|
+
prompt: prefixWithDiscordUser({ username: message.member?.displayName || message.author.displayName, prompt }),
|
|
285
306
|
thread,
|
|
286
307
|
projectDirectory,
|
|
287
308
|
channelId: parent?.id || '',
|
|
@@ -358,7 +379,7 @@ export async function startDiscordBot({
|
|
|
358
379
|
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
359
380
|
: messageContent
|
|
360
381
|
await handleOpencodeSession({
|
|
361
|
-
prompt: promptWithAttachments,
|
|
382
|
+
prompt: prefixWithDiscordUser({ username: message.member?.displayName || message.author.displayName, prompt: promptWithAttachments }),
|
|
362
383
|
thread,
|
|
363
384
|
projectDirectory,
|
|
364
385
|
originalMessage: message,
|
|
@@ -502,7 +523,7 @@ export async function startDiscordBot({
|
|
|
502
523
|
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
503
524
|
: messageContent
|
|
504
525
|
await handleOpencodeSession({
|
|
505
|
-
prompt: promptWithAttachments,
|
|
526
|
+
prompt: prefixWithDiscordUser({ username: message.member?.displayName || message.author.displayName, prompt: promptWithAttachments }),
|
|
506
527
|
thread,
|
|
507
528
|
projectDirectory: sessionDirectory,
|
|
508
529
|
originalMessage: message,
|
|
@@ -517,8 +538,11 @@ export async function startDiscordBot({
|
|
|
517
538
|
try {
|
|
518
539
|
const errMsg = error instanceof Error ? error.message : String(error)
|
|
519
540
|
await message.reply({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS })
|
|
520
|
-
} catch {
|
|
521
|
-
voiceLogger.error(
|
|
541
|
+
} catch (sendError) {
|
|
542
|
+
voiceLogger.error(
|
|
543
|
+
'Discord handler error (fallback):',
|
|
544
|
+
sendError instanceof Error ? sendError.message : String(sendError),
|
|
545
|
+
)
|
|
522
546
|
}
|
|
523
547
|
}
|
|
524
548
|
})
|
|
@@ -539,7 +563,13 @@ export async function startDiscordBot({
|
|
|
539
563
|
}
|
|
540
564
|
|
|
541
565
|
// Get the starter message to check for auto-start marker
|
|
542
|
-
const starterMessage = await thread.fetchStarterMessage().catch(() =>
|
|
566
|
+
const starterMessage = await thread.fetchStarterMessage().catch((error) => {
|
|
567
|
+
discordLogger.warn(
|
|
568
|
+
`[THREAD_CREATE] Failed to fetch starter message for thread ${thread.id}:`,
|
|
569
|
+
error instanceof Error ? error.message : String(error),
|
|
570
|
+
)
|
|
571
|
+
return null
|
|
572
|
+
})
|
|
543
573
|
if (!starterMessage) {
|
|
544
574
|
discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`)
|
|
545
575
|
return
|
|
@@ -601,8 +631,11 @@ export async function startDiscordBot({
|
|
|
601
631
|
try {
|
|
602
632
|
const errMsg = error instanceof Error ? error.message : String(error)
|
|
603
633
|
await thread.send({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS })
|
|
604
|
-
} catch {
|
|
605
|
-
|
|
634
|
+
} catch (sendError) {
|
|
635
|
+
voiceLogger.error(
|
|
636
|
+
'[BOT_SESSION] Failed to send error message:',
|
|
637
|
+
sendError instanceof Error ? sendError.message : String(sendError),
|
|
638
|
+
)
|
|
606
639
|
}
|
|
607
640
|
}
|
|
608
641
|
})
|
|
@@ -29,6 +29,74 @@ function escapeInlineMarkdown(text: string): string {
|
|
|
29
29
|
return text.replace(/([*_~|`\\])/g, '\\$1')
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Parses a patchText string (apply_patch format) and counts additions/deletions per file.
|
|
34
|
+
* Patch format uses `*** Add File:`, `*** Update File:`, `*** Delete File:` headers,
|
|
35
|
+
* with diff lines prefixed by `+` (addition) or `-` (deletion) inside `@@` hunks.
|
|
36
|
+
*/
|
|
37
|
+
function parsePatchCounts(
|
|
38
|
+
patchText: string,
|
|
39
|
+
): Map<string, { additions: number; deletions: number }> {
|
|
40
|
+
const counts = new Map<string, { additions: number; deletions: number }>()
|
|
41
|
+
const lines = patchText.split('\n')
|
|
42
|
+
let currentFile = ''
|
|
43
|
+
let currentType = ''
|
|
44
|
+
let inHunk = false
|
|
45
|
+
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
const addMatch = line.match(/^\*\*\* Add File:\s*(.+)/)
|
|
48
|
+
const updateMatch = line.match(/^\*\*\* Update File:\s*(.+)/)
|
|
49
|
+
const deleteMatch = line.match(/^\*\*\* Delete File:\s*(.+)/)
|
|
50
|
+
|
|
51
|
+
if (addMatch || updateMatch || deleteMatch) {
|
|
52
|
+
const match = addMatch || updateMatch || deleteMatch
|
|
53
|
+
currentFile = (match?.[1] ?? '').trim()
|
|
54
|
+
currentType = addMatch ? 'add' : updateMatch ? 'update' : 'delete'
|
|
55
|
+
counts.set(currentFile, { additions: 0, deletions: 0 })
|
|
56
|
+
inHunk = false
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (line.startsWith('@@')) {
|
|
61
|
+
inHunk = true
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (line.startsWith('*** ')) {
|
|
66
|
+
inHunk = false
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!currentFile) {
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const entry = counts.get(currentFile)
|
|
75
|
+
if (!entry) {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (currentType === 'add') {
|
|
80
|
+
// all content lines in Add File are additions
|
|
81
|
+
if (line.length > 0 && !line.startsWith('*** ')) {
|
|
82
|
+
entry.additions++
|
|
83
|
+
}
|
|
84
|
+
} else if (currentType === 'delete') {
|
|
85
|
+
// all content lines in Delete File are deletions
|
|
86
|
+
if (line.length > 0 && !line.startsWith('*** ')) {
|
|
87
|
+
entry.deletions++
|
|
88
|
+
}
|
|
89
|
+
} else if (inHunk) {
|
|
90
|
+
if (line.startsWith('+')) {
|
|
91
|
+
entry.additions++
|
|
92
|
+
} else if (line.startsWith('-')) {
|
|
93
|
+
entry.deletions++
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return counts
|
|
98
|
+
}
|
|
99
|
+
|
|
32
100
|
/**
|
|
33
101
|
* Normalize whitespace: convert newlines to spaces and collapse consecutive spaces.
|
|
34
102
|
*/
|
|
@@ -178,70 +246,20 @@ export function getToolSummaryText(part: Part): string {
|
|
|
178
246
|
}
|
|
179
247
|
|
|
180
248
|
if (part.tool === 'apply_patch') {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const rawFiles = state.metadata?.files
|
|
186
|
-
const partMetaFiles = (part as { metadata?: { files?: unknown } }).metadata?.files
|
|
187
|
-
const filesList = Array.isArray(rawFiles)
|
|
188
|
-
? rawFiles
|
|
189
|
-
: Array.isArray(partMetaFiles)
|
|
190
|
-
? partMetaFiles
|
|
191
|
-
: []
|
|
192
|
-
|
|
193
|
-
const summarizeFiles = (files: unknown[]): string => {
|
|
194
|
-
const summarized = files
|
|
195
|
-
.map((f) => {
|
|
196
|
-
if (!f) {
|
|
197
|
-
return null
|
|
198
|
-
}
|
|
199
|
-
if (typeof f === 'string') {
|
|
200
|
-
const fileName = f.split('/').pop() || ''
|
|
201
|
-
return fileName ? `*${escapeInlineMarkdown(fileName)}* (+0-0)` : `(+0-0)`
|
|
202
|
-
}
|
|
203
|
-
if (typeof f !== 'object') {
|
|
204
|
-
return null
|
|
205
|
-
}
|
|
206
|
-
const file = f as Record<string, unknown>
|
|
207
|
-
const pathStr = String(file.relativePath || file.filePath || file.path || '')
|
|
208
|
-
const fileName = pathStr.split('/').pop() || ''
|
|
209
|
-
const added = typeof file.additions === 'number' ? file.additions : 0
|
|
210
|
-
const removed = typeof file.deletions === 'number' ? file.deletions : 0
|
|
211
|
-
return fileName
|
|
212
|
-
? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})`
|
|
213
|
-
: `(+${added}-${removed})`
|
|
214
|
-
})
|
|
215
|
-
.filter(Boolean)
|
|
216
|
-
.join(', ')
|
|
217
|
-
return summarized
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (filesList.length > 0) {
|
|
221
|
-
const summarized = summarizeFiles(filesList)
|
|
222
|
-
if (summarized) {
|
|
223
|
-
return summarized
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const outputText = typeof state.output === 'string' ? state.output : ''
|
|
228
|
-
const outputLines = outputText.split('\n')
|
|
229
|
-
const updatedIndex = outputLines.findIndex((line) =>
|
|
230
|
-
line.startsWith('Success. Updated the following files:'),
|
|
231
|
-
)
|
|
232
|
-
if (updatedIndex !== -1) {
|
|
233
|
-
const fileLines = outputLines.slice(updatedIndex + 1).filter(Boolean)
|
|
234
|
-
if (fileLines.length > 0) {
|
|
235
|
-
const summarized = summarizeFiles(
|
|
236
|
-
fileLines.map((line) => line.replace(/^[AMD]\s+/, '').trim()),
|
|
237
|
-
)
|
|
238
|
-
if (summarized) {
|
|
239
|
-
return summarized
|
|
240
|
-
}
|
|
241
|
-
}
|
|
249
|
+
// Only inputs are available when parts are sent during streaming (output/metadata not yet populated)
|
|
250
|
+
const patchText = (part.state.input?.patchText as string) || ''
|
|
251
|
+
if (!patchText) {
|
|
252
|
+
return ''
|
|
242
253
|
}
|
|
243
|
-
|
|
244
|
-
return
|
|
254
|
+
const patchCounts = parsePatchCounts(patchText)
|
|
255
|
+
return [...patchCounts.entries()]
|
|
256
|
+
.map(([filePath, { additions, deletions }]) => {
|
|
257
|
+
const fileName = filePath.split('/').pop() || ''
|
|
258
|
+
return fileName
|
|
259
|
+
? `*${escapeInlineMarkdown(fileName)}* (+${additions}-${deletions})`
|
|
260
|
+
: `(+${additions}-${deletions})`
|
|
261
|
+
})
|
|
262
|
+
.join(', ')
|
|
245
263
|
}
|
|
246
264
|
|
|
247
265
|
if (part.tool === 'write') {
|
package/src/opencode.ts
CHANGED
|
@@ -54,31 +54,24 @@ async function getOpenPort(): Promise<number> {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
async function waitForServer(port: number, maxAttempts = 30): Promise<ServerStartError | true> {
|
|
57
|
+
const endpoint = `http://127.0.0.1:${port}/api/health`
|
|
57
58
|
for (let i = 0; i < maxAttempts; i++) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
if (response.status < 500) {
|
|
75
|
-
return true
|
|
76
|
-
}
|
|
77
|
-
const body = await response.text()
|
|
78
|
-
// Fatal errors that won't resolve with retrying
|
|
79
|
-
if (body.includes('BunInstallFailedError')) {
|
|
80
|
-
return new ServerStartError({ port, reason: body.slice(0, 200) })
|
|
81
|
-
}
|
|
59
|
+
const response = await errore.tryAsync({
|
|
60
|
+
try: () => fetch(endpoint),
|
|
61
|
+
catch: (e) => new FetchError({ url: endpoint, cause: e }),
|
|
62
|
+
})
|
|
63
|
+
if (response instanceof Error) {
|
|
64
|
+
// Connection refused or other transient errors - continue polling
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
if (response.status < 500) {
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
const body = await response.text()
|
|
72
|
+
// Fatal errors that won't resolve with retrying
|
|
73
|
+
if (body.includes('BunInstallFailedError')) {
|
|
74
|
+
return new ServerStartError({ port, reason: body.slice(0, 200) })
|
|
82
75
|
}
|
|
83
76
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
84
77
|
}
|
package/src/session-handler.ts
CHANGED
|
@@ -134,6 +134,7 @@ export async function abortAndRetrySession({
|
|
|
134
134
|
sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`)
|
|
135
135
|
|
|
136
136
|
// Abort with special reason so we don't show "completed" message
|
|
137
|
+
sessionLogger.log(`[ABORT] reason=model-change sessionId=${sessionId} - user changed model mid-request, will retry with new model`)
|
|
137
138
|
controller.abort(new Error('model-change'))
|
|
138
139
|
|
|
139
140
|
// Also call the API abort endpoint
|
|
@@ -142,11 +143,12 @@ export async function abortAndRetrySession({
|
|
|
142
143
|
sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message)
|
|
143
144
|
return false
|
|
144
145
|
}
|
|
146
|
+
sessionLogger.log(`[ABORT-API] reason=model-change sessionId=${sessionId} - sending API abort for model change retry`)
|
|
145
147
|
const abortResult = await errore.tryAsync(() => {
|
|
146
148
|
return getClient().session.abort({ path: { id: sessionId } })
|
|
147
149
|
})
|
|
148
150
|
if (abortResult instanceof Error) {
|
|
149
|
-
sessionLogger.log(`[ABORT
|
|
151
|
+
sessionLogger.log(`[ABORT-API] API abort call failed (may already be done):`, abortResult)
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
// Small delay to let the abort propagate
|
|
@@ -302,7 +304,9 @@ export async function handleOpencodeSession({
|
|
|
302
304
|
const existingController = abortControllers.get(session.id)
|
|
303
305
|
if (existingController) {
|
|
304
306
|
voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`)
|
|
307
|
+
sessionLogger.log(`[ABORT] reason=new-request sessionId=${session.id} threadId=${thread.id} - new user message arrived while previous request was still running`)
|
|
305
308
|
existingController.abort(new Error('New request started'))
|
|
309
|
+
sessionLogger.log(`[ABORT-API] reason=new-request sessionId=${session.id} - sending API abort because new message arrived`)
|
|
306
310
|
const abortResult = await errore.tryAsync(() => {
|
|
307
311
|
return getClient().session.abort({
|
|
308
312
|
path: { id: session.id },
|
|
@@ -310,7 +314,7 @@ export async function handleOpencodeSession({
|
|
|
310
314
|
})
|
|
311
315
|
})
|
|
312
316
|
if (abortResult instanceof Error) {
|
|
313
|
-
sessionLogger.log(`[ABORT] Server abort failed (may be already done):`, abortResult)
|
|
317
|
+
sessionLogger.log(`[ABORT-API] Server abort failed (may be already done):`, abortResult)
|
|
314
318
|
}
|
|
315
319
|
}
|
|
316
320
|
|
|
@@ -424,6 +428,9 @@ export async function handleOpencodeSession({
|
|
|
424
428
|
let handlerPromise: Promise<void> | null = null
|
|
425
429
|
|
|
426
430
|
let typingInterval: NodeJS.Timeout | null = null
|
|
431
|
+
let hasSentParts = false
|
|
432
|
+
let promptResolved = false
|
|
433
|
+
let hasReceivedEvent = false
|
|
427
434
|
|
|
428
435
|
function startTyping(): () => void {
|
|
429
436
|
if (abortController.signal.aborted) {
|
|
@@ -470,13 +477,15 @@ export async function handleOpencodeSession({
|
|
|
470
477
|
}
|
|
471
478
|
}
|
|
472
479
|
|
|
473
|
-
//
|
|
480
|
+
// Read verbosity dynamically so mid-session /verbosity changes take effect immediately
|
|
474
481
|
const verbosityChannelId = channelId || thread.parentId || thread.id
|
|
475
|
-
const
|
|
482
|
+
const getVerbosity = () => {
|
|
483
|
+
return getChannelVerbosity(verbosityChannelId)
|
|
484
|
+
}
|
|
476
485
|
|
|
477
486
|
const sendPartMessage = async (part: Part) => {
|
|
478
487
|
// In text-only mode, only send text parts (the ⬥ diamond messages)
|
|
479
|
-
if (
|
|
488
|
+
if (getVerbosity() === 'text-only' && part.type !== 'text') {
|
|
480
489
|
return
|
|
481
490
|
}
|
|
482
491
|
|
|
@@ -497,6 +506,7 @@ export async function handleOpencodeSession({
|
|
|
497
506
|
discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult)
|
|
498
507
|
return
|
|
499
508
|
}
|
|
509
|
+
hasSentParts = true
|
|
500
510
|
sentPartIds.add(part.id)
|
|
501
511
|
|
|
502
512
|
getDatabase()
|
|
@@ -588,6 +598,7 @@ export async function handleOpencodeSession({
|
|
|
588
598
|
if (msg.sessionID !== session.id) {
|
|
589
599
|
return
|
|
590
600
|
}
|
|
601
|
+
hasReceivedEvent = true
|
|
591
602
|
|
|
592
603
|
if (msg.role !== 'assistant') {
|
|
593
604
|
return
|
|
@@ -687,7 +698,7 @@ export async function handleOpencodeSession({
|
|
|
687
698
|
const label = `${agent}-${agentSpawnCounts[agent]}`
|
|
688
699
|
subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined })
|
|
689
700
|
// Skip task messages in text-only mode
|
|
690
|
-
if (
|
|
701
|
+
if (getVerbosity() !== 'text-only') {
|
|
691
702
|
const taskDisplay = `┣ task **${label}** _${description}_`
|
|
692
703
|
await sendThreadMessage(thread, taskDisplay + '\n\n')
|
|
693
704
|
}
|
|
@@ -697,7 +708,7 @@ export async function handleOpencodeSession({
|
|
|
697
708
|
return
|
|
698
709
|
}
|
|
699
710
|
|
|
700
|
-
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
711
|
+
if (part.type === 'tool' && part.state.status === 'completed' && getVerbosity() !== 'text-only') {
|
|
701
712
|
const output = part.state.output || ''
|
|
702
713
|
const outputTokens = Math.ceil(output.length / 4)
|
|
703
714
|
const largeOutputThreshold = 3000
|
|
@@ -751,7 +762,7 @@ export async function handleOpencodeSession({
|
|
|
751
762
|
subtaskInfo: { label: string; assistantMessageId?: string },
|
|
752
763
|
) => {
|
|
753
764
|
// In text-only mode, skip all subtask output (they're tool-related)
|
|
754
|
-
if (
|
|
765
|
+
if (getVerbosity() === 'text-only') {
|
|
755
766
|
return
|
|
756
767
|
}
|
|
757
768
|
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
@@ -837,13 +848,20 @@ export async function handleOpencodeSession({
|
|
|
837
848
|
}
|
|
838
849
|
|
|
839
850
|
const handlePermissionAsked = async (permission: PermissionRequest) => {
|
|
840
|
-
|
|
851
|
+
const isMainSession = permission.sessionID === session.id
|
|
852
|
+
const isSubtaskSession = subtaskSessions.has(permission.sessionID)
|
|
853
|
+
|
|
854
|
+
if (!isMainSession && !isSubtaskSession) {
|
|
841
855
|
voiceLogger.log(
|
|
842
|
-
`[PERMISSION IGNORED] Permission for
|
|
856
|
+
`[PERMISSION IGNORED] Permission for unknown session (expected: ${session.id} or subtask, got: ${permission.sessionID})`,
|
|
843
857
|
)
|
|
844
858
|
return
|
|
845
859
|
}
|
|
846
860
|
|
|
861
|
+
const subtaskLabel = isSubtaskSession
|
|
862
|
+
? subtaskSessions.get(permission.sessionID)?.label
|
|
863
|
+
: undefined
|
|
864
|
+
|
|
847
865
|
const dedupeKey = buildPermissionDedupeKey({ permission, directory })
|
|
848
866
|
const threadPermissions = pendingPermissions.get(thread.id)
|
|
849
867
|
const existingPending = threadPermissions
|
|
@@ -883,7 +901,7 @@ export async function handleOpencodeSession({
|
|
|
883
901
|
}
|
|
884
902
|
|
|
885
903
|
sessionLogger.log(
|
|
886
|
-
`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
|
|
904
|
+
`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}${subtaskLabel ? `, subtask=${subtaskLabel}` : ''}`,
|
|
887
905
|
)
|
|
888
906
|
|
|
889
907
|
if (stopTyping) {
|
|
@@ -895,6 +913,7 @@ export async function handleOpencodeSession({
|
|
|
895
913
|
thread,
|
|
896
914
|
permission,
|
|
897
915
|
directory,
|
|
916
|
+
subtaskLabel,
|
|
898
917
|
})
|
|
899
918
|
|
|
900
919
|
if (!pendingPermissions.has(thread.id)) {
|
|
@@ -918,7 +937,10 @@ export async function handleOpencodeSession({
|
|
|
918
937
|
reply: string
|
|
919
938
|
sessionID: string
|
|
920
939
|
}) => {
|
|
921
|
-
|
|
940
|
+
const isMainSession = sessionID === session.id
|
|
941
|
+
const isSubtaskSession = subtaskSessions.has(sessionID)
|
|
942
|
+
|
|
943
|
+
if (!isMainSession && !isSubtaskSession) {
|
|
922
944
|
return
|
|
923
945
|
}
|
|
924
946
|
|
|
@@ -989,10 +1011,11 @@ export async function handleOpencodeSession({
|
|
|
989
1011
|
)
|
|
990
1012
|
|
|
991
1013
|
setImmediate(() => {
|
|
1014
|
+
const prefixedPrompt = `<discord-user name="${nextMessage.username}" />\n${nextMessage.prompt}`
|
|
992
1015
|
void errore
|
|
993
1016
|
.tryAsync(async () => {
|
|
994
1017
|
return handleOpencodeSession({
|
|
995
|
-
prompt:
|
|
1018
|
+
prompt: prefixedPrompt,
|
|
996
1019
|
thread,
|
|
997
1020
|
projectDirectory: directory,
|
|
998
1021
|
images: nextMessage.images,
|
|
@@ -1048,16 +1071,14 @@ export async function handleOpencodeSession({
|
|
|
1048
1071
|
|
|
1049
1072
|
const handleSessionIdle = (idleSessionId: string) => {
|
|
1050
1073
|
if (idleSessionId === session.id) {
|
|
1051
|
-
|
|
1052
|
-
// (no assistantMessageId set), this is likely a stale event from before
|
|
1053
|
-
// the prompt was sent or from a previous request's subscription state.
|
|
1054
|
-
if (!assistantMessageId) {
|
|
1074
|
+
if (!promptResolved || !hasReceivedEvent) {
|
|
1055
1075
|
sessionLogger.log(
|
|
1056
|
-
`[SESSION IDLE] Ignoring
|
|
1076
|
+
`[SESSION IDLE] Ignoring idle event for ${session.id} (prompt not resolved or no events yet)`,
|
|
1057
1077
|
)
|
|
1058
1078
|
return
|
|
1059
1079
|
}
|
|
1060
|
-
sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle,
|
|
1080
|
+
sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, ending stream`)
|
|
1081
|
+
sessionLogger.log(`[ABORT] reason=finished sessionId=${session.id} threadId=${thread.id} - session completed normally, received idle event after prompt resolved`)
|
|
1061
1082
|
abortController.abort(new Error('finished'))
|
|
1062
1083
|
return
|
|
1063
1084
|
}
|
|
@@ -1203,8 +1224,9 @@ export async function handleOpencodeSession({
|
|
|
1203
1224
|
// Send the queued message as a new prompt (recursive call)
|
|
1204
1225
|
// Use setImmediate to avoid blocking and allow this finally to complete
|
|
1205
1226
|
setImmediate(() => {
|
|
1227
|
+
const prefixedPrompt = `<discord-user name="${nextMessage.username}" />\n${nextMessage.prompt}`
|
|
1206
1228
|
handleOpencodeSession({
|
|
1207
|
-
prompt:
|
|
1229
|
+
prompt: prefixedPrompt,
|
|
1208
1230
|
thread,
|
|
1209
1231
|
projectDirectory,
|
|
1210
1232
|
images: nextMessage.images,
|
|
@@ -1298,6 +1320,8 @@ export async function handleOpencodeSession({
|
|
|
1298
1320
|
}
|
|
1299
1321
|
: undefined
|
|
1300
1322
|
|
|
1323
|
+
hasSentParts = false
|
|
1324
|
+
|
|
1301
1325
|
const response = command
|
|
1302
1326
|
? await getClient().session.command({
|
|
1303
1327
|
path: { id: session.id },
|
|
@@ -1337,7 +1361,7 @@ export async function handleOpencodeSession({
|
|
|
1337
1361
|
throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`)
|
|
1338
1362
|
}
|
|
1339
1363
|
|
|
1340
|
-
|
|
1364
|
+
promptResolved = true
|
|
1341
1365
|
|
|
1342
1366
|
sessionLogger.log(`Successfully sent prompt, got response`)
|
|
1343
1367
|
|
|
@@ -1373,6 +1397,7 @@ export async function handleOpencodeSession({
|
|
|
1373
1397
|
}
|
|
1374
1398
|
|
|
1375
1399
|
sessionLogger.error(`ERROR: Failed to send prompt:`, promptError)
|
|
1400
|
+
sessionLogger.log(`[ABORT] reason=error sessionId=${session.id} threadId=${thread.id} - prompt failed with error: ${(promptError as Error).message}`)
|
|
1376
1401
|
abortController.abort(new Error('error'))
|
|
1377
1402
|
|
|
1378
1403
|
if (originalMessage) {
|
package/src/system-message.ts
CHANGED
|
@@ -133,19 +133,21 @@ headings are discouraged anyway. instead try to use bold text for titles which r
|
|
|
133
133
|
|
|
134
134
|
you can create diagrams wrapping them in code blocks.
|
|
135
135
|
|
|
136
|
-
##
|
|
136
|
+
## proactivity
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
Be proactive. When the user asks you to do something, do it. Do NOT stop to ask for confirmation.
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
Only ask questions when the request is genuinely ambiguous with multiple valid approaches, or the action is destructive and irreversible.
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
- After showing a plan: offer "Start implementing?" with Yes/No options
|
|
144
|
-
- After completing edits: offer "Commit changes?" with Yes/No options
|
|
145
|
-
- After debugging: offer "How to proceed?" with options like "Apply fix", "Investigate further", "Try different approach"
|
|
142
|
+
## ending conversations with options
|
|
146
143
|
|
|
147
|
-
|
|
144
|
+
After **completing** a task, use the question tool to offer follow-up options. The question tool must be called last, after all text parts.
|
|
148
145
|
|
|
149
|
-
|
|
146
|
+
IMPORTANT: Do NOT use the question tool to ask permission before doing work. Do the work first, then offer follow-ups.
|
|
147
|
+
|
|
148
|
+
Examples:
|
|
149
|
+
- After completing edits: offer "Commit changes?" or "Run tests?"
|
|
150
|
+
- After debugging: offer "Apply fix", "Investigate further", "Try different approach"
|
|
151
|
+
- After a genuinely ambiguous request where you cannot infer intent: offer the different approaches
|
|
150
152
|
`
|
|
151
153
|
}
|
package/src/tools.ts
CHANGED
|
@@ -343,6 +343,7 @@ export async function getTools({
|
|
|
343
343
|
}),
|
|
344
344
|
execute: async ({ sessionId }) => {
|
|
345
345
|
try {
|
|
346
|
+
toolsLogger.log(`[ABORT] reason=voice-tool sessionId=${sessionId} - user requested abort via voice assistant tool`)
|
|
346
347
|
const result = await getClient().session.abort({
|
|
347
348
|
path: { id: sessionId },
|
|
348
349
|
})
|
package/src/utils.ts
CHANGED
|
@@ -73,6 +73,7 @@ export function isAbortError(error: unknown, signal?: AbortSignal): error is Err
|
|
|
73
73
|
error.name === 'Aborterror' ||
|
|
74
74
|
error.name === 'aborterror' ||
|
|
75
75
|
error.name.toLowerCase() === 'aborterror' ||
|
|
76
|
+
error.name === 'MessageAbortedError' ||
|
|
76
77
|
error.message?.includes('aborted') ||
|
|
77
78
|
(signal?.aborted ?? false))) ||
|
|
78
79
|
(error instanceof DOMException && error.name === 'AbortError')
|