kimaki 0.4.38 → 0.4.40

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.
Files changed (55) hide show
  1. package/dist/cli.js +27 -23
  2. package/dist/commands/abort.js +15 -6
  3. package/dist/commands/add-project.js +9 -0
  4. package/dist/commands/agent.js +13 -1
  5. package/dist/commands/fork.js +13 -2
  6. package/dist/commands/model.js +12 -0
  7. package/dist/commands/remove-project.js +26 -16
  8. package/dist/commands/resume.js +9 -0
  9. package/dist/commands/session.js +14 -1
  10. package/dist/commands/share.js +10 -1
  11. package/dist/commands/undo-redo.js +13 -4
  12. package/dist/commands/worktree.js +180 -0
  13. package/dist/database.js +57 -5
  14. package/dist/discord-bot.js +48 -10
  15. package/dist/discord-utils.js +36 -0
  16. package/dist/errors.js +109 -0
  17. package/dist/genai-worker.js +18 -16
  18. package/dist/interaction-handler.js +6 -2
  19. package/dist/markdown.js +100 -85
  20. package/dist/markdown.test.js +10 -3
  21. package/dist/message-formatting.js +50 -37
  22. package/dist/opencode.js +43 -46
  23. package/dist/session-handler.js +100 -2
  24. package/dist/system-message.js +2 -0
  25. package/dist/tools.js +18 -8
  26. package/dist/voice-handler.js +48 -25
  27. package/dist/voice.js +159 -131
  28. package/package.json +4 -2
  29. package/src/cli.ts +31 -32
  30. package/src/commands/abort.ts +17 -7
  31. package/src/commands/add-project.ts +9 -0
  32. package/src/commands/agent.ts +13 -1
  33. package/src/commands/fork.ts +18 -7
  34. package/src/commands/model.ts +12 -0
  35. package/src/commands/remove-project.ts +28 -16
  36. package/src/commands/resume.ts +9 -0
  37. package/src/commands/session.ts +14 -1
  38. package/src/commands/share.ts +11 -1
  39. package/src/commands/undo-redo.ts +15 -6
  40. package/src/commands/worktree.ts +243 -0
  41. package/src/database.ts +104 -4
  42. package/src/discord-bot.ts +49 -9
  43. package/src/discord-utils.ts +50 -0
  44. package/src/errors.ts +138 -0
  45. package/src/genai-worker.ts +20 -17
  46. package/src/interaction-handler.ts +7 -2
  47. package/src/markdown.test.ts +13 -3
  48. package/src/markdown.ts +112 -95
  49. package/src/message-formatting.ts +55 -38
  50. package/src/opencode.ts +52 -49
  51. package/src/session-handler.ts +118 -3
  52. package/src/system-message.ts +2 -0
  53. package/src/tools.ts +18 -8
  54. package/src/voice-handler.ts +48 -23
  55. package/src/voice.ts +195 -148
@@ -30,6 +30,7 @@ import {
30
30
  pendingQuestionContexts,
31
31
  } from './commands/ask-question.js'
32
32
  import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js'
33
+ import * as errore from 'errore'
33
34
 
34
35
  const sessionLogger = createLogger('SESSION')
35
36
  const voiceLogger = createLogger('VOICE')
