kimaki 0.4.34 → 0.4.36

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 (79) hide show
  1. package/dist/ai-tool-to-genai.js +1 -3
  2. package/dist/channel-management.js +1 -1
  3. package/dist/cli.js +142 -39
  4. package/dist/commands/abort.js +1 -1
  5. package/dist/commands/add-project.js +1 -1
  6. package/dist/commands/agent.js +6 -2
  7. package/dist/commands/ask-question.js +2 -1
  8. package/dist/commands/fork.js +7 -7
  9. package/dist/commands/queue.js +2 -2
  10. package/dist/commands/remove-project.js +109 -0
  11. package/dist/commands/resume.js +3 -5
  12. package/dist/commands/session.js +56 -1
  13. package/dist/commands/share.js +1 -1
  14. package/dist/commands/undo-redo.js +2 -2
  15. package/dist/commands/user-command.js +3 -6
  16. package/dist/config.js +1 -1
  17. package/dist/discord-bot.js +4 -10
  18. package/dist/discord-utils.js +33 -9
  19. package/dist/genai.js +4 -6
  20. package/dist/interaction-handler.js +8 -1
  21. package/dist/markdown.js +1 -3
  22. package/dist/message-formatting.js +7 -3
  23. package/dist/openai-realtime.js +3 -5
  24. package/dist/opencode.js +2 -3
  25. package/dist/session-handler.js +42 -25
  26. package/dist/system-message.js +5 -3
  27. package/dist/tools.js +9 -22
  28. package/dist/unnest-code-blocks.js +4 -2
  29. package/dist/unnest-code-blocks.test.js +40 -15
  30. package/dist/voice-handler.js +9 -12
  31. package/dist/voice.js +5 -3
  32. package/dist/xml.js +2 -4
  33. package/package.json +3 -2
  34. package/src/__snapshots__/compact-session-context-no-system.md +24 -24
  35. package/src/__snapshots__/compact-session-context.md +31 -31
  36. package/src/ai-tool-to-genai.ts +3 -11
  37. package/src/channel-management.ts +14 -25
  38. package/src/cli.ts +290 -195
  39. package/src/commands/abort.ts +1 -3
  40. package/src/commands/add-project.ts +8 -14
  41. package/src/commands/agent.ts +16 -9
  42. package/src/commands/ask-question.ts +8 -7
  43. package/src/commands/create-new-project.ts +8 -14
  44. package/src/commands/fork.ts +23 -27
  45. package/src/commands/model.ts +14 -11
  46. package/src/commands/permissions.ts +1 -1
  47. package/src/commands/queue.ts +6 -19
  48. package/src/commands/remove-project.ts +136 -0
  49. package/src/commands/resume.ts +11 -30
  50. package/src/commands/session.ts +68 -9
  51. package/src/commands/share.ts +1 -3
  52. package/src/commands/types.ts +1 -3
  53. package/src/commands/undo-redo.ts +6 -18
  54. package/src/commands/user-command.ts +8 -10
  55. package/src/config.ts +5 -5
  56. package/src/database.ts +10 -8
  57. package/src/discord-bot.ts +22 -46
  58. package/src/discord-utils.ts +35 -18
  59. package/src/escape-backticks.test.ts +0 -2
  60. package/src/format-tables.ts +1 -4
  61. package/src/genai-worker-wrapper.ts +3 -9
  62. package/src/genai-worker.ts +4 -19
  63. package/src/genai.ts +10 -42
  64. package/src/interaction-handler.ts +133 -121
  65. package/src/markdown.test.ts +10 -32
  66. package/src/markdown.ts +6 -14
  67. package/src/message-formatting.ts +13 -14
  68. package/src/openai-realtime.ts +25 -47
  69. package/src/opencode.ts +26 -37
  70. package/src/session-handler.ts +111 -75
  71. package/src/system-message.ts +13 -3
  72. package/src/tools.ts +13 -39
  73. package/src/unnest-code-blocks.test.ts +42 -15
  74. package/src/unnest-code-blocks.ts +4 -2
  75. package/src/utils.ts +1 -4
  76. package/src/voice-handler.ts +34 -78
  77. package/src/voice.ts +11 -19
  78. package/src/xml.test.ts +1 -1
  79. package/src/xml.ts +3 -12
