kimaki 0.4.35 → 0.4.37

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 (76) hide show
  1. package/dist/ai-tool-to-genai.js +1 -3
  2. package/dist/channel-management.js +5 -5
  3. package/dist/cli.js +182 -46
  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 +2 -2
  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/database.js +7 -0
  18. package/dist/discord-bot.js +37 -20
  19. package/dist/discord-utils.js +33 -9
  20. package/dist/genai.js +4 -6
  21. package/dist/interaction-handler.js +8 -1
  22. package/dist/markdown.js +1 -3
  23. package/dist/message-formatting.js +7 -3
  24. package/dist/openai-realtime.js +3 -5
  25. package/dist/opencode.js +1 -1
  26. package/dist/session-handler.js +25 -15
  27. package/dist/system-message.js +10 -4
  28. package/dist/tools.js +9 -22
  29. package/dist/voice-handler.js +9 -12
  30. package/dist/voice.js +5 -3
  31. package/dist/xml.js +2 -4
  32. package/package.json +3 -2
  33. package/src/__snapshots__/compact-session-context-no-system.md +24 -24
  34. package/src/__snapshots__/compact-session-context.md +31 -31
  35. package/src/ai-tool-to-genai.ts +3 -11
  36. package/src/channel-management.ts +18 -29
  37. package/src/cli.ts +334 -205
  38. package/src/commands/abort.ts +1 -3
  39. package/src/commands/add-project.ts +8 -14
  40. package/src/commands/agent.ts +16 -9
  41. package/src/commands/ask-question.ts +8 -7
  42. package/src/commands/create-new-project.ts +8 -14
  43. package/src/commands/fork.ts +23 -27
  44. package/src/commands/model.ts +14 -11
  45. package/src/commands/permissions.ts +1 -1
  46. package/src/commands/queue.ts +6 -19
  47. package/src/commands/remove-project.ts +136 -0
  48. package/src/commands/resume.ts +11 -30
  49. package/src/commands/session.ts +4 -13
  50. package/src/commands/share.ts +1 -3
  51. package/src/commands/types.ts +1 -3
  52. package/src/commands/undo-redo.ts +6 -18
  53. package/src/commands/user-command.ts +8 -10
  54. package/src/config.ts +5 -5
  55. package/src/database.ts +17 -8
  56. package/src/discord-bot.ts +60 -58
  57. package/src/discord-utils.ts +35 -18
  58. package/src/escape-backticks.test.ts +0 -2
  59. package/src/format-tables.ts +1 -4
  60. package/src/genai-worker-wrapper.ts +3 -9
  61. package/src/genai-worker.ts +4 -19
  62. package/src/genai.ts +10 -42
  63. package/src/interaction-handler.ts +133 -121
  64. package/src/markdown.test.ts +10 -32
  65. package/src/markdown.ts +6 -14
  66. package/src/message-formatting.ts +13 -14
  67. package/src/openai-realtime.ts +25 -47
  68. package/src/opencode.ts +24 -34
  69. package/src/session-handler.ts +91 -61
  70. package/src/system-message.ts +18 -4
  71. package/src/tools.ts +13 -39
  72. package/src/utils.ts +1 -4
  73. package/src/voice-handler.ts +34 -78
  74. package/src/voice.ts +11 -19
  75. package/src/xml.test.ts +1 -1
  76. package/src/xml.ts +3 -12
@@ -77,8 +77,8 @@ export function isTextMimeType(contentType: string | null): boolean {
77
77
  }
78
78
 