@@ -104,6 +105,10 @@ export async function abortAndRetrySession({
104
105
 
105
106
  // Also call the API abort endpoint
106
107
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
108
+ if (getClient instanceof Error) {
109
+ sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message)
110
+ return false
111
+ }
107
112
  try {
108
113
  await getClient().session.abort({ path: { id: sessionId } })
109
114
  } catch (e) {
@@ -183,6 +188,10 @@ export async function handleOpencodeSession({
183
188
  sessionLogger.log(`Using directory: ${directory}`)
184
189
 
185
190
  const getClient = await initializeOpencodeForDirectory(directory)
191
+ if (getClient instanceof Error) {
192
+ await sendThreadMessage(thread, `✗ ${getClient.message}`)
193
+ return
194
+ }
186
195
 
187
196
  const serverEntry = getOpencodeServers().get(directory)
188
197
  const port = serverEntry?.port
@@ -392,6 +401,11 @@ export async function handleOpencodeSession({
392
401
  }
393
402
 
394
403
  const eventHandler = async () => {
404
+ // Subtask tracking: child sessionId → { label, assistantMessageId }
405
+ const subtaskSessions = new Map<string, { label: string; assistantMessageId?: string }>()
406
+ // Counts spawned tasks per agent type: "explore" → 2
407
+ const agentSpawnCounts: Record<string, number> = {}
408
+
395
409
  try {
396
410
  let assistantMessageId: string | undefined
397
411
 
@@ -399,6 +413,12 @@ export async function handleOpencodeSession({
399
413
  if (event.type === 'message.updated') {
400
414
  const msg = event.properties.info
401
415
 
416
+ // Track assistant message IDs for subtask sessions
417
+ const subtaskInfo = subtaskSessions.get(msg.sessionID)
418
+ if (subtaskInfo && msg.role === 'assistant') {
419
+ subtaskInfo.assistantMessageId = msg.id
420
+ }
421
+
402
422
  if (msg.sessionID !== session.id) {
403
423
  continue
404
424
  }
@@ -451,10 +471,55 @@ export async function handleOpencodeSession({
451
471
  } else if (event.type === 'message.part.updated') {
452
472
  const part = event.properties.part
453
473
 
454
- if (part.sessionID !== session.id) {
474
+ // Check if this is a subtask event (child session we're tracking)
475
+ const subtaskInfo = subtaskSessions.get(part.sessionID)
476
+ const isSubtaskEvent = Boolean(subtaskInfo)
477
+
478
+ // Accept events from main session OR tracked subtask sessions
479
+ if (part.sessionID !== session.id && !isSubtaskEvent) {
455
480
  continue
456
481
  }
457
482
 
483
+ // For subtask events, send them immediately with prefix (don't buffer in currentParts)
484
+ if (isSubtaskEvent && subtaskInfo) {
485
+ // Skip parts that aren't useful to show (step-start, step-finish, pending tools)
486
+ if (part.type === 'step-start' || part.type === 'step-finish') {
487
+ continue
488
+ }
489
+ if (part.type === 'tool' && part.state.status === 'pending') {
490
+ continue
491
+ }
492
+ // Skip text parts - the outer agent will report the task result anyway
493
+ if (part.type === 'text') {
494
+ continue
495
+ }
496
+ // Only show parts from assistant messages (not user prompts sent to subtask)
497
+ // Skip if we haven't seen an assistant message yet, or if this part is from a different message
498
+ if (
499
+ !subtaskInfo.assistantMessageId ||
500
+ part.messageID !== subtaskInfo.assistantMessageId
501
+ ) {
502
+ continue
503
+ }
504
+
505
+ const content = formatPart(part, subtaskInfo.label)
506
+ if (content.trim() && !sentPartIds.has(part.id)) {
507
+ try {
508
+ const msg = await sendThreadMessage(thread, content + '\n\n')
509
+ sentPartIds.add(part.id)
510
+ getDatabase()
511
+ .prepare(
512
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
513
+ )
514
+ .run(part.id, msg.id, thread.id)
515
+ } catch (error) {
516
+ discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, error)
517
+ }
518
+ }
519
+ continue
520
+ }
521
+
522
+ // Main session events: require matching assistantMessageId
458
523
  if (part.messageID !== assistantMessageId) {
459
524
  continue
460
525
  }
@@ -486,6 +551,20 @@ export async function handleOpencodeSession({
486
551
  }
487
552
  }
488
553
  await sendPartMessage(part)
554
+ // Track task tool and register child session when sessionId is available
555
+ if (part.tool === 'task' && !sentPartIds.has(part.id)) {
556
+ const description = (part.state.input?.description as string) || ''
557
+ const agent = (part.state.input?.subagent_type as string) || 'task'
558
+ const childSessionId = (part.state.metadata?.sessionId as string) || ''
559
+ if (description && childSessionId) {
560
+ agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1
561
+ const label = `${agent}-${agentSpawnCounts[agent]}`
562
+ subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined })
563
+ const taskDisplay = `┣ task **${label}** _${description}_`
564
+ await sendThreadMessage(thread, taskDisplay + '\n\n')
565
+ sentPartIds.add(part.id)
566
+ }
567
+ }
489
568
  }
490
569
 
491
570
  // Show token usage for completed tools with large output (>5k tokens)
@@ -538,6 +617,7 @@ export async function handleOpencodeSession({
538
617
  stopTyping = startTyping()
539
618
  }, 300)
540
619
  }
620
+
541
621
  } else if (event.type === 'session.error') {
542
622
  sessionLogger.error(`ERROR:`, event.properties)
543
623
  if (event.properties.sessionID === session.id) {
@@ -670,15 +750,24 @@ export async function handleOpencodeSession({
670
750
  }).catch(async (e) => {
671
751
  sessionLogger.error(`[QUEUE] Failed to process queued message:`, e)
672
752
  const errorMsg = e instanceof Error ? e.message : String(e)
673
- await sendThreadMessage(thread, `✗ Queued message failed: ${errorMsg.slice(0, 200)}`)
753
+ await sendThreadMessage(
754
+ thread,
755
+ `✗ Queued message failed: ${errorMsg.slice(0, 200)}`,
756
+ )
674
757
  })
675
758
  })
676
759
  }
677
760
  } else if (event.type === 'session.idle') {
761
+ const idleSessionId = event.properties.sessionID
678
762
  // Session is done processing - abort to signal completion
679
- if (event.properties.sessionID === session.id) {
763
+ if (idleSessionId === session.id) {
680
764
  sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`)
681
765
  abortController.abort('finished')
766
+ } else if (subtaskSessions.has(idleSessionId)) {
767
+ // Child session completed - clean up tracking
768
+ const subtask = subtaskSessions.get(idleSessionId)
769
+ sessionLogger.log(`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`)
770
+ subtaskSessions.delete(idleSessionId)
682
771
  }
683
772
  }
684
773
  }
@@ -714,6 +803,32 @@ export async function handleOpencodeSession({
714
803
  let contextInfo = ''
715
804
 
716
805
  try {
806
+ // Fetch final token count from API since message.updated events can arrive
807
+ // after session.idle due to race conditions in event ordering
808
+ if (tokensUsedInSession === 0) {
809
+ const messagesResponse = await getClient().session.messages({
810
+ path: { id: session.id },
811
+ })
812
+ const messages = messagesResponse.data || []
813
+ const lastAssistant = [...messages]
814
+ .reverse()
815
+ .find((m) => m.info.role === 'assistant')
816
+ if (lastAssistant && 'tokens' in lastAssistant.info) {
817
+ const tokens = lastAssistant.info.tokens as {
818
+ input: number
819
+ output: number
820
+ reasoning: number
821
+ cache: { read: number; write: number }
822
+ }
823
+ tokensUsedInSession =
824
+ tokens.input +
825
+ tokens.output +
826
+ tokens.reasoning +
827
+ tokens.cache.read +
828
+ tokens.cache.write
829
+ }
830
+ }
831
+
717
832
  const providersResponse = await getClient().provider.list({ query: { directory } })
718
833
  const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
719
834
  const model = provider?.models?.[usedModel || '']
@@ -85,6 +85,8 @@ you can create diagrams wrapping them in code blocks.
85
85
 
86
86
  IMPORTANT: At the end of each response, especially after completing a task or presenting a plan, use the question tool to offer the user clear options for what to do next.
87
87
 
88
+ IMPORTANT: The question tool must be called last, after all text parts. If it is called before your final text response, the user will not see the text.
89
+
88
90
  Examples:
89
91
  - After showing a plan: offer "Start implementing?" with Yes/No options
90
92
  - After completing edits: offer "Commit changes?" with Yes/No options
package/src/tools.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  type Provider,
14
14
  } from '@opencode-ai/sdk'
15
15
  import { createLogger } from './logger.js'
16
+ import * as errore from 'errore'
16
17
 
17
18
  const toolsLogger = createLogger('TOOLS')
18
19
 
@@ -35,6 +36,9 @@ export async function getTools({
35
36
  }) => void
36
37
  }) {
37
38
  const getClient = await initializeOpencodeForDirectory(directory)
39
+ if (getClient instanceof Error) {
40
+ throw new Error(getClient.message)
41
+ }
38
42
  const client = getClient()
39
43
 
40
44
  const markdownRenderer = new ShareMarkdown(client)
@@ -83,7 +87,7 @@ export async function getTools({
83
87
  },
84
88
  })
85
89
  .then(async (response) => {
86
- const markdown = await markdownRenderer.generate({
90
+ const markdownResult = await markdownRenderer.generate({
87
91
  sessionID: sessionId,
88
92
  lastAssistantOnly: true,
89
93
  })
@@ -91,7 +95,7 @@ export async function getTools({
91
95
  sessionId,
92
96
  messageId: '',
93
97
  data: response.data,
94
- markdown,
98
+ markdown: errore.unwrapOr(markdownResult, ''),
95
99
  })
96
100
  })
97
101
  .catch((error) => {
@@ -149,7 +153,7 @@ export async function getTools({
149
153
  },
150
154
  })
151
155
  .then(async (response) => {
152
- const markdown = await markdownRenderer.generate({
156
+ const markdownResult = await markdownRenderer.generate({
153
157
  sessionID: session.data.id,
154
158
  lastAssistantOnly: true,
155
159
  })
@@ -157,7 +161,7 @@ export async function getTools({
157
161
  sessionId: session.data.id,
158
162
  messageId: '',
159
163
  data: response.data,
160
- markdown,
164
+ markdown: errore.unwrapOr(markdownResult, ''),
161
165
  })
162
166
  })
163
167
  .catch((error) => {
@@ -290,20 +294,26 @@ export async function getTools({
290
294
  ? 'completed'
291
295
  : 'in_progress'
292
296
 
293
- const markdown = await markdownRenderer.generate({
297
+ const markdownResult = await markdownRenderer.generate({
294
298
  sessionID: sessionId,
295
299
  lastAssistantOnly: true,
296
300
  })
301
+ if (markdownResult instanceof Error) {
302
+ throw new Error(markdownResult.message)
303
+ }
297
304
 
298
305
  return {
299
306
  success: true,
300
- markdown,
307
+ markdown: markdownResult,
301
308
  status,
302
309
  }
303
310
  } else {
304
- const markdown = await markdownRenderer.generate({
311
+ const markdownResult = await markdownRenderer.generate({
305
312
  sessionID: sessionId,
306
313
  })
314
+ if (markdownResult instanceof Error) {
315
+ throw new Error(markdownResult.message)
316
+ }
307
317
 
308
318
  const messages = await getClient().session.messages({
309
319
  path: { id: sessionId },
@@ -319,7 +329,7 @@ export async function getTools({
319
329
 
320
330
  return {
321
331
  success: true,
322
- markdown,
332
+ markdown: markdownResult,
323
333
  status,
324
334
  }
325
335
  }
@@ -1,6 +1,7 @@
1
1
  // Discord voice channel connection and audio stream handler.
2
2
  // Manages joining/leaving voice channels, captures user audio, resamples to 16kHz,
3
3
  // and routes audio to the GenAI worker for real-time voice assistant interactions.
4
+ import * as errore from 'errore'
4
5
 
5
6
  import {
6
7
  VoiceConnectionStatus,
@@ -34,6 +35,8 @@ import {
34
35
  SILENT_MESSAGE_FLAGS,
35
36
  } from './discord-utils.js'
36
37
  import { transcribeAudio } from './voice.js'
38
+ import { FetchError } from './errors.js'
39
+
37
40
  import { createLogger } from './logger.js'
38
41
 
39
42
  const voiceLogger = createLogger('VOICE')
@@ -443,7 +446,15 @@ export async function processVoiceAttachment({
443
446
 
444
447
  await sendThreadMessage(thread, '🎤 Transcribing voice message...')
445
448
 
446
- const audioResponse = await fetch(audioAttachment.url)
449
+ const audioResponse = await errore.tryAsync({
450
+ try: () => fetch(audioAttachment.url),
451
+ catch: (e) => new FetchError({ url: audioAttachment.url, cause: e }),
452
+ })
453
+ if (audioResponse instanceof Error) {
454
+ voiceLogger.error(`Failed to download audio attachment:`, audioResponse.message)
455
+ await sendThreadMessage(thread, `⚠️ Failed to download audio: ${audioResponse.message}`)
456
+ return null
457
+ }
447
458
  const audioBuffer = Buffer.from(await audioResponse.arrayBuffer())
448
459
 
449
460
  voiceLogger.log(`Downloaded ${audioBuffer.length} bytes, transcribing...`)
@@ -457,10 +468,9 @@ export async function processVoiceAttachment({
457
468
  const { stdout } = await execAsync('git ls-files | tree --fromfile -a', {
458
469
  cwd: projectDirectory,
459
470
  })
460
- const result = stdout
461
471
 
462
- if (result) {
463
- transcriptionPrompt = `Discord voice message transcription. Project file structure:\n${result}\n\nPlease transcribe file names and paths accurately based on this context.`
472
+ if (stdout) {
473
+ transcriptionPrompt = `Discord voice message transcription. Project file structure:\n${stdout}\n\nPlease transcribe file names and paths accurately based on this context.`
464
474
  voiceLogger.log(`Added project context to transcription prompt`)
465
475
  }
466
476
  } catch (e) {
@@ -479,19 +489,25 @@ export async function processVoiceAttachment({
479
489
  }
480
490
  }
481
491
 
482
- let transcription: string
483
- try {
484
- transcription = await transcribeAudio({
485
- audio: audioBuffer,
486
- prompt: transcriptionPrompt,
487
- geminiApiKey,
488
- directory: projectDirectory,
489
- currentSessionContext,
490
- lastSessionContext,
492
+ const transcription = await transcribeAudio({
493
+ audio: audioBuffer,
494
+ prompt: transcriptionPrompt,
495
+ geminiApiKey,
496
+ directory: projectDirectory,
497
+ currentSessionContext,
498
+ lastSessionContext,
499
+ })
500
+
501
+ if (transcription instanceof Error) {
502
+ const errMsg = errore.matchError(transcription, {
503
+ ApiKeyMissingError: (e) => e.message,
504
+ InvalidAudioFormatError: (e) => e.message,
505
+ TranscriptionError: (e) => e.message,
506
+ EmptyTranscriptionError: (e) => e.message,
507
+ NoResponseContentError: (e) => e.message,
508
+ NoToolResponseError: (e) => e.message,
491
509
  })
492
- } catch (error) {
493
- const errMsg = error instanceof Error ? error.message : String(error)
494
- voiceLogger.error(`Transcription failed:`, error)
510
+ voiceLogger.error(`Transcription failed:`, transcription)
495
511
  await sendThreadMessage(thread, `⚠️ Transcription failed: ${errMsg}`)
496
512
  return null
497
513
  }
@@ -503,14 +519,23 @@ export async function processVoiceAttachment({
503
519
  if (isNewThread) {
504
520
  const threadName = transcription.replace(/\s+/g, ' ').trim().slice(0, 80)
505
521
  if (threadName) {
506
- try {
507
- await Promise.race([
508
- thread.setName(threadName),
509
- new Promise((resolve) => setTimeout(resolve, 2000)),
510
- ])
522
+ const renamed = await Promise.race([
523
+ errore.tryAsync({
524
+ try: () => thread.setName(threadName),
525
+ catch: (e) => e as Error,
526
+ }),
527
+ new Promise<null>((resolve) => {
528
+ setTimeout(() => {
529
+ resolve(null)
530
+ }, 2000)
531
+ }),
532
+ ])
533
+ if (renamed === null) {
534
+ voiceLogger.log(`Thread name update timed out`)
535
+ } else if (renamed instanceof Error) {
536
+ voiceLogger.log(`Could not update thread name:`, renamed.message)
537
+ } else {
511
538
  voiceLogger.log(`Updated thread name to: "${threadName}"`)
512
- } catch (e) {
513
- voiceLogger.log(`Could not update thread name:`, e)
514
539
  }
515
540
  }
516
541
  }