kimaki 0.4.37 → 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 (53) hide show
  1. package/dist/channel-management.js +6 -2
  2. package/dist/cli.js +41 -15
  3. package/dist/commands/abort.js +15 -6
  4. package/dist/commands/add-project.js +9 -0
  5. package/dist/commands/agent.js +114 -20
  6. package/dist/commands/fork.js +13 -2
  7. package/dist/commands/model.js +12 -0
  8. package/dist/commands/remove-project.js +26 -16
  9. package/dist/commands/resume.js +9 -0
  10. package/dist/commands/session.js +13 -0
  11. package/dist/commands/share.js +10 -1
  12. package/dist/commands/undo-redo.js +13 -4
  13. package/dist/database.js +24 -5
  14. package/dist/discord-bot.js +38 -31
  15. package/dist/errors.js +110 -0
  16. package/dist/genai-worker.js +18 -16
  17. package/dist/interaction-handler.js +6 -1
  18. package/dist/markdown.js +96 -85
  19. package/dist/markdown.test.js +10 -3
  20. package/dist/message-formatting.js +50 -37
  21. package/dist/opencode.js +43 -46
  22. package/dist/session-handler.js +136 -8
  23. package/dist/system-message.js +2 -0
  24. package/dist/tools.js +18 -8
  25. package/dist/voice-handler.js +48 -25
  26. package/dist/voice.js +159 -131
  27. package/package.json +2 -1
  28. package/src/channel-management.ts +6 -2
  29. package/src/cli.ts +67 -19
  30. package/src/commands/abort.ts +17 -7
  31. package/src/commands/add-project.ts +9 -0
  32. package/src/commands/agent.ts +160 -25
  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 +13 -0
  38. package/src/commands/share.ts +11 -1
  39. package/src/commands/undo-redo.ts +15 -6
  40. package/src/database.ts +26 -4
  41. package/src/discord-bot.ts +42 -34
  42. package/src/errors.ts +208 -0
  43. package/src/genai-worker.ts +20 -17
  44. package/src/interaction-handler.ts +7 -1
  45. package/src/markdown.test.ts +13 -3
  46. package/src/markdown.ts +111 -95
  47. package/src/message-formatting.ts +55 -38
  48. package/src/opencode.ts +52 -49
  49. package/src/session-handler.ts +164 -11
  50. package/src/system-message.ts +2 -0
  51. package/src/tools.ts +18 -8
  52. package/src/voice-handler.ts +48 -23
  53. package/src/voice.ts +195 -148
@@ -53,6 +53,7 @@ import {
53
53
  type ThreadChannel,
54
54
  } from 'discord.js'
55
55
  import fs from 'node:fs'
56
+ import * as errore from 'errore'
56
57
  import { extractTagsArrays } from './xml.js'
57
58
  import { createLogger } from './logger.js'
58
59
  import { setGlobalDispatcher, Agent } from 'undici'
