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
@@ -2,7 +2,7 @@
2
2
  // Bridges Discord messages to OpenCode sessions, manages voice connections,
3
3
  // and orchestrates the main event loop for the Kimaki bot.
4
4
 
5
- import { getDatabase, closeDatabase } from './database.js'
5
+ import { getDatabase, closeDatabase, getThreadWorktree } from './database.js'
6
6
  import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js'
7
7
  import {
8
8
  escapeBackticksInCodeBlocks,
@@ -53,11 +53,15 @@ 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'
59
60
 
60
- setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }))
61
+ // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
62
+ // Each session's event.subscribe() holds a connection; without enough connections,
63
+ // regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
64
+ setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }))
61
65
 
62
66
  const discordLogger = createLogger('DISCORD')
63
67
  const voiceLogger = createLogger('VOICE')
@@ -149,10 +153,12 @@ export async function startDiscordBot({
149
153
  }
150
154
  if (message.partial) {
151
155
  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)
156
+ const fetched = await errore.tryAsync({
157
+ try: () => message.fetch(),
158
+ catch: (e) => e as Error,
159
+ })
160
+ if (fetched instanceof Error) {
161
+ discordLogger.log(`Failed to fetch partial message ${message.id}:`, fetched.message)
156
162
  return
157
163
  }
158
164
  }
