kimaki 0.4.25 → 0.4.26

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 (50) hide show
  1. package/dist/acp-client.test.js +149 -0
  2. package/dist/channel-management.js +11 -9
  3. package/dist/cli.js +59 -7
  4. package/dist/commands/add-project.js +1 -0
  5. package/dist/commands/agent.js +152 -0
  6. package/dist/commands/ask-question.js +183 -0
  7. package/dist/commands/model.js +23 -4
  8. package/dist/commands/session.js +1 -3
  9. package/dist/commands/user-command.js +145 -0
  10. package/dist/database.js +51 -0
  11. package/dist/discord-bot.js +32 -32
  12. package/dist/discord-utils.js +71 -14
  13. package/dist/interaction-handler.js +20 -0
  14. package/dist/logger.js +43 -5
  15. package/dist/markdown.js +104 -0
  16. package/dist/markdown.test.js +31 -1
  17. package/dist/message-formatting.js +72 -22
  18. package/dist/message-formatting.test.js +73 -0
  19. package/dist/opencode.js +70 -16
  20. package/dist/session-handler.js +131 -62
  21. package/dist/system-message.js +4 -51
  22. package/dist/voice-handler.js +18 -8
  23. package/dist/voice.js +28 -12
  24. package/package.json +14 -13
  25. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  26. package/src/__snapshots__/compact-session-context.md +47 -0
  27. package/src/channel-management.ts +20 -8
  28. package/src/cli.ts +74 -8
  29. package/src/commands/add-project.ts +1 -0
  30. package/src/commands/agent.ts +201 -0
  31. package/src/commands/ask-question.ts +276 -0
  32. package/src/commands/fork.ts +1 -2
  33. package/src/commands/model.ts +24 -4
  34. package/src/commands/session.ts +1 -3
  35. package/src/commands/user-command.ts +178 -0
  36. package/src/database.ts +61 -0
  37. package/src/discord-bot.ts +36 -33
  38. package/src/discord-utils.ts +76 -14
  39. package/src/interaction-handler.ts +25 -0
  40. package/src/logger.ts +47 -10
  41. package/src/markdown.test.ts +45 -1
  42. package/src/markdown.ts +132 -0
  43. package/src/message-formatting.test.ts +81 -0
  44. package/src/message-formatting.ts +93 -25
  45. package/src/opencode.ts +80 -21
  46. package/src/session-handler.ts +180 -90
  47. package/src/system-message.ts +4 -51
  48. package/src/voice-handler.ts +20 -9
  49. package/src/voice.ts +32 -13
  50. package/LICENSE +0 -21
@@ -2,12 +2,31 @@
2
2
  // Converts SDK message parts (text, tools, reasoning) to Discord-friendly format,
3
3
  // handles file attachments, and provides tool summary generation.
4
4
 
5
- import type { Part, FilePartInput, SessionMessagesResponse } from '@opencode-ai/sdk'
5
+ import type { Part } from '@opencode-ai/sdk/v2'
6
+ import type { FilePartInput } from '@opencode-ai/sdk'
6
7
  import type { Message } from 'discord.js'
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
7
10
  import { createLogger } from './logger.js'
8
11
 
12
+ // Generic message type compatible with both v1 and v2 SDK
13
+ type GenericSessionMessage = {
14
+ info: { role: string; id?: string }
15
+ parts: Part[]
16
+ }
17
+
18
+ const ATTACHMENTS_DIR = path.join(process.cwd(), 'tmp', 'discord-attachments')
19
+
9
20
  const logger = createLogger('FORMATTING')
10
21
 
