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
@@ -11,6 +11,7 @@ import { createLogger } from './logger.js';
11
11
  import { isAbortError } from './utils.js';
12
12
  import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts, } from './commands/ask-question.js';
13
13
  import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js';
14
+ import * as errore from 'errore';
14
15
  const sessionLogger = createLogger('SESSION');
15
16
  const voiceLogger = createLogger('VOICE');
16
17
  const discordLogger = createLogger('DISCORD');
@@ -48,6 +49,10 @@ export async function abortAndRetrySession({ sessionId, thread, projectDirectory
48
49
  controller.abort('model-change');
49
50
  // Also call the API abort endpoint
50
51
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
52
+ if (getClient instanceof Error) {
53
+ sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message);
54
+ return false;
55
+ }
51
56
  try {
52
57
  await getClient().session.abort({ path: { id: sessionId } });
53
58
  }
@@ -93,6 +98,10 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
93
98
  const directory = projectDirectory || process.cwd();
94
99
  sessionLogger.log(`Using directory: ${directory}`);
95
100
  const getClient = await initializeOpencodeForDirectory(directory);
101
+ if (getClient instanceof Error) {
102
+ await sendThreadMessage(thread, `✗ ${getClient.message}`);
103
+ return;
104
+ }
96
105
  const serverEntry = getOpencodeServers().get(directory);
97
106
  const port = serverEntry?.port;
98
107
  const row = getDatabase()
@@ -258,11 +267,20 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
258
267
  }
259
268
  };
260
269
  const eventHandler = async () => {
270
+ // Subtask tracking: child sessionId → { label, assistantMessageId }
271
+ const subtaskSessions = new Map();
272
+ // Counts spawned tasks per agent type: "explore" → 2
273
+ const agentSpawnCounts = {};
261
274
  try {
262
275
  let assistantMessageId;
263
276
  for await (const event of events) {
264
277
  if (event.type === 'message.updated') {
265
278
  const msg = event.properties.info;
279
+ // Track assistant message IDs for subtask sessions
280
+ const subtaskInfo = subtaskSessions.get(msg.sessionID);
281
+ if (subtaskInfo && msg.role === 'assistant') {
282
+ subtaskInfo.assistantMessageId = msg.id;
283
+ }
266
284
  if (msg.sessionID !== session.id) {
267
285
  continue;
268
286
  }
@@ -309,9 +327,48 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
309
327
  }
310
328
  else if (event.type === 'message.part.updated') {
311
329
  const part = event.properties.part;
312
- if (part.sessionID !== session.id) {
330
+ // Check if this is a subtask event (child session we're tracking)
331
+ const subtaskInfo = subtaskSessions.get(part.sessionID);
332
+ const isSubtaskEvent = Boolean(subtaskInfo);
333
+ // Accept events from main session OR tracked subtask sessions
334
+ if (part.sessionID !== session.id && !isSubtaskEvent) {
335
+ continue;
336
+ }
337
+ // For subtask events, send them immediately with prefix (don't buffer in currentParts)
338
+ if (isSubtaskEvent && subtaskInfo) {
339
+ // Skip parts that aren't useful to show (step-start, step-finish, pending tools)
340
+ if (part.type === 'step-start' || part.type === 'step-finish') {
341
+ continue;
342
+ }
343
+ if (part.type === 'tool' && part.state.status === 'pending') {
344
+ continue;
345
+ }
346
+ // Skip text parts - the outer agent will report the task result anyway
347
+ if (part.type === 'text') {
348
+ continue;
349
+ }
350
+ // Only show parts from assistant messages (not user prompts sent to subtask)
351
+ // Skip if we haven't seen an assistant message yet, or if this part is from a different message
352
+ if (!subtaskInfo.assistantMessageId ||
353
+ part.messageID !== subtaskInfo.assistantMessageId) {
354
+ continue;
355
+ }
356
+ const content = formatPart(part, subtaskInfo.label);
357
+ if (content.trim() && !sentPartIds.has(part.id)) {
358
+ try {
359
+ const msg = await sendThreadMessage(thread, content + '\n\n');
360
+ sentPartIds.add(part.id);
361
+ getDatabase()
362
+ .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
363
+ .run(part.id, msg.id, thread.id);
364
+ }
365
+ catch (error) {
366
+ discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, error);
367
+ }
368
+ }
313
369
  continue;
314
370
  }
371
+ // Main session events: require matching assistantMessageId
315
372
  if (part.messageID !== assistantMessageId) {
316
373
  continue;
317
374
  }
@@ -339,6 +396,20 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
339
396
  }
340
397
  }
341
398
  await sendPartMessage(part);
399
+ // Track task tool and register child session when sessionId is available
400
+ if (part.tool === 'task' && !sentPartIds.has(part.id)) {
401
+ const description = part.state.input?.description || '';
402
+ const agent = part.state.input?.subagent_type || 'task';
403
+ const childSessionId = part.state.metadata?.sessionId || '';
404
+ if (description && childSessionId) {
405
+ agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1;
406
+ const label = `${agent}-${agentSpawnCounts[agent]}`;
407
+ subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined });
408
+ const taskDisplay = `┣ task **${label}** _${description}_`;
409
+ await sendThreadMessage(thread, taskDisplay + '\n\n');
410
+ sentPartIds.add(part.id);
411
+ }
412
+ }
342
413
  }
