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.
- package/dist/cli.js +9 -3
- package/dist/commands/abort.js +15 -6
- package/dist/commands/add-project.js +9 -0
- package/dist/commands/agent.js +13 -1
- package/dist/commands/fork.js +13 -2
- package/dist/commands/model.js +12 -0
- package/dist/commands/remove-project.js +26 -16
- package/dist/commands/resume.js +9 -0
- package/dist/commands/session.js +13 -0
- package/dist/commands/share.js +10 -1
- package/dist/commands/undo-redo.js +13 -4
- package/dist/database.js +9 -5
- package/dist/discord-bot.js +21 -8
- package/dist/errors.js +110 -0
- package/dist/genai-worker.js +18 -16
- package/dist/markdown.js +96 -85
- package/dist/markdown.test.js +10 -3
- package/dist/message-formatting.js +50 -37
- package/dist/opencode.js +43 -46
- package/dist/session-handler.js +100 -2
- package/dist/system-message.js +2 -0
- package/dist/tools.js +18 -8
- package/dist/voice-handler.js +48 -25
- package/dist/voice.js +159 -131
- package/package.json +2 -1
- package/src/cli.ts +12 -3
- package/src/commands/abort.ts +17 -7
- package/src/commands/add-project.ts +9 -0
- package/src/commands/agent.ts +13 -1
- package/src/commands/fork.ts +18 -7
- package/src/commands/model.ts +12 -0
- package/src/commands/remove-project.ts +28 -16
- package/src/commands/resume.ts +9 -0
- package/src/commands/session.ts +13 -0
- package/src/commands/share.ts +11 -1
- package/src/commands/undo-redo.ts +15 -6
- package/src/database.ts +9 -4
- package/src/discord-bot.ts +21 -7
- package/src/errors.ts +208 -0
- package/src/genai-worker.ts +20 -17
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +111 -95
- package/src/message-formatting.ts +55 -38
- package/src/opencode.ts +52 -49
- package/src/session-handler.ts +118 -3
- package/src/system-message.ts +2 -0
- package/src/tools.ts +18 -8
- package/src/voice-handler.ts +48 -23
- 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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
})
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
if (results.length === 0) {
|
|
45
|
+
return 'No matches found'
|
|
46
|
+
}
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
55
|
+
return output.slice(0, 2000)
|
|
56
|
+
},
|
|
57
|
+
catch: (e) => new GrepSearchError({ pattern, cause: e }),
|
|
58
|
+
})
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
if (files.length === 0) {
|
|
72
|
+
return 'No files found'
|
|
73
|
+
}
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
146
|
+
const result = await runGrep({ pattern, directory })
|
|
147
|
+
const output = (() => {
|
|
148
|
+
if (errore.isError(result)) {
|
|
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
|
|
161
|
+
const result = await runGlob({ pattern, directory })
|
|
162
|
+
const output = (() => {
|
|
163
|
+
if (errore.isError(result)) {
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 (errore.isError(initialResponse)) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
:
|
|
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 (errore.isError(nextResponse)) {
|
|
327
|
+
return nextResponse
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
response = nextResponse
|
|
289
331
|
}
|
|
290
332
|
}
|
|
291
333
|
|
|
292
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
360
|
+
if (!apiKey) {
|
|
361
|
+
return Promise.resolve(new ApiKeyMissingError({ service: 'Gemini' }))
|
|
362
|
+
}
|
|
317
363
|
|
|
318
|
-
|
|
364
|
+
const genAI = new GoogleGenAI({ apiKey })
|
|
319
365
|
|
|
320
|
-
|
|
366
|
+
const audioBase64: string = (() => {
|
|
321
367
|
if (typeof audio === 'string') {
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
343
|
-
|
|
394
|
+
}
|
|
395
|
+
if (currentSessionContext) {
|
|
396
|
+
sessionContextParts.push(`<current_session>
|
|
344
397
|
${currentSessionContext}
|
|
345
398
|
</current_session>`)
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
461
|
+
const toolRunner = createToolRunner({ directory })
|
|
409
462
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
}
|