22
+ /**
23
+ * Escapes Discord inline markdown characters so dynamic content
24
+ * doesn't break formatting when wrapped in *, _, **, etc.
25
+ */
26
+ function escapeInlineMarkdown(text: string): string {
27
+ return text.replace(/([*_~|`\\])/g, '\\$1')
28
+ }
29
+
11
30
  /**
12
31
  * Collects and formats the last N assistant parts from session messages.
13
32
  * Used by both /resume and /fork to show recent assistant context.
@@ -16,7 +35,7 @@ export function collectLastAssistantParts({
16
35
  messages,
17
36
  limit = 30,
18
37
  }: {
19
- messages: SessionMessagesResponse
38
+ messages: GenericSessionMessage[]
20
39
  limit?: number
21
40
  }): { partIds: string[]; content: string; skippedCount: number } {
22
41
  const allAssistantParts: { id: string; content: string }[] = []
@@ -85,7 +104,7 @@ export async function getTextAttachments(message: Message): Promise<string> {
85
104
  return textContents.join('\n\n')
86
105
  }
87
106
 
88
- export function getFileAttachments(message: Message): FilePartInput[] {
107
+ export async function getFileAttachments(message: Message): Promise<FilePartInput[]> {
89
108
  const fileAttachments = Array.from(message.attachments.values()).filter(
90
109
  (attachment) => {
91
110
  const contentType = attachment.contentType || ''
@@ -95,12 +114,44 @@ export function getFileAttachments(message: Message): FilePartInput[] {
95
114
  },
96
115
  )
97
116
 
98
- return fileAttachments.map((attachment) => ({
99
- type: 'file' as const,
100
- mime: attachment.contentType || 'application/octet-stream',
101
- filename: attachment.name,
102
- url: attachment.url,
103
- }))
117
+ if (fileAttachments.length === 0) {
118
+ return []
119
+ }
120
+
121
+ // ensure tmp directory exists
122
+ if (!fs.existsSync(ATTACHMENTS_DIR)) {
123
+ fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true })
124
+ }
125
+
126
+ const results = await Promise.all(
127
+ fileAttachments.map(async (attachment) => {
128
+ try {
129
+ const response = await fetch(attachment.url)
130
+ if (!response.ok) {
131
+ logger.error(`Failed to fetch attachment ${attachment.name}: ${response.status}`)
132
+ return null
133
+ }
134
+
135
+ const buffer = Buffer.from(await response.arrayBuffer())
136
+ const localPath = path.join(ATTACHMENTS_DIR, `${message.id}-${attachment.name}`)
137
+ fs.writeFileSync(localPath, buffer)
138
+
139
+ logger.log(`Downloaded attachment to ${localPath}`)
140
+
141
+ return {
142
+ type: 'file' as const,
143
+ mime: attachment.contentType || 'application/octet-stream',
144
+ filename: attachment.name,
145
+ url: localPath,
146
+ }
147
+ } catch (error) {
148
+ logger.error(`Error downloading attachment ${attachment.name}:`, error)
149
+ return null
150
+ }
151
+ }),
152
+ )
153
+
154
+ return results.filter((r) => r !== null) as FilePartInput[]
104
155
  }
105
156
 
106
157
  export function getToolSummaryText(part: Part): string {
@@ -113,7 +164,7 @@ export function getToolSummaryText(part: Part): string {
113
164
  const added = newString.split('\n').length
114
165
  const removed = oldString.split('\n').length
115
166
  const fileName = filePath.split('/').pop() || ''
116
- return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`
167
+ return fileName ? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})` : `(+${added}-${removed})`
117
168
  }
118
169
 
119
170
  if (part.tool === 'write') {
@@ -121,35 +172,35 @@ export function getToolSummaryText(part: Part): string {
121
172
  const content = (part.state.input?.content as string) || ''
122
173
  const lines = content.split('\n').length
123
174
  const fileName = filePath.split('/').pop() || ''
124
- return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`
175
+ return fileName ? `*${escapeInlineMarkdown(fileName)}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`
125
176
  }
126
177
 
127
178
  if (part.tool === 'webfetch') {
128
179
  const url = (part.state.input?.url as string) || ''
129
180
  const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
130
- return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : ''
181
+ return urlWithoutProtocol ? `*${escapeInlineMarkdown(urlWithoutProtocol)}*` : ''
131
182
  }
132
183
 
133
184
  if (part.tool === 'read') {
134
185
  const filePath = (part.state.input?.filePath as string) || ''
135
186
  const fileName = filePath.split('/').pop() || ''
136
- return fileName ? `*${fileName}*` : ''
187
+ return fileName ? `*${escapeInlineMarkdown(fileName)}*` : ''
137
188
  }
138
189
 
139
190
  if (part.tool === 'list') {
140
191
  const path = (part.state.input?.path as string) || ''
141
192
  const dirName = path.split('/').pop() || path
142
- return dirName ? `*${dirName}*` : ''
193
+ return dirName ? `*${escapeInlineMarkdown(dirName)}*` : ''
143
194
  }
144
195
 
145
196
  if (part.tool === 'glob') {
146
197
  const pattern = (part.state.input?.pattern as string) || ''
147
- return pattern ? `*${pattern}*` : ''
198
+ return pattern ? `*${escapeInlineMarkdown(pattern)}*` : ''
148
199
  }
149
200
 
150
201
  if (part.tool === 'grep') {
151
202
  const pattern = (part.state.input?.pattern as string) || ''
152
- return pattern ? `*${pattern}*` : ''
203
+ return pattern ? `*${escapeInlineMarkdown(pattern)}*` : ''
153
204
  }
154
205
 
155
206
  if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
@@ -158,12 +209,12 @@ export function getToolSummaryText(part: Part): string {
158
209
 
159
210
  if (part.tool === 'task') {
160
211
  const description = (part.state.input?.description as string) || ''
161
- return description ? `_${description}_` : ''
212
+ return description ? `_${escapeInlineMarkdown(description)}_` : ''
162
213
  }
163
214
 
164
215
  if (part.tool === 'skill') {
165
216
  const name = (part.state.input?.name as string) || ''
166
- return name ? `_${name}_` : ''
217
+ return name ? `_${escapeInlineMarkdown(name)}_` : ''
167
218
  }
168
219
 
169
220
  if (!part.state.input) return ''
@@ -194,12 +245,25 @@ export function formatTodoList(part: Part): string {
194
245
  })
195
246
  const activeTodo = todos[activeIndex]
196
247
  if (activeIndex === -1 || !activeTodo) return ''
197
- return `${activeIndex + 1}. **${activeTodo.content}**`
248
+ // parenthesized digits ⑴-⒇ for 1-20, fallback to regular number for 21+
249
+ const parenthesizedDigits = '⑴⑵⑶⑷⑸⑹⑺⑻⑼⑽⑾⑿⒀⒁⒂⒃⒄⒅⒆⒇'
250
+ const todoNumber = activeIndex + 1
251
+ const num = todoNumber <= 20 ? parenthesizedDigits[todoNumber - 1] : `(${todoNumber})`
252
+ const content = activeTodo.content.charAt(0).toLowerCase() + activeTodo.content.slice(1)
253
+ return `${num} **${escapeInlineMarkdown(content)}**`
198
254
  }