@@ -201,6 +207,29 @@ export async function startDiscordBot({
201
207
  channelAppId = extracted['kimaki.app']?.[0]?.trim()
202
208
  }
203
209
 
210
+ // Check if this thread is a worktree thread
211
+ const worktreeInfo = getThreadWorktree(thread.id)
212
+ if (worktreeInfo) {
213
+ if (worktreeInfo.status === 'pending') {
214
+ await message.reply({
215
+ content: '⏳ Worktree is still being created. Please wait...',
216
+ flags: SILENT_MESSAGE_FLAGS,
217
+ })
218
+ return
219
+ }
220
+ if (worktreeInfo.status === 'error') {
221
+ await message.reply({
222
+ content: `❌ Worktree creation failed: ${worktreeInfo.error_message}`,
223
+ flags: SILENT_MESSAGE_FLAGS,
224
+ })
225
+ return
226
+ }
227
+ if (worktreeInfo.worktree_directory) {
228
+ projectDirectory = worktreeInfo.worktree_directory
229
+ discordLogger.log(`Using worktree directory: ${projectDirectory}`)
230
+ }
231
+ }
232
+
204
233
  if (channelAppId && channelAppId !== currentAppId) {
205
234
  voiceLogger.log(
206
235
  `[IGNORED] Thread belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
@@ -256,30 +285,41 @@ export async function startDiscordBot({
256
285
  if (projectDirectory) {
257
286
  try {
258
287
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
288
+ if (getClient instanceof Error) {
289
+ voiceLogger.error(`[SESSION] Failed to initialize OpenCode client:`, getClient.message)
290
+ throw new Error(getClient.message)
291
+ }
259
292
  const client = getClient()
260
293
 
261
294
  // get current session context (without system prompt, it would be duplicated)
262
295
  if (row.session_id) {
263
- currentSessionContext = await getCompactSessionContext({
296
+ const result = await getCompactSessionContext({
264
297
  client,
265
298
  sessionId: row.session_id,
266
299
  includeSystemPrompt: false,
267
300
  maxMessages: 15,
268
301
  })
302
+ if (errore.isOk(result)) {
303
+ currentSessionContext = result
304
+ }
269
305
  }
270
306
 
271
307
  // get last session context (with system prompt for project context)
272
- const lastSessionId = await getLastSessionId({
308
+ const lastSessionResult = await getLastSessionId({
273
309
  client,
274
310
  excludeSessionId: row.session_id,
275
311
  })
312
+ const lastSessionId = errore.unwrapOr(lastSessionResult, null)
276
313
  if (lastSessionId) {
277
- lastSessionContext = await getCompactSessionContext({
314
+ const result = await getCompactSessionContext({
278
315
  client,
279
316
  sessionId: lastSessionId,
280
317
  includeSystemPrompt: true,
281
318
  maxMessages: 10,
282
319
  })
320
+ if (errore.isOk(result)) {
321
+ lastSessionContext = result
322
+ }
283
323
  }
284
324
  } catch (e) {
285
325
  voiceLogger.error(`Could not get session context:`, e)
@@ -9,6 +9,9 @@ import { formatMarkdownTables } from './format-tables.js'
9
9
  import { limitHeadingDepth } from './limit-heading-depth.js'
10
10
  import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
11
11
  import { createLogger } from './logger.js'
12
+ import mime from 'mime'
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
12
15
 
13
16
  const discordLogger = createLogger('DISCORD')
14
17
 
@@ -302,3 +305,50 @@ export function getKimakiMetadata(textChannel: TextChannel | null): {
302
305
 
303
306
  return { projectDirectory, channelAppId }
304
307
  }
308
+
309
+ /**
310
+ * Upload files to a Discord thread/channel in a single message.
311
+ * Sending all files in one message causes Discord to display images in a grid layout.
312
+ */
313
+ export async function uploadFilesToDiscord({
314
+ threadId,
315
+ botToken,
316
+ files,
317
+ }: {
318
+ threadId: string
319
+ botToken: string
320
+ files: string[]
321
+ }): Promise<void> {
322
+ if (files.length === 0) {
323
+ return
324
+ }
325
+
326
+ // Build attachments array for all files
327
+ const attachments = files.map((file, index) => ({
328
+ id: index,
329
+ filename: path.basename(file),
330
+ }))
331
+
332
+ const formData = new FormData()
333
+ formData.append('payload_json', JSON.stringify({ attachments }))
334
+
335
+ // Append each file with its array index, with correct MIME type for grid display
336
+ files.forEach((file, index) => {
337
+ const buffer = fs.readFileSync(file)
338
+ const mimeType = mime.getType(file) || 'application/octet-stream'
339
+ formData.append(`files[${index}]`, new Blob([buffer], { type: mimeType }), path.basename(file))
340
+ })
341
+
342
+ const response = await fetch(`https://discord.com/api/v10/channels/${threadId}/messages`, {
343
+ method: 'POST',
344
+ headers: {
345
+ Authorization: `Bot ${botToken}`,
346
+ },
347
+ body: formData,
348
+ })
349
+
350
+ if (!response.ok) {
351
+ const error = await response.text()
352
+ throw new Error(`Discord API error: ${response.status} - ${error}`)
353
+ }
354
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,138 @@
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 { createTaggedError } from 'errore'
6
+
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+ // INFRASTRUCTURE ERRORS - Server, filesystem, external services
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+
11
+ export class DirectoryNotAccessibleError extends createTaggedError({
12
+ name: 'DirectoryNotAccessibleError',
13
+ message: 'Directory does not exist or is not accessible: $directory',
14
+ }) {}
15
+
16
+ export class ServerStartError extends createTaggedError({
17
+ name: 'ServerStartError',
18
+ message: 'Server failed to start on port $port: $reason',
19
+ }) {}
20
+
21
+ export class ServerNotFoundError extends createTaggedError({
22
+ name: 'ServerNotFoundError',
23
+ message: 'OpenCode server not found for directory: $directory',
24
+ }) {}
25
+
26
+ export class ServerNotReadyError extends createTaggedError({
27
+ name: 'ServerNotReadyError',
28
+ message: 'OpenCode server for directory "$directory" is in an error state (no client available)',
29
+ }) {}
30
+
31
+ export class ApiKeyMissingError extends createTaggedError({
32
+ name: 'ApiKeyMissingError',
33
+ message: '$service API key is required',
34
+ }) {}
35
+
36
+ // ═══════════════════════════════════════════════════════════════════════════
37
+ // DOMAIN ERRORS - Sessions, messages, transcription
38
+ // ═══════════════════════════════════════════════════════════════════════════
39
+
40
+ export class SessionNotFoundError extends createTaggedError({
41
+ name: 'SessionNotFoundError',
42
+ message: 'Session $sessionId not found',
43
+ }) {}
44
+
45
+ export class SessionCreateError extends createTaggedError({
46
+ name: 'SessionCreateError',
47
+ message: '$message',
48
+ }) {}
49
+
50
+ export class MessagesNotFoundError extends createTaggedError({
51
+ name: 'MessagesNotFoundError',
52
+ message: 'No messages found for session $sessionId',
53
+ }) {}
54
+
55
+ export class TranscriptionError extends createTaggedError({
56
+ name: 'TranscriptionError',
57
+ message: 'Transcription failed: $reason',
58
+ }) {}
59
+
60
+ export class GrepSearchError extends createTaggedError({
61
+ name: 'GrepSearchError',
62
+ message: 'Grep search failed for pattern: $pattern',
63
+ }) {}
64
+
65
+ export class GlobSearchError extends createTaggedError({
66
+ name: 'GlobSearchError',
67
+ message: 'Glob search failed for pattern: $pattern',
68
+ }) {}
69
+
70
+ // ═══════════════════════════════════════════════════════════════════════════
71
+ // VALIDATION ERRORS - Input validation, format checks
72
+ // ═══════════════════════════════════════════════════════════════════════════
73
+
74
+ export class InvalidAudioFormatError extends createTaggedError({
75
+ name: 'InvalidAudioFormatError',
76
+ message: 'Invalid audio format',
77
+ }) {}
78
+
79
+ export class EmptyTranscriptionError extends createTaggedError({
80
+ name: 'EmptyTranscriptionError',
81
+ message: 'Model returned empty transcription',
82
+ }) {}
83
+
84
+ export class NoResponseContentError extends createTaggedError({
85
+ name: 'NoResponseContentError',
86
+ message: 'No response content from model',
87
+ }) {}
88
+
89
+ export class NoToolResponseError extends createTaggedError({
90
+ name: 'NoToolResponseError',
91
+ message: 'No valid tool responses',
92
+ }) {}
93
+
94
+ // ═══════════════════════════════════════════════════════════════════════════
95
+ // NETWORK ERRORS - Fetch and HTTP
96
+ // ═══════════════════════════════════════════════════════════════════════════
97
+
98
+ export class FetchError extends createTaggedError({
99
+ name: 'FetchError',
100
+ message: 'Fetch failed for $url',
101
+ }) {}
102
+
103
+ // ═══════════════════════════════════════════════════════════════════════════
104
+ // API ERRORS - External service responses
105
+ // ═══════════════════════════════════════════════════════════════════════════
106
+
107
+ export class DiscordApiError extends createTaggedError({
108
+ name: 'DiscordApiError',
109
+ message: 'Discord API error: $status $body',
110
+ }) {}
111
+
112
+ export class OpenCodeApiError extends createTaggedError({
113
+ name: 'OpenCodeApiError',
114
+ message: 'OpenCode API error ($status): $body',
115
+ }) {}
116
+
117
+ // ═══════════════════════════════════════════════════════════════════════════
118
+ // UNION TYPES - For function signatures
119
+ // ═══════════════════════════════════════════════════════════════════════════
120
+
121
+ export type TranscriptionErrors =
122
+ | ApiKeyMissingError
123
+ | InvalidAudioFormatError
124
+ | TranscriptionError
125
+ | EmptyTranscriptionError
126
+ | NoResponseContentError
127
+ | NoToolResponseError
128
+
129
+ export type OpenCodeErrors =
130
+ | DirectoryNotAccessibleError
131
+ | ServerStartError
132
+ | ServerNotFoundError
133
+ | ServerNotReadyError
134
+
135
+ export type SessionErrors =
136
+ | SessionNotFoundError
137
+ | MessagesNotFoundError
138
+ | 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 (mkdirError instanceof Error) {
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
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { Events, type Client, type Interaction } from 'discord.js'
6
6
  import { handleSessionCommand, handleSessionAutocomplete } from './commands/session.js'
7
+ import { handleNewWorktreeCommand } from './commands/worktree.js'
7
8
  import { handleResumeCommand, handleResumeAutocomplete } from './commands/resume.js'
8
9
  import { handleAddProjectCommand, handleAddProjectAutocomplete } from './commands/add-project.js'
9
10
  import {
@@ -52,7 +53,7 @@ export function registerInteractionHandler({
52
53
 
53
54
  if (interaction.isAutocomplete()) {
54
55
  switch (interaction.commandName) {
55
- case 'session':
56
+ case 'new-session':
56
57
  await handleSessionAutocomplete({ interaction, appId })
57
58
  return
58
59
 
@@ -78,10 +79,14 @@ export function registerInteractionHandler({
78
79
  interactionLogger.log(`[COMMAND] Processing: ${interaction.commandName}`)
79
80
 
80
81
  switch (interaction.commandName) {
81
- case 'session':
82
+ case 'new-session':
82
83
  await handleSessionCommand({ command: interaction, appId })
83
84
  return
84
85
 
86
+ case 'new-worktree':
87
+ await handleNewWorktreeCommand({ command: interaction, appId })
88
+ return
89
+
85
90
  case 'resume':
86
91
  await handleResumeCommand({ command: interaction, appId })
87
92
  return
@@ -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()