@@ -149,10 +150,12 @@ export async function startDiscordBot({
149
150
  }
150
151
  if (message.partial) {
151
152
  discordLogger.log(`Fetching partial message ${message.id}`)
152
- try {
153
- await message.fetch()
154
- } catch (error) {
155
- discordLogger.log(`Failed to fetch partial message ${message.id}:`, error)
153
+ const fetched = await errore.tryAsync({
154
+ try: () => message.fetch(),
155
+ catch: (e) => e as Error,
156
+ })
157
+ if (errore.isError(fetched)) {
158
+ discordLogger.log(`Failed to fetch partial message ${message.id}:`, fetched.message)
156
159
  return
157
160
  }
158
161
  }
@@ -230,17 +233,11 @@ export async function startDiscordBot({
230
233
  return
231
234
  }
232
235
 
233
- // Include starter message (notification) as context for the session
236
+ // Include starter message as context for the session
234
237
  let prompt = message.content
235
238
  const starterMessage = await thread.fetchStarterMessage().catch(() => null)
236
- if (starterMessage?.content) {
237
- // Strip notification prefix if present
238
- const notificationContent = starterMessage.content
239
- .replace(/^📢 \*\*Notification\*\*\n?/, '')
240
- .trim()
241
- if (notificationContent) {
242
- prompt = `Context from notification:\n${notificationContent}\n\nUser request:\n${message.content}`
243
- }
239
+ if (starterMessage?.content && starterMessage.content !== message.content) {
240
+ prompt = `Context from thread:\n${starterMessage.content}\n\nUser request:\n${message.content}`
244
241
  }
245
242
 
246
243
  await handleOpencodeSession({
@@ -262,30 +259,41 @@ export async function startDiscordBot({
262
259
  if (projectDirectory) {
263
260
  try {
264
261
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
262
+ if (errore.isError(getClient)) {
263
+ voiceLogger.error(`[SESSION] Failed to initialize OpenCode client:`, getClient.message)
264
+ throw new Error(getClient.message)
265
+ }
265
266
  const client = getClient()
266
267
 
267
268
  // get current session context (without system prompt, it would be duplicated)
268
269
  if (row.session_id) {
269
- currentSessionContext = await getCompactSessionContext({
270
+ const result = await getCompactSessionContext({
270
271
  client,
271
272
  sessionId: row.session_id,
272
273
  includeSystemPrompt: false,
273
274
  maxMessages: 15,
274
275
  })
276
+ if (errore.isOk(result)) {
277
+ currentSessionContext = result
278
+ }
275
279
  }
276
280
 
277
281
  // get last session context (with system prompt for project context)
278
- const lastSessionId = await getLastSessionId({
282
+ const lastSessionResult = await getLastSessionId({
279
283
  client,
280
284
  excludeSessionId: row.session_id,
281
285
  })
286
+ const lastSessionId = errore.unwrapOr(lastSessionResult, null)
282
287
  if (lastSessionId) {
283
- lastSessionContext = await getCompactSessionContext({
288
+ const result = await getCompactSessionContext({
284
289
  client,
285
290
  sessionId: lastSessionId,
286
291
  includeSystemPrompt: true,
287
292
  maxMessages: 10,
288
293
  })
294
+ if (errore.isOk(result)) {
295
+ lastSessionContext = result
296
+ }
289
297
  }
290
298
  } catch (e) {
291
299
  voiceLogger.error(`Could not get session context:`, e)
@@ -419,42 +427,42 @@ export async function startDiscordBot({
419
427
  }
420
428
  })
421
429
 
422
- // Magic prefix used by `kimaki send` CLI command to initiate sessions
423
- const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
424
-
425
- // Handle bot-initiated threads created by `kimaki send`
430
+ // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
426
431
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
427
432
  try {
428
433
  if (!newlyCreated) {
429
434
  return
430
435
  }
431
436
 
437
+ // Check if this thread is marked for auto-start in the database
438
+ const db = getDatabase()
439
+ const pendingRow = db
440
+ .prepare('SELECT thread_id FROM pending_auto_start WHERE thread_id = ?')
441
+ .get(thread.id) as { thread_id: string } | undefined
442
+
443
+ if (!pendingRow) {
444
+ return // Not a CLI-initiated auto-start thread
445
+ }
446
+
447
+ // Remove from pending table
448
+ db.prepare('DELETE FROM pending_auto_start WHERE thread_id = ?').run(thread.id)
449
+
450
+ discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
451
+
432
452
  // Only handle threads in text channels
433
453
  const parent = thread.parent as TextChannel | null
434
454
  if (!parent || parent.type !== ChannelType.GuildText) {
435
455
  return
436
456
  }
437
457
 
438
- // Get the starter message to check for magic prefix
458
+ // Get the starter message for the prompt
439
459
  const starterMessage = await thread.fetchStarterMessage().catch(() => null)
440
460
  if (!starterMessage) {
441
461
  discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`)
442
462
  return
443
463
  }
444
464
 
445
- // Only handle messages from this bot with the magic prefix
446
- if (starterMessage.author.id !== discordClient.user?.id) {
447
- return
448
- }
449
-
450
- if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
451
- return
452
- }
453
-
454
- discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
455
-
456
- // Extract the prompt (everything after the prefix)
457
- const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim()
465
+ const prompt = starterMessage.content.trim()
458
466
  if (!prompt) {
459
467
  discordLogger.log(`[BOT_SESSION] No prompt found in starter message`)
460
468
  return
package/src/errors.ts ADDED
@@ -0,0 +1,208 @@
1
+ // TaggedError definitions for type-safe error handling with errore.
2
+ // Errors are grouped by category: infrastructure, domain, and validation.
3
+ // Use errore.matchError() for exhaustive error handling in command handlers.
4
+
5
+ import * as errore from 'errore'
6
+
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+ // INFRASTRUCTURE ERRORS - Server, filesystem, external services
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+
11
+ export class DirectoryNotAccessibleError extends errore.TaggedError('DirectoryNotAccessibleError')<{
12
+ directory: string
13
+ message: string
14
+ }>() {
15
+ constructor(args: { directory: string }) {
16
+ super({ ...args, message: `Directory does not exist or is not accessible: ${args.directory}` })
17
+ }
18
+ }
19
+
20
+ export class ServerStartError extends errore.TaggedError('ServerStartError')<{
21
+ port: number
22
+ reason: string
23
+ message: string
24
+ }>() {
25
+ constructor(args: { port: number; reason: string }) {
26
+ super({ ...args, message: `Server failed to start on port ${args.port}: ${args.reason}` })
27
+ }
28
+ }
29
+
30
+ export class ServerNotFoundError extends errore.TaggedError('ServerNotFoundError')<{
31
+ directory: string
32
+ message: string
33
+ }>() {
34
+ constructor(args: { directory: string }) {
35
+ super({ ...args, message: `OpenCode server not found for directory: ${args.directory}` })
36
+ }
37
+ }
38
+
39
+ export class ServerNotReadyError extends errore.TaggedError('ServerNotReadyError')<{
40
+ directory: string
41
+ message: string
42
+ }>() {
43
+ constructor(args: { directory: string }) {
44
+ super({
45
+ ...args,
46
+ message: `OpenCode server for directory "${args.directory}" is in an error state (no client available)`,
47
+ })
48
+ }
49
+ }
50
+
51
+ export class ApiKeyMissingError extends errore.TaggedError('ApiKeyMissingError')<{
52
+ service: string
53
+ message: string
54
+ }>() {
55
+ constructor(args: { service: string }) {
56
+ super({ ...args, message: `${args.service} API key is required` })
57
+ }
58
+ }
59
+
60
+ // ═══════════════════════════════════════════════════════════════════════════
61
+ // DOMAIN ERRORS - Sessions, messages, transcription
62
+ // ═══════════════════════════════════════════════════════════════════════════
63
+
64
+ export class SessionNotFoundError extends errore.TaggedError('SessionNotFoundError')<{
65
+ sessionId: string
66
+ message: string
67
+ }>() {
68
+ constructor(args: { sessionId: string }) {
69
+ super({ ...args, message: `Session ${args.sessionId} not found` })
70
+ }
71
+ }
72
+
73
+ export class SessionCreateError extends errore.TaggedError('SessionCreateError')<{
74
+ message: string
75
+ cause?: unknown
76
+ }>() {}
77
+
78
+ export class MessagesNotFoundError extends errore.TaggedError('MessagesNotFoundError')<{
79
+ sessionId: string
80
+ message: string
81
+ }>() {
82
+ constructor(args: { sessionId: string }) {
83
+ super({ ...args, message: `No messages found for session ${args.sessionId}` })
84
+ }
85
+ }
86
+
87
+ export class TranscriptionError extends errore.TaggedError('TranscriptionError')<{
88
+ reason: string
89
+ message: string
90
+ cause?: unknown
91
+ }>() {
92
+ constructor(args: { reason: string; cause?: unknown }) {
93
+ super({ ...args, message: `Transcription failed: ${args.reason}` })
94
+ }
95
+ }
96
+
97
+ export class GrepSearchError extends errore.TaggedError('GrepSearchError')<{
98
+ pattern: string
99
+ message: string
100
+ cause?: unknown
101
+ }>() {
102
+ constructor(args: { pattern: string; cause?: unknown }) {
103
+ super({ ...args, message: `Grep search failed for pattern: ${args.pattern}` })
104
+ }
105
+ }
106
+
107
+ export class GlobSearchError extends errore.TaggedError('GlobSearchError')<{
108
+ pattern: string
109
+ message: string
110
+ cause?: unknown
111
+ }>() {
112
+ constructor(args: { pattern: string; cause?: unknown }) {
113
+ super({ ...args, message: `Glob search failed for pattern: ${args.pattern}` })
114
+ }
115
+ }
116
+
117
+ // ═══════════════════════════════════════════════════════════════════════════
118
+ // VALIDATION ERRORS - Input validation, format checks
119
+ // ═══════════════════════════════════════════════════════════════════════════
120
+
121
+ export class InvalidAudioFormatError extends errore.TaggedError('InvalidAudioFormatError')<{
122
+ message: string
123
+ }>() {
124
+ constructor() {
125
+ super({ message: 'Invalid audio format' })
126
+ }
127
+ }
128
+
129
+ export class EmptyTranscriptionError extends errore.TaggedError('EmptyTranscriptionError')<{
130
+ message: string
131
+ }>() {
132
+ constructor() {
133
+ super({ message: 'Model returned empty transcription' })
134
+ }
135
+ }
136
+
137
+ export class NoResponseContentError extends errore.TaggedError('NoResponseContentError')<{
138
+ message: string
139
+ }>() {
140
+ constructor() {
141
+ super({ message: 'No response content from model' })
142
+ }
143
+ }
144
+
145
+ export class NoToolResponseError extends errore.TaggedError('NoToolResponseError')<{
146
+ message: string
147
+ }>() {
148
+ constructor() {
149
+ super({ message: 'No valid tool responses' })
150
+ }
151
+ }
152
+
153
+ // ═══════════════════════════════════════════════════════════════════════════
154
+ // NETWORK ERRORS - Fetch and HTTP
155
+ // ═══════════════════════════════════════════════════════════════════════════
156
+
157
+ export class FetchError extends errore.TaggedError('FetchError')<{
158
+ url: string
159
+ message: string
160
+ cause?: unknown
161
+ }>() {
162
+ constructor(args: { url: string; cause?: unknown }) {
163
+ const causeMsg = args.cause instanceof Error ? args.cause.message : String(args.cause)
164
+ super({ ...args, message: `Fetch failed for ${args.url}: ${causeMsg}` })
165
+ }
166
+ }
167
+
168
+ // ═══════════════════════════════════════════════════════════════════════════
169
+ // API ERRORS - External service responses
170
+ // ═══════════════════════════════════════════════════════════════════════════
171
+
172
+ export class DiscordApiError extends errore.TaggedError('DiscordApiError')<{
173
+ status: number
174
+ message: string
175
+ }>() {
176
+ constructor(args: { status: number; body?: string }) {
177
+ super({ ...args, message: `Discord API error: ${args.status}${args.body ? ` - ${args.body}` : ''}` })
178
+ }
179
+ }
180
+
181
+ export class OpenCodeApiError extends errore.TaggedError('OpenCodeApiError')<{
182
+ status: number
183
+ message: string
184
+ }>() {
185
+ constructor(args: { status: number; body?: string }) {
186
+ super({ ...args, message: `OpenCode API error (${args.status})${args.body ? `: ${args.body}` : ''}` })
187
+ }
188
+ }
189
+
190
+ // ═══════════════════════════════════════════════════════════════════════════
191
+ // UNION TYPES - For function signatures
192
+ // ═══════════════════════════════════════════════════════════════════════════
193
+
194
+ export type TranscriptionErrors =
195
+ | ApiKeyMissingError
196
+ | InvalidAudioFormatError
197
+ | TranscriptionError
198
+ | EmptyTranscriptionError
199
+ | NoResponseContentError
200
+ | NoToolResponseError
201
+
202
+ export type OpenCodeErrors =
203
+ | DirectoryNotAccessibleError
204
+ | ServerStartError
205
+ | ServerNotFoundError
206
+ | ServerNotReadyError
207
+
208
+ export type SessionErrors = SessionNotFoundError | MessagesNotFoundError | OpenCodeApiError
@@ -4,13 +4,14 @@
4
4
 
5
5
  import { parentPort, threadId } from 'node:worker_threads'
6
6
  import { createWriteStream, type WriteStream } from 'node:fs'
7
- import { mkdir } from 'node:fs/promises'
8
7
  import path from 'node:path'
8
+ import * as errore from 'errore'
9
9
  import { Resampler } from '@purinton/resampler'
10
10
  import * as prism from 'prism-media'
11
11
  import { startGenAiSession } from './genai.js'
12
12
  import type { Session } from '@google/genai'
13
13
  import { getTools } from './tools.js'
14
+ import { mkdir } from 'node:fs/promises'
14
15
  import type { WorkerInMessage, WorkerOutMessage } from './worker-types.js'
15
16
  import { createLogger } from './logger.js'
16
17
 
@@ -127,26 +128,28 @@ async function createAssistantAudioLogStream(
127
128
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
128
129
  const audioDir = path.join(process.cwd(), 'discord-audio-logs', guildId, channelId)
129
130
 
130
- try {
131
- await mkdir(audioDir, { recursive: true })
131
+ const mkdirError = await errore.tryAsync({
132
+ try: () => mkdir(audioDir, { recursive: true }),
133
+ catch: (e) => e as Error,
134
+ })
135
+ if (errore.isError(mkdirError)) {
136
+ workerLogger.error(`Failed to create audio log directory:`, mkdirError.message)
137
+ return null
138
+ }
132
139
 
133
- // Create stream for assistant audio (24kHz mono s16le PCM)
134
- const outputFileName = `assistant_${timestamp}.24.pcm`
135
- const outputFilePath = path.join(audioDir, outputFileName)
136
- const outputAudioStream = createWriteStream(outputFilePath)
140
+ // Create stream for assistant audio (24kHz mono s16le PCM)
141
+ const outputFileName = `assistant_${timestamp}.24.pcm`
142
+ const outputFilePath = path.join(audioDir, outputFileName)
143
+ const outputAudioStream = createWriteStream(outputFilePath)
137
144
 
138
- // Add error handler to prevent crashes
139
- outputAudioStream.on('error', (error) => {
140
- workerLogger.error(`Assistant audio log stream error:`, error)
141
- })
145
+ // Add error handler to prevent crashes
146
+ outputAudioStream.on('error', (error) => {
147
+ workerLogger.error(`Assistant audio log stream error:`, error)
148
+ })
142
149
 
143
- workerLogger.log(`Created assistant audio log: ${outputFilePath}`)
150
+ workerLogger.log(`Created assistant audio log: ${outputFilePath}`)
144
151
 
145
- return outputAudioStream
146
- } catch (error) {
147
- workerLogger.error(`Failed to create audio log directory:`, error)
148
- return null
149
- }
152
+ return outputAudioStream
150
153
  }
151
154
 
152
155
  // Handle encoded Opus packets
@@ -20,7 +20,7 @@ import {
20
20
  handleProviderSelectMenu,
21
21
  handleModelSelectMenu,
22
22
  } from './commands/model.js'
23
- import { handleAgentCommand, handleAgentSelectMenu } from './commands/agent.js'
23
+ import { handleAgentCommand, handleAgentSelectMenu, handleQuickAgentCommand } from './commands/agent.js'
24
24
  import { handleAskQuestionSelectMenu } from './commands/ask-question.js'
25
25
  import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js'
26
26
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js'
@@ -136,6 +136,12 @@ export function registerInteractionHandler({
136
136
  return
137
137
  }
138
138
 
139
+ // Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
140
+ if (interaction.commandName.endsWith('-agent') && interaction.commandName !== 'agent') {
141
+ await handleQuickAgentCommand({ command: interaction, appId })
142
+ return
143
+ }
144
+
139
145
  // Handle user-defined commands (ending with -cmd suffix)
140
146
  if (interaction.commandName.endsWith('-cmd')) {
141
147
  await handleUserCommand({ command: interaction, appId })
@@ -1,6 +1,7 @@
1
1
  import { test, expect, beforeAll, afterAll } from 'vitest'
2
2
  import { spawn, type ChildProcess } from 'child_process'
3
3
  import { OpencodeClient } from '@opencode-ai/sdk'
4
+ import * as errore from 'errore'
4
5
  import { ShareMarkdown, getCompactSessionContext } from './markdown.js'
5
6
 
6
7
  let serverProcess: ChildProcess
@@ -121,11 +122,14 @@ test('generate markdown from first available session', async () => {
121
122
  const exporter = new ShareMarkdown(client)
122
123
 
123
124
  // Generate markdown with system info
124
- const markdown = await exporter.generate({
125
+ const markdownResult = await exporter.generate({
125
126
  sessionID,
126
127
  includeSystemInfo: true,
127
128
  })
128
129
 
130
+ expect(errore.isOk(markdownResult)).toBe(true)
131
+ const markdown = errore.unwrap(markdownResult)
132
+
129
133
  console.log(`Generated markdown length: ${markdown.length} characters`)
130
134
 
131
135
  // Basic assertions
@@ -299,13 +303,16 @@ test('generate markdown from multiple sessions', async () => {
299
303
  test.skipIf(process.env.CI)('getCompactSessionContext generates compact format', async () => {
300
304
  const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM'
301
305
 
302
- const context = await getCompactSessionContext({
306
+ const contextResult = await getCompactSessionContext({
303
307
  client,
304
308
  sessionId,
305
309
  includeSystemPrompt: true,
306
310
  maxMessages: 15,
307
311
  })
308
312
 
313
+ expect(errore.isOk(contextResult)).toBe(true)
314
+ const context = errore.unwrap(contextResult)
315
+
309
316
  console.log(`Generated compact context length: ${context.length} characters`)
310
317
 
311
318
  expect(context).toBeTruthy()
@@ -319,13 +326,16 @@ test.skipIf(process.env.CI)('getCompactSessionContext generates compact format',
319
326
  test.skipIf(process.env.CI)('getCompactSessionContext without system prompt', async () => {
320
327
  const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM'
321
328
 
322
- const context = await getCompactSessionContext({
329
+ const contextResult = await getCompactSessionContext({
323
330
  client,
324
331
  sessionId,
325
332
  includeSystemPrompt: false,
326
333
  maxMessages: 10,
327
334
  })
328
335
 
336
+ expect(errore.isOk(contextResult)).toBe(true)
337
+ const context = errore.unwrap(contextResult)
338
+
329
339
  console.log(`Generated compact context (no system) length: ${context.length} characters`)
330
340
 
331
341
  expect(context).toBeTruthy()