kimaki 0.4.33 → 0.4.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.33",
5
+ "version": "0.4.35",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
@@ -30,30 +30,29 @@
30
30
  "tsx": "^4.20.5"
31
31
  },
32
32
  "dependencies": {
33
- "@ai-sdk/google": "^2.0.47",
34
33
  "@clack/prompts": "^0.11.0",
35
- "@discordjs/opus": "^0.10.0",
36
34
  "@discordjs/voice": "^0.19.0",
37
35
  "@google/genai": "^1.34.0",
38
36
  "@opencode-ai/sdk": "^1.1.12",
39
37
  "@purinton/resampler": "^1.0.4",
40
- "@snazzah/davey": "^0.1.6",
41
38
  "ai": "^5.0.114",
42
39
  "better-sqlite3": "^12.3.0",
43
40
  "cac": "^6.7.14",
44
41
  "discord.js": "^14.16.3",
45
42
  "domhandler": "^5.0.3",
46
43
  "glob": "^13.0.0",
47
- "go-try": "^3.0.2",
48
44
  "htmlparser2": "^10.0.0",
49
45
  "js-yaml": "^4.1.0",
50
46
  "marked": "^16.3.0",
51
47
  "picocolors": "^1.1.1",
52
48
  "pretty-ms": "^9.3.0",
53
- "prism-media": "^1.3.5",
54
49
  "ripgrep-js": "^3.0.0",
55
50
  "string-dedent": "^3.0.2",
56
51
  "undici": "^7.16.0",
57
52
  "zod": "^4.2.1"
53
+ },
54
+ "optionalDependencies": {
55
+ "@discordjs/opus": "^0.10.0",
56
+ "prism-media": "^1.3.5"
58
57
  }
59
58
  }
package/src/cli.ts CHANGED
@@ -197,6 +197,14 @@ async function registerCommands(token: string, appId: string, userCommands: Open
197
197
 
198
198
  return option
199
199
  })
200
+ .addStringOption((option) => {
201
+ option
202
+ .setName('agent')
203
+ .setDescription('Agent to use for this session')
204
+ .setAutocomplete(true)
205
+
206
+ return option
207
+ })
200
208
  .toJSON(),
201
209
  new SlashCommandBuilder()
202
210
  .setName('add-project')
@@ -279,7 +287,9 @@ async function registerCommands(token: string, appId: string, userCommands: Open
279
287
  continue
280
288
  }
281
289
 
282
- const commandName = `${cmd.name}-cmd`
290
+ // Sanitize command name: oh-my-opencode uses MCP commands with colons, which Discord doesn't allow
291
+ const sanitizedName = cmd.name.replace(/:/g, '-')
292
+ const commandName = `${sanitizedName}-cmd`
283
293
  const description = cmd.description || `Run /${cmd.name} command`
284
294
 