@@ -129,13 +129,7 @@ function createWavHeader(dataLength: number, options: WavConversionOptions) {
129
129
  return buffer
130
130
  }
131
131
 
132
- function defaultAudioChunkHandler({
133
- data,
134
- mimeType,
135
- }: {
136
- data: Buffer
137
- mimeType: string
138
- }) {
132
+ function defaultAudioChunkHandler({ data, mimeType }: { data: Buffer; mimeType: string }) {
139
133
  audioParts.push(data)
140
134
  const fileName = 'audio.wav'
141
135
  const buffer = convertToWav(audioParts, mimeType)
@@ -247,36 +241,23 @@ export async function startGenAiSession({
247
241
  }
248
242
 
249
243
  // Set up event handlers
250
- client.on(
251
- 'conversation.item.created',
252
- ({ item }: { item: ConversationItem }) => {
253
- if (
254
- 'role' in item &&
255
- item.role === 'assistant' &&
256
- item.type === 'message'
257
- ) {
258
- // Check if this is the first audio content
259
- const hasAudio =
260
- 'content' in item &&
261
- Array.isArray(item.content) &&
262
- item.content.some((c) => 'type' in c && c.type === 'audio')
263
- if (hasAudio && !isAssistantSpeaking && onAssistantStartSpeaking) {
264
- isAssistantSpeaking = true
265
- onAssistantStartSpeaking()
266
- }
244
+ client.on('conversation.item.created', ({ item }: { item: ConversationItem }) => {
245
+ if ('role' in item && item.role === 'assistant' && item.type === 'message') {
246
+ // Check if this is the first audio content
247
+ const hasAudio =
248
+ 'content' in item &&
249
+ Array.isArray(item.content) &&
250
+ item.content.some((c) => 'type' in c && c.type === 'audio')
251
+ if (hasAudio && !isAssistantSpeaking && onAssistantStartSpeaking) {
252
+ isAssistantSpeaking = true
253
+ onAssistantStartSpeaking()
267
254
  }
268
- },
269
- )
255
+ }
256
+ })
270
257
 
271
258
  client.on(
272
259
  'conversation.updated',
273
- ({
274
- item,
275
- delta,
276
- }: {
277
- item: ConversationItem
278
- delta: ConversationEventDelta | null
279
- }) => {
260
+ ({ item, delta }: { item: ConversationItem; delta: ConversationEventDelta | null }) => {
280
261
  // Handle audio chunks
281
262
  if (delta?.audio && 'role' in item && item.role === 'assistant') {
282
263
  if (!isAssistantSpeaking && onAssistantStartSpeaking) {
@@ -313,20 +294,17 @@ export async function startGenAiSession({
313
294
  },
314
295
  )
315
296
 
316
- client.on(
317
- 'conversation.item.completed',
318
- ({ item }: { item: ConversationItem }) => {
319
- if (
320
- 'role' in item &&
321
- item.role === 'assistant' &&
322
- isAssistantSpeaking &&
323
- onAssistantStopSpeaking
324
- ) {
325
- isAssistantSpeaking = false
326
- onAssistantStopSpeaking()
327
- }
328
- },
329
- )
297
+ client.on('conversation.item.completed', ({ item }: { item: ConversationItem }) => {
298
+ if (
299
+ 'role' in item &&
300
+ item.role === 'assistant' &&
301
+ isAssistantSpeaking &&
302
+ onAssistantStopSpeaking
303
+ ) {
304
+ isAssistantSpeaking = false
305
+ onAssistantStopSpeaking()
306
+ }
307
+ })
330
308
 
331
309
  client.on('conversation.interrupted', () => {
332
310
  openaiLogger.log('Assistant was interrupted')
package/src/opencode.ts CHANGED
@@ -5,11 +5,7 @@
5
5
  import { spawn, type ChildProcess } from 'node:child_process'
6
6
  import fs from 'node:fs'
7
7
  import net from 'node:net'
8
- import {
9
- createOpencodeClient,
10
- type OpencodeClient,
11
- type Config,
12
- } from '@opencode-ai/sdk'
8
+ import { createOpencodeClient, type OpencodeClient, type Config } from '@opencode-ai/sdk'
13
9
  import {
14
10
  createOpencodeClient as createOpencodeClientV2,
15
11
  type OpencodeClient as OpencodeClientV2,
@@ -84,9 +80,7 @@ async function waitForServer(port: number, maxAttempts = 30): Promise<boolean> {
84
80
  }
85
81
  await new Promise((resolve) => setTimeout(resolve, 1000))
86
82
  }
87
- throw new Error(
88
- `Server did not start on port ${port} after ${maxAttempts} seconds`,
89
- )
83
+ throw new Error(`Server did not start on port ${port} after ${maxAttempts} seconds`)
90
84
  }
91
85
 
92
86
  export async function initializeOpencodeForDirectory(directory: string) {
@@ -115,36 +109,33 @@ export async function initializeOpencodeForDirectory(directory: string) {
115
109
 
116
110
  const port = await getOpenPort()
117
111
 
118
- const opencodeBinDir = `${process.env.HOME}/.opencode/bin`
119
- const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`
120
-
121
- const serverProcess = spawn(
122
- opencodeCommand,
123
- ['serve', '--port', port.toString()],
124
- {
125
- stdio: 'pipe',
126
- detached: false,
127
- cwd: directory,
128
- env: {
129
- ...process.env,
130
- OPENCODE_CONFIG_CONTENT: JSON.stringify({
131
- $schema: 'https://opencode.ai/config.json',
132
- lsp: false,
133
- formatter: false,
134
- permission: {
135
- edit: 'allow',
136
- bash: 'allow',
137
- webfetch: 'allow',
138
- },
139
- } satisfies Config),
140
- OPENCODE_PORT: port.toString(),
141
- },
112
+ const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
113
+
114
+ const serverProcess = spawn(opencodeCommand, ['serve', '--port', port.toString()], {
115
+ stdio: 'pipe',
116
+ detached: false,
117
+ cwd: directory,
118
+ env: {
119
+ ...process.env,
120
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
121
+ $schema: 'https://opencode.ai/config.json',
122
+ lsp: false,
123
+ formatter: false,
124
+ permission: {
125
+ edit: 'allow',
126
+ bash: 'allow',
127
+ webfetch: 'allow',
128
+ },
129
+ } satisfies Config),
130
+ OPENCODE_PORT: port.toString(),
142
131
  },
143
- )
132
+ })
144
133
 
145
134
  // Buffer logs until we know if server started successfully
146
135
  const logBuffer: string[] = []
147
- logBuffer.push(`Spawned opencode serve --port ${port} in ${directory} (pid: ${serverProcess.pid})`)
136
+ logBuffer.push(
137
+ `Spawned opencode serve --port ${port} in ${directory} (pid: ${serverProcess.pid})`,
138
+ )
148
139
 
149
140
  serverProcess.stdout?.on('data', (data) => {
150
141
  logBuffer.push(`[stdout] ${data.toString().trim()}`)
@@ -172,9 +163,7 @@ export async function initializeOpencodeForDirectory(directory: string) {
172
163
  opencodeLogger.error(`Failed to restart opencode server:`, e)
173
164
  })
174
165
  } else {
175
- opencodeLogger.error(
176
- `Server for ${directory} crashed too many times (5), not restarting`,
177
- )
166
+ opencodeLogger.error(`Server for ${directory} crashed too many times (5), not restarting`)
178
167
  }
179
168
  } else {
180
169
  serverRetryCount.delete(directory)
@@ -6,14 +6,29 @@ import type { Part, PermissionRequest } from '@opencode-ai/sdk/v2'
6
6
  import type { FilePartInput } from '@opencode-ai/sdk'
7
7
  import type { Message, ThreadChannel } from 'discord.js'
8
8
  import prettyMilliseconds from 'pretty-ms'
9
- import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js'
10
- import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js'
9
+ import {
10
+ getDatabase,
11
+ getSessionModel,
12
+ getChannelModel,
13
+ getSessionAgent,
14
+ getChannelAgent,
15
+ setSessionAgent,
16
+ } from './database.js'
17
+ import {
18
+ initializeOpencodeForDirectory,
19
+ getOpencodeServers,
20
+ getOpencodeClientV2,
21
+ } from './opencode.js'
11
22
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js'
12
23
  import { formatPart } from './message-formatting.js'
13
24
  import { getOpencodeSystemMessage } from './system-message.js'
14
25
  import { createLogger } from './logger.js'
15
26
  import { isAbortError } from './utils.js'
16
- import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts } from './commands/ask-question.js'
27
+ import {
28
+ showAskUserQuestionDropdowns,
29
+ cancelPendingQuestion,
30
+ pendingQuestionContexts,
31
+ } from './commands/ask-question.js'
17
32
  import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js'
18
33
 
19
34
  const sessionLogger = createLogger('SESSION')
@@ -96,7 +111,9 @@ export async function abortAndRetrySession({
96
111
  }
97
112
 
98
113
  // Small delay to let the abort propagate
99
- await new Promise((resolve) => { setTimeout(resolve, 300) })
114
+ await new Promise((resolve) => {
115
+ setTimeout(resolve, 300)
116
+ })
100
117
 
101
118
  // Fetch last user message from API
102
119
  sessionLogger.log(`[ABORT+RETRY] Fetching last user message for session ${sessionId}`)
@@ -110,7 +127,9 @@ export async function abortAndRetrySession({
110
127
  }
111
128
 
112
129
  // Extract text and images from parts
113
- const textPart = lastUserMessage.parts.find((p) => p.type === 'text') as { type: 'text'; text: string } | undefined
130
+ const textPart = lastUserMessage.parts.find((p) => p.type === 'text') as
131
+ | { type: 'text'; text: string }
132
+ | undefined
114
133
  const prompt = textPart?.text || ''
115
134
  const images = lastUserMessage.parts.filter((p) => p.type === 'file') as FilePartInput[]
116
135
 
@@ -141,6 +160,7 @@ export async function handleOpencodeSession({
141
160
  images = [],
142
161
  channelId,
143
162
  command,
163
+ agent,
144
164
  }: {
145
165
  prompt: string
146
166
  thread: ThreadChannel
@@ -150,6 +170,8 @@ export async function handleOpencodeSession({
150
170
  channelId?: string
151
171
  /** If set, uses session.command API instead of session.prompt */
152
172
  command?: { name: string; arguments: string }
173
+ /** Agent to use for this session */
174
+ agent?: string
153
175
  }): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
154
176
  voiceLogger.log(
155
177
  `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
@@ -180,17 +202,13 @@ export async function handleOpencodeSession({
180
202
  session = sessionResponse.data
181
203
  sessionLogger.log(`Successfully reused session ${sessionId}`)
182
204
  } catch (error) {
183
- voiceLogger.log(
184
- `[SESSION] Session ${sessionId} not found, will create new one`,
185
- )
205
+ voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`)
186
206
  }
187
207
  }
188
208
 
189
209
  if (!session) {
190
210
  const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80)
191
- voiceLogger.log(
192
- `[SESSION] Creating new session with title: "${sessionTitle}"`,
193
- )
211
+ voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`)
194
212
  const sessionResponse = await getClient().session.create({
195
213
  body: { title: sessionTitle },
196
214
  })
@@ -203,24 +221,28 @@ export async function handleOpencodeSession({
203
221
  }
204
222
 
205
223
  getDatabase()
206
- .prepare(
207
- 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
208
- )
224
+ .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
209
225
  .run(thread.id, session.id)
210
226
  sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`)
211
227
 
228
+ // Store agent preference if provided
229
+ if (agent) {
230
+ setSessionAgent(session.id, agent)
231
+ sessionLogger.log(`Set agent preference for session ${session.id}: ${agent}`)
232
+ }
233
+
212
234
  const existingController = abortControllers.get(session.id)
213
235
  if (existingController) {
214
- voiceLogger.log(
215
- `[ABORT] Cancelling existing request for session: ${session.id}`,
216
- )
236
+ voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`)
217
237
  existingController.abort(new Error('New request started'))
218
238
  }
219
239
 
220
240
  const pendingPerm = pendingPermissions.get(thread.id)
221
241
  if (pendingPerm) {
222
242
  try {
223
- sessionLogger.log(`[PERMISSION] Auto-rejecting pending permission ${pendingPerm.permission.id} due to new message`)
243
+ sessionLogger.log(
244
+ `[PERMISSION] Auto-rejecting pending permission ${pendingPerm.permission.id} due to new message`,
245
+ )
224
246
  const clientV2 = getOpencodeClientV2(directory)
225
247
  if (clientV2) {
226
248
  await clientV2.permission.reply({
@@ -231,7 +253,10 @@ export async function handleOpencodeSession({
231
253
  // Clean up both the pending permission and its dropdown context
232
254
  cleanupPermissionContext(pendingPerm.contextHash)
233
255
  pendingPermissions.delete(thread.id)
234
- await sendThreadMessage(thread, `⚠️ Previous permission request auto-rejected due to new message`)
256
+ await sendThreadMessage(
257
+ thread,
258
+ `⚠️ Previous permission request auto-rejected due to new message`,
259
+ )
235
260
  } catch (e) {
236
261
  sessionLogger.log(`[PERMISSION] Failed to auto-reject permission:`, e)
237
262
  cleanupPermissionContext(pendingPerm.contextHash)
@@ -249,7 +274,9 @@ export async function handleOpencodeSession({
249
274
  abortControllers.set(session.id, abortController)
250
275
 
251
276
  if (existingController) {
252
- await new Promise((resolve) => { setTimeout(resolve, 200) })
277
+ await new Promise((resolve) => {
278
+ setTimeout(resolve, 200)
279
+ })
253
280
  if (abortController.signal.aborted) {
254
281
  sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`)
255
282
  return
@@ -268,7 +295,7 @@ export async function handleOpencodeSession({
268
295
  }
269
296
  const eventsResult = await clientV2.event.subscribe(
270
297
  { directory },
271
- { signal: abortController.signal }
298
+ { signal: abortController.signal },
272
299
  )
273
300
 
274
301
  if (abortController.signal.aborted) {
@@ -280,10 +307,11 @@ export async function handleOpencodeSession({
280
307
  sessionLogger.log(`Subscribed to OpenCode events`)
281
308
 
282
309
  const sentPartIds = new Set<string>(
283
- (getDatabase()
284
- .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
285
- .all(thread.id) as { part_id: string }[])
286
- .map((row) => row.part_id)
310
+ (
311
+ getDatabase()
312
+ .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
313
+ .all(thread.id) as { part_id: string }[]
314
+ ).map((row) => row.part_id),
287
315
  )
288
316
 
289
317
  let currentParts: Part[] = []
@@ -376,7 +404,12 @@ export async function handleOpencodeSession({
376
404
  }
377
405
 
378
406
  if (msg.role === 'assistant') {
379
- const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
407
+ const newTokensTotal =
408
+ msg.tokens.input +
409
+ msg.tokens.output +
410
+ msg.tokens.reasoning +
411
+ msg.tokens.cache.read +
412
+ msg.tokens.cache.write
380
413
  if (newTokensTotal > 0) {
381
414
  tokensUsedInSession = newTokensTotal
382
415
  }
@@ -389,7 +422,9 @@ export async function handleOpencodeSession({
389
422
  if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
390
423
  if (!modelContextLimit) {
391
424
  try {
392
- const providersResponse = await getClient().provider.list({ query: { directory } })
425
+ const providersResponse = await getClient().provider.list({
426
+ query: { directory },
427
+ })
393
428
  const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
394
429
  const model = provider?.models?.[usedModel]
395
430
  if (model?.limit?.context) {
@@ -401,7 +436,9 @@ export async function handleOpencodeSession({
401
436
  }
402
437
 
403
438
  if (modelContextLimit) {
404
- const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100)
439
+ const currentPercentage = Math.floor(
440
+ (tokensUsedInSession / modelContextLimit) * 100,
441
+ )
405
442
  const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
406
443
  if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
407
444
  lastDisplayedContextPercentage = thresholdCrossed
@@ -422,9 +459,7 @@ export async function handleOpencodeSession({
422
459
  continue
423
460
  }
424
461
 
425
- const existingIndex = currentParts.findIndex(
426
- (p: Part) => p.id === part.id,
427
- )
462
+ const existingIndex = currentParts.findIndex((p: Part) => p.id === part.id)
428
463
  if (existingIndex >= 0) {
429
464
  currentParts[existingIndex] = part
430
465
  } else {
@@ -459,9 +494,8 @@ export async function handleOpencodeSession({
459
494
  const outputTokens = Math.ceil(output.length / 4)
460
495
  const LARGE_OUTPUT_THRESHOLD = 3000
461
496
  if (outputTokens >= LARGE_OUTPUT_THRESHOLD) {
462
- const formattedTokens = outputTokens >= 1000
463
- ? `${(outputTokens / 1000).toFixed(1)}k`
464
- : String(outputTokens)
497
+ const formattedTokens =
498
+ outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens)
465
499
  const percentageSuffix = (() => {
466
500
  if (!modelContextLimit) {
467
501
  return ''
@@ -510,18 +544,13 @@ export async function handleOpencodeSession({
510
544
  const errorData = event.properties.error
511
545
  const errorMessage = errorData?.data?.message || 'Unknown error'
512
546
  sessionLogger.error(`Sending error to thread: ${errorMessage}`)
513
- await sendThreadMessage(
514
- thread,
515
- `✗ opencode session error: ${errorMessage}`,
516
- )
547
+ await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`)
517
548
 
518
549
  if (originalMessage) {
519
550
  try {
520
551
  await originalMessage.reactions.removeAll()
521
552
  await originalMessage.react('❌')
522
- voiceLogger.log(
523
- `[REACTION] Added error reaction due to session error`,
524
- )
553
+ voiceLogger.log(`[REACTION] Added error reaction due to session error`)
525
554
  } catch (e) {
526
555
  discordLogger.log(`Could not update reaction:`, e)
527
556
  }
@@ -570,9 +599,7 @@ export async function handleOpencodeSession({
570
599
  continue
571
600
  }
572
601
 
573
- sessionLogger.log(
574
- `Permission ${requestID} replied with: ${reply}`,
575
- )
602
+ sessionLogger.log(`Permission ${requestID} replied with: ${reply}`)
576
603
 
577
604
  const pending = pendingPermissions.get(thread.id)
578
605
  if (pending && pending.permission.id === requestID) {
@@ -624,9 +651,7 @@ export async function handleOpencodeSession({
624
651
  }
625
652
  } catch (e) {
626
653
  if (isAbortError(e, abortController.signal)) {
627
- sessionLogger.log(
628
- 'AbortController aborted event handling (normal exit)',
629
- )
654
+ sessionLogger.log('AbortController aborted event handling (normal exit)')
630
655
  return
631
656
  }
632
657
  sessionLogger.error(`Unexpected error in event handling code`, e)
@@ -647,16 +672,12 @@ export async function handleOpencodeSession({
647
672
  stopTyping = null
648
673
  }
649
674
 
650
- if (
651
- !abortController.signal.aborted ||
652
- abortController.signal.reason === 'finished'
653
- ) {
654
- const sessionDuration = prettyMilliseconds(
655
- Date.now() - sessionStartTime,
656
- )
675
+ if (!abortController.signal.aborted || abortController.signal.reason === 'finished') {
676
+ const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime)
657
677
  const attachCommand = port ? ` ⋅ ${session.id}` : ''
658
678
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
659
- const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : ''
679
+ const agentInfo =
680
+ usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : ''
660
681
  let contextInfo = ''
661
682
 
662
683
  try {
@@ -671,8 +692,14 @@ export async function handleOpencodeSession({
671
692
  sessionLogger.error('Failed to fetch provider info for context percentage:', e)
672
693
  }
673
694
 
674
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS })
675
- sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`)
695
+ await sendThreadMessage(
696
+ thread,
697
+ `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`,
698
+ { flags: NOTIFY_MESSAGE_FLAGS },
699
+ )
700
+ sessionLogger.log(
701
+ `DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`,
702
+ )
676
703
 
677
704
  // Process queued messages after completion
678
705
  const queue = messageQueue.get(thread.id)
@@ -685,7 +712,10 @@ export async function handleOpencodeSession({
685
712
  sessionLogger.log(`[QUEUE] Processing queued message from ${nextMessage.username}`)
686
713
 
687
714
  // Show that queued message is being sent
688
- await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`)
715
+ await sendThreadMessage(
716
+ thread,
717
+ `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`,
718
+ )
689
719
 
690
720
  // Send the queued message as a new prompt (recursive call)
691
721
  // Use setImmediate to avoid blocking and allow this finally to complete
@@ -729,7 +759,14 @@ export async function handleOpencodeSession({
729
759
  if (images.length === 0) {
730
760
  return prompt
731
761
  }
732
- sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
762
+ sessionLogger.log(
763
+ `[PROMPT] Sending ${images.length} image(s):`,
764
+ images.map((img) => ({
765
+ mime: img.mime,
766
+ filename: img.filename,
767
+ url: img.url.slice(0, 100),
768
+ })),
769
+ )
733
770
  const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n')
734
771
  return `${prompt}\n\n**attached images:**\n${imagePathsList}`
735
772
  })()
@@ -738,7 +775,8 @@ export async function handleOpencodeSession({
738
775
  sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
739
776
 
740
777
  // Get model preference: session-level overrides channel-level
741
- const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
778
+ const modelPreference =
779
+ getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
742
780
  const modelParam = (() => {
743
781
  if (!modelPreference) {
744
782
  return undefined
@@ -753,7 +791,8 @@ export async function handleOpencodeSession({
753
791
  })()
754
792
 
755
793
  // Get agent preference: session-level overrides channel-level
756
- const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
794
+ const agentPreference =
795
+ getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
757
796
  if (agentPreference) {
758
797
  sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
759
798
  }
@@ -825,20 +864,17 @@ export async function handleOpencodeSession({
825
864
  discordLogger.log(`Could not update reaction:`, e)
826
865
  }
827
866
  }
828
- const errorName =
829
- error &&
830
- typeof error === 'object' &&
831
- 'constructor' in error &&
832
- error.constructor &&
833
- typeof error.constructor.name === 'string'
834
- ? error.constructor.name
835
- : typeof error
836
- const errorMsg =
837
- error instanceof Error ? error.stack || error.message : String(error)
838
- await sendThreadMessage(
839
- thread,
840
- `✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`,
841
- )
867
+ const errorDisplay = (() => {
868
+ if (error instanceof Error) {
869
+ const name = error.constructor.name || 'Error'
870
+ return `[${name}]\n${error.stack || error.message}`
871
+ }
872
+ if (typeof error === 'string') {
873
+ return error
874
+ }
875
+ return String(error)
876
+ })()
877
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`)
842
878
  }
843
879
  }
844
880
  }
@@ -2,7 +2,13 @@
2
2
  // Creates the system message injected into every OpenCode session,
3
3
  // including Discord-specific formatting rules, diff commands, and permissions info.
4
4
 
5
- export function getOpencodeSystemMessage({ sessionId, channelId }: { sessionId: string; channelId?: string }) {
5
+ export function getOpencodeSystemMessage({
6
+ sessionId,
7
+ channelId,
8
+ }: {
9
+ sessionId: string
10
+ channelId?: string
11
+ }) {
6
12
  return `
7
13
  The user is reading your messages from inside Discord, via kimaki.xyz
8
14
 
@@ -23,7 +29,9 @@ Only users with these Discord permissions can send messages to the bot:
23
29
  To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
24
30
 
25
31
  npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
26
- ${channelId ? `
32
+ ${
33
+ channelId
34
+ ? `
27
35
  ## starting new sessions from CLI
28
36
 
29
37
  To start a new thread/session in this channel programmatically, run:
@@ -31,7 +39,9 @@ To start a new thread/session in this channel programmatically, run:
31
39
  npx -y kimaki start-session --channel ${channelId} --prompt "your prompt here"
32
40
 
33
41
  This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
34
- ` : ''}
42
+ `
43
+ : ''
44
+ }
35
45
  ## showing diffs
36
46
 
37
47
  IMPORTANT: After editing any files, you MUST execute the critique command using the Bash tool to get a diff URL, then share that URL with the user.