343
414
  // Show token usage for completed tools with large output (>5k tokens)
344
415
  if (part.type === 'tool' && part.state.status === 'completed') {
@@ -499,11 +570,18 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
499
570
  }
500
571
  }
501
572
  else if (event.type === 'session.idle') {
573
+ const idleSessionId = event.properties.sessionID;
502
574
  // Session is done processing - abort to signal completion
503
- if (event.properties.sessionID === session.id) {
575
+ if (idleSessionId === session.id) {
504
576
  sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`);
505
577
  abortController.abort('finished');
506
578
  }
579
+ else if (subtaskSessions.has(idleSessionId)) {
580
+ // Child session completed - clean up tracking
581
+ const subtask = subtaskSessions.get(idleSessionId);
582
+ sessionLogger.log(`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`);
583
+ subtaskSessions.delete(idleSessionId);
584
+ }
507
585
  }
508
586
  }
509
587
  }
@@ -537,6 +615,26 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
537
615
  const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : '';
538
616
  let contextInfo = '';
539
617
  try {
618
+ // Fetch final token count from API since message.updated events can arrive
619
+ // after session.idle due to race conditions in event ordering
620
+ if (tokensUsedInSession === 0) {
621
+ const messagesResponse = await getClient().session.messages({
622
+ path: { id: session.id },
623
+ });
624
+ const messages = messagesResponse.data || [];
625
+ const lastAssistant = [...messages]
626
+ .reverse()
627
+ .find((m) => m.info.role === 'assistant');
628
+ if (lastAssistant && 'tokens' in lastAssistant.info) {
629
+ const tokens = lastAssistant.info.tokens;
630
+ tokensUsedInSession =
631
+ tokens.input +
632
+ tokens.output +
633
+ tokens.reasoning +
634
+ tokens.cache.read +
635
+ tokens.cache.write;
636
+ }
637
+ }
540
638
  const providersResponse = await getClient().provider.list({ query: { directory } });
541
639
  const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
542
640
  const model = provider?.models?.[usedModel || ''];
@@ -76,6 +76,8 @@ you can create diagrams wrapping them in code blocks.
76
76
 
77
77
  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.
78
78
 
79
+ 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.
80
+
79
81
  Examples:
80
82
  - After showing a plan: offer "Start implementing?" with Yes/No options
81
83
  - After completing edits: offer "Commit changes?" with Yes/No options
package/dist/tools.js CHANGED
@@ -7,6 +7,7 @@ import { spawn } from 'node:child_process';
7
7
  import net from 'node:net';
8
8
  import { createOpencodeClient, } from '@opencode-ai/sdk';
9
9
  import { createLogger } from './logger.js';
10
+ import * as errore from 'errore';
10
11
  const toolsLogger = createLogger('TOOLS');
11
12
  import { ShareMarkdown } from './markdown.js';
12
13
  import { formatDistanceToNow } from './utils.js';
@@ -14,6 +15,9 @@ import pc from 'picocolors';
14
15
  import { initializeOpencodeForDirectory, getOpencodeSystemMessage } from './discord-bot.js';
15
16
  export async function getTools({ onMessageCompleted, directory, }) {
16
17
  const getClient = await initializeOpencodeForDirectory(directory);
18
+ if (getClient instanceof Error) {
19
+ throw new Error(getClient.message);
20
+ }
17
21
  const client = getClient();
18
22
  const markdownRenderer = new ShareMarkdown(client);
19
23
  const providersResponse = await client.config.providers({});
@@ -55,7 +59,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
55
59
  },
56
60
  })
57
61
  .then(async (response) => {
58
- const markdown = await markdownRenderer.generate({
62
+ const markdownResult = await markdownRenderer.generate({
59
63
  sessionID: sessionId,
60
64
  lastAssistantOnly: true,
61
65
  });
@@ -63,7 +67,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
63
67
  sessionId,
64
68
  messageId: '',
65
69
  data: response.data,
66
- markdown,
70
+ markdown: errore.unwrapOr(markdownResult, ''),
67
71
  });
68
72
  })
69
73
  .catch((error) => {
@@ -116,7 +120,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
116
120
  },
117
121
  })
118
122
  .then(async (response) => {
119
- const markdown = await markdownRenderer.generate({
123
+ const markdownResult = await markdownRenderer.generate({
120
124
  sessionID: session.data.id,
121
125
  lastAssistantOnly: true,
122
126
  });
@@ -124,7 +128,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
124
128
  sessionId: session.data.id,
125
129
  messageId: '',
126
130
  data: response.data,
127
- markdown,
131
+ markdown: errore.unwrapOr(markdownResult, ''),
128
132
  });
129
133
  })
130
134
  .catch((error) => {
@@ -240,20 +244,26 @@ export async function getTools({ onMessageCompleted, directory, }) {
240
244
  const status = 'completed' in lastMessage.info.time && lastMessage.info.time.completed
241
245
  ? 'completed'
242
246
  : 'in_progress';
243
- const markdown = await markdownRenderer.generate({
247
+ const markdownResult = await markdownRenderer.generate({
244
248
  sessionID: sessionId,
245
249
  lastAssistantOnly: true,
246
250
  });
251
+ if (markdownResult instanceof Error) {
252
+ throw new Error(markdownResult.message);
253
+ }
247
254
  return {
248
255
  success: true,
249
- markdown,
256
+ markdown: markdownResult,
250
257
  status,
251
258
  };
252
259
  }
253
260
  else {
254
- const markdown = await markdownRenderer.generate({
261
+ const markdownResult = await markdownRenderer.generate({
255
262
  sessionID: sessionId,
256
263
  });
264
+ if (markdownResult instanceof Error) {
265
+ throw new Error(markdownResult.message);
266
+ }
257
267
  const messages = await getClient().session.messages({
258
268
  path: { id: sessionId },
259
269
  });
@@ -266,7 +276,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
266
276
  : 'completed';
267
277
  return {
268
278
  success: true,
269
- markdown,
279
+ markdown: markdownResult,
270
280
  status,
271
281
  };
272
282
  }
@@ -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
  import { VoiceConnectionStatus, EndBehaviorType, joinVoiceChannel, entersState, } from '@discordjs/voice';
5
6
  import { exec } from 'node:child_process';
6
7
  import fs, { createWriteStream } from 'node:fs';
@@ -15,6 +16,7 @@ import { createGenAIWorker } from './genai-worker-wrapper.js';
15
16
  import { getDatabase } from './database.js';
16
17
  import { sendThreadMessage, escapeDiscordFormatting, SILENT_MESSAGE_FLAGS, } from './discord-utils.js';
17
18
  import { transcribeAudio } from './voice.js';
19
+ import { FetchError } from './errors.js';
18
20
  import { createLogger } from './logger.js';
19
21
  const voiceLogger = createLogger('VOICE');
20
22
  export const voiceConnections = new Map();
@@ -320,7 +322,15 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
320
322
  return null;
321
323
  voiceLogger.log(`Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`);
322
324
  await sendThreadMessage(thread, '🎤 Transcribing voice message...');
323
- const audioResponse = await fetch(audioAttachment.url);
325
+ const audioResponse = await errore.tryAsync({
326
+ try: () => fetch(audioAttachment.url),
327
+ catch: (e) => new FetchError({ url: audioAttachment.url, cause: e }),
328
+ });
329
+ if (audioResponse instanceof Error) {
330
+ voiceLogger.error(`Failed to download audio attachment:`, audioResponse.message);
331
+ await sendThreadMessage(thread, `⚠️ Failed to download audio: ${audioResponse.message}`);
332
+ return null;
333
+ }
324
334
  const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
325
335
  voiceLogger.log(`Downloaded ${audioBuffer.length} bytes, transcribing...`);
326
336
  let transcriptionPrompt = 'Discord voice message transcription';
@@ -331,9 +341,8 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
331
341
  const { stdout } = await execAsync('git ls-files | tree --fromfile -a', {
332
342
  cwd: projectDirectory,
333
343
  });
334
- const result = stdout;
335
- if (result) {
336
- transcriptionPrompt = `Discord voice message transcription. Project file structure:\n${result}\n\nPlease transcribe file names and paths accurately based on this context.`;
344
+ if (stdout) {
345
+ transcriptionPrompt = `Discord voice message transcription. Project file structure:\n${stdout}\n\nPlease transcribe file names and paths accurately based on this context.`;
337
346
  voiceLogger.log(`Added project context to transcription prompt`);
338
347
  }
339
348
  }
@@ -350,20 +359,24 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
350
359
  geminiApiKey = apiKeys.gemini_api_key;
351
360
  }
352
361
  }
353
- let transcription;
354
- try {
355
- transcription = await transcribeAudio({
356
- audio: audioBuffer,
357
- prompt: transcriptionPrompt,
358
- geminiApiKey,
359
- directory: projectDirectory,
360
- currentSessionContext,
361
- lastSessionContext,
362
+ const transcription = await transcribeAudio({
363
+ audio: audioBuffer,
364
+ prompt: transcriptionPrompt,
365
+ geminiApiKey,
366
+ directory: projectDirectory,
367
+ currentSessionContext,
368
+ lastSessionContext,
369
+ });
370
+ if (transcription instanceof Error) {
371
+ const errMsg = errore.matchError(transcription, {
372
+ ApiKeyMissingError: (e) => e.message,
373
+ InvalidAudioFormatError: (e) => e.message,
374
+ TranscriptionError: (e) => e.message,
375
+ EmptyTranscriptionError: (e) => e.message,
376
+ NoResponseContentError: (e) => e.message,
377
+ NoToolResponseError: (e) => e.message,
362
378
  });
363
- }
364
- catch (error) {
365
- const errMsg = error instanceof Error ? error.message : String(error);
366
- voiceLogger.error(`Transcription failed:`, error);
379
+ voiceLogger.error(`Transcription failed:`, transcription);
367
380
  await sendThreadMessage(thread, `⚠️ Transcription failed: ${errMsg}`);
368
381
  return null;
369
382
  }
@@ -371,15 +384,25 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
371
384
  if (isNewThread) {
372
385
  const threadName = transcription.replace(/\s+/g, ' ').trim().slice(0, 80);
373
386
  if (threadName) {
374
- try {
375
- await Promise.race([
376
- thread.setName(threadName),
377
- new Promise((resolve) => setTimeout(resolve, 2000)),
378
- ]);
379
- voiceLogger.log(`Updated thread name to: "${threadName}"`);
387
+ const renamed = await Promise.race([
388
+ errore.tryAsync({
389
+ try: () => thread.setName(threadName),
390
+ catch: (e) => e,
391
+ }),
392
+ new Promise((resolve) => {
393
+ setTimeout(() => {
394
+ resolve(null);
395
+ }, 2000);
396
+ }),
397
+ ]);
398
+ if (renamed === null) {
399
+ voiceLogger.log(`Thread name update timed out`);
380
400
  }
381
- catch (e) {
382
- voiceLogger.log(`Could not update thread name:`, e);
401
+ else if (renamed instanceof Error) {
402
+ voiceLogger.log(`Could not update thread name:`, renamed.message);
403
+ }
404
+ else {
405
+ voiceLogger.log(`Updated thread name to: "${threadName}"`);
383
406
  }
384
407
  }
385
408
  }