kimaki 0.4.38 → 0.4.39

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 (49) hide show
  1. package/dist/cli.js +9 -3
  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 +13 -0
  10. package/dist/commands/share.js +10 -1
  11. package/dist/commands/undo-redo.js +13 -4
  12. package/dist/database.js +9 -5
  13. package/dist/discord-bot.js +21 -8
  14. package/dist/errors.js +110 -0
  15. package/dist/genai-worker.js +18 -16
  16. package/dist/markdown.js +96 -85
  17. package/dist/markdown.test.js +10 -3
  18. package/dist/message-formatting.js +50 -37
  19. package/dist/opencode.js +43 -46
  20. package/dist/session-handler.js +100 -2
  21. package/dist/system-message.js +2 -0
  22. package/dist/tools.js +18 -8
  23. package/dist/voice-handler.js +48 -25
  24. package/dist/voice.js +159 -131
  25. package/package.json +2 -1
  26. package/src/cli.ts +12 -3
  27. package/src/commands/abort.ts +17 -7
  28. package/src/commands/add-project.ts +9 -0
  29. package/src/commands/agent.ts +13 -1
  30. package/src/commands/fork.ts +18 -7
  31. package/src/commands/model.ts +12 -0
  32. package/src/commands/remove-project.ts +28 -16
  33. package/src/commands/resume.ts +9 -0
  34. package/src/commands/session.ts +13 -0
  35. package/src/commands/share.ts +11 -1
  36. package/src/commands/undo-redo.ts +15 -6
  37. package/src/database.ts +9 -4
  38. package/src/discord-bot.ts +21 -7
  39. package/src/errors.ts +208 -0
  40. package/src/genai-worker.ts +20 -17
  41. package/src/markdown.test.ts +13 -3
  42. package/src/markdown.ts +111 -95
  43. package/src/message-formatting.ts +55 -38
  44. package/src/opencode.ts +52 -49
  45. package/src/session-handler.ts +118 -3
  46. package/src/system-message.ts +2 -0
  47. package/src/tools.ts +18 -8
  48. package/src/voice-handler.ts +48 -23
  49. package/src/voice.ts +195 -148
package/dist/opencode.js CHANGED
@@ -1,12 +1,15 @@
1
1
  // OpenCode server process manager.
2
2
  // Spawns and maintains OpenCode API servers per project directory,
3
3
  // handles automatic restarts on failure, and provides typed SDK clients.
4
+ // Uses errore for type-safe error handling.
4
5
  import { spawn } from 'node:child_process';
5
6
  import fs from 'node:fs';
6
7
  import net from 'node:net';
7
8
  import { createOpencodeClient } from '@opencode-ai/sdk';
8
9
  import { createOpencodeClient as createOpencodeClientV2, } from '@opencode-ai/sdk/v2';
10
+ import * as errore from 'errore';
9
11
  import { createLogger } from './logger.js';
12
+ import { DirectoryNotAccessibleError, ServerStartError, ServerNotReadyError, FetchError, } from './errors.js';
10
13
  const opencodeLogger = createLogger('OPENCODE');
11
14
  const opencodeServers = new Map();
12
15
  const serverRetryCount = new Map();
@@ -30,42 +33,33 @@ async function getOpenPort() {
30
33
  }