79
79
  export async function getTextAttachments(message: Message): Promise<string> {
80
- const textAttachments = Array.from(message.attachments.values()).filter(
81
- (attachment) => isTextMimeType(attachment.contentType),
80
+ const textAttachments = Array.from(message.attachments.values()).filter((attachment) =>
81
+ isTextMimeType(attachment.contentType),
82
82
  )
83
83
 
84
84
  if (textAttachments.length === 0) {
@@ -105,14 +105,10 @@ export async function getTextAttachments(message: Message): Promise<string> {
105
105
  }
106
106
 
107
107
  export async function getFileAttachments(message: Message): Promise<FilePartInput[]> {
108
- const fileAttachments = Array.from(message.attachments.values()).filter(
109
- (attachment) => {
110
- const contentType = attachment.contentType || ''
111
- return (
112
- contentType.startsWith('image/') || contentType === 'application/pdf'
113
- )
114
- },
115
- )
108
+ const fileAttachments = Array.from(message.attachments.values()).filter((attachment) => {
109
+ const contentType = attachment.contentType || ''
110
+ return contentType.startsWith('image/') || contentType === 'application/pdf'
111
+ })
116
112
 
117
113
  if (fileAttachments.length === 0) {
118
114
  return []
@@ -164,7 +160,9 @@ export function getToolSummaryText(part: Part): string {
164
160
  const added = newString.split('\n').length
165
161
  const removed = oldString.split('\n').length
166
162
  const fileName = filePath.split('/').pop() || ''
167
- return fileName ? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})` : `(+${added}-${removed})`
163
+ return fileName
164
+ ? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})`
165
+ : `(+${added}-${removed})`
168
166
  }
169
167
 
170
168
  if (part.tool === 'write') {
@@ -172,7 +170,9 @@ export function getToolSummaryText(part: Part): string {
172
170
  const content = (part.state.input?.content as string) || ''
173
171
  const lines = content.split('\n').length
174
172
  const fileName = filePath.split('/').pop() || ''
175
- return fileName ? `*${escapeInlineMarkdown(fileName)}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`
173
+ return fileName
174
+ ? `*${escapeInlineMarkdown(fileName)}* (${lines} line${lines === 1 ? '' : 's'})`
175
+ : `(${lines} line${lines === 1 ? '' : 's'})`
176
176
  }
177
177
 
178
178
  if (part.tool === 'webfetch') {
@@ -259,8 +259,7 @@ export function formatPart(part: Part): string {
259
259
  const trimmed = part.text.trimStart()
260
260
  const firstChar = trimmed[0] || ''
261
261
  const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|']
262
- const startsWithMarkdown =
263
- markdownStarters.includes(firstChar) || /^\d+\./.test(trimmed)
262
+ const startsWithMarkdown = markdownStarters.includes(firstChar) || /^\d+\./.test(trimmed)
264
263
  if (startsWithMarkdown) {
265
264
  return `\n${part.text}`
266
265
  }
@@ -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) {
@@ -117,33 +111,31 @@ export async function initializeOpencodeForDirectory(directory: string) {
117
111
 
118
112
  const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
119
113
 
120
- const serverProcess = spawn(
121
- opencodeCommand,
122
- ['serve', '--port', port.toString()],
123
- {
124
- stdio: 'pipe',
125
- detached: false,
126
- cwd: directory,
127
- env: {
128
- ...process.env,
129
- OPENCODE_CONFIG_CONTENT: JSON.stringify({
130
- $schema: 'https://opencode.ai/config.json',
131
- lsp: false,
132
- formatter: false,
133
- permission: {
134
- edit: 'allow',
135
- bash: 'allow',
136
- webfetch: 'allow',
137
- },
138
- } satisfies Config),
139
- OPENCODE_PORT: port.toString(),
140
- },
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(),
141
131
  },
142
- )
132
+ })
143
133
 
144
134
  // Buffer logs until we know if server started successfully
145
135
  const logBuffer: string[] = []
146
- 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
+ )
147
139
 
148
140
  serverProcess.stdout?.on('data', (data) => {
149
141
  logBuffer.push(`[stdout] ${data.toString().trim()}`)
@@ -171,9 +163,7 @@ export async function initializeOpencodeForDirectory(directory: string) {
171
163
  opencodeLogger.error(`Failed to restart opencode server:`, e)
172
164
  })
173
165
  } else {
174
- opencodeLogger.error(
175
- `Server for ${directory} crashed too many times (5), not restarting`,
176
- )
166
+ opencodeLogger.error(`Server for ${directory} crashed too many times (5), not restarting`)
177
167
  }
178
168
  } else {
179
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, setSessionAgent } 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
 
@@ -183,17 +202,13 @@ export async function handleOpencodeSession({
183
202
  session = sessionResponse.data
184
203
  sessionLogger.log(`Successfully reused session ${sessionId}`)
185
204
  } catch (error) {
186
- voiceLogger.log(
187
- `[SESSION] Session ${sessionId} not found, will create new one`,
188
- )
205
+ voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`)
189
206
  }
190
207
  }
191
208
 
192
209
  if (!session) {
193
210
  const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80)
194
- voiceLogger.log(
195
- `[SESSION] Creating new session with title: "${sessionTitle}"`,
196
- )
211
+ voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`)
197
212
  const sessionResponse = await getClient().session.create({
198
213
  body: { title: sessionTitle },
199
214
  })
@@ -206,9 +221,7 @@ export async function handleOpencodeSession({
206
221
  }
207
222
 
208
223
  getDatabase()
209
- .prepare(
210
- 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
211
- )
224
+ .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
212
225
  .run(thread.id, session.id)
213
226
  sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`)
214
227
 
@@ -220,16 +233,16 @@ export async function handleOpencodeSession({
220
233
 
221
234
  const existingController = abortControllers.get(session.id)
222
235
  if (existingController) {
223
- voiceLogger.log(
224
- `[ABORT] Cancelling existing request for session: ${session.id}`,
225
- )
236
+ voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`)
226
237
  existingController.abort(new Error('New request started'))
227
238
  }
228
239
 
229
240
  const pendingPerm = pendingPermissions.get(thread.id)
230
241
  if (pendingPerm) {
231
242
  try {
232
- 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
+ )
233
246
  const clientV2 = getOpencodeClientV2(directory)
234
247
  if (clientV2) {
235
248
  await clientV2.permission.reply({
@@ -240,7 +253,10 @@ export async function handleOpencodeSession({
240
253
  // Clean up both the pending permission and its dropdown context
241
254
  cleanupPermissionContext(pendingPerm.contextHash)
242
255
  pendingPermissions.delete(thread.id)
243
- 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
+ )
244
260
  } catch (e) {
245
261
  sessionLogger.log(`[PERMISSION] Failed to auto-reject permission:`, e)
246
262
  cleanupPermissionContext(pendingPerm.contextHash)
@@ -258,7 +274,9 @@ export async function handleOpencodeSession({
258
274
  abortControllers.set(session.id, abortController)
259
275
 
260
276
  if (existingController) {
261
- await new Promise((resolve) => { setTimeout(resolve, 200) })
277
+ await new Promise((resolve) => {
278
+ setTimeout(resolve, 200)
279
+ })
262
280
  if (abortController.signal.aborted) {
263
281
  sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`)
264
282
  return
@@ -277,7 +295,7 @@ export async function handleOpencodeSession({
277
295
  }
278
296
  const eventsResult = await clientV2.event.subscribe(
279
297
  { directory },
280
- { signal: abortController.signal }
298
+ { signal: abortController.signal },
281
299
  )
282
300
 
283
301
  if (abortController.signal.aborted) {
@@ -289,10 +307,11 @@ export async function handleOpencodeSession({
289
307
  sessionLogger.log(`Subscribed to OpenCode events`)
290
308
 
291
309
  const sentPartIds = new Set<string>(
292
- (getDatabase()
293
- .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
294
- .all(thread.id) as { part_id: string }[])
295
- .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),
296
315
  )
297
316
 
298
317
  let currentParts: Part[] = []
@@ -385,7 +404,12 @@ export async function handleOpencodeSession({
385
404
  }
386
405
 
387
406
  if (msg.role === 'assistant') {
388
- 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
389
413
  if (newTokensTotal > 0) {
390
414
  tokensUsedInSession = newTokensTotal
391
415
  }
@@ -398,7 +422,9 @@ export async function handleOpencodeSession({
398
422
  if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
399
423
  if (!modelContextLimit) {
400
424
  try {
401
- const providersResponse = await getClient().provider.list({ query: { directory } })
425
+ const providersResponse = await getClient().provider.list({
426
+ query: { directory },
427
+ })
402
428
  const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
403
429
  const model = provider?.models?.[usedModel]
404
430
  if (model?.limit?.context) {
@@ -410,7 +436,9 @@ export async function handleOpencodeSession({
410
436
  }
411
437
 
412
438
  if (modelContextLimit) {
413
- const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100)
439
+ const currentPercentage = Math.floor(
440
+ (tokensUsedInSession / modelContextLimit) * 100,
441
+ )
414
442
  const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
415
443
  if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
416
444
  lastDisplayedContextPercentage = thresholdCrossed
@@ -431,9 +459,7 @@ export async function handleOpencodeSession({
431
459
  continue
432
460
  }
433
461
 
434
- const existingIndex = currentParts.findIndex(
435
- (p: Part) => p.id === part.id,
436
- )
462
+ const existingIndex = currentParts.findIndex((p: Part) => p.id === part.id)
437
463
  if (existingIndex >= 0) {
438
464
  currentParts[existingIndex] = part
439
465
  } else {
@@ -468,9 +494,8 @@ export async function handleOpencodeSession({
468
494
  const outputTokens = Math.ceil(output.length / 4)
469
495
  const LARGE_OUTPUT_THRESHOLD = 3000
470
496
  if (outputTokens >= LARGE_OUTPUT_THRESHOLD) {
471
- const formattedTokens = outputTokens >= 1000
472
- ? `${(outputTokens / 1000).toFixed(1)}k`
473
- : String(outputTokens)
497
+ const formattedTokens =
498
+ outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens)
474
499
  const percentageSuffix = (() => {
475
500
  if (!modelContextLimit) {
476
501
  return ''
@@ -519,18 +544,13 @@ export async function handleOpencodeSession({
519
544
  const errorData = event.properties.error
520
545
  const errorMessage = errorData?.data?.message || 'Unknown error'
521
546
  sessionLogger.error(`Sending error to thread: ${errorMessage}`)
522
- await sendThreadMessage(
523
- thread,
524
- `✗ opencode session error: ${errorMessage}`,
525
- )
547
+ await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`)
526
548
 
527
549
  if (originalMessage) {
528
550
  try {
529
551
  await originalMessage.reactions.removeAll()
530
552
  await originalMessage.react('❌')
531
- voiceLogger.log(
532
- `[REACTION] Added error reaction due to session error`,
533
- )
553
+ voiceLogger.log(`[REACTION] Added error reaction due to session error`)
534
554
  } catch (e) {
535
555
  discordLogger.log(`Could not update reaction:`, e)
536
556
  }
@@ -579,9 +599,7 @@ export async function handleOpencodeSession({
579
599
  continue
580
600
  }
581
601
 
582
- sessionLogger.log(
583
- `Permission ${requestID} replied with: ${reply}`,
584
- )
602
+ sessionLogger.log(`Permission ${requestID} replied with: ${reply}`)
585
603
 
586
604
  const pending = pendingPermissions.get(thread.id)
587
605
  if (pending && pending.permission.id === requestID) {
@@ -633,9 +651,7 @@ export async function handleOpencodeSession({
633
651
  }
634
652
  } catch (e) {
635
653
  if (isAbortError(e, abortController.signal)) {
636
- sessionLogger.log(
637
- 'AbortController aborted event handling (normal exit)',
638
- )
654
+ sessionLogger.log('AbortController aborted event handling (normal exit)')
639
655
  return
640
656
  }
641
657
  sessionLogger.error(`Unexpected error in event handling code`, e)
@@ -656,16 +672,12 @@ export async function handleOpencodeSession({
656
672
  stopTyping = null
657
673
  }
658
674
 
659
- if (
660
- !abortController.signal.aborted ||
661
- abortController.signal.reason === 'finished'
662
- ) {
663
- const sessionDuration = prettyMilliseconds(
664
- Date.now() - sessionStartTime,
665
- )
675
+ if (!abortController.signal.aborted || abortController.signal.reason === 'finished') {
676
+ const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime)
666
677
  const attachCommand = port ? ` ⋅ ${session.id}` : ''
667
678
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
668
- const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : ''
679
+ const agentInfo =
680
+ usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : ''
669
681
  let contextInfo = ''
670
682
 
671
683
  try {
@@ -680,8 +692,14 @@ export async function handleOpencodeSession({
680
692
  sessionLogger.error('Failed to fetch provider info for context percentage:', e)
681
693
  }
682
694
 
683
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS })
684
- 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
+ )
685
703
 
686
704
  // Process queued messages after completion
687
705
  const queue = messageQueue.get(thread.id)
@@ -694,7 +712,10 @@ export async function handleOpencodeSession({
694
712
  sessionLogger.log(`[QUEUE] Processing queued message from ${nextMessage.username}`)
695
713
 
696
714
  // Show that queued message is being sent
697
- 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
+ )
698
719
 
699
720
  // Send the queued message as a new prompt (recursive call)
700
721
  // Use setImmediate to avoid blocking and allow this finally to complete
@@ -738,7 +759,14 @@ export async function handleOpencodeSession({
738
759
  if (images.length === 0) {
739
760
  return prompt
740
761
  }
741
- 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
+ )
742
770
  const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n')
743
771
  return `${prompt}\n\n**attached images:**\n${imagePathsList}`
744
772
  })()
@@ -747,7 +775,8 @@ export async function handleOpencodeSession({
747
775
  sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
748
776
 
749
777
  // Get model preference: session-level overrides channel-level
750
- const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
778
+ const modelPreference =
779
+ getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
751
780
  const modelParam = (() => {
752
781
  if (!modelPreference) {
753
782
  return undefined
@@ -762,7 +791,8 @@ export async function handleOpencodeSession({
762
791
  })()
763
792
 
764
793
  // Get agent preference: session-level overrides channel-level
765
- const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
794
+ const agentPreference =
795
+ getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
766
796
  if (agentPreference) {
767
797
  sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
768
798
  }
@@ -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,15 +29,23 @@ 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:
30
38
 
31
- npx -y kimaki start-session --channel ${channelId} --prompt "your prompt here"
39
+ npx -y kimaki send --channel ${channelId} --prompt "your prompt here"
40
+
41
+ Use --notify-only to create a notification thread without starting an AI session:
42
+
43
+ npx -y kimaki send --channel ${channelId} --prompt "User cancelled subscription" --notify-only
32
44
 
33
45
  This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
34
- ` : ''}
46
+ `
47
+ : ''
48
+ }
35
49
  ## showing diffs
36
50
 
37
51
  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.