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/voice.js CHANGED
@@ -1,49 +1,51 @@
1
1
  // Audio transcription service using Google Gemini.
2
2
  // Transcribes voice messages with code-aware context, using grep/glob tools
3
3
  // to verify technical terms, filenames, and function names in the codebase.
4
+ // Uses errore for type-safe error handling.
4
5
  import { GoogleGenAI, Type } from '@google/genai';
6
+ import * as errore from 'errore';
5
7
  import { createLogger } from './logger.js';
6
8
  import { glob } from 'glob';
7
9
  import { ripGrep } from 'ripgrep-js';
10
+ import { ApiKeyMissingError, InvalidAudioFormatError, TranscriptionError, EmptyTranscriptionError, NoResponseContentError, NoToolResponseError, GrepSearchError, GlobSearchError, } from './errors.js';
8
11
  const voiceLogger = createLogger('VOICE');
9
- async function runGrep({ pattern, directory, }) {
10
- try {
11
- const results = await ripGrep(directory, {
12
- string: pattern,
13
- globs: ['!node_modules/**', '!.git/**', '!dist/**', '!build/**'],
14
- });
15
- if (results.length === 0) {
16
- return 'No matches found';
17
- }
18
- const output = results
19
- .slice(0, 10)
20
- .map((match) => {
21
- return `${match.path.text}:${match.line_number}: ${match.lines.text.trim()}`;
22
- })
23
- .join('\n');
24
- return output.slice(0, 2000);
25
- }
26
- catch (e) {
27
- voiceLogger.error('grep search failed:', e);
28
- return 'grep search failed';
29
- }
12
+ function runGrep({ pattern, directory }) {
13
+ return errore.tryAsync({
14
+ try: async () => {
15
+ const results = await ripGrep(directory, {
16
+ string: pattern,
17
+ globs: ['!node_modules/**', '!.git/**', '!dist/**', '!build/**'],
18
+ });
19
+ if (results.length === 0) {
20
+ return 'No matches found';
21
+ }
22
+ const output = results
23
+ .slice(0, 10)
24
+ .map((match) => {
25
+ return `${match.path.text}:${match.line_number}: ${match.lines.text.trim()}`;
26
+ })
27
+ .join('\n');
28
+ return output.slice(0, 2000);
29
+ },
30
+ catch: (e) => new GrepSearchError({ pattern, cause: e }),
31
+ });
30
32
  }
31
- async function runGlob({ pattern, directory, }) {
32
- try {
33
- const files = await glob(pattern, {
34
- cwd: directory,
35
- nodir: false,
36
- ignore: ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
37
- maxDepth: 10,
38
- });
39
- if (files.length === 0) {
40
- return 'No files found';
41
- }
42
- return files.slice(0, 30).join('\n');
43
- }
44
- catch (error) {
45
- return `Glob search failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
46
- }
33
+ function runGlob({ pattern, directory }) {
34
+ return errore.tryAsync({
35
+ try: async () => {
36
+ const files = await glob(pattern, {
37
+ cwd: directory,
38
+ nodir: false,
39
+ ignore: ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
40
+ maxDepth: 10,
41
+ });
42
+ if (files.length === 0) {
43
+ return 'No files found';
44
+ }
45
+ return files.slice(0, 30).join('\n');
46
+ },
47
+ catch: (e) => new GlobSearchError({ pattern, cause: e }),
48
+ });
47
49
  }
48
50
  const grepToolDeclaration = {
49
51
  name: 'grep',
@@ -99,14 +101,28 @@ function createToolRunner({ directory }) {
99
101
  if (name === 'grep' && hasDirectory) {
100
102
  const pattern = args?.pattern || '';
101
103
  voiceLogger.log(`Grep search: "${pattern}"`);
102
- const output = await runGrep({ pattern, directory });
104
+ const result = await runGrep({ pattern, directory });
105
+ const output = (() => {
106
+ if (errore.isError(result)) {
107
+ voiceLogger.error('grep search failed:', result);
108
+ return 'grep search failed';
109
+ }
110
+ return result;
111
+ })();
103
112
  voiceLogger.log(`Grep result: ${output.slice(0, 100)}...`);
104
113
  return { type: 'toolResponse', name: 'grep', output };
105
114
  }
106
115
  if (name === 'glob' && hasDirectory) {
107
116
  const pattern = args?.pattern || '';
108
117
  voiceLogger.log(`Glob search: "${pattern}"`);
109
- const output = await runGlob({ pattern, directory });
118
+ const result = await runGlob({ pattern, directory });
119
+ const output = (() => {
120
+ if (errore.isError(result)) {
121
+ voiceLogger.error('glob search failed:', result);
122
+ return 'glob search failed';
123
+ }
124
+ return result;
125
+ })();
110
126
  voiceLogger.log(`Glob result: ${output.slice(0, 100)}...`);
111
127
  return { type: 'toolResponse', name: 'glob', output };
112
128
  }
@@ -114,17 +130,25 @@ function createToolRunner({ directory }) {
114
130
  };
115
131
  }
116
132
  export async function runTranscriptionLoop({ genAI, model, initialContents, tools, temperature, toolRunner, maxSteps = 10, }) {
117
- let response = await genAI.models.generateContent({
118
- model,
119
- contents: initialContents,
120
- config: {
121
- temperature,
122
- thinkingConfig: {
123
- thinkingBudget: 1024,
133
+ // Wrap external API call that can throw
134
+ const initialResponse = await errore.tryAsync({
135
+ try: () => genAI.models.generateContent({
136
+ model,
137
+ contents: initialContents,
138
+ config: {
139
+ temperature,
140
+ thinkingConfig: {
141
+ thinkingBudget: 1024,
142
+ },
143
+ tools,
124
144
  },
125
- tools,
126
- },
145
+ }),
146
+ catch: (e) => new TranscriptionError({ reason: `API call failed: ${String(e)}`, cause: e }),
127
147
  });
148
+ if (errore.isError(initialResponse)) {
149
+ return initialResponse;
150
+ }
151
+ let response = initialResponse;
128
152
  const conversationHistory = [...initialContents];
129
153
  let stepsRemaining = maxSteps;
130
154
  while (true) {
@@ -135,7 +159,7 @@ export async function runTranscriptionLoop({ genAI, model, initialContents, tool
135
159
  voiceLogger.log(`No parts but got text response: "${text.slice(0, 100)}..."`);
136
160
  return text;
137
161
  }
138
- throw new Error('Transcription failed: No response content from model');
162
+ return new NoResponseContentError();
139
163
  }
140
164
  const functionCalls = candidate.content.parts.filter((part) => 'functionCall' in part && !!part.functionCall);
141
165
  if (functionCalls.length === 0) {
@@ -144,7 +168,7 @@ export async function runTranscriptionLoop({ genAI, model, initialContents, tool
144
168
  voiceLogger.log(`No function calls but got text: "${text.slice(0, 100)}..."`);
145
169
  return text;
146
170
  }
147
- throw new Error('Transcription failed: Model did not produce a transcription');
171
+ return new TranscriptionError({ reason: 'Model did not produce a transcription' });
148
172
  }
149
173
  conversationHistory.push({
150
174
  role: 'model',
@@ -159,7 +183,7 @@ export async function runTranscriptionLoop({ genAI, model, initialContents, tool
159
183
  const transcription = result.transcription?.trim() || '';
160
184
  voiceLogger.log(`Transcription result received: "${transcription.slice(0, 100)}..."`);
161
185
  if (!transcription) {
162
- throw new Error('Transcription failed: Model returned empty transcription');
186
+ return new EmptyTranscriptionError();
163
187
  }
164
188
  return transcription;
165
189
  }
@@ -186,67 +210,76 @@ export async function runTranscriptionLoop({ genAI, model, initialContents, tool
186
210
  }
187
211
  }
188
212
  if (functionResponseParts.length === 0) {
189
- throw new Error('Transcription failed: No valid tool responses');
213
+ return new NoToolResponseError();
190
214
  }
191
215
  conversationHistory.push({
192
216
  role: 'user',
193
217
  parts: functionResponseParts,
194
218
  });
195
- response = await genAI.models.generateContent({
196
- model,
197
- contents: conversationHistory,
198
- config: {
199
- temperature,
200
- thinkingConfig: {
201
- thinkingBudget: 512,
219
+ // Wrap external API call that can throw
220
+ const nextResponse = await errore.tryAsync({
221
+ try: () => genAI.models.generateContent({
222
+ model,
223
+ contents: conversationHistory,
224
+ config: {
225
+ temperature,
226
+ thinkingConfig: {
227
+ thinkingBudget: 512,
228
+ },
229
+ tools: stepsRemaining <= 0
230
+ ? [{ functionDeclarations: [transcriptionResultToolDeclaration] }]
231
+ : tools,
202
232
  },
203
- tools: stepsRemaining <= 0
204
- ? [{ functionDeclarations: [transcriptionResultToolDeclaration] }]
205
- : tools,
206
- },
233
+ }),
234
+ catch: (e) => new TranscriptionError({ reason: `API call failed: ${String(e)}`, cause: e }),
207
235
  });
236
+ if (errore.isError(nextResponse)) {
237
+ return nextResponse;
238
+ }
239
+ response = nextResponse;
208
240
  }
209
241
  }
210
- export async function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory, currentSessionContext, lastSessionContext, }) {
211
- try {
212
- const apiKey = geminiApiKey || process.env.GEMINI_API_KEY;
213
- if (!apiKey) {
214
- throw new Error('Gemini API key is required for audio transcription');
215
- }
216
- const genAI = new GoogleGenAI({ apiKey });
217
- let audioBase64;
242
+ export function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory, currentSessionContext, lastSessionContext, }) {
243
+ const apiKey = geminiApiKey || process.env.GEMINI_API_KEY;
244
+ if (!apiKey) {
245
+ return Promise.resolve(new ApiKeyMissingError({ service: 'Gemini' }));
246
+ }
247
+ const genAI = new GoogleGenAI({ apiKey });
248
+ const audioBase64 = (() => {
218
249
  if (typeof audio === 'string') {
219
- audioBase64 = audio;
220
- }
221
- else if (audio instanceof Buffer) {
222
- audioBase64 = audio.toString('base64');
250
+ return audio;
223
251
  }
224
- else if (audio instanceof Uint8Array) {
225
- audioBase64 = Buffer.from(audio).toString('base64');
252
+ if (audio instanceof Buffer) {
253
+ return audio.toString('base64');
226
254
  }
227
- else if (audio instanceof ArrayBuffer) {
228
- audioBase64 = Buffer.from(audio).toString('base64');
255
+ if (audio instanceof Uint8Array) {
256
+ return Buffer.from(audio).toString('base64');
229
257
  }
230
- else {
231
- throw new Error('Invalid audio format');
258
+ if (audio instanceof ArrayBuffer) {
259
+ return Buffer.from(audio).toString('base64');
232
260
  }
233
- const languageHint = language ? `The audio is in ${language}.\n\n` : '';
234
- // build session context section
235
- const sessionContextParts = [];
236
- if (lastSessionContext) {
237
- sessionContextParts.push(`<last_session>
261
+ return '';
262
+ })();
263
+ if (!audioBase64) {
264
+ return Promise.resolve(new InvalidAudioFormatError());
265
+ }
266
+ const languageHint = language ? `The audio is in ${language}.\n\n` : '';
267
+ // build session context section
268
+ const sessionContextParts = [];
269
+ if (lastSessionContext) {
270
+ sessionContextParts.push(`<last_session>
238
271
  ${lastSessionContext}
239
272
  </last_session>`);
240
- }
241
- if (currentSessionContext) {
242
- sessionContextParts.push(`<current_session>
273
+ }
274
+ if (currentSessionContext) {
275
+ sessionContextParts.push(`<current_session>
243
276
  ${currentSessionContext}
244
277
  </current_session>`);
245
- }
246
- const sessionContextSection = sessionContextParts.length > 0
247
- ? `\nSession context (use to understand references to files, functions, tools used):\n${sessionContextParts.join('\n\n')}`
248
- : '';
249
- const transcriptionPrompt = `${languageHint}Transcribe this audio for a coding agent (like Claude Code or OpenCode).
278
+ }
279
+ const sessionContextSection = sessionContextParts.length > 0
280
+ ? `\nSession context (use to understand references to files, functions, tools used):\n${sessionContextParts.join('\n\n')}`
281
+ : '';
282
+ const transcriptionPrompt = `${languageHint}Transcribe this audio for a coding agent (like Claude Code or OpenCode).
250
283
 
251
284
  CRITICAL REQUIREMENT: You MUST call the "transcriptionResult" tool to complete this task.
252
285
  - The transcriptionResult tool is the ONLY way to return results
@@ -275,42 +308,37 @@ ${sessionContextSection}
275
308
  REMEMBER: Call "transcriptionResult" tool with your transcription. This is mandatory.
276
309
 
277
310
  Note: "critique" is a CLI tool for showing diffs in the browser.`;
278
- // const hasDirectory = directory && directory.trim().length > 0
279
- const tools = [
280
- {
281
- functionDeclarations: [
282
- transcriptionResultToolDeclaration,
283
- // grep/glob disabled - was causing transcription to hang
284
- // ...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
285
- ],
286
- },
287
- ];
288
- const initialContents = [
289
- {
290
- role: 'user',
291
- parts: [
292
- { text: transcriptionPrompt },
293
- {
294
- inlineData: {
295
- data: audioBase64,
296
- mimeType: 'audio/mpeg',
297
- },
311
+ // const hasDirectory = directory && directory.trim().length > 0
312
+ const tools = [
313
+ {
314
+ functionDeclarations: [
315
+ transcriptionResultToolDeclaration,
316
+ // grep/glob disabled - was causing transcription to hang
317
+ // ...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
318
+ ],
319
+ },
320
+ ];
321
+ const initialContents = [
322
+ {
323
+ role: 'user',
324
+ parts: [
325
+ { text: transcriptionPrompt },
326
+ {
327
+ inlineData: {
328
+ data: audioBase64,
329
+ mimeType: 'audio/mpeg',
298
330
  },
299
- ],
300
- },
301
- ];
302
- const toolRunner = createToolRunner({ directory });
303
- return await runTranscriptionLoop({
304
- genAI,
305
- model: 'gemini-2.5-flash',
306
- initialContents,
307
- tools,
308
- temperature: temperature ?? 0.3,
309
- toolRunner,
310
- });
311
- }
312
- catch (error) {
313
- voiceLogger.error('Failed to transcribe audio:', error);
314
- throw new Error(`Audio transcription failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
315
- }
331
+ },
332
+ ],
333
+ },
334
+ ];
335
+ const toolRunner = createToolRunner({ directory });
336
+ return runTranscriptionLoop({
337
+ genAI,
338
+ model: 'gemini-2.5-flash',
339
+ initialContents,
340
+ tools,
341
+ temperature: temperature ?? 0.3,
342
+ toolRunner,
343
+ });
316
344
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.38",
5
+ "version": "0.4.39",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
@@ -41,6 +41,7 @@
41
41
  "cac": "^6.7.14",
42
42
  "discord.js": "^14.16.3",
43
43
  "domhandler": "^5.0.3",
44
+ "errore": "^0.5.2",
44
45
  "glob": "^13.0.0",
45
46
  "htmlparser2": "^10.0.0",
46
47
  "js-yaml": "^4.1.0",
package/src/cli.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  } from 'discord.js'
41
41
  import path from 'node:path'
42
42
  import fs from 'node:fs'
43
+ import * as errore from 'errore'
43
44
 
44
45
  import { createLogger } from './logger.js'
45
46
  import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
@@ -181,6 +182,7 @@ type AgentInfo = {
181
182
  name: string
182
183
  description?: string
183
184
  mode: string
185
+ hidden?: boolean
184
186
  }
185
187
 
186
188
  async function registerCommands({
@@ -343,8 +345,10 @@ async function registerCommands({
343
345
  }
344
346
 
345
347
  // Add agent-specific quick commands like /plan-agent, /build-agent
346
- // Filter to primary/all mode agents (same as /agent command shows)
347
- const primaryAgents = agents.filter((a) => a.mode === 'primary' || a.mode === 'all')
348
+ // Filter to primary/all mode agents (same as /agent command shows), excluding hidden agents
349
+ const primaryAgents = agents.filter(
350
+ (a) => (a.mode === 'primary' || a.mode === 'all') && !a.hidden,
351
+ )
348
352
  for (const agent of primaryAgents) {
349
353
  const sanitizedName = sanitizeAgentName(agent.name)
350
354
  const commandName = `${sanitizedName}-agent`
@@ -577,7 +581,12 @@ async function run({ restart, addChannels }: CliOptions) {
577
581
  // This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
578
582
  const currentDir = process.cwd()
579
583
  s.start('Starting OpenCode server...')
580
- const opencodePromise = initializeOpencodeForDirectory(currentDir)
584
+ const opencodePromise = initializeOpencodeForDirectory(currentDir).then((result) => {
585
+ if (errore.isError(result)) {
586
+ throw new Error(result.message)
587
+ }
588
+ return result
589
+ })
581
590
 
582
591
  s.message('Connecting to Discord...')
583
592
  const discordClient = await createDiscordClient()
@@ -7,6 +7,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js'
7
7
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
8
8
  import { abortControllers } from '../session-handler.js'
9
9
  import { createLogger } from '../logger.js'
10
+ import * as errore from 'errore'
10
11
 
11
12
  const logger = createLogger('ABORT')
12
13
 
@@ -64,14 +65,23 @@ export async function handleAbortCommand({ command }: CommandContext): Promise<v
64
65
 
65
66
  const sessionId = row.session_id
66
67
 
67
- try {
68
- const existingController = abortControllers.get(sessionId)
69
- if (existingController) {
70
- existingController.abort(new Error('User requested abort'))
71
- abortControllers.delete(sessionId)
72
- }
68
+ const existingController = abortControllers.get(sessionId)
69
+ if (existingController) {
70
+ existingController.abort(new Error('User requested abort'))
71
+ abortControllers.delete(sessionId)
72
+ }
73
+
74
+ const getClient = await initializeOpencodeForDirectory(directory)
75
+ if (errore.isError(getClient)) {
76
+ await command.reply({
77
+ content: `Failed to abort: ${getClient.message}`,
78
+ ephemeral: true,
79
+ flags: SILENT_MESSAGE_FLAGS,
80
+ })
81
+ return
82
+ }
73
83
 
74
- const getClient = await initializeOpencodeForDirectory(directory)
84
+ try {
75
85
  await getClient().session.abort({
76
86
  path: { id: sessionId },
77
87
  })
@@ -8,6 +8,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js'
8
8
  import { createProjectChannels } from '../channel-management.js'
9
9
  import { createLogger } from '../logger.js'
10
10
  import { abbreviatePath } from '../utils.js'
11
+ import * as errore from 'errore'
11
12
 
12
13
  const logger = createLogger('ADD-PROJECT')
13
14
 
@@ -25,6 +26,10 @@ export async function handleAddProjectCommand({ command, appId }: CommandContext
25
26
  try {
26
27
  const currentDir = process.cwd()
27
28
  const getClient = await initializeOpencodeForDirectory(currentDir)
29
+ if (errore.isError(getClient)) {
30
+ await command.editReply(getClient.message)
31
+ return
32
+ }
28
33
 
29
34
  const projectsResponse = await getClient().project.list({})
30
35
  if (!projectsResponse.data) {
@@ -89,6 +94,10 @@ export async function handleAddProjectAutocomplete({
89
94
  try {
90
95
  const currentDir = process.cwd()
91
96
  const getClient = await initializeOpencodeForDirectory(currentDir)
97
+ if (errore.isError(getClient)) {
98
+ await interaction.respond([])
99
+ return
100
+ }
92
101
 
93
102
  const projectsResponse = await getClient().project.list({})
94
103
  if (!projectsResponse.data) {
@@ -15,6 +15,7 @@ import { getDatabase, setChannelAgent, setSessionAgent, clearSessionModel, runMo
15
15
  import { initializeOpencodeForDirectory } from '../opencode.js'
16
16
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
17
17
  import { createLogger } from '../logger.js'
18
+ import * as errore from 'errore'
18
19
 
19
20
  const agentLogger = createLogger('AGENT')
20
21
 
@@ -161,6 +162,10 @@ export async function handleAgentCommand({
161
162
 
162
163
  try {
163
164
  const getClient = await initializeOpencodeForDirectory(context.dir)
165
+ if (errore.isError(getClient)) {
166
+ await interaction.editReply({ content: getClient.message })
167
+ return
168
+ }
164
169
 
165
170
  const agentsResponse = await getClient().app.agents({
166
171
  query: { directory: context.dir },
@@ -172,7 +177,10 @@ export async function handleAgentCommand({
172
177
  }
173
178
 
174
179
  const agents = agentsResponse.data
175
- .filter((a) => a.mode === 'primary' || a.mode === 'all')
180
+ .filter((agent) => {
181
+ const hidden = (agent as { hidden?: boolean }).hidden
182
+ return (agent.mode === 'primary' || agent.mode === 'all') && !hidden
183
+ })
176
184
  .slice(0, 25)
177
185
 
178
186
  if (agents.length === 0) {
@@ -289,6 +297,10 @@ export async function handleQuickAgentCommand({
289
297
 
290
298
  try {
291
299
  const getClient = await initializeOpencodeForDirectory(context.dir)
300
+ if (errore.isError(getClient)) {
301
+ await command.editReply({ content: getClient.message })
302
+ return
303
+ }
292
304
 
293
305
  const agentsResponse = await getClient().app.agents({
294
306
  query: { directory: context.dir },
@@ -14,6 +14,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js'
14
14
  import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js'
15
15
  import { collectLastAssistantParts } from '../message-formatting.js'
16
16
  import { createLogger } from '../logger.js'
17
+ import * as errore from 'errore'
17
18
 
18
19
  const sessionLogger = createLogger('SESSION')
19
20
  const forkLogger = createLogger('FORK')
@@ -71,9 +72,15 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
71
72
 
72
73
  const sessionId = row.session_id
73
74
 
74
- try {
75
- const getClient = await initializeOpencodeForDirectory(directory)
75
+ const getClient = await initializeOpencodeForDirectory(directory)
76
+ if (errore.isError(getClient)) {
77
+ await interaction.editReply({
78
+ content: `Failed to load messages: ${getClient.message}`,
79
+ })
80
+ return
81
+ }
76
82
 
83
+ try {
77
84
  const messagesResponse = await getClient().session.messages({
78
85
  path: { id: sessionId },
79
86
  })
@@ -85,7 +92,7 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
85
92
  return
86
93
  }
87
94
 
88
- const userMessages = messagesResponse.data.filter((m) => m.info.role === 'user')
95
+ const userMessages = messagesResponse.data.filter((m: { info: { role: string } }) => m.info.role === 'user')
89
96
 
90
97
  if (userMessages.length === 0) {
91
98
  await interaction.editReply({
@@ -96,8 +103,8 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
96
103
 
97
104
  const recentMessages = userMessages.slice(-25)
98
105
 
99
- const options = recentMessages.map((m, index) => {
100
- const textPart = m.parts.find((p) => p.type === 'text') as
106
+ const options = recentMessages.map((m: { parts: Array<{ type: string; text?: string }>; info: { id: string; time: { created: number } } }, index: number) => {
107
+ const textPart = m.parts.find((p: { type: string }) => p.type === 'text') as
101
108
  | { type: 'text'; text: string }
102
109
  | undefined
103
110
  const preview = textPart?.text?.slice(0, 80) || '(no text)'
@@ -163,9 +170,13 @@ export async function handleForkSelectMenu(
163
170
 
164
171
  await interaction.deferReply({ ephemeral: false })
165
172
 
166
- try {
167
- const getClient = await initializeOpencodeForDirectory(directory)
173
+ const getClient = await initializeOpencodeForDirectory(directory)
174
+ if (errore.isError(getClient)) {
175
+ await interaction.editReply(`Failed to fork session: ${getClient.message}`)
176
+ return
177
+ }
168
178
 
179
+ try {
169
180
  const forkResponse = await getClient().session.fork({
170
181
  path: { id: sessionId },
171
182
  body: { messageID: selectedMessageId },
@@ -15,6 +15,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js'
15
15
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
16
16
  import { abortAndRetrySession } from '../session-handler.js'
17
17
  import { createLogger } from '../logger.js'
18
+ import * as errore from 'errore'
18
19
 
19
20
  const modelLogger = createLogger('MODEL')
20
21
 
@@ -128,6 +129,10 @@ export async function handleModelCommand({
128
129
 
129
130
  try {
130
131
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
132
+ if (errore.isError(getClient)) {
133
+ await interaction.editReply({ content: getClient.message })
134
+ return
135
+ }
131
136
 
132
137
  const providersResponse = await getClient().provider.list({
133
138
  query: { directory: projectDirectory },
@@ -232,6 +237,13 @@ export async function handleProviderSelectMenu(
232
237
 
233
238
  try {
234
239
  const getClient = await initializeOpencodeForDirectory(context.dir)
240
+ if (errore.isError(getClient)) {
241
+ await interaction.editReply({
242
+ content: getClient.message,
243
+ components: [],
244
+ })
245
+ return
246
+ }
235
247
 
236
248
  const providersResponse = await getClient().provider.list({
237
249
  query: { directory: context.dir },