199
255
 
200
256
  export function formatPart(part: Part): string {
201
257
  if (part.type === 'text') {
202
258
  if (!part.text?.trim()) return ''
259
+ const trimmed = part.text.trimStart()
260
+ const firstChar = trimmed[0] || ''
261
+ const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|']
262
+ const startsWithMarkdown =
263
+ markdownStarters.includes(firstChar) || /^\d+\./.test(trimmed)
264
+ if (startsWithMarkdown) {
265
+ return `\n${part.text}`
266
+ }
203
267
  return `⬥ ${part.text}`
204
268
  }
205
269
 
@@ -229,6 +293,11 @@ export function formatPart(part: Part): string {
229
293
  return formatTodoList(part)
230
294
  }
231
295
 
296
+ // Question tool is handled via Discord dropdowns, not text
297
+ if (part.tool === 'question') {
298
+ return ''
299
+ }
300
+
232
301
  if (part.state.status === 'pending') {
233
302
  return ''
234
303
  }
@@ -243,16 +312,15 @@ export function formatPart(part: Part): string {
243
312
  const command = (part.state.input?.command as string) || ''
244
313
  const description = (part.state.input?.description as string) || ''
245
314
  const isSingleLine = !command.includes('\n')
246
- const hasUnderscores = command.includes('_')
247
- if (isSingleLine && !hasUnderscores && command.length <= 50) {
248
- toolTitle = `_${command}_`
315
+ if (isSingleLine && command.length <= 50) {
316
+ toolTitle = `_${escapeInlineMarkdown(command)}_`
249
317
  } else if (description) {
250
- toolTitle = `_${description}_`
318
+ toolTitle = `_${escapeInlineMarkdown(description)}_`
251
319
  } else if (stateTitle) {
252
- toolTitle = `_${stateTitle}_`
320
+ toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`
253
321
  }
254
322
  } else if (stateTitle) {
255
- toolTitle = `_${stateTitle}_`
323
+ toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`
256
324
  }
257
325
 
258
326
  const icon = (() => {
package/src/opencode.ts CHANGED
@@ -3,12 +3,17 @@
3
3
  // handles automatic restarts on failure, and provides typed SDK clients.
4
4
 
5
5
  import { spawn, type ChildProcess } from 'node:child_process'
6
+ import fs from 'node:fs'
6
7
  import net from 'node:net'
7
8
  import {
8
9
  createOpencodeClient,
9
10
  type OpencodeClient,
10
11
  type Config,
11
12
  } from '@opencode-ai/sdk'
13
+ import {
14
+ createOpencodeClient as createOpencodeClientV2,
15
+ type OpencodeClient as OpencodeClientV2,
16
+ } from '@opencode-ai/sdk/v2'
12
17
  import { createLogger } from './logger.js'
13
18
 
14
19
  const opencodeLogger = createLogger('OPENCODE')
@@ -18,6 +23,7 @@ const opencodeServers = new Map<
18
23
  {
19
24
  process: ChildProcess
20
25
  client: OpencodeClient
26
+ clientV2: OpencodeClientV2
21
27
  port: number
22
28
  }
23
29
  >()
@@ -46,21 +52,36 @@ async function waitForServer(port: number, maxAttempts = 30): Promise<boolean> {
46
52
  for (let i = 0; i < maxAttempts; i++) {
47
53
  try {
48
54
  const endpoints = [
49
- `http://localhost:${port}/api/health`,
50
- `http://localhost:${port}/`,
51
- `http://localhost:${port}/api`,
55
+ `http://127.0.0.1:${port}/api/health`,
56
+ `http://127.0.0.1:${port}/`,
57
+ `http://127.0.0.1:${port}/api`,
52
58
  ]
53
59
 
54
60
  for (const endpoint of endpoints) {
55
61
  try {
56
62
  const response = await fetch(endpoint)
57
63
  if (response.status < 500) {
58
- opencodeLogger.log(`Server ready on port `)
59
64
  return true
60
65
  }
61
- } catch (e) {}
66
+ const body = await response.text()
67
+ // Fatal errors that won't resolve with retrying
68
+ if (body.includes('BunInstallFailedError')) {
69
+ throw new Error(`Server failed to start: ${body.slice(0, 200)}`)
70
+ }
71
+ } catch (e) {
72
+ // Re-throw fatal errors
73
+ if ((e as Error).message?.includes('Server failed to start')) {
74
+ throw e
75
+ }
76
+ }
62
77
  }
63
- } catch (e) {}
78
+ } catch (e) {
79
+ // Re-throw fatal errors that won't resolve with retrying
80
+ if ((e as Error).message?.includes('Server failed to start')) {
81
+ throw e
82
+ }
83
+ opencodeLogger.debug(`Server polling attempt failed: ${(e as Error).message}`)
84
+ }
64
85
  await new Promise((resolve) => setTimeout(resolve, 1000))
65
86
  }
66
87
  throw new Error(
@@ -85,9 +106,17 @@ export async function initializeOpencodeForDirectory(directory: string) {
85
106
  }
86
107
  }
87
108
 
109
+ // Verify directory exists and is accessible before spawning
110
+ try {
111
+ fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK)
112
+ } catch {
113
+ throw new Error(`Directory does not exist or is not accessible: ${directory}`)
114
+ }
115
+
88
116
  const port = await getOpenPort()
89
117
 
90
- const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
118
+ const opencodeBinDir = `${process.env.HOME}/.opencode/bin`
119
+ const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`
91
120
 
92
121
  const serverProcess = spawn(
93
122
  opencodeCommand,
@@ -113,23 +142,24 @@ export async function initializeOpencodeForDirectory(directory: string) {
113
142
  },
114
143
  )
115
144
 
145
+ // Buffer logs until we know if server started successfully
146
+ const logBuffer: string[] = []
147
+ logBuffer.push(`Spawned opencode serve --port ${port} in ${directory} (pid: ${serverProcess.pid})`)
148
+
116
149
  serverProcess.stdout?.on('data', (data) => {
117
- opencodeLogger.log(`opencode ${directory}: ${data.toString().trim()}`)
150
+ logBuffer.push(`[stdout] ${data.toString().trim()}`)
118
151
  })
119
152
 
120
153
  serverProcess.stderr?.on('data', (data) => {
121
- opencodeLogger.error(`opencode ${directory}: ${data.toString().trim()}`)
154
+ logBuffer.push(`[stderr] ${data.toString().trim()}`)
122
155
  })
123
156
 
124
157
  serverProcess.on('error', (error) => {
125
- opencodeLogger.error(`Failed to start server on port :`, port, error)
158
+ logBuffer.push(`Failed to start server on port ${port}: ${error}`)
126
159
  })
127
160
 
128
161
  serverProcess.on('exit', (code) => {
129
- opencodeLogger.log(
130
- `Opencode server on ${directory} exited with code:`,
131
- code,
132
- )
162
+ opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code)
133
163
  opencodeServers.delete(directory)
134
164
  if (code !== 0) {
135
165
  const retryCount = serverRetryCount.get(directory) || 0
@@ -151,20 +181,39 @@ export async function initializeOpencodeForDirectory(directory: string) {
151
181
  }
152
182
  })
153
183
 
154
- await waitForServer(port)
184
+ try {
185
+ await waitForServer(port)
186
+ opencodeLogger.log(`Server ready on port ${port}`)
187
+ } catch (e) {
188
+ // Dump buffered logs on failure
189
+ opencodeLogger.error(`Server failed to start for ${directory}:`)
190
+ for (const line of logBuffer) {
191
+ opencodeLogger.error(` ${line}`)
192
+ }
193
+ throw e
194
+ }
195
+
196
+ const baseUrl = `http://127.0.0.1:${port}`
197
+ const fetchWithTimeout = (request: Request) =>
198
+ fetch(request, {
199
+ // @ts-ignore
200
+ timeout: false,
201
+ })
155
202
 
156
203
  const client = createOpencodeClient({
157
- baseUrl: `http://localhost:${port}`,
158
- fetch: (request: Request) =>
159
- fetch(request, {
160
- // @ts-ignore
161
- timeout: false,
162
- }),
204
+ baseUrl,
205
+ fetch: fetchWithTimeout,
206
+ })
207
+
208
+ const clientV2 = createOpencodeClientV2({
209
+ baseUrl,
210
+ fetch: fetchWithTimeout as typeof fetch,
163
211
  })
164
212
 
165
213
  opencodeServers.set(directory, {
166
214
  process: serverProcess,
167
215
  client,
216
+ clientV2,
168
217
  port,
169
218
  })
170
219
 
@@ -182,3 +231,13 @@ export async function initializeOpencodeForDirectory(directory: string) {
182
231
  export function getOpencodeServers() {
183
232
  return opencodeServers
184
233
  }
234
+
235
+ export function getOpencodeServerPort(directory: string): number | null {
236
+ const entry = opencodeServers.get(directory)
237
+ return entry?.port ?? null
238
+ }
239
+
240
+ export function getOpencodeClientV2(directory: string): OpencodeClientV2 | null {
241
+ const entry = opencodeServers.get(directory)
242
+ return entry?.clientV2 ?? null
243
+ }