31
34
  async function waitForServer(port, maxAttempts = 30) {
32
35
  for (let i = 0; i < maxAttempts; i++) {
33
- try {
34
- const endpoints = [
35
- `http://127.0.0.1:${port}/api/health`,
36
- `http://127.0.0.1:${port}/`,
37
- `http://127.0.0.1:${port}/api`,
38
- ];
39
- for (const endpoint of endpoints) {
40
- try {
41
- const response = await fetch(endpoint);
42
- if (response.status < 500) {
43
- return true;
44
- }
45
- const body = await response.text();
46
- // Fatal errors that won't resolve with retrying
47
- if (body.includes('BunInstallFailedError')) {
48
- throw new Error(`Server failed to start: ${body.slice(0, 200)}`);
49
- }
50
- }
51
- catch (e) {
52
- // Re-throw fatal errors
53
- if (e.message?.includes('Server failed to start')) {
54
- throw e;
55
- }
56
- }
36
+ const endpoints = [
37
+ `http://127.0.0.1:${port}/api/health`,
38
+ `http://127.0.0.1:${port}/`,
39
+ `http://127.0.0.1:${port}/api`,
40
+ ];
41
+ for (const endpoint of endpoints) {
42
+ const response = await errore.tryAsync({
43
+ try: () => fetch(endpoint),
44
+ catch: (e) => new FetchError({ url: endpoint, cause: e }),
45
+ });
46
+ if (errore.isError(response)) {
47
+ // Connection refused or other transient errors - continue polling
48
+ opencodeLogger.debug(`Server polling attempt failed: ${response.message}`);
49
+ continue;
57
50
  }
58
- }
59
- catch (e) {
60
- // Re-throw fatal errors that won't resolve with retrying
61
- if (e.message?.includes('Server failed to start')) {
62
- throw e;
51
+ if (response.status < 500) {
52
+ return true;
53
+ }
54
+ const body = await response.text();
55
+ // Fatal errors that won't resolve with retrying
56
+ if (body.includes('BunInstallFailedError')) {
57
+ return new ServerStartError({ port, reason: body.slice(0, 200) });
63
58
  }
64
- opencodeLogger.debug(`Server polling attempt failed: ${e.message}`);
65
59
  }
66
60
  await new Promise((resolve) => setTimeout(resolve, 1000));
67
61
  }
68
- throw new Error(`Server did not start on port ${port} after ${maxAttempts} seconds`);
62
+ return new ServerStartError({ port, reason: `Server did not start after ${maxAttempts} seconds` });
69
63
  }
70
64
  export async function initializeOpencodeForDirectory(directory) {
71
65
  const existing = opencodeServers.get(directory);
@@ -74,17 +68,20 @@ export async function initializeOpencodeForDirectory(directory) {
74
68
  return () => {
75
69
  const entry = opencodeServers.get(directory);
76
70
  if (!entry?.client) {
77
- throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
71
+ throw new ServerNotReadyError({ directory });
78
72
  }
79
73
  return entry.client;
80
74
  };
81
75
  }
82
76
  // Verify directory exists and is accessible before spawning
83
- try {
84
- fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK);
85
- }
86
- catch {
87
- throw new Error(`Directory does not exist or is not accessible: ${directory}`);
77
+ const accessCheck = errore.tryFn({
78
+ try: () => {
79
+ fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK);
80
+ },
81
+ catch: () => new DirectoryNotAccessibleError({ directory }),
82
+ });
83
+ if (errore.isError(accessCheck)) {
84
+ return accessCheck;
88
85
  }
89
86
  const port = await getOpenPort();
90
87
  const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
@@ -127,8 +124,10 @@ export async function initializeOpencodeForDirectory(directory) {
127
124
  if (retryCount < 5) {
128
125
  serverRetryCount.set(directory, retryCount + 1);
129
126
  opencodeLogger.log(`Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`);
130
- initializeOpencodeForDirectory(directory).catch((e) => {
131
- opencodeLogger.error(`Failed to restart opencode server:`, e);
127
+ initializeOpencodeForDirectory(directory).then((result) => {
128
+ if (errore.isError(result)) {
129
+ opencodeLogger.error(`Failed to restart opencode server:`, result);
130
+ }
132
131
  });
133
132
  }
134
133
  else {
@@ -139,18 +138,16 @@ export async function initializeOpencodeForDirectory(directory) {
139
138
  serverRetryCount.delete(directory);
140
139
  }
141
140
  });
142
- try {
143
- await waitForServer(port);
144
- opencodeLogger.log(`Server ready on port ${port}`);
145
- }
146
- catch (e) {
141
+ const waitResult = await waitForServer(port);
142
+ if (errore.isError(waitResult)) {
147
143
  // Dump buffered logs on failure
148
144
  opencodeLogger.error(`Server failed to start for ${directory}:`);
149
145
  for (const line of logBuffer) {
150
146
  opencodeLogger.error(` ${line}`);
151
147
  }
152
- throw e;
148
+ return waitResult;
153
149
  }
150
+ opencodeLogger.log(`Server ready on port ${port}`);
154
151
  const baseUrl = `http://127.0.0.1:${port}`;
155
152
  const fetchWithTimeout = (request) => fetch(request, {
156
153
  // @ts-ignore
@@ -173,7 +170,7 @@ export async function initializeOpencodeForDirectory(directory) {
173
170
  return () => {
174
171
  const entry = opencodeServers.get(directory);
175
172
  if (!entry?.client) {
176
- throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
173
+ throw new ServerNotReadyError({ directory });
177
174
  }
178
175
  return entry.client;
179
176
  };
@@ -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 (errore.isError(getClient)) {
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 (errore.isError(getClient)) {
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 (errore.isError(getClient)) {
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 (errore.isError(markdownResult)) {
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 (errore.isError(markdownResult)) {
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 (errore.isError(audioResponse)) {
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 (errore.isError(transcription)) {
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 (errore.isError(renamed)) {
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
  }