kimaki 0.4.25 → 0.4.27
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/dist/acp-client.test.js +149 -0
- package/dist/channel-management.js +11 -9
- package/dist/cli.js +58 -18
- package/dist/commands/add-project.js +1 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +184 -0
- package/dist/commands/model.js +23 -4
- package/dist/commands/permissions.js +101 -105
- package/dist/commands/session.js +1 -3
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +51 -0
- package/dist/discord-bot.js +32 -32
- package/dist/discord-utils.js +71 -14
- package/dist/interaction-handler.js +25 -8
- package/dist/logger.js +43 -5
- package/dist/markdown.js +104 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +72 -22
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +70 -16
- package/dist/session-handler.js +142 -66
- package/dist/system-message.js +4 -51
- package/dist/voice-handler.js +18 -8
- package/dist/voice.js +28 -12
- package/package.json +14 -13
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/channel-management.ts +20 -8
- package/src/cli.ts +73 -19
- package/src/commands/add-project.ts +1 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +277 -0
- package/src/commands/fork.ts +1 -2
- package/src/commands/model.ts +24 -4
- package/src/commands/permissions.ts +139 -114
- package/src/commands/session.ts +1 -3
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +61 -0
- package/src/discord-bot.ts +36 -33
- package/src/discord-utils.ts +76 -14
- package/src/interaction-handler.ts +31 -10
- package/src/logger.ts +47 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +132 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +93 -25
- package/src/opencode.ts +80 -21
- package/src/session-handler.ts +190 -97
- package/src/system-message.ts +4 -51
- package/src/voice-handler.ts +20 -9
- package/src/voice.ts +32 -13
- package/LICENSE +0 -21
package/src/markdown.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect, beforeAll, afterAll } from 'vitest'
|
|
2
2
|
import { spawn, type ChildProcess } from 'child_process'
|
|
3
3
|
import { OpencodeClient } from '@opencode-ai/sdk'
|
|
4
|
-
import { ShareMarkdown } from './markdown.js'
|
|
4
|
+
import { ShareMarkdown, getCompactSessionContext } from './markdown.js'
|
|
5
5
|
|
|
6
6
|
let serverProcess: ChildProcess
|
|
7
7
|
let client: OpencodeClient
|
|
@@ -312,3 +312,47 @@ test('generate markdown from multiple sessions', async () => {
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
})
|
|
315
|
+
|
|
316
|
+
// test for getCompactSessionContext - disabled in CI since it requires a specific session
|
|
317
|
+
test.skipIf(process.env.CI)('getCompactSessionContext generates compact format', async () => {
|
|
318
|
+
const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM'
|
|
319
|
+
|
|
320
|
+
const context = await getCompactSessionContext({
|
|
321
|
+
client,
|
|
322
|
+
sessionId,
|
|
323
|
+
includeSystemPrompt: true,
|
|
324
|
+
maxMessages: 15,
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
console.log(`Generated compact context length: ${context.length} characters`)
|
|
328
|
+
|
|
329
|
+
expect(context).toBeTruthy()
|
|
330
|
+
expect(context.length).toBeGreaterThan(0)
|
|
331
|
+
// should have tool calls or messages
|
|
332
|
+
expect(context).toMatch(/\[Tool \w+\]:|\[User\]:|\[Assistant\]:/)
|
|
333
|
+
|
|
334
|
+
await expect(context).toMatchFileSnapshot(
|
|
335
|
+
'./__snapshots__/compact-session-context.md',
|
|
336
|
+
)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test.skipIf(process.env.CI)('getCompactSessionContext without system prompt', async () => {
|
|
340
|
+
const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM'
|
|
341
|
+
|
|
342
|
+
const context = await getCompactSessionContext({
|
|
343
|
+
client,
|
|
344
|
+
sessionId,
|
|
345
|
+
includeSystemPrompt: false,
|
|
346
|
+
maxMessages: 10,
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
console.log(`Generated compact context (no system) length: ${context.length} characters`)
|
|
350
|
+
|
|
351
|
+
expect(context).toBeTruthy()
|
|
352
|
+
// should NOT have system prompt
|
|
353
|
+
expect(context).not.toContain('[System Prompt]')
|
|
354
|
+
|
|
355
|
+
await expect(context).toMatchFileSnapshot(
|
|
356
|
+
'./__snapshots__/compact-session-context-no-system.md',
|
|
357
|
+
)
|
|
358
|
+
})
|
package/src/markdown.ts
CHANGED
|
@@ -6,6 +6,9 @@ import type { OpencodeClient } from '@opencode-ai/sdk'
|
|
|
6
6
|
import * as yaml from 'js-yaml'
|
|
7
7
|
import { formatDateTime } from './utils.js'
|
|
8
8
|
import { extractNonXmlContent } from './xml.js'
|
|
9
|
+
import { createLogger } from './logger.js'
|
|
10
|
+
|
|
11
|
+
const markdownLogger = createLogger('MARKDOWN')
|
|
9
12
|
|
|
10
13
|
export class ShareMarkdown {
|
|
11
14
|
constructor(private client: OpencodeClient) {}
|
|
@@ -231,3 +234,132 @@ export class ShareMarkdown {
|
|
|
231
234
|
return `${minutes}m ${seconds}s`
|
|
232
235
|
}
|
|
233
236
|
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Generate compact session context for voice transcription.
|
|
240
|
+
* Includes system prompt (optional), user messages, assistant text,
|
|
241
|
+
* and tool calls in compact form (name + params only, no output).
|
|
242
|
+
*/
|
|
243
|
+
export async function getCompactSessionContext({
|
|
244
|
+
client,
|
|
245
|
+
sessionId,
|
|
246
|
+
includeSystemPrompt = false,
|
|
247
|
+
maxMessages = 20,
|
|
248
|
+
}: {
|
|
249
|
+
client: OpencodeClient
|
|
250
|
+
sessionId: string
|
|
251
|
+
includeSystemPrompt?: boolean
|
|
252
|
+
maxMessages?: number
|
|
253
|
+
}): Promise<string> {
|
|
254
|
+
try {
|
|
255
|
+
const messagesResponse = await client.session.messages({
|
|
256
|
+
path: { id: sessionId },
|
|
257
|
+
})
|
|
258
|
+
const messages = messagesResponse.data || []
|
|
259
|
+
|
|
260
|
+
const lines: string[] = []
|
|
261
|
+
|
|
262
|
+
// Get system prompt if requested
|
|
263
|
+
// Note: OpenCode SDK doesn't expose system prompt directly. We try multiple approaches:
|
|
264
|
+
// 1. session.system field (if available in future SDK versions)
|
|
265
|
+
// 2. synthetic text part in first assistant message (current approach)
|
|
266
|
+
if (includeSystemPrompt && messages.length > 0) {
|
|
267
|
+
const firstAssistant = messages.find((m) => m.info.role === 'assistant')
|
|
268
|
+
if (firstAssistant) {
|
|
269
|
+
// look for text part marked as synthetic (system prompt)
|
|
270
|
+
const systemPart = (firstAssistant.parts || []).find(
|
|
271
|
+
(p) => p.type === 'text' && (p as any).synthetic === true,
|
|
272
|
+
)
|
|
273
|
+
if (systemPart && 'text' in systemPart && systemPart.text) {
|
|
274
|
+
lines.push('[System Prompt]')
|
|
275
|
+
const truncated = systemPart.text.slice(0, 3000)
|
|
276
|
+
lines.push(truncated)
|
|
277
|
+
if (systemPart.text.length > 3000) {
|
|
278
|
+
lines.push('...(truncated)')
|
|
279
|
+
}
|
|
280
|
+
lines.push('')
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Process recent messages
|
|
286
|
+
const recentMessages = messages.slice(-maxMessages)
|
|
287
|
+
|
|
288
|
+
for (const msg of recentMessages) {
|
|
289
|
+
if (msg.info.role === 'user') {
|
|
290
|
+
const textParts = (msg.parts || [])
|
|
291
|
+
.filter((p) => p.type === 'text' && 'text' in p)
|
|
292
|
+
.map((p) => ('text' in p ? extractNonXmlContent(p.text || '') : ''))
|
|
293
|
+
.filter(Boolean)
|
|
294
|
+
if (textParts.length > 0) {
|
|
295
|
+
lines.push(`[User]: ${textParts.join(' ').slice(0, 1000)}`)
|
|
296
|
+
lines.push('')
|
|
297
|
+
}
|
|
298
|
+
} else if (msg.info.role === 'assistant') {
|
|
299
|
+
// Get assistant text parts (non-synthetic, non-empty)
|
|
300
|
+
const textParts = (msg.parts || [])
|
|
301
|
+
.filter((p) => p.type === 'text' && 'text' in p && !p.synthetic && p.text)
|
|
302
|
+
.map((p) => ('text' in p ? p.text : ''))
|
|
303
|
+
.filter(Boolean)
|
|
304
|
+
if (textParts.length > 0) {
|
|
305
|
+
lines.push(`[Assistant]: ${textParts.join(' ').slice(0, 1000)}`)
|
|
306
|
+
lines.push('')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Get tool calls in compact form (name + params only)
|
|
310
|
+
const toolParts = (msg.parts || []).filter(
|
|
311
|
+
(p) =>
|
|
312
|
+
p.type === 'tool' &&
|
|
313
|
+
'state' in p &&
|
|
314
|
+
p.state?.status === 'completed',
|
|
315
|
+
)
|
|
316
|
+
for (const part of toolParts) {
|
|
317
|
+
if (part.type === 'tool' && 'tool' in part && 'state' in part) {
|
|
318
|
+
const toolName = part.tool
|
|
319
|
+
// skip noisy tools
|
|
320
|
+
if (toolName === 'todoread' || toolName === 'todowrite') {
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
const input = part.state?.input || {}
|
|
324
|
+
// compact params: just key=value on one line
|
|
325
|
+
const params = Object.entries(input)
|
|
326
|
+
.map(([k, v]) => {
|
|
327
|
+
const val = typeof v === 'string' ? v.slice(0, 100) : JSON.stringify(v).slice(0, 100)
|
|
328
|
+
return `${k}=${val}`
|
|
329
|
+
})
|
|
330
|
+
.join(', ')
|
|
331
|
+
lines.push(`[Tool ${toolName}]: ${params}`)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return lines.join('\n').slice(0, 8000)
|
|
338
|
+
} catch (e) {
|
|
339
|
+
markdownLogger.error('Failed to get compact session context:', e)
|
|
340
|
+
return ''
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Get the last session for a directory (excluding the current one).
|
|
346
|
+
*/
|
|
347
|
+
export async function getLastSessionId({
|
|
348
|
+
client,
|
|
349
|
+
excludeSessionId,
|
|
350
|
+
}: {
|
|
351
|
+
client: OpencodeClient
|
|
352
|
+
excludeSessionId?: string
|
|
353
|
+
}): Promise<string | null> {
|
|
354
|
+
try {
|
|
355
|
+
const sessionsResponse = await client.session.list()
|
|
356
|
+
const sessions = sessionsResponse.data || []
|
|
357
|
+
|
|
358
|
+
// Sessions are sorted by time, get the most recent one that isn't the current
|
|
359
|
+
const lastSession = sessions.find((s) => s.id !== excludeSessionId)
|
|
360
|
+
return lastSession?.id || null
|
|
361
|
+
} catch (e) {
|
|
362
|
+
markdownLogger.error('Failed to get last session:', e)
|
|
363
|
+
return null
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest'
|
|
2
|
+
import { formatTodoList } from './message-formatting.js'
|
|
3
|
+
import type { Part } from '@opencode-ai/sdk'
|
|
4
|
+
|
|
5
|
+
describe('formatTodoList', () => {
|
|
6
|
+
test('formats active todo with monospace numbers', () => {
|
|
7
|
+
const part: Part = {
|
|
8
|
+
id: 'test',
|
|
9
|
+
type: 'tool',
|
|
10
|
+
tool: 'todowrite',
|
|
11
|
+
sessionID: 'ses_test',
|
|
12
|
+
messageID: 'msg_test',
|
|
13
|
+
callID: 'call_test',
|
|
14
|
+
state: {
|
|
15
|
+
status: 'completed',
|
|
16
|
+
input: {
|
|
17
|
+
todos: [
|
|
18
|
+
{ content: 'First task', status: 'completed' },
|
|
19
|
+
{ content: 'Second task', status: 'in_progress' },
|
|
20
|
+
{ content: 'Third task', status: 'pending' },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
output: '',
|
|
24
|
+
title: 'todowrite',
|
|
25
|
+
metadata: {},
|
|
26
|
+
time: { start: 0, end: 0 },
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
expect(formatTodoList(part)).toMatchInlineSnapshot(`"⑵ **second task**"`)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('formats double digit todo numbers', () => {
|
|
34
|
+
const todos = Array.from({ length: 12 }, (_, i) => ({
|
|
35
|
+
content: `Task ${i + 1}`,
|
|
36
|
+
status: i === 11 ? 'in_progress' : 'completed',
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
const part: Part = {
|
|
40
|
+
id: 'test',
|
|
41
|
+
type: 'tool',
|
|
42
|
+
tool: 'todowrite',
|
|
43
|
+
sessionID: 'ses_test',
|
|
44
|
+
messageID: 'msg_test',
|
|
45
|
+
callID: 'call_test',
|
|
46
|
+
state: {
|
|
47
|
+
status: 'completed',
|
|
48
|
+
input: { todos },
|
|
49
|
+
output: '',
|
|
50
|
+
title: 'todowrite',
|
|
51
|
+
metadata: {},
|
|
52
|
+
time: { start: 0, end: 0 },
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
expect(formatTodoList(part)).toMatchInlineSnapshot(`"⑿ **task 12**"`)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('lowercases first letter of content', () => {
|
|
60
|
+
const part: Part = {
|
|
61
|
+
id: 'test',
|
|
62
|
+
type: 'tool',
|
|
63
|
+
tool: 'todowrite',
|
|
64
|
+
sessionID: 'ses_test',
|
|
65
|
+
messageID: 'msg_test',
|
|
66
|
+
callID: 'call_test',
|
|
67
|
+
state: {
|
|
68
|
+
status: 'completed',
|
|
69
|
+
input: {
|
|
70
|
+
todos: [{ content: 'Fix the bug', status: 'in_progress' }],
|
|
71
|
+
},
|
|
72
|
+
output: '',
|
|
73
|
+
title: 'todowrite',
|
|
74
|
+
metadata: {},
|
|
75
|
+
time: { start: 0, end: 0 },
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
expect(formatTodoList(part)).toMatchInlineSnapshot(`"⑴ **fix the bug**"`)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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://
|
|
50
|
-
`http://
|
|
51
|
-
`http://
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
150
|
+
logBuffer.push(`[stdout] ${data.toString().trim()}`)
|
|
118
151
|
})
|
|
119
152
|
|
|
120
153
|
serverProcess.stderr?.on('data', (data) => {
|
|
121
|
-
|
|
154
|
+
logBuffer.push(`[stderr] ${data.toString().trim()}`)
|
|
122
155
|
})
|
|
123
156
|
|
|
124
157
|
serverProcess.on('error', (error) => {
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
158
|
-
fetch:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
}
|