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
package/src/voice.ts CHANGED
@@ -1,11 +1,23 @@
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
 
5
6
  import { GoogleGenAI, Type, type Content, type Part, type Tool } from '@google/genai'
7
+ import * as errore from 'errore'
6
8
  import { createLogger } from './logger.js'
7
9
  import { glob } from 'glob'
8
10
  import { ripGrep } from 'ripgrep-js'
11
+ import {
12
+ ApiKeyMissingError,
13
+ InvalidAudioFormatError,
14
+ TranscriptionError,
15
+ EmptyTranscriptionError,
16
+ NoResponseContentError,
17
+ NoToolResponseError,
18
+ GrepSearchError,
19
+ GlobSearchError,
20
+ } from './errors.js'
9
21
 
10
22
  const voiceLogger = createLogger('VOICE')
11
23
 
@@ -21,60 +33,49 @@ export type TranscriptionToolRunner = ({
21
33
  | { type: 'skip' }
22
34
  >
23
35
 
24
- async function runGrep({
25
- pattern,
26
- directory,
27
- }: {
28
- pattern: string
29
- directory: string
30
- }): Promise<string> {
31
- try {
32
- const results = await ripGrep(directory, {
33
- string: pattern,
34
- globs: ['!node_modules/**', '!.git/**', '!dist/**', '!build/**'],
35
- })
36
+ function runGrep({ pattern, directory }: { pattern: string; directory: string }): Promise<GrepSearchError | string> {
37
+ return errore.tryAsync({
38
+ try: async () => {
39
+ const results = await ripGrep(directory, {
40
+ string: pattern,
41
+ globs: ['!node_modules/**', '!.git/**', '!dist/**', '!build/**'],
42
+ })
36
43
 
37
- if (results.length === 0) {
38
- return 'No matches found'
39
- }
44
+ if (results.length === 0) {
45
+ return 'No matches found'
46
+ }
40
47
 
41
- const output = results
42
- .slice(0, 10)
43
- .map((match) => {
44
- return `${match.path.text}:${match.line_number}: ${match.lines.text.trim()}`
45
- })
46
- .join('\n')
48
+ const output = results
49
+ .slice(0, 10)
50
+ .map((match) => {
51
+ return `${match.path.text}:${match.line_number}: ${match.lines.text.trim()}`
52
+ })
53
+ .join('\n')
47
54
 
48
- return output.slice(0, 2000)
49
- } catch (e) {
50
- voiceLogger.error('grep search failed:', e)
51
- return 'grep search failed'
52
- }
55
+ return output.slice(0, 2000)
56
+ },
57
+ catch: (e) => new GrepSearchError({ pattern, cause: e }),
58
+ })
53
59
  }
54
60
 
55
- async function runGlob({
56
- pattern,
57
- directory,
58
- }: {
59
- pattern: string
60
- directory: string
61
- }): Promise<string> {
62
- try {
63
- const files = await glob(pattern, {
64
- cwd: directory,
65
- nodir: false,
66
- ignore: ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
67
- maxDepth: 10,
68
- })
61
+ function runGlob({ pattern, directory }: { pattern: string; directory: string }): Promise<GlobSearchError | string> {
62
+ return errore.tryAsync({
63
+ try: async () => {
64
+ const files = await glob(pattern, {
65
+ cwd: directory,
66
+ nodir: false,
67
+ ignore: ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
68
+ maxDepth: 10,
69
+ })
69
70
 
70
- if (files.length === 0) {
71
- return 'No files found'
72
- }
71
+ if (files.length === 0) {
72
+ return 'No files found'
73
+ }
73
74
 
74
- return files.slice(0, 30).join('\n')
75
- } catch (error) {
76
- return `Glob search failed: ${error instanceof Error ? error.message : 'Unknown error'}`
77
- }
75
+ return files.slice(0, 30).join('\n')
76
+ },
77
+ catch: (e) => new GlobSearchError({ pattern, cause: e }),
78
+ })
78
79
  }
79
80
 
80
81
  const grepToolDeclaration = {
@@ -142,7 +143,14 @@ function createToolRunner({ directory }: { directory?: string }): TranscriptionT
142
143
  if (name === 'grep' && hasDirectory) {
143
144
  const pattern = args?.pattern || ''
144
145
  voiceLogger.log(`Grep search: "${pattern}"`)
145
- const output = await runGrep({ pattern, directory })
146
+ const result = await runGrep({ pattern, directory })
147
+ const output = (() => {
148
+ if (result instanceof Error) {
149
+ voiceLogger.error('grep search failed:', result)
150
+ return 'grep search failed'
151
+ }
152
+ return result
153
+ })()
146
154
  voiceLogger.log(`Grep result: ${output.slice(0, 100)}...`)
147
155
  return { type: 'toolResponse', name: 'grep', output }
148
156
  }
@@ -150,7 +158,14 @@ function createToolRunner({ directory }: { directory?: string }): TranscriptionT
150
158
  if (name === 'glob' && hasDirectory) {
151
159
  const pattern = args?.pattern || ''
152
160
  voiceLogger.log(`Glob search: "${pattern}"`)
153
- const output = await runGlob({ pattern, directory })
161
+ const result = await runGlob({ pattern, directory })
162
+ const output = (() => {
163
+ if (result instanceof Error) {
164
+ voiceLogger.error('glob search failed:', result)
165
+ return 'glob search failed'
166
+ }
167
+ return result
168
+ })()
154
169
  voiceLogger.log(`Glob result: ${output.slice(0, 100)}...`)
155
170
  return { type: 'toolResponse', name: 'glob', output }
156
171
  }
@@ -159,6 +174,12 @@ function createToolRunner({ directory }: { directory?: string }): TranscriptionT
159
174
  }
160
175
  }
161
176
 
177
+ type TranscriptionLoopError =
178
+ | NoResponseContentError
179
+ | TranscriptionError
180
+ | EmptyTranscriptionError
181
+ | NoToolResponseError
182
+
162
183
  export async function runTranscriptionLoop({
163
184
  genAI,
164
185
  model,
@@ -175,19 +196,29 @@ export async function runTranscriptionLoop({
175
196
  temperature: number
176
197
  toolRunner: TranscriptionToolRunner
177
198
  maxSteps?: number
178
- }): Promise<string> {
179
- let response = await genAI.models.generateContent({
180
- model,
181
- contents: initialContents,
182
- config: {
183
- temperature,
184
- thinkingConfig: {
185
- thinkingBudget: 1024,
186
- },
187
- tools,
188
- },
199
+ }): Promise<TranscriptionLoopError | string> {
200
+ // Wrap external API call that can throw
201
+ const initialResponse = await errore.tryAsync({
202
+ try: () =>
203
+ genAI.models.generateContent({
204
+ model,
205
+ contents: initialContents,
206
+ config: {
207
+ temperature,
208
+ thinkingConfig: {
209
+ thinkingBudget: 1024,
210
+ },
211
+ tools,
212
+ },
213
+ }),
214
+ catch: (e) => new TranscriptionError({ reason: `API call failed: ${String(e)}`, cause: e }),
189
215
  })
190
216
 
217
+ if (initialResponse instanceof Error) {
218
+ return initialResponse
219
+ }
220
+
221
+ let response = initialResponse
191
222
  const conversationHistory: Content[] = [...initialContents]
192
223
  let stepsRemaining = maxSteps
193
224
 
@@ -199,7 +230,7 @@ export async function runTranscriptionLoop({
199
230
  voiceLogger.log(`No parts but got text response: "${text.slice(0, 100)}..."`)
200
231
  return text
201
232
  }
202
- throw new Error('Transcription failed: No response content from model')
233
+ return new NoResponseContentError()
203
234
  }
204
235
 
205
236
  const functionCalls = candidate.content.parts.filter(
@@ -213,7 +244,7 @@ export async function runTranscriptionLoop({
213
244
  voiceLogger.log(`No function calls but got text: "${text.slice(0, 100)}..."`)
214
245
  return text
215
246
  }
216
- throw new Error('Transcription failed: Model did not produce a transcription')
247
+ return new TranscriptionError({ reason: 'Model did not produce a transcription' })
217
248
  }
218
249
 
219
250
  conversationHistory.push({
@@ -234,7 +265,7 @@ export async function runTranscriptionLoop({
234
265
  const transcription = result.transcription?.trim() || ''
235
266
  voiceLogger.log(`Transcription result received: "${transcription.slice(0, 100)}..."`)
236
267
  if (!transcription) {
237
- throw new Error('Transcription failed: Model returned empty transcription')
268
+ return new EmptyTranscriptionError()
238
269
  }
239
270
  return transcription
240
271
  }
@@ -264,7 +295,7 @@ export async function runTranscriptionLoop({
264
295
  }
265
296
 
266
297
  if (functionResponseParts.length === 0) {
267
- throw new Error('Transcription failed: No valid tool responses')
298
+ return new NoToolResponseError()
268
299
  }
269
300
 
270
301
  conversationHistory.push({
@@ -272,24 +303,40 @@ export async function runTranscriptionLoop({
272
303
  parts: functionResponseParts,
273
304
  } as Content)
274
305
 
275
- response = await genAI.models.generateContent({
276
- model,
277
- contents: conversationHistory,
278
- config: {
279
- temperature,
280
- thinkingConfig: {
281
- thinkingBudget: 512,
282
- },
283
- tools:
284
- stepsRemaining <= 0
285
- ? [{ functionDeclarations: [transcriptionResultToolDeclaration] }]
286
- : tools,
287
- },
306
+ // Wrap external API call that can throw
307
+ const nextResponse = await errore.tryAsync({
308
+ try: () =>
309
+ genAI.models.generateContent({
310
+ model,
311
+ contents: conversationHistory,
312
+ config: {
313
+ temperature,
314
+ thinkingConfig: {
315
+ thinkingBudget: 512,
316
+ },
317
+ tools:
318
+ stepsRemaining <= 0
319
+ ? [{ functionDeclarations: [transcriptionResultToolDeclaration] }]
320
+ : tools,
321
+ },
322
+ }),
323
+ catch: (e) => new TranscriptionError({ reason: `API call failed: ${String(e)}`, cause: e }),
288
324
  })
325
+
326
+ if (nextResponse instanceof Error) {
327
+ return nextResponse
328
+ }
329
+
330
+ response = nextResponse
289
331
  }
290
332
  }
291
333
 
292
- export async function transcribeAudio({
334
+ export type TranscribeAudioErrors =
335
+ | ApiKeyMissingError
336
+ | InvalidAudioFormatError
337
+ | TranscriptionLoopError
338
+
339
+ export function transcribeAudio({
293
340
  audio,
294
341
  prompt,
295
342
  language,
@@ -307,49 +354,55 @@ export async function transcribeAudio({
307
354
  directory?: string
308
355
  currentSessionContext?: string
309
356
  lastSessionContext?: string
310
- }): Promise<string> {
311
- try {
312
- const apiKey = geminiApiKey || process.env.GEMINI_API_KEY
357
+ }): Promise<TranscribeAudioErrors | string> {
358
+ const apiKey = geminiApiKey || process.env.GEMINI_API_KEY
313
359
 
314
- if (!apiKey) {
315
- throw new Error('Gemini API key is required for audio transcription')
316
- }
360
+ if (!apiKey) {
361
+ return Promise.resolve(new ApiKeyMissingError({ service: 'Gemini' }))
362
+ }
317
363
 
318
- const genAI = new GoogleGenAI({ apiKey })
364
+ const genAI = new GoogleGenAI({ apiKey })
319
365
 
320
- let audioBase64: string
366
+ const audioBase64: string = (() => {
321
367
  if (typeof audio === 'string') {
322
- audioBase64 = audio
323
- } else if (audio instanceof Buffer) {
324
- audioBase64 = audio.toString('base64')
325
- } else if (audio instanceof Uint8Array) {
326
- audioBase64 = Buffer.from(audio).toString('base64')
327
- } else if (audio instanceof ArrayBuffer) {
328
- audioBase64 = Buffer.from(audio).toString('base64')
329
- } else {
330
- throw new Error('Invalid audio format')
368
+ return audio
369
+ }
370
+ if (audio instanceof Buffer) {
371
+ return audio.toString('base64')
372
+ }
373
+ if (audio instanceof Uint8Array) {
374
+ return Buffer.from(audio).toString('base64')
375
+ }
376
+ if (audio instanceof ArrayBuffer) {
377
+ return Buffer.from(audio).toString('base64')
331
378
  }
379
+ return ''
380
+ })()
332
381
 
333
- const languageHint = language ? `The audio is in ${language}.\n\n` : ''
382
+ if (!audioBase64) {
383
+ return Promise.resolve(new InvalidAudioFormatError())
384
+ }
385
+
386
+ const languageHint = language ? `The audio is in ${language}.\n\n` : ''
334
387
 
335
- // build session context section
336
- const sessionContextParts: string[] = []
337
- if (lastSessionContext) {
338
- sessionContextParts.push(`<last_session>
388
+ // build session context section
389
+ const sessionContextParts: string[] = []
390
+ if (lastSessionContext) {
391
+ sessionContextParts.push(`<last_session>
339
392
  ${lastSessionContext}
340
393
  </last_session>`)
341
- }
342
- if (currentSessionContext) {
343
- sessionContextParts.push(`<current_session>
394
+ }
395
+ if (currentSessionContext) {
396
+ sessionContextParts.push(`<current_session>
344
397
  ${currentSessionContext}
345
398
  </current_session>`)
346
- }
347
- const sessionContextSection =
348
- sessionContextParts.length > 0
349
- ? `\nSession context (use to understand references to files, functions, tools used):\n${sessionContextParts.join('\n\n')}`
350
- : ''
399
+ }
400
+ const sessionContextSection =
401
+ sessionContextParts.length > 0
402
+ ? `\nSession context (use to understand references to files, functions, tools used):\n${sessionContextParts.join('\n\n')}`
403
+ : ''
351
404
 
352
- const transcriptionPrompt = `${languageHint}Transcribe this audio for a coding agent (like Claude Code or OpenCode).
405
+ const transcriptionPrompt = `${languageHint}Transcribe this audio for a coding agent (like Claude Code or OpenCode).
353
406
 
354
407
  CRITICAL REQUIREMENT: You MUST call the "transcriptionResult" tool to complete this task.
355
408
  - The transcriptionResult tool is the ONLY way to return results
@@ -379,46 +432,40 @@ REMEMBER: Call "transcriptionResult" tool with your transcription. This is manda
379
432
 
380
433
  Note: "critique" is a CLI tool for showing diffs in the browser.`
381
434
 
382
- // const hasDirectory = directory && directory.trim().length > 0
383
- const tools = [
384
- {
385
- functionDeclarations: [
386
- transcriptionResultToolDeclaration,
387
- // grep/glob disabled - was causing transcription to hang
388
- // ...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
389
- ],
390
- },
391
- ]
392
-
393
- const initialContents: Content[] = [
394
- {
395
- role: 'user',
396
- parts: [
397
- { text: transcriptionPrompt },
398
- {
399
- inlineData: {
400
- data: audioBase64,
401
- mimeType: 'audio/mpeg',
402
- },
435
+ // const hasDirectory = directory && directory.trim().length > 0
436
+ const tools = [
437
+ {
438
+ functionDeclarations: [
439
+ transcriptionResultToolDeclaration,
440
+ // grep/glob disabled - was causing transcription to hang
441
+ // ...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
442
+ ],
443
+ },
444
+ ]
445
+
446
+ const initialContents: Content[] = [
447
+ {
448
+ role: 'user',
449
+ parts: [
450
+ { text: transcriptionPrompt },
451
+ {
452
+ inlineData: {
453
+ data: audioBase64,
454
+ mimeType: 'audio/mpeg',
403
455
  },
404
- ],
405
- },
406
- ]
456
+ },
457
+ ],
458
+ },
459
+ ]
407
460
 
408
- const toolRunner = createToolRunner({ directory })
461
+ const toolRunner = createToolRunner({ directory })
409
462
 
410
- return await runTranscriptionLoop({
411
- genAI,
412
- model: 'gemini-2.5-flash',
413
- initialContents,
414
- tools,
415
- temperature: temperature ?? 0.3,
416
- toolRunner,
417
- })
418
- } catch (error) {
419
- voiceLogger.error('Failed to transcribe audio:', error)
420
- throw new Error(
421
- `Audio transcription failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
422
- )
423
- }
463
+ return runTranscriptionLoop({
464
+ genAI,
465
+ model: 'gemini-2.5-flash',
466
+ initialContents,
467
+ tools,
468
+ temperature: temperature ?? 0.3,
469
+ toolRunner,
470
+ })
424
471
  }