285
295
  commands.push(
@@ -21,6 +21,7 @@ export async function handleSessionCommand({
21
21
 
22
22
  const prompt = command.options.getString('prompt', true)
23
23
  const filesString = command.options.getString('files') || ''
24
+ const agent = command.options.getString('agent') || undefined
24
25
  const channel = command.channel
25
26
 
26
27
  if (!channel || channel.type !== ChannelType.GuildText) {
@@ -91,6 +92,7 @@ export async function handleSessionCommand({
91
92
  thread,
92
93
  projectDirectory,
93
94
  channelId: textChannel.id,
95
+ agent,
94
96
  })
95
97
  } catch (error) {
96
98
  logger.error('[SESSION] Error:', error)
@@ -100,12 +102,78 @@ export async function handleSessionCommand({
100
102
  }
101
103
  }
102
104
 
105
+ async function handleAgentAutocomplete({
106
+ interaction,
107
+ appId,
108
+ }: AutocompleteContext): Promise<void> {
109
+ const focusedValue = interaction.options.getFocused()
110
+
111
+ let projectDirectory: string | undefined
112
+
113
+ if (interaction.channel) {
114
+ const channel = interaction.channel
115
+ if (channel.type === ChannelType.GuildText) {
116
+ const textChannel = channel as TextChannel
117
+ if (textChannel.topic) {
118
+ const extracted = extractTagsArrays({
119
+ xml: textChannel.topic,
120
+ tags: ['kimaki.directory', 'kimaki.app'],
121
+ })
122
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim()
123
+ if (channelAppId && channelAppId !== appId) {
124
+ await interaction.respond([])
125
+ return
126
+ }
127
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
128
+ }
129
+ }
130
+ }
131
+
132
+ if (!projectDirectory) {
133
+ await interaction.respond([])
134
+ return
135
+ }
136
+
137
+ try {
138
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
139
+
140
+ const agentsResponse = await getClient().app.agents({
141
+ query: { directory: projectDirectory },
142
+ })
143
+
144
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
145
+ await interaction.respond([])
146
+ return
147
+ }
148
+
149
+ const agents = agentsResponse.data
150
+ .filter((a) => a.mode === 'primary' || a.mode === 'all')
151
+ .filter((a) => a.name.toLowerCase().includes(focusedValue.toLowerCase()))
152
+ .slice(0, 25)
153
+
154
+ const choices = agents.map((agent) => ({
155
+ name: agent.name.slice(0, 100),
156
+ value: agent.name,
157
+ }))
158
+
159
+ await interaction.respond(choices)
160
+ } catch (error) {
161
+ logger.error('[AUTOCOMPLETE] Error fetching agents:', error)
162
+ await interaction.respond([])
163
+ }
164
+ }
165
+
103
166
  export async function handleSessionAutocomplete({
104
167
  interaction,
105
168
  appId,
106
169
  }: AutocompleteContext): Promise<void> {
107
170
  const focusedOption = interaction.options.getFocused(true)
108
171
 
172
+ if (focusedOption.name === 'agent') {
173
+ await handleAgentAutocomplete({ interaction, appId })
174
+ return
175
+ }
176
+
109
177
  if (focusedOption.name !== 'files') {
110
178
  return
111
179
  }
package/src/logger.ts CHANGED
@@ -6,6 +6,7 @@ import { log } from '@clack/prompts'
6
6
  import fs from 'node:fs'
7
7
  import path, { dirname } from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
+ import util from 'node:util'
9
10
 
10
11
  const __filename = fileURLToPath(import.meta.url)
11
12
  const __dirname = dirname(__filename)
@@ -22,36 +23,43 @@ if (isDev) {
22
23
  fs.writeFileSync(logFilePath, `--- kimaki log started at ${new Date().toISOString()} ---\n`)
23
24
  }
24
25
 
25
- function writeToFile(level: string, prefix: string, args: any[]) {
26
+ function formatArg(arg: unknown): string {
27
+ if (typeof arg === 'string') {
28
+ return arg
29
+ }
30
+ return util.inspect(arg, { colors: true, depth: 4 })
31
+ }
32
+
33
+ function writeToFile(level: string, prefix: string, args: unknown[]) {
26
34
  if (!isDev) {
27
35
  return
28
36
  }
29
37
  const timestamp = new Date().toISOString()
30
- const message = `[${timestamp}] [${level}] [${prefix}] ${args.map((arg) => String(arg)).join(' ')}\n`
38
+ const message = `[${timestamp}] [${level}] [${prefix}] ${args.map(formatArg).join(' ')}\n`
31
39
  fs.appendFileSync(logFilePath, message)
32
40
  }
33
41
 
34
42
  export function createLogger(prefix: string) {
35
43
  return {
36
- log: (...args: any[]) => {
44
+ log: (...args: unknown[]) => {
37
45
  writeToFile('INFO', prefix, args)
38
- log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
46
+ log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
39
47
  },
40
- error: (...args: any[]) => {
48
+ error: (...args: unknown[]) => {
41
49
  writeToFile('ERROR', prefix, args)
42
- log.error([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
50
+ log.error([`[${prefix}]`, ...args.map(formatArg)].join(' '))
43
51
  },
44
- warn: (...args: any[]) => {
52
+ warn: (...args: unknown[]) => {
45
53
  writeToFile('WARN', prefix, args)
46
- log.warn([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
54
+ log.warn([`[${prefix}]`, ...args.map(formatArg)].join(' '))
47
55
  },
48
- info: (...args: any[]) => {
56
+ info: (...args: unknown[]) => {
49
57
  writeToFile('INFO', prefix, args)
50
- log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
58
+ log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
51
59
  },
52
- debug: (...args: any[]) => {
60
+ debug: (...args: unknown[]) => {
53
61
  writeToFile('DEBUG', prefix, args)
54
- log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
62
+ log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
55
63
  },
56
64
  }
57
65
  }
package/src/opencode.ts CHANGED
@@ -115,8 +115,7 @@ export async function initializeOpencodeForDirectory(directory: string) {
115
115
 
116
116
  const port = await getOpenPort()
117
117
 
118
- const opencodeBinDir = `${process.env.HOME}/.opencode/bin`
119
- const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`
118
+ const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
120
119
 
121
120
  const serverProcess = spawn(
122
121
  opencodeCommand,
@@ -6,14 +6,14 @@ 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'
9
+ import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent } from './database.js'
10
10
  import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js'
11
11
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js'
12
12
  import { formatPart } from './message-formatting.js'
13
13
  import { getOpencodeSystemMessage } from './system-message.js'
14
14
  import { createLogger } from './logger.js'
15
15
  import { isAbortError } from './utils.js'
16
- import { showAskUserQuestionDropdowns, cancelPendingQuestion } from './commands/ask-question.js'
16
+ import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts } from './commands/ask-question.js'
17
17
  import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js'
18
18
 
19
19
  const sessionLogger = createLogger('SESSION')
@@ -141,6 +141,7 @@ export async function handleOpencodeSession({
141
141
  images = [],
142
142
  channelId,
143
143
  command,
144
+ agent,
144
145
  }: {
145
146
  prompt: string
146
147
  thread: ThreadChannel
@@ -150,6 +151,8 @@ export async function handleOpencodeSession({
150
151
  channelId?: string
151
152
  /** If set, uses session.command API instead of session.prompt */
152
153
  command?: { name: string; arguments: string }
154
+ /** Agent to use for this session */
155
+ agent?: string
153
156
  }): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
154
157
  voiceLogger.log(
155
158
  `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
@@ -209,6 +212,12 @@ export async function handleOpencodeSession({
209
212
  .run(thread.id, session.id)
210
213
  sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`)
211
214
 
215
+ // Store agent preference if provided
216
+ if (agent) {
217
+ setSessionAgent(session.id, agent)
218
+ sessionLogger.log(`Set agent preference for session ${session.id}: ${agent}`)
219
+ }
220
+
212
221
  const existingController = abortControllers.get(session.id)
213
222
  if (existingController) {
214
223
  voiceLogger.log(
@@ -239,11 +248,10 @@ export async function handleOpencodeSession({
239
248
  }
240
249
  }
241
250
 
242
- // Cancel any pending question tool if user sends a new message
251
+ // Cancel any pending question tool if user sends a new message (silently, no thread message)
243
252
  const questionCancelled = await cancelPendingQuestion(thread.id)
244
253
  if (questionCancelled) {
245
254
  sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`)
246
- await sendThreadMessage(thread, `⚠️ Previous question cancelled - processing your new message`)
247
255
  }
248
256
 
249
257
  const abortController = new AbortController()
@@ -433,7 +441,14 @@ export async function handleOpencodeSession({
433
441
  }
434
442
 
435
443
  if (part.type === 'step-start') {
436
- stopTyping = startTyping()
444
+ // Don't start typing if user needs to respond to a question or permission
445
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
446
+ (ctx) => ctx.thread.id === thread.id,
447
+ )
448
+ const hasPendingPermission = pendingPermissions.has(thread.id)
449
+ if (!hasPendingQuestion && !hasPendingPermission) {
450
+ stopTyping = startTyping()
451
+ }
437
452
  }
438
453
 
439
454
  if (part.type === 'tool' && part.state.status === 'running') {
@@ -475,6 +490,12 @@ export async function handleOpencodeSession({
475
490
  await sendPartMessage(part)
476
491
  }
477
492
 
493
+ // Send text parts when complete (time.end is set)
494
+ // Text parts stream incrementally; only send when finished to avoid partial text
495
+ if (part.type === 'text' && part.time?.end) {
496
+ await sendPartMessage(part)
497
+ }
498
+
478
499
  if (part.type === 'step-finish') {
479
500
  for (const p of currentParts) {
480
501
  if (p.type !== 'step-start' && p.type !== 'step-finish') {
@@ -483,6 +504,12 @@ export async function handleOpencodeSession({
483
504
  }
484
505
  setTimeout(() => {
485
506
  if (abortController.signal.aborted) return
507
+ // Don't restart typing if user needs to respond to a question or permission
508
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
509
+ (ctx) => ctx.thread.id === thread.id,
510
+ )
511
+ const hasPendingPermission = pendingPermissions.has(thread.id)
512
+ if (hasPendingQuestion || hasPendingPermission) return
486
513
  stopTyping = startTyping()
487
514
  }, 300)
488
515
  }
@@ -527,6 +554,12 @@ export async function handleOpencodeSession({
527
554
  `Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
528
555
  )
529
556
 
557
+ // Stop typing - user needs to respond now, not the bot
558
+ if (stopTyping) {
559
+ stopTyping()
560
+ stopTyping = null
561
+ }
562
+
530
563
  // Show dropdown instead of text message
531
564
  const { messageId, contextHash } = await showPermissionDropdown({
532
565
  thread,
@@ -569,6 +602,12 @@ export async function handleOpencodeSession({
569
602
  `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
570
603
  )
571
604
 
605
+ // Stop typing - user needs to respond now, not the bot
606
+ if (stopTyping) {
607
+ stopTyping()
608
+ stopTyping = null
609
+ }
610
+
572
611
  // Flush any pending text/reasoning parts before showing the dropdown
573
612
  // This ensures text the LLM generated before the question tool is shown first
574
613
  for (const p of currentParts) {
@@ -584,6 +623,12 @@ export async function handleOpencodeSession({
584
623
  requestId: questionRequest.id,
585
624
  input: { questions: questionRequest.questions },
586
625
  })
626
+ } else if (event.type === 'session.idle') {
627
+ // Session is done processing - abort to signal completion
628
+ if (event.properties.sessionID === session.id) {
629
+ sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`)
630
+ abortController.abort('finished')
631
+ }
587
632
  }
588
633
  }
589
634
  } catch (e) {
@@ -789,20 +834,17 @@ export async function handleOpencodeSession({
789
834
  discordLogger.log(`Could not update reaction:`, e)
790
835
  }
791
836
  }
792
- const errorName =
793
- error &&
794
- typeof error === 'object' &&
795
- 'constructor' in error &&
796
- error.constructor &&
797
- typeof error.constructor.name === 'string'
798
- ? error.constructor.name
799
- : typeof error
800
- const errorMsg =
801
- error instanceof Error ? error.stack || error.message : String(error)
802
- await sendThreadMessage(
803
- thread,
804
- `✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`,
805
- )
837
+ const errorDisplay = (() => {
838
+ if (error instanceof Error) {
839
+ const name = error.constructor.name || 'Error'
840
+ return `[${name}]\n${error.stack || error.message}`
841
+ }
842
+ if (typeof error === 'string') {
843
+ return error
844
+ }
845
+ return String(error)
846
+ })()
847
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`)
806
848
  }
807
849
  }
808
850
  }
@@ -66,5 +66,18 @@ headings are discouraged anyway. instead try to use bold text for titles which r
66
66
  ## diagrams
67
67
 
68
68
  you can create diagrams wrapping them in code blocks.
69
+
70
+ ## ending conversations with options
71
+
72
+ IMPORTANT: At the end of each response, especially after completing a task or presenting a plan, use the question tool to offer the user clear options for what to do next.
73
+
74
+ Examples:
75
+ - After showing a plan: offer "Start implementing?" with Yes/No options
76
+ - After completing edits: offer "Commit changes?" with Yes/No options
77
+ - After debugging: offer "How to proceed?" with options like "Apply fix", "Investigate further", "Try different approach"
78
+
79
+ The user can always select "Other" to type a custom response if the provided options don't fit their needs, or if the plan needs updating.
80
+
81
+ This makes the interaction more guided and reduces friction for the user.
69
82
  `
70
83
  }